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.

newpanecommandstatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · divramod-subagent-context
│ ┃ Subagents: context ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./table-view.tsx │ ┃ warn 30% · stop 35% of 200k · live ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ 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. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Subagents: context
▣ client module ./table-view.tsx warn 30% · stop 35% of 200k · live
README

divramod-subagent-context

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.

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

Install

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

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:

ColumnMeaning
(state)● running, ✓ completed, ✗ failed or killed
Subagentits description
Model, Effortwhat it runs on
Callsmodel responses so far
Now, Peakcontext in use now, and its highest so far
%1M / %200kthe peak as a share of the window
Cmpcompactions: its context fell below half of what it was
Minminutes 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):

  • Sort: click a header: ascending, descending, off.
  • Resize: drag a border between two headers.
  • Filter: click the tabs All, Running, Finished, or press a, r, f.
  • Close: press 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.

Options

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.

OptionDefaultMeaning
windowautoThe window a peak is counted against: auto takes the session's window, or 200k / 1m. Claude Code gives no window per subagent.
warn_percent30A row turns yellow from this share of the window.
alert_percent35A row turns red from this share of the window.

Develop

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.

License

MIT. Not affiliated with Anthropic.

Source 8 files
hooks/register.tsx 142 lines
1import { 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}
142
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, 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}
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    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 }]
45
hooks/grid.ts 152 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 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(' ')
152
hooks/table.ts 61 lines
1import 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)}`
61
hooks/table-view.tsx 68 lines
1import 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}
68
hooks/transcript.ts 45 lines
1import { 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)
45
types/index.d.ts 32 lines
1export 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