SLOPSHOPPER

Subagent Context

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

newpanebandcommandstatusprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · divramod-subagent-context
│ ┃ divramod subagents context ✕ › fix the failing auth test and add an audit log call │ ┃ divramod subagents context │ ┃ s: subagents 0 p: plans 0 a: agents 0 b ⏺ Read(src/auth.ts) │ ┃ ▣ client module ./table-view.tsx ⎿ Read 6 lines │ ┃ warn 30% · stop 35% of 200k · live ⏺ Update(src/auth.ts) │ ┃ h: prev l: next j: down k: up · q: close ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /divramod-subagent-context │ ┃ ⎿ divramod-subagent-context: Subagents pane opened. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ s: subagents ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
s: subagents
Pane · divramod subagents context
divramod subagents context s: subagents 0 p: plans 0 a: agents 0 b: jobs 0 ▣ client module ./table-view.tsx warn 30% · stop 35% of 200k · live h: prev l: next j: down k: up · q: close
README

subagent-context

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

The pane: one row per subagent, running rows cyan, a warning row yellow, a compacted one red

Use

  • Open: the 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).
  • Keyboard: the pane takes the keyboard only when you open it. While it holds it, its title line starts with [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).
  • Close: q while the pane has the keyboard, the close button, or ctrl+x x.
  • Tabs: subagents (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.
  • Plans tab, two groups above its list (plan 0275), each only where it has rows:
  • The masterplan of this session's folder: one line <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.
  • Servants of this field: the sessions of this repository whose 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.
  • Remembered rows: kept in ~/.claude/divramod-subagent-context/rows-<folder>.json (300 rows, 14 days) so a /clear keeps them.
  • Options (/plugin, the mod's configuration): window (auto, 200k, 1m), warn_percent (30), alert_percent (35).

Module map

  • .claude-plugin/plugin.json: the manifest, its version and userConfig options.
  • hooks/hooks.json: the hooks module, register.tsx.
  • hooks/, the mod's source; each *.test.ts(x) beside the file it tests, run by claude plugin test:
  • register.tsx: registers the pane, the command, the button and the hooks; does the IO.
  • keyboard.ts: the keyboard rules (never opens or takes the keyboard by itself, marker, idle release), pure.
  • pane-view.tsx, table-view.tsx: the pane and its table.
  • grid.ts, table.ts, tabs.ts: columns, widths, sort, filter and the four tabs.
  • rows.ts, transcript.ts, backfill.ts: a subagent's row from its steps and its transcript.
  • sessions.ts, jobs.ts: the plans, agents and jobs tabs' rows.
  • live.ts (its test live.test.ts): the session check behind the plans and agents tabs: one 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.
  • load.ts (its test load.test.ts): what follows each refresh inside its turn, through closures over $ 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.
  • store.ts: the remembered rows.
  • testkit.ts: what the tests share.
  • types/index.d.ts: the mod's own types; tsconfig.json extends the engine's types, which claude writes to the ignored .claude-plugin/types/.
  • scripts/screenshot.ts: draws docs/screenshot.svg from fixture rows through hooks/table.ts.

Develop

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).

Source 17 files
hooks/register.tsx 391 lines
1import { 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}
391
hooks/backfill.ts 32 lines
1import 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}
32
hooks/rows.ts 45 lines
1import 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 }]
45
hooks/store.ts 32 lines
1import 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}
32
hooks/sessions.ts 101 lines
1import 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})
101
hooks/live.ts 90 lines
1import 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}
90
hooks/load.ts 38 lines
1import 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}
38
hooks/masterplan.ts 100 lines
1import 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}
100
hooks/jobs.ts 49 lines
1import 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('  ')
49
hooks/tabs.ts 23 lines
1import 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
23
hooks/grid.ts 212 lines
1import 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}
212
hooks/table.ts 96 lines
1import 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