A Claude Code mod: a live pane of every subagent of the session with its model, effort, context fill, peak and compactions.

The Claude Code mod divramod-subagent-context: a live pane of every subagent of the session with its model, effort, context fill, peak and compactions.
Imported from the mods repository by plan 0233 (its import.sh); published to divramod/divramod-claude-code-plugins as plugins/divramod-subagent-context by the publish job (claude-code-plugins). Install it (more in that repository's README):
/plugin install divramod-subagent-context --marketplace divramod/divramod-claude-code-plugins
subagents button above the prompt (its key s once that band has the keyboard) or /divramod-subagent-context. The pane never opens by itself: not on a session's start, on /reload-plugins or on a subagent's start (plan 0233 D6).[pane has the keyboard - Esc returns it] (a stable prefix hal2's typing guard reads, plan 0233 D3); Escape gives it back, and after a minute without a key it gives it back by itself. Every focus move is logged to ~/.claude/divramod-subagent-context/focus.log ($CLAUDE_CONFIG_DIR if set).q while the pane has the keyboard, the close button, or ctrl+x x.s), plans (p), agents (a) and jobs (b); sort by a header, drag a border to resize, h / l switch the filter, j / k move a row.<crop glyph> <number> · 2/17 plans · 19/147+ steps, then a row per plan with its number, the plan, the field it runs in (- when none), its steps done/total, its status (done, running, next, draft, blocked) and its Needs. It needs hal2: the mod runs hal2-cli-plans masterplan --repo <folder> --json (3 s timeout) once per reload of the sessions, only while the pane is open and shows the plans tab; without the binary, an older one or a folder with no masterplan the group is absent.plans/LEAD (<foreman's field> <plan> <step>) names this session's field, with the plan they do, the step, that step's status, the plan's done/total, their state and age. For a servant of a masterplan (the marker names the masterplan's slug and the row) the step and the numbers come from the same JSON, no second process, and the masterplan group shows its field in the row; any other marker, or no hal2, gives the marker's plan and step with the two status cells empty. The mod never reads a plan's step table.~/.claude/divramod-subagent-context/rows-<folder>.json (300 rows, 14 days) so a /clear keeps them./plugin, the mod's configuration): window (auto, 200k, 1m), warn_percent (30), alert_percent (35).version and userConfig options.register.tsx.hooks/, the mod's source; each *.test.ts(x) beside the file it tests, run by claude plugin test:kill -0 per pid, one refresh at a time, every 30 s and only while the pane shows one of the two; each session's row with its plans/CURRENT_PLAN and plans/LEAD.$ that register.tsx hands it: this session's folder and, only on an open plans tab, the masterplan's one process; masterplan.ts: the roll-up's parse and the two groups' rows, pure.claude writes to the ignored .claude-plugin/types/.hooks/table.ts.From this folder:
claude plugin validate --strict .
claude plugin test .
bun scripts/screenshot.ts # regenerates docs/screenshot.svg
The plugin's version is bumped by the landing, never by hand (claude-code-plugins).
hooks/register.tsx 391 lines1import { atom, read, update } from 'claude-code'
2import type { AgentInfo, EngineInterface, Register, TurnStepInput, TurnUsage } from 'claude-code'
3
4import type { JobRow, RollUp, SessionRow, SubagentRow, TableState, Tab } from '../types'
5import { LIMIT, type File, idOf, metaOf, rowOf } from './backfill'
6import { adopt, fill, mark, record } from './rows'
7import { DAYS, parse, serialize } from './store'
8import { agentsGrid, asTable, plansGrid } from './sessions'
9import { CHECK_MS, PACE, type World, sessionCheck } from './live'
10import { type Io, afterRefresh } from './load'
11import { groupsOf } from './masterplan'
12import { jobsGrid, merge, stepType } from './jobs'
13import { bodyOf } from './tabs'
14import { type Filter, isClose, isState, order, stepOf, tabOf, shown, step } from './grid'
15import { type Limits, WIDTHS, limits, tableProps, tableRows } from './table'
16import { TITLE, appended, idle, logLine, unfocused, versionIn, wanted } from './keyboard'
17import { type PaneData, paneView } from './pane-view'
18
19// The pane's id, a literal here so the scan names the hooks' matchers.
20const PANE = 'subagent-context'
21const rows = atom({ plugin: 'divramod-subagent-context', key: 'rows' } as const, [] as SubagentRow[])
22// The table's sort, widths and filter, so a redraw or a reopened pane shows them as they were (view-state rule).
23const view = atom({ plugin: 'divramod-subagent-context', key: 'view' } as const, null as TableState | null)
24// Bumped when a pane-level key changes the filter: the table is keyed by it, so it starts again from the kept view.
25const epoch = atom({ plugin: 'divramod-subagent-context', key: 'epoch' } as const, 0)
26
27// The pane's own keys `h` `l` `j` `k`, like `q`: they work while the pane holds the keyboard, without a click into the table.
28async function press($: EngineInterface, l: Limits, key: string) {
29 const kept = (await read($, view)) ?? { sort: null, widths: WIDTHS, filter: 'all' as Filter, cursor: null }
30 const state = { ...kept, cursor: kept.cursor ?? undefined }
31 const ids = order(shown(tableRows(await read($, rows), l), state.filter), state.sort ?? undefined).map(r => r.id)
32 const next = step(state, key, ids)
33 if (next === state) return
34 await update($, view, () => ({ sort: next.sort, widths: next.widths, filter: next.filter, cursor: next.cursor ?? null }))
35 await update($, epoch, n => n + 1)
36}
37// `j` `k` on the plans, agents or jobs tab: the row cursor of that table, so the keys never fall through to the prompt.
38async function pressSessions($: EngineInterface, key: string, which: 'plans' | 'agents' | 'jobs') {
39 const found = await read($, sessions)
40 const grid = which === 'plans' ? plansGrid(found, 0) : which === 'agents' ? agentsGrid(found, 0) : jobsGrid(await read($, jobs), await read($, jobType), 0)
41 const kept = which === 'plans' ? await read($, plansView) : which === 'agents' ? await read($, agentsView) : await read($, jobsView)
42 const rows = grid.cells.map((cells, i) => ({ id: grid.ids[i]!, cells, values: cells, color: null, running: false }))
43 const state = { sort: kept?.sort ?? null, widths: kept?.widths ?? asTable(grid, '', null, grid.ids, 0).widths, filter: 'all' as Filter, cursor: kept?.cursor ?? undefined }
44 const next = step(state, key, order(rows, state.sort ?? undefined).map(r => r.id))
45 if (next.cursor === state.cursor) return
46 const kept_ = () => ({ sort: state.sort, widths: state.widths, filter: 'all' as Filter, cursor: next.cursor ?? null })
47 await (which === 'plans' ? update($, plansView, kept_) : which === 'agents' ? update($, agentsView, kept_) : update($, jobsView, kept_))
48 await update($, epoch, n => n + 1)
49}
50// `h` `l` on the jobs tab: the type before or after; the cursor starts again.
51async function stepJobs($: EngineInterface, dir: 'prev' | 'next') {
52 const to = stepType(await read($, jobs), await read($, jobType), dir)
53 if (to === (await read($, jobType))) return
54 await update($, jobType, () => to)
55 await update($, jobsView, v => (v ? { ...v, cursor: null } : v))
56 await update($, epoch, n => n + 1)
57}
58
59// The pane's top-level tab and the sessions of this machine the Plans and Agents tabs list (reloaded every 30 seconds, live.ts).
60const tab = atom({ plugin: 'divramod-subagent-context', key: 'tab' } as const, 'subagents' as Tab)
61const sessions = atom({ plugin: 'divramod-subagent-context', key: 'sessions' } as const, [] as SessionRow[])
62// The plans tab's group "masterplan" (the CLI's roll-up of this session's folder, null when there is none) and the
63// folder it was asked for; the "servants" group is cut from the sessions and this folder.
64const masterplan = atom({ plugin: 'divramod-subagent-context', key: 'masterplan' } as const, null as RollUp | null)
65const home = atom({ plugin: 'divramod-subagent-context', key: 'home' } as const, '')
66// The plans and agents tables' own sort, widths and cursor (the subagents' is `view`).
67const plansView = atom({ plugin: 'divramod-subagent-context', key: 'plansView' } as const, null as TableState | null)
68const agentsView = atom({ plugin: 'divramod-subagent-context', key: 'agentsView' } as const, null as TableState | null)
69// This session's background jobs (the engine's `background_tasks` and `session_crons`), the type the Jobs tab shows and its table's view.
70const jobs = atom({ plugin: 'divramod-subagent-context', key: 'jobs' } as const, [] as JobRow[])
71const jobType = atom({ plugin: 'divramod-subagent-context', key: 'jobType' } as const, 'all')
72const jobsView = atom({ plugin: 'divramod-subagent-context', key: 'jobsView' } as const, null as TableState | null)
73
74// A Stop hook's snapshot of the session's in-flight work, merged into the jobs.
75async function snapshot($: EngineInterface, e: { background_tasks?: Parameters<typeof merge>[1]; session_crons?: Parameters<typeof merge>[2] }) {
76 if (!e.background_tasks && !e.session_crons) return
77 const at = await $.clock.now()
78 await update($, jobs, list => merge(list, e.background_tasks ?? [], e.session_crons ?? [], at))
79}
80
81const configDir = async ($: EngineInterface) => (await $.env.get('CLAUDE_CONFIG_DIR')) || `${await $.env.get('HOME')}/.claude`
82
83// What follows a refresh of the sessions (load.ts), as closures over `$`: this session's folder and, only while the
84// pane is open on the plans tab (plan 0275 D18), the masterplan.
85const ioOf = ($: EngineInterface): Io => ({
86 cwd: () => $.session.cwd(),
87 run: (argv, init) => $.process.run(argv, init),
88 shown: async () => (await read($, tab)) === 'plans' && (await $.ui.panes()).some(p => p.id === PANE),
89 home: () => read($, home),
90 setHome: cwd => update($, home, () => cwd),
91 get: () => read($, masterplan),
92 set: plan => update($, masterplan, () => plan),
93})
94
95// The session check (live.ts): the first refresh at the start, then only on an open Plans or Agents tab. `$` stays in
96// this file, so the check gets what it needs as functions.
97const check = sessionCheck()
98const world = ($: EngineInterface): World => ({
99 config: () => configDir($),
100 session: () => $.session.id(),
101 list: dir => $.fs.list(dir),
102 read: path => $.fs.read(path),
103 run: argv => $.process.run(argv, { timeoutMs: CHECK_MS }),
104 open: async () => (await $.ui.panes()).some(p => p.id === PANE),
105 tab: () => read($, tab),
106 get: () => read($, sessions),
107 set: next => update($, sessions, () => next),
108 after: () => afterRefresh(ioOf($)),
109})
110const refresh = ($: EngineInterface) => check.refresh(world($))
111const tick = ($: EngineInterface) => check.tick(world($))
112
113async function show($: EngineInterface, to: Tab) {
114 await update($, tab, () => to)
115 if (to === 'plans' || to === 'agents') await refresh($)
116}
117
118// The agents `$.agent.list()` named, and the loop ids it does not (the engine's compaction and memory forks).
119type Known = { agents: Map<string, AgentInfo>; unlisted: Set<string> }
120
121async function agent($: EngineInterface, known: Known, id: string) {
122 if (!known.agents.has(id) && !known.unlisted.has(id)) {
123 for (const a of await $.agent.list()) known.agents.set(a.id, a)
124 if (!known.agents.has(id)) known.unlisted.add(id)
125 }
126 return known.agents.get(id)
127}
128
129async function seen($: EngineInterface, known: Known, l: Limits, e: TurnStepInput, usage: TurnUsage) {
130 const a = await agent($, known, e.agentId!)
131 if (!a) return
132 live.add(a.id)
133 const step = { id: a.id, description: a.description, status: 'running', model: usage.model || e.model, effort: e.effort === undefined ? '' : String(e.effort), fill: fill(usage), at: await $.clock.now() }
134 await keep($, await update($, rows, list => record(list, step)))
135}
136
137// Asks an open pane for the room its rows need, never the keyboard; a closed pane stays closed (D6: it never opens by itself).
138async function room($: EngineInterface, n: number) {
139 const up = (await $.ui.panes()).some(p => p.id === PANE)
140 if (up) await $.ui.open({ id: PANE, title: TITLE, rows: wanted(n) })
141}
142
143// The person's own opens (the typed command, the button), logged: the only ones whose `focus` the `ui.open` hook lets through.
144let asking = 0
145async function asked($: EngineInterface, by: 'command' | 'button') {
146 const n = (await read($, rows)).length
147 await logFocus($, 'ui.open', by, true, 'asked')
148 asking++
149 await $.ui.open({ id: PANE, title: TITLE, rows: wanted(n), focus: true }).finally(() => asking--)
150}
151
152// When the module last saw a key in the pane, and since when the pane holds the keyboard (0: it does not).
153let lastKey = 0
154let focusedAt = 0
155
156// Appends a line to the focus log (`<config>/divramod-subagent-context/focus.log`, the last 200): the proof of who took
157// the keyboard, for the next incident. Writes run one after the other; a failed one is dropped.
158let logging = Promise.resolve()
159async function logFocus($: EngineInterface, what: string, origin: string, granted: boolean, detail = '') {
160 logging = logging.then(async () => {
161 const [at, path] = [await $.clock.now(), `${await configDir($)}/divramod-subagent-context/focus.log`]
162 await $.fs.write(path, appended(String(await $.fs.read(path).catch(() => '')), logLine(at, what, origin, granted, detail)))
163 }).catch(() => {})
164 await logging
165}
166
167// The pane's focus as a drawing or the timer saw it: a change is logged, a new hold starts the idle clock.
168async function focusSeen($: EngineInterface, focused: boolean) {
169 if (focused === focusedAt > 0) return
170 focusedAt = focused ? await $.clock.now() : 0
171 await logFocus($, 'pane', focused ? 'holds the keyboard' : 'gave the keyboard back', true)
172}
173
174// Focused and no key seen for a minute, the pane gives the keyboard back: closed and opened again without `focus`.
175async function release($: EngineInterface) {
176 const pane = (await $.ui.panes()).find(p => p.id === PANE)
177 await focusSeen($, pane?.isFocused ?? false)
178 if (!idle(await $.clock.now(), focusedAt, lastKey)) return
179 await logFocus($, 'idle', 'release', true, 'closed and reopened without focus')
180 focusedAt = 0
181 await $.ui.close({ id: PANE })
182 await $.ui.open({ id: PANE, title: TITLE, rows: wanted((await read($, rows)).length) })
183}
184
185// The version in the plugin's own plugin.json, shown at the pane's top right; empty when it cannot be read.
186async function versionOf($: EngineInterface) {
187 return versionIn(String(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`).catch(() => '')))
188}
189
190// Where the rows are kept between sessions, and the last time they were written.
191// One file per working folder: a `/clear` keeps the folder, and sessions of other folders never overwrite it.
192const storeFile = async ($: EngineInterface) => {
193 const slug = (await $.session.cwd().catch(() => '')).replace(/[^A-Za-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'default'
194 return `${await configDir($)}/divramod-subagent-context/rows-${slug}.json`
195}
196let saved = 0
197
198// Writes the rows now, or at most every 5 seconds; a failed write loses nothing the next one will not carry.
199async function keep($: EngineInterface, list: readonly SubagentRow[], force = false) {
200 const now = await $.clock.now()
201 if (!force && now - saved < 5000) return
202 saved = now
203 await $.fs.write(await storeFile($), serialize(list)).catch(() => {})
204}
205
206const list = ($: EngineInterface, dir: string) => $.fs.list(dir).catch(() => [])
207
208// The rows this module instance made from events (its own session's subagents), which a purge never drops.
209const live = new Set<string>()
210let purged = false
211
212// Once per load: drops the remembered rows whose subagent has no transcript in this folder's project (rows another
213// folder's session left in a shared file or in the state `/reload-plugins` keeps). With no transcript to judge by it keeps all.
214async function purge($: EngineInterface) {
215 if (purged) return
216 purged = true
217 try {
218 const dir = `${await configDir($)}/projects/${(await $.session.cwd()).replace(/[^A-Za-z0-9]/g, '-')}`
219 const now = await $.clock.now()
220 const mine = new Set<string>()
221 for (const s of await list($, dir)) {
222 if (s.kind !== 'dir' || now - s.mtimeMs > DAYS * 86_400_000) continue
223 for (const f of await list($, `${dir}/${s.name}/subagents`)) if (idOf(f.name)) mine.add(idOf(f.name)!)
224 }
225 if (!mine.size) return
226 await keep($, await update($, rows, all => all.filter(r => mine.has(r.id) || live.has(r.id))), true)
227 } catch {}
228}
229
230// The transcripts of this session's subagents: `<config>/projects/*/<session>/subagents/agent-*.jsonl`, with their dirs.
231async function transcripts($: EngineInterface, config: string, session: string) {
232 const found: (File & { dir: string })[] = []
233 for (const project of await list($, `${config}/projects`)) {
234 const dir = `${config}/projects/${project.name}/${session}/subagents`
235 if (project.kind === 'dir') for (const f of await list($, dir)) if (f.kind === 'file' && idOf(f.name)) found.push({ id: idOf(f.name)!, size: f.size, mtimeMs: f.mtimeMs, dir })
236 }
237 return found
238}
239
240// The subagents that ran before the mod loaded, from their transcripts; a row `turn.step` made already wins.
241async function earlier($: EngineInterface) {
242 // Rows an earlier session left, first: a live row or a transcript of this session wins over them.
243 const left = parse(String(await $.fs.read(await storeFile($)).catch(() => '')), await $.clock.now())
244 if (left.length) await update($, rows, list => adopt(list, left))
245 const agents = new Map((await $.agent.list()).map(a => [a.id, a]))
246 const found: SubagentRow[] = []
247 for (const f of await transcripts($, await configDir($), await $.session.id())) {
248 const path = `${f.dir}/agent-${f.id}`
249 const meta = metaOf(await $.fs.read(`${path}.meta.json`).catch(() => undefined))
250 const text = f.size > LIMIT ? undefined : await $.fs.read(`${path}.jsonl`).catch(() => null)
251 if (text !== null) found.push(rowOf(f, meta, text, agents.get(f.id)))
252 }
253 await keep($, await update($, rows, list => adopt(list, found)), true)
254}
255
256export const register: Register = (on, options) => {
257 let l: Limits = limits(options, 1_000_000)
258 const known: Known = { agents: new Map(), unlisted: new Set() }
259
260 on('session.start', async ($, e, next) => {
261 const started = await next(e)
262 await $.command.register({ name: 'divramod-subagent-context', description: 'Show the context use of this session\'s subagents' })
263 l = limits(options, (await $.session.usage()).context.window)
264 // A module older than 0.1.7 pinned a summary line under the prompt; clearing it is harmless when none is set.
265 void $.ui.status(undefined)
266 // The pane never opens by itself (D6): not here, not at a reload (which runs this again), not on a subagent's start.
267 await update($, jobs, () => [])
268 void earlier($).then(() => purge($)).catch(() => {})
269 void refresh($)
270 void $.clock.every(PACE, () => void tick($).catch(() => {}))
271 void $.clock.every(10_000, () => void release($).catch(() => {}))
272 return started
273 })
274
275 // Every response of a subagent's loop updates its row as it ends: no poll, no transcript read.
276 on('turn.step', async function* ($, e, next) {
277 const result = yield* next(e)
278 if (e.agentId && result.usage) await seen($, known, l, e, result.usage)
279 return result
280 })
281
282 // Every subagent of the session has a row from its start; its stop gives it its final status (D15's refresh).
283 on('classic.SubagentStart', async ($, e, next) => {
284 const result = await next(e)
285 const a = await agent($, known, e.agent_id)
286 live.add(e.agent_id)
287 const at = await $.clock.now()
288 const list = await update($, rows, list => mark(list, { id: e.agent_id, description: a?.description ?? e.agent_type, status: 'running', at }))
289 await keep($, list)
290 await room($, list.length).catch(() => {})
291 return result
292 })
293
294 on('classic.Stop', async ($, e, next) => {
295 const result = await next(e)
296 await snapshot($, e)
297 return result
298 })
299
300 on('classic.SubagentStop', async ($, e, next) => {
301 const result = await next(e)
302 await snapshot($, e)
303 const listed = (await $.agent.list()).find(a => a.id === e.agent_id)?.status
304 const status = listed === 'failed' || listed === 'killed' ? listed : 'completed'
305 const at = await $.clock.now()
306 await keep($, await update($, rows, list => (list.some(r => r.id === e.agent_id) ? mark(list, { id: e.agent_id, description: e.agent_type, status, at }) : list)), true)
307 return result
308 })
309
310 on('command.run', { command: 'divramod-subagent-context' }, async $ => {
311 await asked($, 'command')
312 return { text: 'Subagents pane opened.' }
313 })
314
315 // A button above the prompt opens the pane, or closes it when it is open; a click presses it, `s` once the band has the keys.
316 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
317 if (e.props.hasSurvey) return next(e)
318 const { Box, Button } = $.ui.resolve(e)
319 const toggle = async () => {
320 const up = (await $.ui.panes()).some(p => p.id === PANE)
321 if (up) await $.ui.close({ id: PANE })
322 else await asked($, 'button')
323 }
324 return (
325 <Box>
326 <Button label="subagents" hotkey="s" plain onPress={toggle} />
327 </Box>
328 )
329 })
330
331 on('ui.message', async ($, e, next) => {
332 const result = await next(e)
333 if (e.requestId !== PANE) return result
334 lastKey = await $.clock.now()
335 if (isClose(e.data)) await $.ui.close({ id: PANE })
336 else if (tabOf(e.data)) await show($, tabOf(e.data)!)
337 else if (stepOf(e.data)) await stepJobs($, stepOf(e.data)!)
338 else if (isState(e.data)) {
339 const state = () => e.data as TableState
340 if (e.element === 'table') await update($, view, state)
341 else if (e.element === 'plans-table') await update($, plansView, state)
342 else if (e.element === 'agents-table') await update($, agentsView, state)
343 else if (e.element === 'jobs-table') await update($, jobsView, state)
344 }
345 return result
346 })
347
348 // The table is a `Client` where the surface draws one (terminal, desktop); elsewhere its rows as plain lines.
349 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
350 const el = $.ui.resolve(e)
351 // VS Code's table names a Client it does not draw yet: the surface decides.
352 const Client = 'Client' in el && (e.surface === 'terminal' || e.surface === 'desktop') ? el.Client : undefined
353 void purge($)
354 await focusSeen($, e.props.isFocused).catch(() => {})
355 const list = await read($, rows)
356 const current = await read($, tab)
357 const found = await read($, sessions)
358 const tabJobs = await read($, jobs)
359 const now = current === 'subagents' ? 0 : await $.clock.now()
360 const groups = current === 'plans' ? groupsOf(found, await read($, home), await read($, masterplan), now) : []
361 const other = current === 'subagents' ? undefined : bodyOf(current, found, tabJobs, await read($, jobType), { plans: await read($, plansView), agents: await read($, agentsView), jobs: await read($, jobsView) }, await read($, epoch), now)
362 const table = tableProps(list, l, await read($, view), await read($, epoch))
363 const client = !Client ? undefined : other ? <Client key={`${current}-table`} module="./table-view.tsx" props={other.table} flexGrow={1} /> : <Client key="table" module="./table-view.tsx" props={table} flexGrow={1} />
364 // The body is as tall as the pane the surface gave it, so the table grows and shrinks with the window.
365 const data: PaneData = { focused: e.props.isFocused, version: await versionOf($), current, found, jobs: tabJobs, list, l, table: client, other, groups, height: e.props.scroll?.bodyRows, room: Math.max(1, (e.viewport?.rows ?? 24) - 5), columns: e.props.bodyColumns ?? e.viewport?.columns ?? 0 }
366 return paneView(el, data, { show: t => show($, t), press: k => press($, l, k), pressSessions: k => pressSessions($, k, current as Exclude<Tab, 'subagents'>), stepJobs: d => stepJobs($, d), close: () => $.ui.close({ id: PANE }) })
367 })
368
369 // Only the typed command and the button ask for the keyboard: any other open of the pane loses `focus` (D3).
370 on('ui.open', { id: PANE }, async ($, e, next) => {
371 if (!e.focus) return next(e)
372 if (asking > 0) return next(e)
373 await logFocus($, 'ui.open', next.origin.plugin, false, 'focus stripped')
374 return next(unfocused(e))
375 })
376
377 // Every move of the focus ring in the pane, logged; only the person's lands (a plugin's `$.ui.focus` or `autoFocus` is refused).
378 on('ui.focus', { requestId: PANE }, async ($, e, next) => {
379 const person = e.origin.kind === 'person'
380 if (person) lastKey = await $.clock.now()
381 await logFocus($, 'ui.focus', e.origin.kind === 'plugin' ? `plugin:${e.origin.name}` : 'person', person, e.element ?? '')
382 return person ? next(e) : { deny: 'divramod-subagent-context: only the person moves the focus in its pane' }
383 })
384
385 // A press in the pane is a key the module sees: it holds the idle release off.
386 on('ui.press', { requestId: PANE }, async ($, e, next) => {
387 lastKey = await $.clock.now()
388 return next(e)
389 })
390}
391hooks/backfill.ts 32 lines1import type { AgentInfo } from 'claude-code'
2
3import type { SubagentRow } from '../types'
4import { family } from './rows'
5import { fold, modelOf } from './transcript'
6
7// `$.fs.read` rejects a file over 4 MiB; a larger transcript becomes a marked row instead of a read.
8export const LIMIT = 4 * 1024 * 1024
9
10type Meta = { description?: string; model?: string; effort?: string }
11export type File = { id: string; size: number; mtimeMs: number }
12
13// `agent-<id>.jsonl` → `<id>`; anything else in a `subagents` folder (the `.meta.json` files) is none.
14export const idOf = (name: string) => /^agent-(.+)\.jsonl$/.exec(name)?.[1]
15
16export const metaOf = (text: string | undefined): Meta => {
17 try {
18 return JSON.parse(text ?? '')
19 } catch {
20 return {}
21 }
22}
23
24// The row of a transcript that ran before the mod loaded; `text` is absent for one over LIMIT (not read).
25export function rowOf(file: File, meta: Meta, text: string | undefined, agent?: AgentInfo): SubagentRow {
26 const base = { id: file.id, description: meta.description || agent?.description || '', effort: meta.effort ?? '', mtime: file.mtimeMs, status: agent?.status ?? 'completed' }
27 if (text === undefined) return { ...base, model: family(meta.model ?? ''), calls: 0, now: 0, peak: 0, compactions: 0, seconds: 0, started: file.mtimeMs, large: true }
28 const f = fold(text)
29 const started = f.first || file.mtimeMs
30 return { ...base, model: modelOf(f, meta), calls: f.calls, now: f.now, peak: f.peak, compactions: f.compactions, seconds: Math.round((f.last - started) / 1000), started }
31}
32hooks/rows.ts 45 lines1import type { ModelUsage } from 'claude-code'
2
3import type { SubagentRow } from '../types'
4
5// One model response of a subagent's loop, as the `turn.step` hook saw it.
6export type Step = { id: string; description: string; status: string; model: string; effort: string; fill: number; at: number }
7
8// A request's context is its input, cache read and cache write tokens; output is not in the window yet.
9export const fill = (u: ModelUsage) =>
10 u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
11
12// `claude-opus-5-5` → `opus`: the family is what fits the column; an unknown id stays whole.
13export const family = (model: string) => /opus|sonnet|haiku|fable/.exec(model)?.[0] ?? model
14
15// The rows after one step: a new agent gets a row, a known one its counts; a fill that drops by more than half
16// counts as a compaction until an event for them is known (D4).
17export const record = (rows: readonly SubagentRow[], s: Step): SubagentRow[] => {
18 const was = rows.find(r => r.id === s.id)
19 const row: SubagentRow = {
20 id: s.id,
21 description: s.description,
22 model: family(s.model),
23 effort: s.effort,
24 calls: (was?.calls ?? 0) + 1,
25 now: s.fill,
26 peak: Math.max(was?.peak ?? 0, s.fill),
27 compactions: (was?.compactions ?? 0) + (was && s.fill < was.now / 2 ? 1 : 0),
28 seconds: Math.round((s.at - (was?.started ?? s.at)) / 1000),
29 started: was?.started ?? s.at,
30 mtime: s.at,
31 status: s.status,
32 }
33 return was ? rows.map(r => (r === was ? row : r)) : [...rows, row]
34}
35
36// Rows read from transcripts join the live ones: a row `turn.step` already made wins (D4); all keep the order of `started`.
37export const adopt = (rows: readonly SubagentRow[], found: readonly SubagentRow[]): SubagentRow[] =>
38 [...rows, ...found.filter(f => !rows.some(r => r.id === f.id))].sort((a, b) => a.started - b.started)
39
40// A subagent's status changed (it started, a step ran, it stopped): a new one gets a row without counts yet.
41export const mark = (rows: readonly SubagentRow[], m: { id: string; description: string; status: string; at: number }): SubagentRow[] =>
42 rows.some(r => r.id === m.id)
43 ? rows.map(r => (r.id === m.id ? { ...r, status: m.status, description: r.description || m.description } : r))
44 : [...rows, { id: m.id, description: m.description, model: '', effort: '', calls: 0, now: 0, peak: 0, compactions: 0, seconds: 0, started: m.at, mtime: m.at, status: m.status }]
45hooks/store.ts 32 lines1import type { SubagentRow } from '../types'
2
3// What a session leaves for the next one (a clear starts a new session, so its rows would be gone): at most this
4// many rows, none older than this many days.
5export const KEEP = 300
6export const DAYS = 14
7
8// The file's text: the newest rows by their latest step.
9export const serialize = (rows: readonly SubagentRow[]) =>
10 JSON.stringify({ version: 1, rows: [...rows].sort((a, b) => b.mtime - a.mtime).slice(0, KEEP).sort((a, b) => a.started - b.started) })
11
12const isRow = (r: unknown): r is SubagentRow => {
13 const o = r as Record<string, unknown> | null
14 return typeof o === 'object' && o !== null && typeof o.id === 'string' && typeof o.description === 'string' && typeof o.status === 'string' &&
15 ['calls', 'now', 'peak', 'compactions', 'seconds', 'started', 'mtime'].every(k => Number.isFinite(o[k]))
16}
17
18// The rows a file holds, as an earlier session left them: a bad file gives none, an old row goes, and a row that was
19// still running then has ended with its session.
20export const parse = (text: string, now: number): SubagentRow[] => {
21 try {
22 const rows = (JSON.parse(text) as { rows?: unknown }).rows
23 if (!Array.isArray(rows)) return []
24 return rows
25 .filter(isRow)
26 .filter(r => now - r.mtime < DAYS * 86_400_000)
27 .map(r => (['completed', 'failed', 'killed'].includes(r.status) ? r : { ...r, status: 'completed' }))
28 } catch {
29 return []
30 }
31}
32hooks/sessions.ts 101 lines1import type { SessionRow, TableState } from '../types'
2import { type Align, type TableProps, line, rule } from './grid'
3
4// One Claude Code session as its `~/.claude/sessions/<pid>.json` says it; undefined when the text is no session.
5export const parse = (text: string): Omit<SessionRow, 'plan' | 'lead' | 'here'> | undefined => {
6 try {
7 const s = JSON.parse(text)
8 if (typeof s?.pid !== 'number' || typeof s.cwd !== 'string') return undefined
9 return {
10 id: typeof s.sessionId === 'string' ? s.sessionId : '',
11 pid: s.pid,
12 name: typeof s.name === 'string' && s.name ? s.name : s.cwd.split('/').filter(Boolean).at(-1) ?? String(s.pid),
13 cwd: s.cwd,
14 kind: typeof s.kind === 'string' ? s.kind : '',
15 status: typeof s.status === 'string' ? s.status : '',
16 startedAt: typeof s.startedAt === 'number' ? s.startedAt : 0,
17 }
18 } catch {
19 return undefined
20 }
21}
22
23// A plan file's first line: the plan the worktree works on (`plans/CURRENT_PLAN`); empty when there is none.
24export const planOf = (text: string | undefined) => (text ?? '').split('\n')[0]!.trim()
25
26// `/…/worktree/<repo>/<NN>` reads `<repo> <NN>`; any other folder its last two parts.
27export const folder = (cwd: string) => {
28 const parts = cwd.split('/').filter(Boolean)
29 const at = parts.indexOf('worktree')
30 return at >= 0 && parts.length > at + 2 ? `${parts[at + 1]} ${parts.slice(at + 2).join('/')}` : parts.slice(-2).join('/')
31}
32
33// How long ago, as `5m`, `3h 12m` or `2d 4h`.
34export const ago = (ms: number, now: number) => {
35 const m = Math.max(0, Math.floor((now - ms) / 60_000))
36 return m < 60 ? `${m}m` : m < 1440 ? `${Math.floor(m / 60)}h ${m % 60}m` : `${Math.floor(m / 1440)}d ${Math.floor((m % 1440) / 60)}h`
37}
38
39// The sessions with a plan, or all of them, by name; this session first.
40export const ordered = (rows: readonly SessionRow[], onlyPlans: boolean) =>
41 rows.filter(r => !onlyPlans || r.plan).sort((a, b) => Number(b.here) - Number(a.here) || a.name.localeCompare(b.name))
42
43export type Grid = { heads: string[]; aligns: Align[]; cells: string[][]; ids: string[] }
44
45export const agentsGrid = (rows: readonly SessionRow[], now: number): Grid => ({
46 heads: ['Agent', 'Folder', 'Status', 'Plan', 'Age'],
47 aligns: ['l', 'l', 'c', 'l', 'r'],
48 cells: ordered(rows, false).map(r => [`${r.here ? '● ' : ''}${r.name}`, folder(r.cwd), r.status || r.kind, r.plan || '-', ago(r.startedAt, now)]),
49 ids: ordered(rows, false).map(r => r.id || String(r.pid)),
50})
51
52export const plansGrid = (rows: readonly SessionRow[], now: number): Grid => ({
53 heads: ['Plan', 'Agent', 'Folder', 'Status', 'Age'],
54 aligns: ['l', 'l', 'l', 'c', 'r'],
55 cells: ordered(rows, true).map(r => [r.plan, `${r.here ? '● ' : ''}${r.name}`, folder(r.cwd), r.status || r.kind, ago(r.startedAt, now)]),
56 ids: ordered(rows, true).map(r => r.id || String(r.pid)),
57})
58
59const MIN = 8
60
61// The column widths for a pane `columns` wide: each as wide as its longest cell, then the widest shrunk one cell at a
62// time until the table fits (a cut cell ends in `…`); never below MIN.
63export const widths = (g: Grid, columns: number) => {
64 const laid = g.heads.map((h, i) => Math.max(h.length, ...g.cells.map(c => c[i]!.length)) + 2)
65 const total = () => laid.reduce((sum, w) => sum + w + 1, 1)
66 while (columns > 0 && total() > columns) {
67 const widest = laid.indexOf(Math.max(...laid))
68 if (laid[widest]! <= MIN) break
69 laid[widest]!--
70 }
71 return laid
72}
73
74// The grid as box-drawn lines like the subagents' table, fitted to `columns` wide, no rules between rows (so a session
75// takes one line); at most `max` rows, the rest counted in a last line. `empty` says so when there is no row.
76export const lines = (g: Grid, empty: string, columns = 0, max = Infinity) => {
77 const laid = widths(g, columns)
78 const shown = g.cells.slice(0, Math.max(1, max))
79 const out = [rule(laid, 'top'), line(g.heads, laid, g.aligns), rule(laid, 'mid'), ...shown.map(c => line(c, laid, g.aligns))]
80 if (!g.cells.length) out.push(line([empty], [laid.reduce((sum, w) => sum + w + 1, -1)], ['l']))
81 out.push(rule(laid, 'bottom'))
82 if (g.cells.length > shown.length) out.push(`… ${g.cells.length - shown.length} more: make the pane taller`)
83 return out
84}
85
86// The grid as the table's props (the `Client` lays it out and follows the pane's size): each column as wide as its
87// longest cell (at most CAP), the first column giving way when the pane is narrow.
88const CAP = 44
89const total = (g: Grid, noun: string) => [`${g.cells.length} ${noun}`, ...g.heads.slice(1).map(() => '')]
90export const asTable = (g: Grid, label: string, view: TableState | null, ids: readonly string[], give: number, noun = 'sessions', steps = false): TableProps => ({
91 heads: g.heads,
92 widths: g.heads.map((h, i) => Math.min(CAP, Math.max(h.length, ...g.cells.map(c => c[i]!.length))) + 2),
93 aligns: g.aligns,
94 rows: g.cells.map((cells, i) => ({ id: ids[i]!, cells, values: cells, color: null, running: false })),
95 sums: { all: total(g, noun), running: total(g, noun), finished: total(g, noun) },
96 view,
97 plain: label,
98 give,
99 ...(steps ? { steps } : {}),
100})
101hooks/live.ts 90 lines1import type { SessionRow, Tab } from '../types'
2import { parse, planOf } from './sessions'
3
4// How often the timer asks for a refresh, in milliseconds (plan 0297 D3).
5export const PACE = 30_000
6// How long one pid's check may take before it counts as not alive.
7export const CHECK_MS = 5_000
8
9// What the check needs of the engine and the pane, as plain functions: the engine follows `$` only inside the hooks
10// module's own file, so `register.tsx` builds this from its `$` and a test from fakes.
11export type World = {
12 // The Claude config folder (`~/.claude`) and this session's id.
13 config: () => Promise<string>
14 session: () => Promise<string>
15 list: (dir: string) => Promise<readonly { name: string; kind: string }[]>
16 read: (path: string) => Promise<unknown>
17 // Starts one process and resolves once it has exited.
18 run: (argv: readonly string[]) => Promise<{ exitCode: number }>
19 // Whether the pane is open, and its current tab.
20 open: () => Promise<boolean>
21 tab: () => Promise<Tab>
22 // The sessions the tabs show now, and their replacement.
23 get: () => Promise<readonly SessionRow[]>
24 set: (rows: SessionRow[]) => Promise<unknown>
25 // What follows a refresh inside its turn (the masterplan, load.ts): it has ended before the next refresh may start.
26 after?: () => Promise<unknown>
27}
28
29// The command that checks one pid: `kill -0` sends nothing (signal 0), it only asks whether the pid exists. Never a
30// `ps` over a list of pids, which walks the whole process table.
31export const argvOf = (pid: number) => ['kill', '-0', String(pid)]
32
33// Whether the pid's process runs: exit code 0. Any other code, a run that fails or a pid that is none is not alive.
34export async function alive(w: Pick<World, 'run'>, pid: number) {
35 if (!Number.isInteger(pid) || pid <= 0) return false
36 const run = await w.run(argvOf(pid)).catch(() => undefined)
37 return run?.exitCode === 0
38}
39
40// The live sessions of this machine: each `<config>/sessions/<pid>.json` whose process still runs, one check per pid
41// and one at a time, with the plan its folder names and the foreman its `plans/LEAD` names. A file or a plan that
42// cannot be read is left out, never an error.
43export async function loadSessions(w: World, config: string, session: string) {
44 const dir = `${config}/sessions`
45 const out: SessionRow[] = []
46 for (const f of await w.list(dir).catch(() => [])) {
47 if (f.kind !== 'file' || !/^\d+\.json$/.test(f.name)) continue
48 const s = parse(String(await w.read(`${dir}/${f.name}`).catch(() => '')))
49 if (!s || !(await alive(w, s.pid))) continue
50 const plan = planOf(String(await w.read(`${s.cwd}/plans/CURRENT_PLAN`).catch(() => '')))
51 out.push({ ...s, plan, lead: planOf(String(await w.read(`${s.cwd}/plans/LEAD`).catch(() => ''))), here: s.id === session })
52 }
53 return out
54}
55
56// The session check of one loaded module: `refresh` reloads the sessions, `tick` is the timer's refresh.
57export const sessionCheck = () => {
58 // True while a refresh runs: a second one is skipped, never queued and never run beside it.
59 let running = false
60
61 // Reloads the sessions; they are written only when they changed, so a quiet round redraws nothing.
62 const refresh = async (w: World) => {
63 if (running) return
64 running = true
65 try {
66 const next = await loadSessions(w, await w.config(), await w.session())
67 if (JSON.stringify(next) !== JSON.stringify(await w.get())) await w.set(next)
68 await w.after?.()
69 } catch {
70 } finally {
71 running = false
72 }
73 }
74
75 // The timer's refresh: only while the pane is open on the Plans or Agents tab; else no process and no file read.
76 const tick = async (w: World) => {
77 if (running) return
78 try {
79 if (!(await w.open())) return
80 const tab = await w.tab()
81 if (tab !== 'plans' && tab !== 'agents') return
82 } catch {
83 return
84 }
85 await refresh(w)
86 }
87
88 return { refresh, tick }
89}
90hooks/load.ts 38 lines1import type { RollUp } from '../types'
2import { parseRollUp } from './masterplan'
3
4// What follows a refresh of the sessions (live.ts) needs of the engine and the pane, as plain functions built in
5// register.tsx (the engine's `$` is followed only inside the file that holds the hooks module, never across an import).
6export type Io = {
7 // This session's folder.
8 cwd: () => Promise<string>
9 // Starts one process and resolves once it has exited.
10 run: (argv: readonly string[], init?: { timeoutMs?: number }) => Promise<{ exitCode: number; stdout: string }>
11 // Whether the pane is open and shows the plans tab.
12 shown: () => Promise<boolean>
13 // The folder and the masterplan the plans tab shows now, and their replacements.
14 home: () => Promise<string>
15 setHome: (cwd: string) => Promise<unknown>
16 get: () => Promise<RollUp | null>
17 set: (plan: RollUp | null) => Promise<unknown>
18}
19
20// The masterplan of the folder `cwd`, from `hal2-cli-plans masterplan --repo <cwd> --json`: one process, a timeout, and
21// null for everything else (the word `null`, a missing binary, an unknown subcommand of an older CLI, a failure).
22export async function loadMasterplan(io: Pick<Io, 'run'>, cwd: string): Promise<RollUp | null> {
23 if (!cwd) return null
24 const out = await io.run(['hal2-cli-plans', 'masterplan', '--repo', cwd, '--json'], { timeoutMs: 3000 }).catch(() => undefined)
25 return out && out.exitCode === 0 ? parseRollUp(out.stdout) : null
26}
27
28// What follows each refresh of the sessions, inside its turn (live.ts's `after`: never beside a session check, never
29// two at once): the folder is kept, and the masterplan is asked only while the pane is open on the plans tab (plan
30// 0275 D18: no process for a hidden tab or a closed pane). Each is written only when it changed.
31export async function afterRefresh(io: Io) {
32 const cwd = await io.cwd()
33 if (cwd !== (await io.home())) await io.setHome(cwd)
34 if (!(await io.shown())) return
35 const plan = await loadMasterplan(io, cwd)
36 if (JSON.stringify(plan) !== JSON.stringify(await io.get())) await io.set(plan)
37}
38hooks/masterplan.ts 100 lines1import type { RollUp, RollUpRow, SessionRow } from '../types'
2import { type Grid, ago } from './sessions'
3
4// The plans tab's two groups above the sessions: the masterplan this session's folder belongs to (the roll-up of
5// `hal2-cli-plans masterplan --json`, plan 0275 D3) and the servants of this field (sessions whose `plans/LEAD` names
6// this field, D26). Pure: register.tsx and load.ts do the IO.
7
8const text = (v: unknown) => (typeof v === 'string' ? v : '')
9const count = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
10const strings = (v: unknown) => (Array.isArray(v) ? v.filter((s): s is string => typeof s === 'string') : [])
11
12const rowOf = (r: any): RollUpRow => ({
13 row: text(r.row),
14 status: text(r.status),
15 needs: strings(r.needs),
16 slug: text(r.slug),
17 done: count(r.done),
18 total: count(r.total),
19 next: r.next && typeof r.next === 'object' ? { number: text(r.next.number), title: text(r.next.title), status: text(r.next.status) } : null,
20 current_in: strings(r.current_in),
21})
22
23// The CLI's output as a roll-up; `null` (the CLI's word for "no masterplan"), anything unreadable or a JSON of another
24// shape gives null, never an error.
25export const parseRollUp = (out: string): RollUp | null => {
26 try {
27 const r = JSON.parse(out)
28 if (!r || typeof r !== 'object' || typeof r.slug !== 'string' || !Array.isArray(r.rows)) return null
29 const crop = r.crop && typeof r.crop === 'object' ? { name: text(r.crop.name), glyph: text(r.crop.glyph), code: text(r.crop.code) } : null
30 return {
31 id: count(r.id), slug: r.slug, title: text(r.title), crop, current_in: strings(r.current_in),
32 plans_done: count(r.plans_done), plans_total: count(r.plans_total), steps_done: count(r.steps_done), steps_total: count(r.steps_total),
33 plus: r.plus === true, rows: r.rows.filter((x: unknown) => x && typeof x === 'object').map(rowOf),
34 }
35 } catch {
36 return null
37 }
38}
39
40// `/…/worktree/<repo>/<NN>` is repo `<repo>` and field `<NN>`; any other folder has no field (the yard).
41export const fieldOf = (cwd: string) => {
42 const parts = cwd.split('/').filter(Boolean)
43 const at = parts.indexOf('worktree')
44 return at >= 0 && parts.length > at + 2 ? { repo: parts[at + 1]!, field: parts.slice(at + 2).join('/') } : { repo: parts.at(-1) ?? '', field: '' }
45}
46
47// A `plans/LEAD` line: `<foreman's field> <plan> <step>`; undefined when it has no field and plan.
48export const leadOf = (line: string) => {
49 const [field, plan, ...step] = line.trim().split(/\s+/)
50 return field && plan ? { field, plan, step: step.join(' ') } : undefined
51}
52
53// The servants of the field `home` stands in: sessions of the same repository whose marker names this field.
54export const servantsOf = (found: readonly SessionRow[], home: string) => {
55 const me = fieldOf(home)
56 if (!me.field) return []
57 return found
58 .map(s => ({ s, lead: leadOf(s.lead), at: fieldOf(s.cwd) }))
59 .filter(x => x.lead && x.lead.field === me.field && x.at.repo === me.repo && x.at.field !== me.field)
60 .map(x => ({ session: x.s, lead: x.lead!, field: x.at.field }))
61 .sort((a, b) => a.field.localeCompare(b.field))
62}
63
64export type Group = { title: string; grid: Grid }
65
66// `🌾 274 · 2/17 plans · 19/147+ steps`; a masterplan without a crop gets `◆`.
67export const lineOf = (m: RollUp) => `${m.crop?.glyph || '◆'} ${m.id} · ${m.plans_done}/${m.plans_total} plans · ${m.steps_done}/${m.steps_total}${m.plus ? '+' : ''} steps`
68
69export const groupsOf = (found: readonly SessionRow[], home: string, mp: RollUp | null, now: number): Group[] => {
70 const servants = servantsOf(found, home)
71 const groups: Group[] = []
72 if (mp && mp.rows.length) {
73 const fields = (r: RollUpRow) =>
74 [...new Set([...r.current_in, ...servants.filter(x => x.lead.plan === mp.slug && x.lead.step === r.row).map(x => x.field)])].sort().join(' ') || '-'
75 groups.push({
76 title: lineOf(mp),
77 grid: {
78 heads: ['Row', 'Plan', 'Field', 'Steps', 'Status', 'Needs'],
79 aligns: ['l', 'l', 'l', 'r', 'c', 'l'],
80 cells: mp.rows.map(r => [r.row, r.slug || '-', fields(r), `${r.done}/${r.total}`, r.status, r.needs.join(' ') || '-']),
81 ids: mp.rows.map(r => r.row),
82 },
83 })
84 }
85 if (servants.length) {
86 const cells = servants.map(({ session, lead, field }) => {
87 const row = mp && lead.plan === mp.slug ? mp.rows.find(r => r.row === lead.step) : undefined
88 const [plan, step, status, steps] = row
89 ? [row.slug || lead.plan, row.next ? `${row.next.number} ${row.next.title}`.trim() : '-', row.next ? row.next.status || 'open' : 'done', `${row.done}/${row.total}`]
90 : [lead.plan, lead.step || '-', '', '']
91 return [field, plan, step, status, steps, session.status || session.kind, ago(session.startedAt, now)]
92 })
93 groups.push({
94 title: 'servants of this field',
95 grid: { heads: ['Field', 'Plan', 'Step', 'Step status', 'Steps', 'State', 'Age'], aligns: ['l', 'l', 'l', 'c', 'r', 'c', 'r'], cells, ids: servants.map(x => x.session.id || String(x.session.pid)) },
96 })
97 }
98 return groups
99}
100hooks/jobs.ts 49 lines1import type { JobRow } from '../types'
2import { type Grid, ago } from './sessions'
3
4// What the engine's Stop hooks list: `background_tasks` (type, status, description and a type-specific field) and `session_crons`.
5export type Task = { id: string; type: string; status: string; description: string; command?: string; agent_type?: string; server?: string; tool?: string; name?: string }
6export type Cron = { id: string; schedule: string; recurring: boolean; prompt: string }
7
8const ENDED = ['completed', 'failed', 'killed', 'cancelled', 'canceled', 'stopped', 'done', 'error']
9const KEEP = 200
10
11const detailOf = (t: Task) => t.command ?? t.agent_type ?? t.name ?? [t.server, t.tool].filter(Boolean).join(' / ')
12
13// The jobs after a snapshot: each listed job added or updated (first seen kept), each earlier one it no longer lists ended.
14export const merge = (list: readonly JobRow[], tasks: readonly Task[], crons: readonly Cron[], now: number): JobRow[] => {
15 const seen = new Map(list.map(j => [j.id, j]))
16 const now_: JobRow[] = [
17 ...tasks.map(t => ({ id: t.id, type: t.type, status: t.status, text: t.description, detail: detailOf(t), ended: ENDED.includes(t.status.toLowerCase()) })),
18 ...crons.map(c => ({ id: c.id, type: 'cron', status: c.recurring ? 'recurring' : 'once', text: c.prompt, detail: c.schedule, ended: false })),
19 ].map(j => ({ ...j, first: seen.get(j.id)?.first ?? now, last: now }))
20 const listed = new Set(now_.map(j => j.id))
21 const gone = list.filter(j => !listed.has(j.id)).map(j => (j.ended ? j : { ...j, ended: true, status: 'ended', last: now }))
22 return [...gone, ...now_].sort((a, b) => a.first - b.first).slice(-KEEP)
23}
24
25// The types the list has, `all` first, then by name.
26export const typesOf = (list: readonly JobRow[]) => ['all', ...[...new Set(list.map(j => j.type))].sort()]
27
28// The type one step before or after `current` in the list's types (the ends stay); an unknown one starts at `all`.
29export const stepType = (list: readonly JobRow[], current: string, dir: 'prev' | 'next') => {
30 const types = typesOf(list)
31 const at = Math.max(0, types.indexOf(current))
32 return types[Math.min(types.length - 1, Math.max(0, at + (dir === 'next' ? 1 : -1)))]!
33}
34
35// The jobs' table for the chosen type: running ones first, then the newest.
36export const jobsGrid = (list: readonly JobRow[], type: string, now: number): Grid => {
37 const rows = list.filter(j => type === 'all' || j.type === type).sort((a, b) => Number(a.ended) - Number(b.ended) || b.first - a.first)
38 return {
39 heads: ['Type', 'Status', 'Job', 'Detail', 'Age'],
40 aligns: ['l', 'c', 'l', 'l', 'r'],
41 cells: rows.map(j => [j.type, j.status, j.text, j.detail, ago(j.first, now)]),
42 ids: rows.map(j => j.id),
43 }
44}
45
46// The line above the table: each type with its count, the chosen one in brackets.
47export const typesLine = (list: readonly JobRow[], type: string) =>
48 typesOf(list).map(t => { const n = t === 'all' ? list.length : list.filter(j => j.type === t).length; return t === type ? `[${t} ${n}]` : `${t} ${n}` }).join(' ')
49hooks/tabs.ts 23 lines1import type { JobRow, SessionRow, TableState, Tab } from '../types'
2import type { TableProps } from './grid'
3import { jobsGrid, typesLine } from './jobs'
4import { agentsGrid, asTable, ordered, plansGrid } from './sessions'
5
6export const TABS = [['subagents', 's'], ['plans', 'p'], ['agents', 'a'], ['jobs', 'b']] as const
7
8// What the body of a tab other than the subagents shows: its table's props, its plain-line grid and the empty text.
9export type Views = { plans: TableState | null; agents: TableState | null; jobs: TableState | null }
10export const bodyOf = (current: Exclude<Tab, 'subagents'>, found: readonly SessionRow[], jobs: readonly JobRow[], type: string, views: Views, epoch: number, now: number) => {
11 if (current === 'jobs') {
12 const grid = jobsGrid(jobs, type, now)
13 return { grid, empty: 'No background jobs in this view.', foot: `${jobs.filter(j => !j.ended).length} running · ${jobs.length} seen this session`, table: { ...asTable(grid, typesLine(jobs, type), views.jobs, grid.ids, 2, 'jobs', true), epoch } as TableProps }
14 }
15 const grid = current === 'plans' ? plansGrid(found, now) : agentsGrid(found, now)
16 const foot = `${ordered(found, current === 'plans').length} sessions · reloaded every 30 s · ● this session`
17 return { grid, empty: current === 'plans' ? 'No session has a current plan.' : 'No Claude sessions found.', foot, table: { ...asTable(grid, `${grid.cells.length} sessions · reloaded every 30 s · ● this session`, views[current], grid.ids, current === 'plans' ? 0 : 3), epoch } as TableProps }
18}
19
20// The count in a tab's label: the subagents, the running jobs, or the sessions the tab lists.
21export const count = (t: Tab, subagents: number, found: readonly SessionRow[], jobs: readonly JobRow[]) =>
22 t === 'subagents' ? subagents : t === 'jobs' ? jobs.filter(j => !j.ended).length : ordered(found, t === 'plans').length
23hooks/grid.ts 212 lines1import type { ClientPointerEvent } from 'claude-code'
2
3import type { TableState } from '../types'
4
5// What the hooks module hands the table: the header labels, the start widths and per row its cells, sort values,
6// color (`null`: the surface's default) and whether it still runs.
7export type TableRow = { id: string; cells: string[]; values: (string | number | null)[]; color: string | null; running: boolean }
8export type Align = 'l' | 'c' | 'r'
9// `sums` is the sum row for each filter; `aligns` the alignment of each column; `epoch` changes when the pane chose the view; `plain` (the tabs row's text) says there are no filters; `steps` says `h` `l` and the arrows step something outside the table (the jobs' type); `give` is the column that gives way first when the pane is narrow (1: the Subagent column).
10export type TableProps = { heads: string[]; widths: number[]; aligns: Align[]; rows: TableRow[]; sums: Record<Filter, string[]>; view: TableState | null; epoch?: number; plain?: string; give?: number; steps?: boolean }
11
12export type Sort = { col: number; dir: 'asc' | 'desc' }
13
14export const FILTERS = ['all', 'running', 'finished'] as const
15export type Filter = (typeof FILTERS)[number]
16
17// The table's own state: the sort, the widths, the filter, a border being dragged and what a button went down on.
18export type View = { sort?: Sort; widths: number[]; filter?: Filter; cursor?: string; drag?: { col: number; from: number; width: number }; press?: string }
19
20// What a new instance starts from: the kept state, its widths only while the columns are the same.
21export const restore = (props: TableProps): View => {
22 const kept = props.view
23 if (!kept) return { widths: props.widths }
24 const widths = kept.widths.length === props.widths.length ? kept.widths : props.widths
25 return { widths, filter: kept.filter, ...(kept.sort ? { sort: kept.sort } : {}), ...(kept.cursor ? { cursor: kept.cursor } : {}) }
26}
27
28// What is kept of a view: not a drag or a press under way.
29export const saved = (view: View): TableState => ({ sort: view.sort ?? null, widths: view.widths, filter: view.filter ?? 'all', cursor: view.cursor ?? null })
30
31// A post's data is the Client's own, but crosses as `unknown`: only a whole TableState is kept.
32// What the Client posts when `q` is pressed.
33export const isClose = (data: unknown) => typeof data === 'object' && data !== null && (data as { close?: unknown }).close === true
34
35// What the Client posts when `s` `p` or `a` is pressed: the tab the pane should show.
36export const tabOf = (data: unknown) => {
37 const t = (data as { tab?: unknown } | null)?.tab
38 return t === 'subagents' || t === 'plans' || t === 'agents' || t === 'jobs' ? t : undefined
39}
40
41// What the Client posts when `h` `l` or an arrow should step the jobs' type: which way.
42export const stepOf = (data: unknown) => {
43 const s = (data as { step?: unknown } | null)?.step
44 return s === 'prev' || s === 'next' ? s : undefined
45}
46
47export const isState = (data: unknown): data is TableState => {
48 const d = data as TableState | null
49 return (
50 typeof d === 'object' && d !== null &&
51 Array.isArray(d.widths) && d.widths.every(w => Number.isInteger(w) && w > 0) &&
52 (FILTERS as readonly string[]).includes(d.filter) &&
53 (d.cursor === undefined || d.cursor === null || typeof d.cursor === 'string') &&
54 (d.sort === null || (typeof d.sort === 'object' && Number.isInteger(d.sort.col) && (d.sort.dir === 'asc' || d.sort.dir === 'desc')))
55 )
56}
57
58// The Client's lines from the top: the filter's tabs, the table's top rule, its header, then a rule and the rows.
59export const TABS_Y = 0
60export const TOP_Y = 1
61export const HEAD_Y = 2
62
63export const MIN = 3
64
65export const pad = (s: string, n: number) => (s.length > n ? s.slice(0, Math.max(0, n - 1)) + '…' : s.padEnd(n))
66
67// A cell of `w` cells: a space each side, the text left, centered or right in between, cut with `…` when too long.
68export const inner = (text: string, w: number, align: Align = 'l') => {
69 const room = Math.max(0, w - 2)
70 const t = text.length > room ? text.slice(0, Math.max(0, room - 1)) + '…' : text
71 const gap = room - t.length
72 const [left, right] = align === 'r' ? [gap, 0] : align === 'c' ? [Math.floor(gap / 2), gap - Math.floor(gap / 2)] : [0, gap]
73 return ` ${' '.repeat(left)}${t}${' '.repeat(right)} `.slice(0, w)
74}
75
76// One line of the table: the cells between `│`.
77export const line = (cells: readonly string[], laid: readonly number[], aligns: readonly Align[]) =>
78 '│' + cells.map((c, i) => inner(c, laid[i] ?? c.length + 2, aligns[i])).join('│') + '│'
79
80// A rule across the table: the top, between rows, or the bottom.
81export const rule = (laid: readonly number[], kind: 'top' | 'mid' | 'bottom') => {
82 const [l, m, r] = kind === 'top' ? ['┌', '┬', '┐'] : kind === 'mid' ? ['├', '┼', '┤'] : ['└', '┴', '┘']
83 return l + laid.map(w => '─'.repeat(w)).join(m) + r
84}
85
86// The header: each label with the sort's mark on the sorted one, in its column's alignment.
87export const header = (heads: readonly string[], laid: readonly number[], aligns: readonly Align[], sort: Sort | undefined) =>
88 line(heads.map((label, i) => (sort?.col === i ? `${label} ${sort.dir === 'asc' ? '▲' : '▼'}` : label)), laid, aligns)
89
90// One click on a column's label: asc, then desc, then off; another column starts at asc.
91export const cycle = (sort: Sort | undefined, col: number): Sort | undefined =>
92 sort?.col !== col ? { col, dir: 'asc' } : sort.dir === 'asc' ? { col, dir: 'desc' } : undefined
93
94const compare = (a: string | number | null, b: string | number | null) =>
95 a === b ? 0 : a === null ? 1 : b === null ? -1 : a < b ? -1 : 1
96
97// The rows in the sort's order; off keeps their own order (when each started). `null` sorts last either way.
98export const order = (rows: readonly TableRow[], sort: Sort | undefined) => {
99 if (!sort) return [...rows]
100 const sign = sort.dir === 'asc' ? 1 : -1
101 return rows
102 .map((row, i) => ({ row, i }))
103 .sort((a, b) => {
104 const [x, y] = [a.row.values[sort.col] ?? null, b.row.values[sort.col] ?? null]
105 return (x === null || y === null ? compare(x, y) : sign * compare(x, y)) || a.i - b.i
106 })
107 .map(r => r.row)
108}
109
110export const shown = (rows: readonly TableRow[], filter: Filter = 'all') =>
111 filter === 'all' ? [...rows] : rows.filter(r => r.running === (filter === 'running'))
112
113// The tabs' labels with their counts, as drawn one cell apart from x 0.
114export const tabs = (rows: readonly TableRow[]) =>
115 FILTERS.map(f => ` ${f[0]!.toUpperCase()}${f.slice(1)} ${shown(rows, f).length} `)
116
117const tabAt = (labels: readonly string[], x: number) => {
118 let start = 0
119 for (const [i, label] of labels.entries()) {
120 if (x >= start && x < start + label.length) return FILTERS[i]
121 start += label.length + 1
122 }
123 return undefined
124}
125
126// The table's width: a border before each column and one after the last.
127export const widthOf = (widths: readonly number[]) => widths.reduce((sum, w) => sum + w + 1, 1)
128
129// The widths laid out in `columns` cells: too wide, the `give` column gives way down to MIN, then the widest others;
130// never stretched.
131export const fit = (widths: readonly number[], columns: number, give = 1) => {
132 const laid = [...widths]
133 if (!columns) return laid
134 while (widthOf(laid) > columns) {
135 const col = laid[give]! > MIN ? give : laid.indexOf(Math.max(...laid))
136 if (laid[col]! <= MIN) break
137 laid[col]!--
138 }
139 return laid
140}
141
142// What cell `x` of a line is: a column's cell, or the border right of it (`border`); the left edge is nobody's.
143export const hit = (widths: readonly number[], x: number) => {
144 let edge = 0
145 for (const [col, w] of widths.entries()) {
146 if (x > edge && x < edge + w + 1) return { col, border: false }
147 if (x === edge + w + 1) return { col, border: true }
148 edge += w + 1
149 }
150 return undefined
151}
152
153// What a cell is: a tab (`f:<filter>`), a column's label in the header (`h:<col>`), or a border right of a column on
154// any line of the table (`b:<col>`).
155const target = (e: ClientPointerEvent, laid: readonly number[], labels: readonly string[]) => {
156 if (e.y === TABS_Y) return tabAt(labels, e.x) && `f:${tabAt(labels, e.x)}`
157 const at = e.y >= TOP_Y ? hit(laid, e.x) : undefined
158 if (!at) return undefined
159 return at.border ? `b:${at.col}` : e.y === HEAD_Y ? `h:${at.col}` : undefined
160}
161
162// The view after one pointer event: a press on a border drags it, a press and release on one label sorts by it,
163// on one tab filters by it.
164export const point = (view: View, e: ClientPointerEvent, laid: readonly number[], labels: readonly string[]): View => {
165 const at = target(e, laid, labels)
166 if (e.type === 'down' && e.button === 'left') {
167 const col = Number(at?.slice(2))
168 if (at?.startsWith('b:')) return { ...view, press: undefined, drag: { col, from: e.x, width: laid[col]! } }
169 return { ...view, press: at, drag: undefined }
170 }
171 if (e.type === 'move' && view.drag) {
172 const { col, from, width } = view.drag
173 return { ...view, widths: view.widths.map((w, i) => (i === col ? Math.max(MIN, width + e.x - from) : w)) }
174 }
175 if (e.type === 'up' && (view.drag || view.press)) {
176 const done = { ...view, drag: undefined, press: undefined }
177 if (!view.press || at !== view.press) return done
178 if (at.startsWith('f:')) return { ...done, filter: at.slice(2) as Filter }
179 return { ...done, sort: cycle(view.sort, Number(at.slice(2))) }
180 }
181 return view
182}
183
184// What a key does to the filter and the cursor: `left` `right` `h` `l` the tab before or
185// after (the ends stay), `up` `down` `k` `j` the row before or after in `ids` (the shown rows' ids, in order); a new
186// filter drops the cursor. Anything else, and a move that changes nothing, returns the same state.
187export const step = <S extends { filter?: Filter; cursor?: string | undefined }>(state: S, k: string, ids: readonly string[]): S => {
188 const at = FILTERS.indexOf(state.filter ?? 'all')
189 const tab = k === 'left' || k === 'h' ? FILTERS[Math.max(0, at - 1)] : k === 'right' || k === 'l' ? FILTERS[Math.min(FILTERS.length - 1, at + 1)] : undefined
190 if (tab) return tab === (state.filter ?? 'all') ? state : { ...state, filter: tab, cursor: undefined }
191 const dir = k === 'down' || k === 'j' ? 1 : k === 'up' || k === 'k' ? -1 : 0
192 if (!dir || !ids.length) return state
193 const now = state.cursor === undefined ? -1 : ids.indexOf(state.cursor)
194 const to = now < 0 ? (dir > 0 ? 0 : ids.length - 1) : Math.min(ids.length - 1, Math.max(0, now + dir))
195 return ids[to] === state.cursor ? state : { ...state, cursor: ids[to] }
196}
197
198// The first row shown of `rows` with room for `room`: the newest unsorted, the first ones sorted, moved to keep the cursor in.
199export const first = (rows: readonly TableRow[], room: number, sorted: boolean, cursor: string | undefined) => {
200 const start = sorted ? 0 : rows.length - room
201 const at = rows.findIndex(r => r.id === cursor)
202 return at < 0 ? start : at < start ? at : at >= start + room ? at - room + 1 : start
203}
204
205// How many rows fit in `lines` lines: the tabs, the top rule, the header, a rule, the rows with a rule between each,
206// a rule, the sum row and the bottom rule; a last line counts those left out.
207export const fits = (rows: number, lines: number) => {
208 if (!lines) return rows
209 const all = Math.floor((lines - 6) / 2)
210 return rows <= all ? rows : Math.max(1, Math.floor((lines - 7) / 2))
211}
212hooks/table.ts 96 lines1import type { PluginOptions } from 'claude-code'
2
3import type { SubagentRow, TableState } from '../types'
4import { FILTERS, type TableProps, type TableRow } from './grid'
5
6// The window a share is counted against and the two thresholds, from the mod's options.
7export type Limits = { window: number; warn: number; alert: number }
8
9const WINDOWS: Record<string, number> = { '200k': 200_000, '1m': 1_000_000 }
10
11// `auto` takes the session's own window: no API gives a subagent's (research 0063, E9).
12export const limits = (options: PluginOptions, sessionWindow: number): Limits => ({
13 window: WINDOWS[String(options.window)] ?? sessionWindow,
14 warn: Number(options.warn_percent ?? 30) / 100,
15 alert: Number(options.alert_percent ?? 35) / 100,
16})
17
18const k = (n: number) => `${Math.round(n / 1000)}k`
19const pad = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '…' : s.padEnd(n))
20const label = (window: number) => (window >= 1_000_000 ? `${window / 1_000_000}M` : k(window))
21
22export const share = (row: SubagentRow, l: Limits) => row.peak / l.window
23
24export const tone = (row: SubagentRow, l: Limits): 'red' | 'yellow' | undefined =>
25 share(row, l) >= l.alert || row.compactions > 0 ? 'red' : share(row, l) >= l.warn ? 'yellow' : undefined
26
27// An idle agent can still be woken: only completed, failed and killed ones are finished (D16).
28export const running = (row: SubagentRow) => !['completed', 'failed', 'killed'].includes(row.status)
29
30const glyph = (row: SubagentRow) => (running(row) ? '●' : row.status === 'completed' ? '✓' : '✗')
31
32// The context tone wins; a running row is cyan otherwise, the state column keeps it recognisable (D14).
33export const color = (row: SubagentRow, l: Limits) => tone(row, l) ?? (running(row) ? 'cyan' : undefined)
34
35// `Plan 0149 row 232: Automations plugin` → `149-232: Automations plugin`; a part stays (`149-163 part 2: e2e`).
36export const short = (description: string) => {
37 const m = /^Plan 0*(\d+) row (\d+)( part \d+)?: (.*)$/.exec(description)
38 return m ? `${m[1]}-${m[2]}${m[3] ?? ''}: ${m[4]}` : description
39}
40
41// A duration as `mm:ss` (`125:30` past two hours).
42export const clock = (seconds: number) => `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`
43
44// The table's columns, their start widths (a cell is padded by a space each side) and how each is aligned.
45export const WIDTHS = [3, 34, 9, 10, 9, 7, 8, 9, 7, 8]
46export type Align = 'l' | 'c' | 'r'
47export const ALIGNS: Align[] = ['c', 'l', 'c', 'c', 'r', 'r', 'r', 'r', 'r', 'r']
48
49export const heads = (l: Limits) => [' ', 'Subagent', 'Model', 'Effort', 'Calls', 'Now', 'Peak', `%${label(l.window)}`, 'Cmp', 'Time']
50
51// A row whose transcript was too large to read shows `-` where it has no counts.
52export const cells = (row: SubagentRow, l: Limits) => {
53 const [calls, now, peak, pct, cmp, time] = row.large
54 ? ['-', '-', '-', '-', '-', '-']
55 : [String(row.calls), k(row.now), k(row.peak), (share(row, l) * 100).toFixed(1), String(row.compactions), clock(row.seconds)]
56 return [glyph(row), short(row.description || row.id), row.model || '-', row.effort || '-', calls, now, peak, pct, cmp, time]
57}
58
59// The sum row of some rows: how many, and the totals of the counts (a row without counts adds nothing).
60export const sums = (list: readonly SubagentRow[], l: Limits) => {
61 const add = (pick: (r: SubagentRow) => number) => list.reduce((sum, r) => sum + (r.large ? 0 : pick(r)), 0)
62 return ['Σ', `${list.length} subagent${list.length === 1 ? '' : 's'}`, '', '', String(add(r => r.calls)), k(add(r => r.now)), k(add(r => r.peak)), (add(r => share(r, l)) * 100).toFixed(1), String(add(r => r.compactions)), clock(add(r => r.seconds))]
63}
64
65// What a column sorts by: text, or a number; `null` (a large row's counts) sorts last either way. The state column
66// sorts running, completed, then failed and killed.
67export const values = (row: SubagentRow, l: Limits): (string | number | null)[] => {
68 const n = (v: number) => (row.large ? null : v)
69 return [running(row) ? 0 : row.status === 'completed' ? 1 : 2, short(row.description || row.id), row.model, row.effort, n(row.calls), n(row.now), n(row.peak), n(share(row, l)), n(row.compactions), n(row.seconds)]
70}
71
72// The plain lines of a surface that draws no table (VS Code, mobile).
73const PLAIN = [1, 36, 7, 7, 5, 6, 6, 5, 3, 6]
74const join = (texts: string[]) => texts.map((t, i) => (i === texts.length - 1 ? t : pad(t, PLAIN[i]!))).join(' ')
75
76export const head = (l: Limits) => join(heads(l))
77
78export const line = (row: SubagentRow, l: Limits) => join(cells(row, l))
79
80export const foot = (l: Limits) => `warn ${Math.round(l.warn * 100)}% · stop ${Math.round(l.alert * 100)}% of ${label(l.window)}`
81
82// The table's rows as it draws them, so a pane-level key moves over what is shown.
83export const tableRows = (list: readonly SubagentRow[], l: Limits): TableRow[] =>
84 list.map(row => ({ id: row.id, cells: cells(row, l), values: values(row, l), color: color(row, l) ?? null, running: running(row) }))
85
86// The subagents table's props for its `Client`: the rows, a sum row per filter, and the kept view.
87export const tableProps = (list: readonly SubagentRow[], l: Limits, view: TableState | null, epoch: number): TableProps => ({
88 heads: heads(l),
89 widths: WIDTHS,
90 aligns: ALIGNS,
91 rows: tableRows(list, l),
92 sums: Object.fromEntries(FILTERS.map(f => [f, sums(list.filter(r => f === 'all' || running(r) === (f === 'running')), l)])) as TableProps['sums'],
93 view,
94 epoch,
95})
96