SLOPSHOPPER

subagent-ledger

Shows each subagent of the session with its turns, time, model and tokens, the costliest first, yellow while it runs and green once it answered, and marks one…

newcommandstatusagents
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · subagent-ledger
› fix the failing auth test and add an audit log call ⏺ 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 › /subagent-ledger ⎿ subagent-ledger: on · limit 200k · no subagent ran yet ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

subagent-ledger

You fan a task out to five subagents, and afterwards /cost gives one total: which agent read the whole repository, and which one ran for four minutes, stays hidden. This mod shows what each subagent of the session spent: its turns, its wall-clock time and its tokens, one row per agent, the costliest first.

What it does

  1. The mod hooks agent.spawn, which names what the subagent is: its agent type (Explore, general-purpose, a plugin's agent, fork) and the one-line description of its task. It keeps that against the agent id the spawn answers with, together with the model the spawn resolved, and draws the row as running at once.
  2. It hooks turn.complete of every subagent loop. That turn is the subagent's answer, so it ends the run: done when the turn ended with an answer, stopped when it was interrupted, refused or ended on an API error. Each of those turns adds one turn, its durationMs, and its tokens: the input, the output, the cache reads and the cache writes the engine reports for that turn. The turn also names the model that answered it, which replaces the spawn's model, drawn without the vendor prefix and without the date of a full id, so claude-haiku-4-5-20251001 reads as haiku-4-5. A main-loop turn is not counted.
  3. It hooks turn.step, one model request, to draw a subagent as running again when its loop runs after it answered, as it does when SendMessage resumes it. A main-loop step is not read.
  4. While the sidebar is open, the ledger is one subagents section that stays for the session and is rewritten at each spawn, at each subagent turn and when a subagent runs again:

subagents port the config loader to the new schem… · opus-5 · 7 turn · 4m 10s · T 260k · I 5k · O 9k · CR 210k · CW 36k · running find the parser · haiku-4-5 · 3 turn · 42s · T 81k · I 2k · O 1k · CR 70k · CW 8k · done read the tests · haiku-4-5 · 1 turn · 9s · T 30k · I 1k · O 500 · CR 24k · CW 5k · stopped 2 more · 150k

A row's name is the description of its task, cut at 40 characters; a spawn that named no description shows its agent type there instead. Its tokens read as T the total, I the input the cache did not serve, O the output, CR the cache reads and CW the cache writes; the last four add up to T. A row ends with its status word: running yellow while its subagent runs, done green once it answered, and stopped faint when its run ended without an answer. The model is coloured by family (opus red, fable yellow, sonnet green, haiku faint). The T total is yellow from 80% of the limit (200k tokens by default) and red once the subagent reached it, in every status. The label, the turns, the time and the tokens by kind stay in the default colour. The rows past the third are one faint line with their tokens added up, so a fan-out of twenty agents still holds four rows.

  1. With the sidebar closed, or without that mod installed, the totals go to the status line instead:

subagent-ledger: 4 subagent · 12 turn · 3m 10s · 210k

  1. /subagent-ledger prints the totals and every subagent of the session, the costliest first, whatever the sidebar shows.

Command

/subagent-ledger on or off, the limit, the totals and every subagent /subagent-ledger on | off on by default /subagent-ledger limit 500 a subagent over 500k tokens is drawn red; 1 to 10000, 200 by default, stored across sessions

Install

claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install subagent-ledger@kilimcininkoroglu-mods

Function hooks are early access. Claude Code 2.1.288 and later load them by default, so there is nothing to switch on.

After installing

  1. Restart Claude Code.
  2. Install the sidebar mod for the per-agent rows. Without it the mod shows the totals on the status line.

What it can reach

Validated with claude plugin validate on Claude Code 2.1.283:

❯ ./register.ts hooks: session.start, command.run{command=subagent-ledger}, agent.spawn, turn.step, turn.complete ❯ ./register.ts calls: $.command.register, $.sidebar.clear (via clearShown), $.sidebar.set (via show), $.store.get (via readLimit, readSettings), $.store.set (via setEnabled, setLimit), $.ui.status (via clearShown, show)

Reach L0, draws and remembers.

  1. Reads: each spawn's agent type, description and resolved model, each subagent turn's id, duration, end reason, model and token counts, and the agent id of each model request. It reads no prompt, no answer, no file and no tool result.
  2. Runs: nothing
  3. Sends: nothing to the model; the rows and the status line are for the person only
  4. Persists: in $.store, the on/off setting and the limit; the ledger itself lives in memory and ends with the session
  5. Hostile input: the only text drawn is the agent type, the spawn's own description, cut to 40 characters, and the model id the engine reports

Limits

  • The ledger counts turns as they end. A subagent still running is in the rows, yellow, with what it has spent so far.
  • A run ends in the ledger only at the subagent's turn.complete. A subagent whose run ends without one stays yellow. Whether every kind of end (a TaskStop, a killed background agent) raises that event is not measured.
  • A subagent whose spawn this mod did not see (one started before it loaded) is counted under the label agent, because only the spawn names the type. Its model is empty until one of its turns names one, and a row without a model draws without that field.
  • The tokens are the engine's own per-turn counts. A turn that got no response carries none and adds zero.
  • Tokens are not dollars. What a cache read costs against a subscription's limits is not documented.
  • The ledger is per session. A restart starts empty, and the counts are not written to disk.

Development

make install # eslint, typescript-eslint, typescript make lint # complexity limit 10, the build fails above it make typecheck # needs .claude/types/ from /plugin-types make validate make test # claude plugin test

Source 2 files
hooks/register.ts 166 lines
1import type { EngineInterface, Register } from 'claude-code'
2import { addSplit, DEFAULT_LIMIT_K, limitOf, limitText, NO_SPLIT, sidebarLines, statusAfter, statusText, tokensOf, totalText, type Run, type Usage } from './ledger.ts'
3
4const ENABLED_KEY = 'enabled'
5const LIMIT_KEY = 'limit'
6
7const USAGE = 'expects nothing (the status), on, off or limit <k>'
8
9/** The section this mod owns in the shared sidebar. */
10const SECTION = { consumer: 'subagent-ledger', key: 'subagents' }
11
12/** The on/off setting, the limit in thousands of tokens, and one run per subagent of this session. */
13type State = { enabled: boolean; limitK: number; runs: Map<string, Run> }
14
15/**
16 * The ledger the person watches: a section of the shared sidebar, rewritten at each subagent turn. With
17 * the sidebar closed, and without that mod installed, the totals go to the status line instead.
18 */
19async function show($: EngineInterface, state: State): Promise<void> {
20  const runs = [...state.runs.values()]
21  try {
22    if (await $.sidebar.set({ ...SECTION, title: 'subagents', lines: sidebarLines(runs, state.limitK), until: 'session', order: 20 })) {
23      $.ui.status(undefined)
24      return
25    }
26  } catch {
27    // The sidebar mod is not installed.
28  }
29  $.ui.status(totalText(runs))
30}
31
32/** Takes the ledger down, because a session with no subagent has nothing to show. */
33async function clearShown($: EngineInterface): Promise<void> {
34  try {
35    await $.sidebar.clear(SECTION)
36  } catch {
37    // The sidebar mod is not installed.
38  }
39  $.ui.status(undefined)
40}
41
42/** The run of one subagent, started with what its spawn said it is. */
43function runOf(state: State, agentId: string): Run {
44  const had = state.runs.get(agentId)
45  if (had !== undefined) return had
46  const made: Run = { type: 'agent', description: '', model: '', turns: 0, ms: 0, tokens: 0, split: NO_SPLIT, status: 'running' }
47  state.runs.set(agentId, made)
48  return made
49}
50
51/** Draws a subagent's row as running again, once per run: a SendMessage resumes a subagent that had answered. */
52async function markRunning($: EngineInterface, state: State, agentId: string): Promise<void> {
53  const run = runOf(state, agentId)
54  if (run.status === 'running') return
55  run.status = 'running'
56  await show($, state)
57}
58
59/** The fields of a subagent's `turn.complete` the ledger counts. */
60type Ended = { agentId: string; durationMs: number; reason: string; usage?: Usage }
61
62/** Counts one turn of one subagent (its turns, duration, tokens and model) and where the turn left it. */
63async function countTurn($: EngineInterface, state: State, e: Ended): Promise<void> {
64  const run = runOf(state, e.agentId)
65  const usage = e.usage
66  run.turns += 1
67  run.ms += e.durationMs
68  run.tokens += tokensOf(usage)
69  run.split = addSplit(run.split, usage)
70  run.status = statusAfter(e.reason)
71  // The turn's own model is what answered; the spawn's resolved model stands until a turn names one.
72  if (usage?.model !== undefined) run.model = usage.model
73  await show($, state)
74}
75
76/** Writes the limit the person set; it holds across sessions, because it lives in $.store. */
77async function setLimit($: EngineInterface, state: State, arg: string): Promise<string> {
78  const limit = limitOf(arg)
79  if (limit === undefined) return limitText(undefined)
80  state.limitK = limit
81  await $.store.set(LIMIT_KEY, limit)
82  await show($, state)
83  return limitText(limit)
84}
85
86/** The stored limit, or the default when nothing is stored and when the stored value is not one. */
87async function readLimit($: EngineInterface): Promise<number> {
88  const stored = await $.store.get(LIMIT_KEY)
89  return typeof stored === 'number' && limitOf(String(stored)) !== undefined ? stored : DEFAULT_LIMIT_K
90}
91
92/**
93 * Reads the on/off setting and the limit from the store, which every window shares, so a change made in
94 * another window applies here at the next hook that acts on it. `turn.step` runs on every model request
95 * and never reads the store; it uses the copy that `agent.spawn` and `turn.complete` refresh. A mod turned
96 * off there takes the ledger down here too, as `off` does.
97 */
98async function readSettings($: EngineInterface, state: State): Promise<void> {
99  const was = state.enabled
100  state.enabled = (await $.store.get(ENABLED_KEY)) !== false
101  state.limitK = await readLimit($)
102  if (was && !state.enabled) await clearShown($)
103}
104
105async function setEnabled($: EngineInterface, state: State, on: boolean): Promise<string> {
106  state.enabled = on
107  await $.store.set(ENABLED_KEY, on)
108  if (on) await show($, state)
109  else await clearShown($)
110  return on ? 'on: each subagent is counted' : 'off: subagents are not counted; the counts stay'
111}
112
113async function runCommand($: EngineInterface, state: State, args: string): Promise<string> {
114  const arg = args.trim()
115  await readSettings($, state)
116  if (arg === 'on' || arg === 'off') return setEnabled($, state, arg === 'on')
117  if (arg.startsWith('limit')) return setLimit($, state, arg.slice(5).trim())
118  if (arg !== '' && arg !== 'status') return USAGE
119  return statusText(state.enabled, [...state.runs.values()], state.limitK)
120}
121
122export const register: Register = on => {
123  const state: State = { enabled: true, limitK: DEFAULT_LIMIT_K, runs: new Map() }
124
125  on('session.start', async ($, e, next) => {
126    const r = await next(e)
127    await readSettings($, state)
128    await $.command.register({
129      name: 'subagent-ledger',
130      description: 'What each subagent of this session spent: status, on, off, limit <k> (subagent-ledger)',
131      argumentHint: '[on | off | limit <k>]',
132      immediate: true,
133    })
134    return r
135  })
136
137  // The engine prints the plugin name in front of command text and the status line, so the texts do not repeat it.
138  on('command.run', { command: 'subagent-ledger' }, async ($, e) => ({ text: await runCommand($, state, String(e.args ?? '')) }))
139
140  // The spawn names what the subagent is and draws it running; only its own turns say what it spent.
141  on('agent.spawn', async ($, e, next) => {
142    const r = await next(e)
143    if (r.agentId === undefined) return r
144    await readSettings($, state)
145    if (!state.enabled) return r
146    state.runs.set(r.agentId, { type: e.subagentType, description: e.description, model: r.model, turns: 0, ms: 0, tokens: 0, split: NO_SPLIT, status: 'running' })
147    await show($, state)
148    return r
149  })
150
151  // A subagent's model request means its loop runs, also after a SendMessage resumed it.
152  on('turn.step', async function* ($, e, next) {
153    if (state.enabled && e.agentId !== undefined) await markRunning($, state, e.agentId)
154    return yield* next(e)
155  })
156
157  on('turn.complete', async ($, e, next) => {
158    const r = await next(e)
159    if (e.agentId === undefined) return r
160    await readSettings($, state)
161    if (!state.enabled) return r
162    await countTurn($, state, { agentId: e.agentId, durationMs: e.durationMs, reason: e.reason, usage: e.usage })
163    return r
164  })
165}
166
hooks/ledger.ts 191 lines
1/** What each subagent of the session spent, and how the pane reads it. */
2
3/** The tokens one subagent may spend before its row turns red, in thousands. */
4export const DEFAULT_LIMIT_K = 200
5
6/** The band `/subagent-ledger limit <k>` takes; a value outside it is refused, never clamped. */
7export const MIN_LIMIT_K = 1
8export const MAX_LIMIT_K = 10_000
9
10/** The pane draws this many subagents, the costliest first; the rest are counted. */
11export const ROWS = 3
12
13/** Where a subagent stands: its loop runs, it answered, or its run ended without an answer. */
14export type Status = 'running' | 'done' | 'stopped'
15
16/** The tokens of a run by kind: the input the cache did not serve, the output, the cache reads and the cache writes. */
17export type Split = { input: number; output: number; cacheRead: number; cacheWrite: number }
18
19export const NO_SPLIT: Split = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
20
21/**
22 * One subagent's run: what it is, what it runs on, what it has spent so far, and where it stands. `tokens`
23 * is the sum of `split`, kept whole because the order, the limit and the totals read it.
24 */
25export type Run = { type: string; description: string; model: string; turns: number; ms: number; tokens: number; split: Split; status: Status }
26
27/** The status a subagent's `turn.complete` leaves: done on an answer, stopped on an interrupt, an error or a refusal. */
28export function statusAfter(reason: string): Status {
29  return reason === 'answer' ? 'done' : 'stopped'
30}
31
32/** The token counts of one turn, and the model that answered it, as `turn.complete` carries them. */
33export type Usage = {
34  input_tokens?: number
35  output_tokens?: number
36  cache_read_input_tokens?: number
37  cache_creation_input_tokens?: number
38  model?: string
39}
40
41/**
42 * The model id as one row names it: without the vendor prefix and without the date a full id carries, so
43 * `claude-haiku-4-5-20251001` reads as `haiku-4-5`. An id of another shape is drawn as it is.
44 */
45export function shortModel(model: string): string {
46  return model.replace(/^claude-/, '').replace(/-\d{8}$/, '')
47}
48
49/** Every token of one turn: what was sent, what was cached, and what came back. */
50export function tokensOf(usage: Usage | undefined): number {
51  if (usage === undefined) return 0
52  return (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0)
53}
54
55/** A run's split with one more turn's tokens added. */
56export function addSplit(split: Split, usage: Usage | undefined): Split {
57  if (usage === undefined) return split
58  return {
59    input: split.input + (usage.input_tokens ?? 0),
60    output: split.output + (usage.output_tokens ?? 0),
61    cacheRead: split.cacheRead + (usage.cache_read_input_tokens ?? 0),
62    cacheWrite: split.cacheWrite + (usage.cache_creation_input_tokens ?? 0),
63  }
64}
65
66/** The row's token part after the total: the input, the output, the cache reads and the cache writes. */
67function kindsText(split: Split): string {
68  return ` · I ${fmtTok(split.input)} · O ${fmtTok(split.output)} · CR ${fmtTok(split.cacheRead)} · CW ${fmtTok(split.cacheWrite)}`
69}
70
71/** The row's token part: the total, then the input, the output, the cache reads and the cache writes. */
72export function splitText(tokens: number, split: Split): string {
73  return `T ${fmtTok(tokens)}${kindsText(split)}`
74}
75
76export function fmtTok(n: number): string {
77  if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`
78  return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
79}
80
81/** A duration in seconds under a minute, else minutes and seconds. */
82export function fmtDuration(ms: number): string {
83  const total = Math.round(ms / 1000)
84  if (total < 60) return `${total}s`
85  return `${Math.floor(total / 60)}m ${total % 60}s`
86}
87
88/** How wide a row's label may be, so one long description does not push the numbers off. */
89const MAX_LABEL = 40
90
91/** The label of one run: the work it was given, or its agent type when the spawn named none. */
92export function labelOf(run: Run): string {
93  const full = run.description === '' ? run.type : run.description
94  return full.length <= MAX_LABEL ? full : `${full.slice(0, MAX_LABEL - 1)}…`
95}
96
97/** One row of the pane: what the subagent is, what it runs on, its turns, its time and its tokens by kind. */
98export function rowText(run: Run): string {
99  const model = run.model === '' ? '' : `${shortModel(run.model)} · `
100  const stopped = run.status === 'stopped' ? ' · stopped' : ''
101  return `${labelOf(run)} · ${model}${run.turns} turn · ${fmtDuration(run.ms)} · ${splitText(run.tokens, run.split)}${stopped}`
102}
103
104/** The runs the pane draws, the costliest first. */
105export function ranked(runs: readonly Run[]): Run[] {
106  return [...runs].sort((a, b) => b.tokens - a.tokens || b.ms - a.ms)
107}
108
109/** A sidebar line and a part of one, as the sidebar mod's contract names them. */
110type Kind = 'ok' | 'warn' | 'error' | 'dim'
111export type Part = { text: string; kind?: Kind }
112/** A line; `parts` colour pieces of it, and `text` holds the whole line for a sidebar that draws no parts. */
113export type Line = { text: string; kind?: Kind; parts?: Part[] }
114
115const part = (text: string, kind: Kind | undefined): Part => (kind === undefined ? { text } : { text, kind })
116
117/** A line made of parts, its `text` their texts joined. */
118const partsLine = (parts: Part[]): Line => ({ text: parts.map(p => p.text).join(''), parts })
119
120/** The model's colour by family, the dearest the warmest: opus red, fable yellow, sonnet green, haiku faint. */
121export function modelTone(model: string): Kind | undefined {
122  const families: [RegExp, Kind][] = [[/opus/i, 'error'], [/fable/i, 'warn'], [/sonnet/i, 'ok'], [/haiku/i, 'dim']]
123  return families.find(([family]) => family.test(model))?.[1]
124}
125
126/** The total's colour: red at or past the limit, yellow from 80% of it, the default under that. */
127export function tokensTone(tokens: number, limitK: number): Kind | undefined {
128  if (tokens >= limitK * 1000) return 'error'
129  return tokens >= limitK * 800 ? 'warn' : undefined
130}
131
132/** The status word's colour: yellow while running, green when done, faint when stopped. */
133const STATUS_TONE: Record<Status, Kind> = { running: 'warn', done: 'ok', stopped: 'dim' }
134
135/**
136 * One row of the pane in parts: the model coloured by its family, the total by the limit and the status
137 * word by the status; the label, the turns, the time and the tokens by kind stay in the default colour.
138 */
139export function rowLine(run: Run, limitK: number): Line {
140  const model = run.model === '' ? [] : [part(shortModel(run.model), modelTone(run.model)), part(' · ', undefined)]
141  return partsLine([
142    part(`${labelOf(run)} · `, undefined),
143    ...model,
144    part(`${run.turns} turn · ${fmtDuration(run.ms)} · `, undefined),
145    part(`T ${fmtTok(run.tokens)}`, tokensTone(run.tokens, limitK)),
146    part(kindsText(run.split), undefined),
147    part(' · ', undefined),
148    part(run.status, STATUS_TONE[run.status]),
149  ])
150}
151
152/**
153 * The pane's lines: one row per subagent, the costliest first, each drawn by `rowLine`. The rows past
154 * the third are one faint line, so a fan-out of twenty agents still holds four rows.
155 */
156export function sidebarLines(runs: readonly Run[], limitK: number): Line[] {
157  const order = ranked(runs)
158  const lines: Line[] = order.slice(0, ROWS).map(run => rowLine(run, limitK))
159  const rest = order.length - ROWS
160  if (rest > 0) lines.push({ text: `${rest} more · ${fmtTok(order.slice(ROWS).reduce((sum, r) => sum + r.tokens, 0))}`, kind: 'dim' })
161  return lines
162}
163
164/** The status line, drawn while the sidebar is closed: the totals in one line. */
165export function totalText(runs: readonly Run[]): string | undefined {
166  if (runs.length === 0) return undefined
167  const turns = runs.reduce((sum, r) => sum + r.turns, 0)
168  const ms = runs.reduce((sum, r) => sum + r.ms, 0)
169  const tokens = runs.reduce((sum, r) => sum + r.tokens, 0)
170  return `${runs.length} subagent · ${turns} turn · ${fmtDuration(ms)} · ${fmtTok(tokens)}`
171}
172
173/** The limit a `/subagent-ledger limit <word>` argument names, or undefined when it is not one. */
174export function limitOf(arg: string): number | undefined {
175  if (!/^\d{1,5}$/.test(arg)) return undefined
176  const n = Number(arg)
177  return n >= MIN_LIMIT_K && n <= MAX_LIMIT_K ? n : undefined
178}
179
180/** The answer of `/subagent-ledger limit <k>`, or of an argument it cannot read. */
181export function limitText(limit: number | undefined): string {
182  if (limit === undefined) return `limit expects a whole number of thousands of tokens from ${MIN_LIMIT_K} to ${MAX_LIMIT_K}`
183  return `limit ${limit}k: a subagent over ${limit}k tokens is drawn red`
184}
185
186/** The `/subagent-ledger` answer: the setting, the limit, and every subagent of this session. */
187export function statusText(enabled: boolean, runs: readonly Run[], limitK: number): string {
188  const head = `${enabled ? 'on' : 'off'} · limit ${limitK}k · ${totalText(runs) ?? 'no subagent ran yet'}`
189  return ranked(runs).length === 0 ? head : `${head}\n${ranked(runs).map(rowText).join('\n')}`
190}
191