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

A live pane of every subagent of the session: how much of its context window each one fills, with its model and effort, so a subagent running out of room shows before it compacts.
claude plugin marketplace add divramod/divramod-claude-code-mods
claude plugin install divramod-subagent-context@divramod-claude-code-mods
See the repository README for the requirements.
The pane opens with the session. Reopen it any time with the command /divramod-subagent-context.
One row per subagent, filled while it works (no polling), and from the transcripts for subagents that ran before the mod loaded:
| Column | Meaning |
|---|---|
| (state) | ● running, ✓ completed, ✗ failed or killed |
| Subagent | its description |
| Model, Effort | what it runs on |
| Calls | model responses so far |
| Now, Peak | context in use now, and its highest so far |
%1M / %200k | the peak as a share of the window |
| Cmp | compactions: its context fell below half of what it was |
| Min | minutes between its first and latest response |
Colors: a running row is cyan; a row turns yellow from the warn share of its peak and red from the alert share or after a compaction. A transcript over 4 MiB is not read: its counts show -.
Sort, resize and filter (in the terminal and desktop apps; VS Code and mobile show plain lines):
a, r, f.q while the table has the keyboard (Esc hands the keyboard back to the prompt; ctrl+x x also closes).Sort, widths and filter stay while the pane redraws or is closed and reopened.
Set them when installing, or later in /plugin under the mod's configuration. Installing prints "3 userConfig options not yet set": the defaults below apply until you set them.
| Option | Default | Meaning |
|---|---|---|
window | auto | The window a peak is counted against: auto takes the session's window, or 200k / 1m. Claude Code gives no window per subagent. |
warn_percent | 30 | A row turns yellow from this share of the window. |
alert_percent | 35 | A row turns red from this share of the window. |
claude plugin validate --strict plugins/divramod-subagent-context
claude plugin test plugins/divramod-subagent-context
bun plugins/divramod-subagent-context/scripts/screenshot.ts # regenerates docs/screenshot.svg
scripts/screenshot.ts draws the picture from fixture rows through the mod's own hooks/table.ts, so the picture cannot drift from the pane.
MIT. Not affiliated with Anthropic.
hooks/register.tsx 142 lines1import { atom, read, update } from 'claude-code'
2import type { AgentInfo, EngineInterface, Register, TurnStepInput, TurnUsage } from 'claude-code'
3
4import type { SubagentRow, TableState } from '../types'
5import { LIMIT, type File, idOf, metaOf, rowOf } from './backfill'
6import { adopt, fill, mark, record } from './rows'
7import { type TableProps, isClose, isState } from './grid'
8import { type Limits, WIDTHS, cells, color, foot, head, heads, limits, line, running, share, tone, values } from './table'
9
10const PANE = 'subagent-context'
11const rows = atom({ plugin: 'divramod-subagent-context', key: 'rows' } as const, [] as SubagentRow[])
12// The table's sort, widths and filter, so a redraw or a reopened pane shows them as they were (view-state rule).
13const view = atom({ plugin: 'divramod-subagent-context', key: 'view' } as const, null as TableState | null)
14
15// The agents `$.agent.list()` named, and the loop ids it does not (the engine's compaction and memory forks).
16type Known = { agents: Map<string, AgentInfo>; unlisted: Set<string> }
17
18async function agent($: EngineInterface, known: Known, id: string) {
19 if (!known.agents.has(id) && !known.unlisted.has(id)) {
20 for (const a of await $.agent.list()) known.agents.set(a.id, a)
21 if (!known.agents.has(id)) known.unlisted.add(id)
22 }
23 return known.agents.get(id)
24}
25
26async function seen($: EngineInterface, known: Known, l: Limits, e: TurnStepInput, usage: TurnUsage) {
27 const a = await agent($, known, e.agentId!)
28 if (!a) return
29 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() }
30 const was = (await read($, rows)).find(r => r.id === a.id)
31 const list = await update($, rows, list => record(list, step))
32 const row = list.find(r => r.id === a.id)!
33 if (tone(row, l) && (!was || tone(was, l) !== tone(row, l))) $.ui.status(`subagent at ${Math.round(share(row, l) * 100)}% context`)
34}
35
36const list = ($: EngineInterface, dir: string) => $.fs.list(dir).catch(() => [])
37
38// The transcripts of this session's subagents: `<config>/projects/*/<session>/subagents/agent-*.jsonl`, with their dirs.
39async function transcripts($: EngineInterface, config: string, session: string) {
40 const found: (File & { dir: string })[] = []
41 for (const project of await list($, `${config}/projects`)) {
42 const dir = `${config}/projects/${project.name}/${session}/subagents`
43 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 })
44 }
45 return found
46}
47
48// The subagents that ran before the mod loaded, from their transcripts; a row `turn.step` made already wins.
49async function earlier($: EngineInterface) {
50 const config = (await $.env.get('CLAUDE_CONFIG_DIR')) || `${await $.env.get('HOME')}/.claude`
51 const agents = new Map((await $.agent.list()).map(a => [a.id, a]))
52 const found: SubagentRow[] = []
53 for (const f of await transcripts($, config, await $.session.id())) {
54 const path = `${f.dir}/agent-${f.id}`
55 const meta = metaOf(await $.fs.read(`${path}.meta.json`).catch(() => undefined))
56 const text = f.size > LIMIT ? undefined : await $.fs.read(`${path}.jsonl`).catch(() => null)
57 if (text !== null) found.push(rowOf(f, meta, text, agents.get(f.id)))
58 }
59 await update($, rows, list => adopt(list, found))
60}
61
62export const register: Register = (on, options) => {
63 let l: Limits = limits(options, 1_000_000)
64 const known: Known = { agents: new Map(), unlisted: new Set() }
65
66 on('session.start', async ($, e, next) => {
67 const started = await next(e)
68 await $.command.register({ name: 'divramod-subagent-context', description: 'Show the context use of this session\'s subagents' })
69 l = limits(options, (await $.session.usage()).context.window)
70 void $.ui.open({ id: PANE, title: 'Subagents: context' })
71 void earlier($).catch(() => {})
72 return started
73 })
74
75 // Every response of a subagent's loop updates its row as it ends: no poll, no transcript read.
76 on('turn.step', async function* ($, e, next) {
77 const result = yield* next(e)
78 if (e.agentId && result.usage) await seen($, known, l, e, result.usage)
79 return result
80 })
81
82 // Every subagent of the session has a row from its start; its stop gives it its final status (D15's refresh).
83 on('classic.SubagentStart', async ($, e, next) => {
84 const result = await next(e)
85 const a = await agent($, known, e.agent_id)
86 const at = await $.clock.now()
87 await update($, rows, list => mark(list, { id: e.agent_id, description: a?.description ?? e.agent_type, status: 'running', at }))
88 return result
89 })
90
91 on('classic.SubagentStop', async ($, e, next) => {
92 const result = await next(e)
93 const listed = (await $.agent.list()).find(a => a.id === e.agent_id)?.status
94 const status = listed === 'failed' || listed === 'killed' ? listed : 'completed'
95 const at = await $.clock.now()
96 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))
97 return result
98 })
99
100 on('command.run', { command: 'divramod-subagent-context' }, async $ => {
101 await $.ui.open({ id: PANE, title: 'Subagents: context' })
102 return { text: 'Subagents pane opened.' }
103 })
104
105 on('ui.message', async ($, e, next) => {
106 const result = await next(e)
107 if (e.requestId === PANE && e.element === 'table' && isClose(e.data)) await $.ui.close({ id: PANE })
108 else if (e.requestId === PANE && e.element === 'table' && isState(e.data)) await update($, view, () => e.data as TableState)
109 return result
110 })
111
112 // The table is a `Client` where the surface draws one (terminal, desktop); elsewhere its rows as plain lines.
113 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
114 const el = $.ui.resolve(e)
115 const { Box, Text } = el
116 // VS Code's table names a Client it does not draw yet: the surface decides.
117 const Client = 'Client' in el && (e.surface === 'terminal' || e.surface === 'desktop') ? el.Client : undefined
118 const list = await read($, rows)
119 const table: TableProps = {
120 heads: heads(l),
121 widths: WIDTHS,
122 rows: list.map(row => ({ id: row.id, cells: cells(row, l), values: values(row, l), color: color(row, l) ?? null, running: running(row) })),
123 view: await read($, view),
124 }
125 const room = Math.max(1, (e.viewport?.rows ?? 24) - 4)
126 return (
127 <Box flexDirection="column">
128 {Client ? (
129 <Client key="table" module="./table-view.tsx" props={table} flexGrow={1} />
130 ) : (
131 <Box flexDirection="column">
132 <Text bold>{head(l)}</Text>
133 {list.length === 0 && <Text dimColor>No subagents yet.</Text>}
134 {list.slice(-room).map(row => <Text color={color(row, l)}>{line(row, l)}</Text>)}
135 </Box>
136 )}
137 <Text dimColor>{`${foot(l)} · live`}</Text>
138 </Box>
139 )
140 })
141}
142hooks/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, minutes: 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, minutes: Math.round((f.last - started) / 6_000) / 10, 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 minutes: Math.round((s.at - (was?.started ?? s.at)) / 6_000) / 10,
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, minutes: 0, started: m.at, mtime: m.at, status: m.status }]
45hooks/grid.ts 152 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 TableProps = { heads: string[]; widths: number[]; rows: TableRow[]; view: TableState | null }
9
10export type Sort = { col: number; dir: 'asc' | 'desc' }
11
12export const FILTERS = ['all', 'running', 'finished'] as const
13export type Filter = (typeof FILTERS)[number]
14
15// The table's own state: the sort, the widths, the filter, a border being dragged and what a button went down on.
16export type View = { sort?: Sort; widths: number[]; filter?: Filter; drag?: { col: number; from: number; width: number }; press?: string }
17
18// What a new instance starts from: the kept state, its widths only while the columns are the same.
19export const restore = (props: TableProps): View => {
20 const kept = props.view
21 if (!kept) return { widths: props.widths }
22 const widths = kept.widths.length === props.widths.length ? kept.widths : props.widths
23 return { widths, filter: kept.filter, ...(kept.sort ? { sort: kept.sort } : {}) }
24}
25
26// What is kept of a view: not a drag or a press under way.
27export const saved = (view: View): TableState => ({ sort: view.sort ?? null, widths: view.widths, filter: view.filter ?? 'all' })
28
29// A post's data is the Client's own, but crosses as `unknown`: only a whole TableState is kept.
30// What the Client posts when `q` is pressed.
31export const isClose = (data: unknown) => typeof data === 'object' && data !== null && (data as { close?: unknown }).close === true
32
33export const isState = (data: unknown): data is TableState => {
34 const d = data as TableState | null
35 return (
36 typeof d === 'object' && d !== null &&
37 Array.isArray(d.widths) && d.widths.every(w => Number.isInteger(w) && w > 0) &&
38 (FILTERS as readonly string[]).includes(d.filter) &&
39 (d.sort === null || (typeof d.sort === 'object' && Number.isInteger(d.sort.col) && (d.sort.dir === 'asc' || d.sort.dir === 'desc')))
40 )
41}
42
43// The Client's rows: the filter's tabs, the header, then the table's rows.
44export const TABS_Y = 0
45export const HEAD_Y = 1
46
47export const MIN = 3
48
49export const pad = (s: string, n: number) => (s.length > n ? s.slice(0, Math.max(0, n - 1)) + '…' : s.padEnd(n))
50
51// One click on a column's label: asc, then desc, then off; another column starts at asc.
52export const cycle = (sort: Sort | undefined, col: number): Sort | undefined =>
53 sort?.col !== col ? { col, dir: 'asc' } : sort.dir === 'asc' ? { col, dir: 'desc' } : undefined
54
55const compare = (a: string | number | null, b: string | number | null) =>
56 a === b ? 0 : a === null ? 1 : b === null ? -1 : a < b ? -1 : 1
57
58// The rows in the sort's order; off keeps their own order (when each started). `null` sorts last either way.
59export const order = (rows: readonly TableRow[], sort: Sort | undefined) => {
60 if (!sort) return [...rows]
61 const sign = sort.dir === 'asc' ? 1 : -1
62 return rows
63 .map((row, i) => ({ row, i }))
64 .sort((a, b) => {
65 const [x, y] = [a.row.values[sort.col] ?? null, b.row.values[sort.col] ?? null]
66 return (x === null || y === null ? compare(x, y) : sign * compare(x, y)) || a.i - b.i
67 })
68 .map(r => r.row)
69}
70
71export const shown = (rows: readonly TableRow[], filter: Filter = 'all') =>
72 filter === 'all' ? [...rows] : rows.filter(r => r.running === (filter === 'running'))
73
74// The tabs' labels with their counts, as drawn one cell apart from x 0.
75export const tabs = (rows: readonly TableRow[]) =>
76 FILTERS.map(f => ` ${f[0]!.toUpperCase()}${f.slice(1)} ${shown(rows, f).length} `)
77
78const tabAt = (labels: readonly string[], x: number) => {
79 let start = 0
80 for (const [i, label] of labels.entries()) {
81 if (x >= start && x < start + label.length) return FILTERS[i]
82 start += label.length + 1
83 }
84 return undefined
85}
86
87// The widths laid out in `columns` cells: the last column takes the rest, at least MIN; unmeasured (0) keeps them.
88export const fit = (widths: readonly number[], columns: number) => {
89 if (!columns) return [...widths]
90 const used = widths.slice(0, -1).reduce((sum, w) => sum + w + 1, 0)
91 return [...widths.slice(0, -1), Math.max(MIN, columns - used)]
92}
93
94// What cell `x` of the header is: a column's label, or the border right of it (the last column has none).
95export const hit = (widths: readonly number[], x: number) => {
96 let start = 0
97 for (const [col, w] of widths.entries()) {
98 if (x >= start && x < start + w) return { col, border: false }
99 if (x === start + w && col < widths.length - 1) return { col, border: true }
100 start += w + 1
101 }
102 return undefined
103}
104
105// What a cell of the top rows is: a tab (`f:<filter>`), a column's label (`h:<col>`) or the border right of it (`b:<col>`).
106const target = (e: ClientPointerEvent, laid: readonly number[], labels: readonly string[]) => {
107 if (e.y === TABS_Y) return tabAt(labels, e.x) && `f:${tabAt(labels, e.x)}`
108 const at = e.y === HEAD_Y ? hit(laid, e.x) : undefined
109 return at && `${at.border ? 'b' : 'h'}:${at.col}`
110}
111
112// The view after one pointer event: a press on a border drags it, a press and release on one label sorts by it,
113// on one tab filters by it.
114export const point = (view: View, e: ClientPointerEvent, laid: readonly number[], labels: readonly string[]): View => {
115 const at = target(e, laid, labels)
116 if (e.type === 'down' && e.button === 'left') {
117 const col = Number(at?.slice(2))
118 if (at?.startsWith('b:')) return { ...view, press: undefined, drag: { col, from: e.x, width: laid[col]! } }
119 return { ...view, press: at, drag: undefined }
120 }
121 if (e.type === 'move' && view.drag) {
122 const { col, from, width } = view.drag
123 return { ...view, widths: view.widths.map((w, i) => (i === col ? Math.max(MIN, width + e.x - from) : w)) }
124 }
125 if (e.type === 'up' && (view.drag || view.press)) {
126 const done = { ...view, drag: undefined, press: undefined }
127 if (!view.press || at !== view.press) return done
128 if (at.startsWith('f:')) return { ...done, filter: at.slice(2) as Filter }
129 return { ...done, sort: cycle(view.sort, Number(at.slice(2))) }
130 }
131 return view
132}
133
134// The keys `a` `r` `f` choose the filter.
135export const key = (view: View, k: string): View => {
136 const filter = FILTERS.find(f => f[0] === k)
137 return filter && filter !== (view.filter ?? 'all') ? { ...view, filter } : view
138}
139
140// The header's labels in their widths, `│` between them, the sorted one marked ▲ or ▼.
141export const header = (heads: readonly string[], laid: readonly number[], sort: Sort | undefined) =>
142 heads
143 .map((label, i) => {
144 const w = laid[i] ?? label.length
145 const mark = sort?.dir === 'asc' ? '▲' : '▼'
146 return sort?.col !== i ? pad(label, w) : w < 3 ? pad(mark, w) : pad(label, w - 2) + ' ' + mark
147 })
148 .join('│')
149
150export const row = (cells: readonly string[], laid: readonly number[]) =>
151 cells.map((cell, i) => pad(cell, laid[i] ?? cell.length)).join(' ')
152hooks/table.ts 61 lines1import type { PluginOptions } from 'claude-code'
2
3import type { SubagentRow } from '../types'
4
5// The window a share is counted against and the two thresholds, from the mod's options.
6export type Limits = { window: number; warn: number; alert: number }
7
8const WINDOWS: Record<string, number> = { '200k': 200_000, '1m': 1_000_000 }
9
10// `auto` takes the session's own window: no API gives a subagent's (research 0063, E9).
11export const limits = (options: PluginOptions, sessionWindow: number): Limits => ({
12 window: WINDOWS[String(options.window)] ?? sessionWindow,
13 warn: Number(options.warn_percent ?? 30) / 100,
14 alert: Number(options.alert_percent ?? 35) / 100,
15})
16
17const k = (n: number) => `${Math.round(n / 1000)}k`
18const pad = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '…' : s.padEnd(n))
19const label = (window: number) => (window >= 1_000_000 ? `${window / 1_000_000}M` : k(window))
20
21export const share = (row: SubagentRow, l: Limits) => row.peak / l.window
22
23export const tone = (row: SubagentRow, l: Limits): 'red' | 'yellow' | undefined =>
24 share(row, l) >= l.alert || row.compactions > 0 ? 'red' : share(row, l) >= l.warn ? 'yellow' : undefined
25
26// An idle agent can still be woken: only completed, failed and killed ones are finished (D16).
27export const running = (row: SubagentRow) => !['completed', 'failed', 'killed'].includes(row.status)
28
29const glyph = (row: SubagentRow) => (running(row) ? '●' : row.status === 'completed' ? '✓' : '✗')
30
31// The context tone wins; a running row is cyan otherwise, the state column keeps it recognisable (D14).
32export const color = (row: SubagentRow, l: Limits) => tone(row, l) ?? (running(row) ? 'cyan' : undefined)
33
34// The table's columns and their start widths; the last one takes the rest of the room.
35export const WIDTHS = [1, 36, 7, 7, 5, 6, 6, 5, 3, 4]
36
37export const heads = (l: Limits) => [' ', 'Subagent', 'Model', 'Effort', 'Calls', 'Now', 'Peak', `%${label(l.window)}`, 'Cmp', 'Min']
38
39// A row whose transcript was too large to read shows `-` where it has no counts.
40export const cells = (row: SubagentRow, l: Limits) => {
41 const [calls, now, peak, pct, cmp, min] = row.large
42 ? ['-', '-', '-', '-', '-', '-']
43 : [String(row.calls), k(row.now), k(row.peak), (share(row, l) * 100).toFixed(1), String(row.compactions), String(row.minutes)]
44 return [glyph(row), row.description || row.id, row.model || '-', row.effort || '-', calls, now, peak, pct, cmp, min]
45}
46
47// What a column sorts by: text, or a number; `null` (a large row's counts) sorts last either way. The state column
48// sorts running, completed, then failed and killed.
49export const values = (row: SubagentRow, l: Limits): (string | number | null)[] => {
50 const n = (v: number) => (row.large ? null : v)
51 return [running(row) ? 0 : row.status === 'completed' ? 1 : 2, 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.minutes)]
52}
53
54const join = (texts: string[]) => texts.map((t, i) => (i === texts.length - 1 ? t : pad(t, WIDTHS[i]!))).join(' ')
55
56export const head = (l: Limits) => join(heads(l))
57
58export const line = (row: SubagentRow, l: Limits) => join(cells(row, l))
59
60export const foot = (l: Limits) => `warn ${Math.round(l.warn * 100)}% · stop ${Math.round(l.alert * 100)}% of ${label(l.window)}`
61hooks/table-view.tsx 68 lines1import type { ClientSurface } from 'claude-code'
2
3import { type TableProps, type View, FILTERS, fit, header, key, order, point, restore, row, saved, shown, tabs } from './grid'
4
5// Each instance's latest props: its listeners are set once and must see the tabs as last drawn.
6const latest = new WeakMap<object, TableProps>()
7
8// What each instance last posted (or started from), as JSON.
9const posted = new WeakMap<object, string>()
10
11// The next view; its kept part goes to the hooks module when it differs from the last posted, a drag's once it ends.
12const commit = (surface: ClientSurface<View>, next: View) => {
13 surface.setState(next)
14 const kept = saved(next)
15 if (next.drag || JSON.stringify(kept) === posted.get(surface)) return
16 posted.set(surface, JSON.stringify(kept))
17 surface.post(kept)
18}
19
20// The subagents' table, drawn on the surface: a click on a tab or `a` `r` `f` filters, `q` closes, a click on a header label
21// sorts, a drag on a border resizes (D14, D16).
22export default function TableView(props: TableProps, surface: ClientSurface<View>) {
23 const { Box, Text } = surface.elements
24 latest.set(surface, props)
25 if (!surface.state) {
26 surface.setState(restore(props))
27 posted.set(surface, JSON.stringify(saved(restore(props))))
28 surface.onPointer(e => {
29 const view = surface.state
30 if (!view) return
31 const next = point(view, e, fit(view.widths, surface.columns), tabs((latest.get(surface) ?? props).rows))
32 if (next !== view) commit(surface, next)
33 })
34 surface.onKey(e => {
35 // `q` closes the pane: the hooks module does it, a Client cannot (D18).
36 if (e.key === 'q' && !e.ctrl && !e.meta) return surface.post({ close: true })
37 const view = surface.state
38 const next = view && !e.ctrl && !e.meta ? key(view, e.key) : view
39 if (next && next !== view) commit(surface, next)
40 })
41 }
42 const view = surface.state ?? restore(props)
43 const filter = view.filter ?? 'all'
44 const laid = fit(view.widths, surface.columns)
45 const rows = order(shown(props.rows, filter), view.sort)
46 // As many rows as fit below the tabs and the header: the newest unsorted, the first ones sorted.
47 const room = surface.rows > 2 ? surface.rows - 2 : rows.length
48 const visible = view.sort ? rows.slice(0, room) : rows.slice(-room)
49 return (
50 <Box flexDirection="column">
51 <Box flexDirection="row" gap={1}>
52 {tabs(props.rows).map((label, i) => (
53 <Text key={FILTERS[i]} inverse={FILTERS[i] === filter} dimColor={FILTERS[i] !== filter}>
54 {label}
55 </Text>
56 ))}
57 </Box>
58 <Text bold>{header(props.heads, laid, view.sort)}</Text>
59 {rows.length === 0 && <Text dimColor>{props.rows.length ? 'No subagents in this view.' : 'No subagents yet.'}</Text>}
60 {visible.map(r => (
61 <Text key={r.id} color={r.color ?? undefined}>
62 {row(r.cells, laid)}
63 </Text>
64 ))}
65 </Box>
66 )
67}
68hooks/transcript.ts 45 lines1import { family } from './rows'
2
3// What one subagent transcript (`agent-<id>.jsonl`) says: its requests' fills, compactions and span.
4export type Fold = { calls: number; now: number; peak: number; compactions: number; first: number; last: number; model: string }
5
6type Line = {
7 type?: string
8 subtype?: string
9 timestamp?: string
10 message?: { model?: string; usage?: Record<string, number | undefined> }
11}
12
13const KEYS = ['input_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens']
14
15// A compaction is a `compact_boundary` entry or, as `record` counts it, a fill that drops by more than half.
16export const fold = (text: string): Fold => {
17 const out: Fold = { calls: 0, now: 0, peak: 0, compactions: 0, first: 0, last: 0, model: '' }
18 for (const raw of text.split('\n')) {
19 let e: Line
20 try {
21 e = JSON.parse(raw)
22 } catch {
23 continue
24 }
25 const at = Date.parse(e.timestamp ?? '')
26 if (at) {
27 out.first ||= at
28 out.last = at
29 }
30 if (e.type === 'system' && String(e.subtype ?? '').includes('compact')) out.compactions++
31 if (e.type !== 'assistant') continue
32 const usage = e.message?.usage ?? {}
33 const fill = KEYS.reduce((sum, k) => sum + (usage[k] ?? 0), 0)
34 if (!fill) continue
35 if (out.now && fill < out.now / 2) out.compactions++
36 out.calls++
37 out.now = fill
38 out.peak = Math.max(out.peak, fill)
39 out.model = e.message?.model || out.model
40 }
41 return out
42}
43
44export const modelOf = (f: Fold, meta: { model?: string }) => family(meta.model || f.model)
45types/index.d.ts 32 lines1export type SubagentRow = {
2 id: string
3 description: string
4 model: string
5 effort: string
6 calls: number
7 now: number
8 peak: number
9 compactions: number
10 minutes: number
11 // When its first and its latest step were seen (`$.clock.now()` ms); rows keep the order of `started`.
12 started: number
13 mtime: number
14 // Its AgentStatus as `$.agent.list()` last gave it.
15 status: string
16 // Its transcript was over 4 MiB and was not read; calls, now, peak and compactions are 0 then.
17 large?: boolean
18}
19
20// The table's sort, column widths and filter, as its Client last posted them; kept for the session.
21export type TableState = {
22 sort: { col: number; dir: 'asc' | 'desc' } | null
23 widths: number[]
24 filter: 'all' | 'running' | 'finished'
25}
26
27declare module 'claude-code' {
28 interface PluginState {
29 'divramod-subagent-context': { rows: SubagentRow[]; view: TableState | null }
30 }
31}
32