Live band and pane for Claude's plan tasks and subagents: task and helper progress bars, a subagent tree, tokens, est. cost, time and tool counts.

Install (Claude Code 2.1.287 or later): /plugin install progress --marketplace samuelbfernandes/moddeck
See Claude's plan progress and every helper (subagent) it runs, live, and roughly what it costs.
◈ ▰▰▰▱▱ 3/5 tasks · helpers: 3 done · 41.2k tok · est. $0.38 · 1m12s [details] [hide]. While helpers run it grows to a few rows: a bar of helpers finished (▰▱▱ 1/3 helpers), the task bar, and up to three running helpers with elapsed time and tool count, nested under the helper that started them. Quiet until there is a task list or a helper./progress): each helper with type, model, status, context bar, tokens, est. cost, elapsed time and tool count; a helper started by another helper is indented under it. Every running helper has a Stop button (it calls Claude's own TaskStop tool, so permission rules still apply)./progress tree prints the same tree, one line per helper./progress band shows or hides the band (remembered). Slash commands run locally and cost no tokens.turn.step usage, replaced by its turn.complete total. "tok" = input + output + cache read + cache write.PRICES in hooks/lib.ts, matched by model-id substring); override with the priceOverrides setting, e.g. sonnet=3/15,opus=5/25/0.5/6.25 (in/out[/cacheRead/cacheWrite]). Context bar = last request / assumed window (200k, 1M for [1m]).newTodos, TaskCreate task.id, TaskUpdate status, TaskList). No other source exists.agent.spawn says which agent started a helper (parentAgentId); the engine's agent list fills gaps. Helpers the main conversation started are top level./plugin disable progress (or /progress band to hide just the band).
hooks/register.tsx 436 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentRow } from '../types'
5import {
6 addStep,
7 applyTaskTool,
8 bandText,
9 busyBar,
10 countTool,
11 elapsedOf,
12 emptyBar,
13 endByStatus,
14 endTurn,
15 fillBar,
16 fmtCost,
17 fmtDuration,
18 fmtTokens,
19 fromModelUsage,
20 helperText,
21 indentOf,
22 isStalled,
23 isTaskTool,
24 markRunning,
25 parseOverrides,
26 rowCost,
27 rowFill,
28 rowHasUsage,
29 rowUsage,
30 stallMsOf,
31 summarize,
32 taskText,
33 toolsText,
34 totalTokens,
35 touch,
36 treeLines,
37 treeOrder,
38} from './lib'
39import type { Price } from './lib'
40
41const PANE = 'progress'
42const COMMAND = 'progress'
43const STORE_HIDDEN = 'hidden'
44/** Running helpers the band lists under its line (the pane has them all). */
45const BAND_ROWS = 3
46
47const agents = atom({ plugin: 'progress', key: 'agents' } as const, [])
48const isHidden = atom({ plugin: 'progress', key: 'isHidden' } as const, null)
49const tasks = atom({ plugin: 'progress', key: 'tasks' } as const, [])
50const tick = atom({ plugin: 'progress', key: 'tick' } as const, 0)
51
52type Prices = readonly Price[]
53type Timer = { cancel: () => void }
54
55// The once-a-second timer lives and dies with this module instance: a hot
56// reload drops the engine's timers too, and the next agent event restarts it.
57let timer: Timer | null = null
58
59/** Never let bookkeeping break a hook that other plugins and the engine depend on (fail-open). */
60async function safely(work: () => Promise<unknown>) {
61 try {
62 await work()
63 } catch {
64 /* a missed update only costs a stale row */
65 }
66}
67
68async function change($: EngineInterface, id: string, init: Partial<AgentRow>, fn: (r: AgentRow) => AgentRow) {
69 const now = await $.clock.now()
70 await update($, agents, rows => touch(rows, id, init, now, fn))
71}
72
73/** Mirrors the engine's own agent list into our rows: ends the ones it says ended, fills missing names. */
74async function sync($: EngineInterface) {
75 const [list, now] = await Promise.all([$.agent.list(), $.clock.now()])
76 await update($, agents, rows =>
77 rows.map(r => {
78 const info = list.find(a => a.id === r.id)
79 if (info === undefined) return r
80 const labelled = r.label === '' ? { ...r, label: info.description || info.name || info.type, type: r.type || info.type } : r
81 const named = r.parentId === undefined && info.parentId ? { ...labelled, parentId: info.parentId } : labelled
82 if (info.status === 'completed') return endByStatus(named, true, now)
83 if (info.status === 'failed' || info.status === 'killed') return endByStatus(named, false, now)
84 return named
85 }),
86 )
87}
88
89async function onTick($: EngineInterface, stallMs: number) {
90 await safely(() => sync($))
91 await safely(() => noteStalls($, stallMs))
92 const rows = await read($, agents)
93 await update($, tick, n => (n ?? 0) + 1)
94 if (!rows.some(r => r.status === 'running')) {
95 timer?.cancel()
96 timer = null
97 }
98}
99
100function ensureTimer($: EngineInterface, stallMs: number) {
101 timer ??= $.clock.every(1000, () => void onTick($, stallMs))
102}
103
104/** Says once, in a toast, that a running helper has gone quiet; the pane row shows it and keeps its Stop button. */
105async function noteStalls($: EngineInterface, stallMs: number) {
106 if (stallMs <= 0) return
107 const now = await $.clock.now()
108 const quiet = (await read($, agents)).filter(r => isStalled(r, now, stallMs) && r.stallNoted !== true)
109 if (quiet.length === 0) return
110 await update($, agents, rows => rows.map(r => (quiet.some(q => q.id === r.id) ? { ...r, stallNoted: true } : r)))
111 for (const r of quiet) {
112 $.ui.toast(`${r.label || r.id.slice(0, 8)} looks stalled · no activity for ${fmtDuration(now - (r.lastAt ?? r.startedAt))} · Stop it in the Subagents pane`, { timeoutMs: 10_000 })
113 }
114}
115
116async function setHidden($: EngineInterface, hidden: boolean) {
117 await update($, isHidden, () => hidden)
118 await safely(() => $.store.set(STORE_HIDDEN, hidden))
119}
120
121async function readHidden($: EngineInterface): Promise<boolean> {
122 return (await read($, isHidden)) ?? (await $.store.get(STORE_HIDDEN)) === true
123}
124
125async function describeAgent($: EngineInterface, id: string, model: string): Promise<Partial<AgentRow>> {
126 try {
127 const info = (await $.agent.list()).find(a => a.id === id)
128 return { label: info?.description || info?.name || info?.type || '', type: info?.type ?? '', model, parentId: info?.parentId }
129 } catch {
130 return { model }
131 }
132}
133
134const glyph = (r: AgentRow, stalled: boolean) => (stalled ? '⚠' : r.status === 'running' ? '▶' : r.status === 'finished' ? '✓' : '✗')
135const statusColor = (r: AgentRow, stalled: boolean) => (stalled ? 'warning' : r.status === 'failed' ? 'error' : r.status === 'finished' ? 'success' : undefined)
136
137/** "claude-sonnet-4-5-20250929" -> "sonnet-4-5". */
138const shortModel = (model: string) => model.replace(/^claude-/, '').replace(/-\d{8}$/, '')
139
140function barFor(r: AgentRow, width: number, now: number): string {
141 if (r.status === 'finished') return fillBar(1, width) // done: full, whatever the context fill was
142 const fill = rowFill(r) // failed or stopped: where it stopped
143 if (fill !== null) return fillBar(fill, width)
144 if (r.status === 'running' && !rowHasUsage(r)) return busyBar(width, Math.floor(elapsedOf(r, now) / 1000))
145 return emptyBar(width)
146}
147
148function figures(r: AgentRow, now: number, overrides: Prices): string {
149 const time = `${fmtDuration(elapsedOf(r, now))} · ${toolsText(r)}`
150 if (!rowHasUsage(r)) return `no usage reported · ${time}`
151 const fill = rowFill(r)
152 const ctx = fill === null ? 'ctx n/a' : `ctx ${Math.round(fill * 100)}%`
153 const cost = rowCost(r, overrides)
154 const u = rowUsage(r)
155 return `${ctx} · ${fmtTokens(totalTokens(u))} tok (out ${fmtTokens(u.output)}) · ${cost === null ? 'est. n/a' : fmtCost(cost)} · ${time}`
156}
157
158/**
159 * The Stop button: asks the engine's own TaskStop tool to end this helper (the same call the model would
160 * make; the permission rules still apply). The row turns "failed" by itself once the engine reports it killed.
161 */
162async function stopAgent($: EngineInterface, id: string, name: string) {
163 try {
164 await $.tool.call({ tool: 'TaskStop', task_id: id } as never)
165 $.ui.toast(`Stopped ${name}`)
166 await safely(() => sync($))
167 } catch {
168 $.ui.toast(`Could not stop ${name} · try /tasks`)
169 }
170}
171
172const PROGRESS_FAILED = '/progress could not run; see the debug log.'
173
174/** What `/progress` does: toggle the pane; `band` toggles the line above the prompt, `tree` lists the helpers as a tree. */
175async function runAgentsInfo($: EngineInterface, args: string): Promise<string> {
176 // on / off: the band above the prompt (the part that works by itself); moddeck's switch runs these.
177 if (args.trim() === 'on' || args.trim() === 'off') {
178 await setHidden($, args.trim() === 'off')
179 return args.trim() === 'off' ? 'Subagent band off. /progress on shows it again.' : 'Subagent band on: it appears while subagents or plan tasks run.'
180 }
181 if (args.trim() === 'band') {
182 const hidden = await readHidden($)
183 await setHidden($, !hidden)
184 return hidden ? 'Subagent band shown.' : 'Subagent band hidden. /progress band shows it again.'
185 }
186 if (args.trim() === 'tree') {
187 const lines = treeLines(await read($, agents), await $.clock.now())
188 return lines.length === 0 ? 'No subagents have run in this session.' : lines.join('\n')
189 }
190 const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
191 if (isOpen) {
192 await $.ui.close({ id: PANE })
193 return 'Subagent pane closed.'
194 }
195 await $.ui.open({ id: PANE, title: 'Subagents' })
196 return 'Subagent pane opened (token counts are real, costs are est.).'
197}
198
199/** What the hooks below this one drew in the band. Only one tree can fill the band, so a mod that draws returns its row with this under it (never instead of it), and the other mods' rows stay visible. */
200async function drawnBelow<E>(next: (e: E) => unknown, e: E): Promise<object | undefined> {
201 try {
202 const tree = await next(e)
203 return tree !== null && typeof tree === 'object' ? tree : undefined
204 } catch {
205 return undefined
206 }
207}
208
209export const register: Register = (on, options) => {
210 const overrides: Prices = parseOverrides(options.priceOverrides)
211 const stallMs = stallMsOf(options.stallMinutes)
212
213 on('session.start', async ($, e, next) => {
214 await $.command.register({
215 name: COMMAND,
216 description: 'Show or hide the pane listing every subagent with tokens and est. cost (`on`/`off` turns the line above the prompt on or off, `tree` prints them nested)',
217 argumentHint: '[on|off|band|tree]',
218 })
219 // The band's on/off is kept across sessions: load it into the state the moddeck switch reads.
220 await safely(async () => {
221 const hidden = (await $.store.get(STORE_HIDDEN)) === true
222 await update($, isHidden, () => hidden)
223 })
224 return next(e)
225 })
226
227 on('command.run', { command: COMMAND }, async ($, e) => ({ text: await runAgentsInfo($, e.args) })).catch(
228 // Fail-open: a failing toggle just says so; nothing else depends on this command.
229 () => ({ text: PROGRESS_FAILED }),
230 )
231
232 // A subagent is born: this is where its description, type and model are known.
233 // Fail-open: if the bookkeeping throws, the spawn goes on untouched.
234 on('agent.spawn', async ($, e, next) => {
235 const started = await next(e)
236 if (started.deny === undefined && started.agentId !== undefined) {
237 const id = started.agentId
238 const init = {
239 label: e.description || e.name || e.subagentType,
240 type: e.isTeammate ? 'teammate' : e.subagentType,
241 model: started.model || e.model || e.parentModel,
242 // Who started it: spawn events carry the id of the loop they happened in (absent for the main loop).
243 parentId: e.parentAgentId,
244 }
245 await safely(async () => {
246 await change($, id, init, r => ({
247 ...r,
248 label: init.label || r.label,
249 type: init.type || r.type,
250 model: init.model || r.model,
251 ...(init.parentId ? { parentId: init.parentId } : {}),
252 }))
253 ensureTimer($, stallMs)
254 })
255 }
256 return started
257 }).catch(($, e, next) => next(e))
258
259 // Every model request of a subagent: tokens arrive in the step's result.
260 on('turn.step', async function* ($, e, next) {
261 const id = e.agentId
262 if (id === undefined) return yield* next(e)
263
264 await safely(async () => {
265 const now = await $.clock.now()
266 const known = (await read($, agents)).some(r => r.id === id)
267 const init: Partial<AgentRow> = known ? {} : await describeAgent($, id, e.model)
268 await update($, agents, rows => touch(rows, id, init, now, r => markRunning(r.model === '' ? { ...r, model: e.model } : r, now)))
269 ensureTimer($, stallMs)
270 })
271
272 const response = yield* next(e)
273
274 const usage = response.usage
275 if (usage !== null) {
276 await safely(() => change($, id, {}, r => addStep(r, e.turnId, fromModelUsage(usage), usage.model)))
277 }
278 return response
279 })
280
281 // Tool counts per helper and Claude's own task list, both read from tool.call events after the tool ran.
282 // A helper's tool call (agentId set) only bumps its counter; the main loop's task tools update the task bar.
283 // Fail-open: it observes only, never refuses; all bookkeeping is in `safely`, so only a failing `next` can
284 // throw, and then the handler replays what next settled to (nothing runs twice).
285 on('tool.call', async ($, e, next) => {
286 const ran = await next(e)
287 if (ran.deny !== undefined) return ran
288 await safely(async () => {
289 if (e.agentId !== undefined) {
290 const id = e.agentId
291 const at = await $.clock.now()
292 await update($, agents, rows => countTool(rows, id, at))
293 } else if (isTaskTool(e.tool)) {
294 const result = (ran as { result?: unknown }).result
295 await update($, tasks, items => applyTaskTool(items ?? [], e.tool, e, result))
296 }
297 })
298 return ran
299 }).catch(($, e, next) => next(e))
300
301 // The subagent's run ended; its usage is the turn's total, replacing the steps' sum.
302 on('turn.complete', async ($, e, next) => {
303 const id = e.agentId
304 if (id !== undefined) {
305 await safely(async () => {
306 const now = await $.clock.now()
307 const total = e.usage === undefined ? null : fromModelUsage(e.usage)
308 const model = e.usage?.model ?? ''
309 await update($, agents, rows =>
310 touch(rows, id, {}, now, r => ({ ...endTurn(r, e.reason === 'answer', e.durationMs, total, now), model: model || r.model })),
311 )
312 })
313 }
314 return next(e)
315 })
316
317 // --- the band above the prompt ------------------------------------------
318 // Idle (nothing running): ONE line, e.g. "◈ ▰▰▰▱▱ 3/5 tasks · helpers: 3 done · 41.2k tok · est. $0.38 · 1m12s".
319 // While helpers run: the same line, a progress row (helpers and tasks) and up to three running helpers.
320
321 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
322 const rows = await read($, agents)
323 const todo = await read($, tasks)
324 const hidden = await readHidden($)
325 if (e.props.hasSurvey || hidden || (rows.length === 0 && todo.length === 0)) return next(e)
326
327 await read($, tick)
328 const now = await $.clock.now()
329 const summary = summarize(rows, now, overrides)
330 const isBusy = summary.running > 0
331 const taskLine = taskText(todo)
332 const tasksDone = todo.length > 0 && todo.every(t => t.status === 'completed')
333 const { Box, Button, Text } = $.ui.resolve(e)
334
335 const line = (
336 <Box key={isBusy ? 'savvy-line' : 'savvy-band'}>
337 <Text dimColor>◈ </Text>
338 {!isBusy && taskLine !== '' && <Text color={tasksDone ? 'success' : undefined}>{taskLine} </Text>}
339 {!isBusy && taskLine !== '' && rows.length > 0 && <Text dimColor>· </Text>}
340 {rows.length > 0 && <Text color={summary.failed > 0 ? 'warning' : undefined}>{bandText(summary)} </Text>}
341 {rows.length > 0 && (
342 <Button key="details" plain onPress={() => void $.ui.open({ id: PANE, title: 'Subagents' })}>
343 [details]
344 </Button>
345 )}
346 <Text> </Text>
347 <Button
348 key="hide"
349 plain
350 onPress={async () => {
351 await setHidden($, true)
352 $.ui.toast('Subagent band hidden. /progress band shows it again.')
353 }}
354 >
355 [hide]
356 </Button>
357 </Box>
358 )
359 const below = await drawnBelow(next, e)
360 const under = below === undefined ? null : (below as never)
361 if (!isBusy) return under === null ? line : <Box key="savvy-stack" flexDirection="column">{line}{under}</Box>
362
363 const live = treeOrder(rows).filter(t => t.row.status === 'running')
364 return (
365 <Box key="savvy-band" flexDirection="column">
366 {line}
367 <Box key="progress">
368 <Text>{' '}{helperText(summary)}</Text>
369 {taskLine !== '' && <Text dimColor>{' · '}</Text>}
370 {taskLine !== '' && <Text color={tasksDone ? 'success' : undefined}>{taskLine}</Text>}
371 </Box>
372 {live.slice(0, BAND_ROWS).map(({ row, depth }) => (
373 <Text key={`live:${row.id}`} dimColor>
374 {' '}{indentOf(depth)}▶ {row.label || row.id.slice(0, 8)} · {fmtDuration(elapsedOf(row, now))} · {toolsText(row)}
375 </Text>
376 ))}
377 {live.length > BAND_ROWS && <Text dimColor>{' '}+{live.length - BAND_ROWS} more running</Text>}
378 {under}
379 </Box>
380 )
381 })
382
383 // --- the pane ------------------------------------------------------------
384
385 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
386 const rows = await read($, agents)
387 const todo = await read($, tasks)
388 await read($, tick)
389 const now = await $.clock.now()
390 const { Box, Text, Button } = $.ui.resolve(e)
391 const summary = summarize(rows, now, overrides)
392 const width = Math.max(6, Math.min(14, Math.floor((e.props.bodyColumns ?? 60) / 5)))
393 const room = Math.max(3, Math.floor(((e.viewport?.rows ?? 24) - 6) / 2))
394 // A helper started by another helper sits under it; the main loop's helpers are the roots.
395 const shown = treeOrder(rows)
396
397 if (rows.length === 0 && todo.length === 0) return <Text dimColor>No subagents have run in this session.</Text>
398
399 return (
400 <Box flexDirection="column">
401 {todo.length > 0 && <Text key="tasks">{taskText(todo)}</Text>}
402 {rows.length === 0 && <Text dimColor>No subagents have run in this session.</Text>}
403 {rows.length > 0 && (
404 <Text bold key="summary">
405 {summary.count} helper{summary.count === 1 ? '' : 's'} ({summary.running} running) · {fmtTokens(summary.tokens)} tok ·{' '}
406 {summary.tokens === 0 ? 'no usage yet' : fmtCost(summary.cost, summary.unpriced > 0)} · {fmtDuration(summary.elapsedMs)}
407 </Text>
408 )}
409 {shown.slice(0, room).map(({ row: r, depth }) => {
410 const stalled = isStalled(r, now, stallMs)
411 return (
412 <Box key={`row:${r.id}`} flexDirection="column">
413 <Box>
414 <Text color={statusColor(r, stalled)}>
415 {indentOf(depth)}
416 {glyph(r, stalled)} {r.label || r.id.slice(0, 8)} <Text dimColor>({[r.type, shortModel(r.model)].filter(Boolean).join(', ')}) {stalled ? `stalled · no activity for ${fmtDuration(now - (r.lastAt ?? r.startedAt))}` : r.status}</Text>
417 </Text>
418 {r.status === 'running' && (
419 <Button key={`stop:${r.id}`} label="Stop" variant="secondary" onPress={() => void stopAgent($, r.id, r.label || r.id.slice(0, 8))} />
420 )}
421 </Box>
422 <Text dimColor={!rowHasUsage(r)}>
423 {' '}
424 {depth > 0 ? ' '.repeat(Math.min(depth, 4)) : ''}
425 {barFor(r, width, now)} {figures(r, now, overrides)}
426 </Text>
427 </Box>
428 )
429 })}
430 {shown.length > room && <Text dimColor>+{shown.length - room} more (older)</Text>}
431 {rows.length > 0 && <Text dimColor>est. = token counts x built-in price table; context bar = latest request vs an assumed window.</Text>}
432 </Box>
433 )
434 })
435}
436hooks/lib.ts 436 lines1import type { AgentRow, TaskItem, TaskStatus, Usage } from '../types'
2
3// ---------------------------------------------------------------------------
4// Prices: the ONE place cost numbers live. USD per million tokens.
5// Matched as a substring of the lower-cased model id, first hit wins, so the
6// specific (older, dearer) entries come before the family fallbacks. These are
7// list prices as I know them; new models fall into their family's row. The
8// result is always shown as "est.", and userConfig `priceOverrides` wins.
9// ---------------------------------------------------------------------------
10export type Price = { match: readonly string[]; in: number; out: number; cacheRead: number; cacheWrite: number }
11
12// Checked against the pricing page on 2026-10-12; the specific 5.x rows come first. Haiku 5.5 is the up-to-100K-token price.
13export const PRICES: readonly Price[] = [
14 { match: ['fable', 'mythos'], in: 10, out: 50, cacheRead: 0.25, cacheWrite: 12.5 },
15 { match: ['opus-5-5'], in: 4, out: 20, cacheRead: 0.2, cacheWrite: 5 },
16 { match: ['sonnet-5-5'], in: 2, out: 10, cacheRead: 0.1, cacheWrite: 2.5 },
17 { match: ['sonnet-5'], in: 2, out: 10, cacheRead: 0.2, cacheWrite: 2.5 },
18 { match: ['haiku-5-5'], in: 0.1, out: 0.5, cacheRead: 0.01, cacheWrite: 0.125 },
19 { match: ['opus-4-1', 'opus-4-0', 'opus-4-2025', '3-opus', 'opus-3'], in: 15, out: 75, cacheRead: 1.5, cacheWrite: 18.75 },
20 { match: ['opus'], in: 5, out: 25, cacheRead: 0.5, cacheWrite: 6.25 },
21 { match: ['sonnet'], in: 3, out: 15, cacheRead: 0.3, cacheWrite: 3.75 },
22 { match: ['3-haiku', 'haiku-3-2'], in: 0.25, out: 1.25, cacheRead: 0.03, cacheWrite: 0.3 },
23 { match: ['3-5-haiku', 'haiku-3-5'], in: 0.8, out: 4, cacheRead: 0.08, cacheWrite: 1 },
24 { match: ['haiku'], in: 1, out: 5, cacheRead: 0.1, cacheWrite: 1.25 },
25]
26
27/** Context window assumed per model; the API does not report it per agent. */
28export const DEFAULT_WINDOW = 200_000
29export const LONG_WINDOW = 1_000_000
30
31const MAX_ROWS = 60
32
33export function emptyUsage(): Usage {
34 return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
35}
36
37export function addUsage(a: Usage, b: Usage): Usage {
38 return {
39 input: a.input + b.input,
40 output: a.output + b.output,
41 cacheRead: a.cacheRead + b.cacheRead,
42 cacheWrite: a.cacheWrite + b.cacheWrite,
43 }
44}
45
46/** The engine's ModelUsage (API spelling) as this mod's Usage. */
47export function fromModelUsage(u: {
48 input_tokens: number
49 output_tokens: number
50 cache_read_input_tokens: number
51 cache_creation_input_tokens: number
52}): Usage {
53 return {
54 input: u.input_tokens,
55 output: u.output_tokens,
56 cacheRead: u.cache_read_input_tokens,
57 cacheWrite: u.cache_creation_input_tokens,
58 }
59}
60
61export const totalTokens = (u: Usage): number => u.input + u.output + u.cacheRead + u.cacheWrite
62const hasUsage = (u: Usage): boolean => totalTokens(u) > 0
63
64// --- price overrides -------------------------------------------------------
65
66/** "sonnet=3/15, opus=5/25/0.5/6.25" -> table rows; malformed entries are skipped. */
67export function parseOverrides(text: unknown): Price[] {
68 if (typeof text !== 'string') return []
69 const out: Price[] = []
70 for (const part of text.split(',')) {
71 const [name, nums] = part.split('=')
72 const key = name?.trim().toLowerCase()
73 const n = (nums ?? '').split('/').map(s => Number(s.trim()))
74 const [inp, outp] = n
75 if (!key || n.some(x => !Number.isFinite(x) || x < 0) || inp === undefined || outp === undefined) continue
76 out.push({ match: [key], in: inp, out: outp, cacheRead: n[2] ?? inp * 0.1, cacheWrite: n[3] ?? inp * 1.25 })
77 }
78 return out
79}
80
81export function priceFor(model: string, overrides: readonly Price[] = []): Price | null {
82 const id = model.toLowerCase()
83 if (id === '') return null
84 const hit = (p: Price) => p.match.some(m => id.includes(m))
85 return overrides.find(hit) ?? PRICES.find(hit) ?? null
86}
87
88export function costOf(u: Usage, p: Price): number {
89 return (u.input * p.in + u.output * p.out + u.cacheRead * p.cacheRead + u.cacheWrite * p.cacheWrite) / 1e6
90}
91
92// --- per-row figures -------------------------------------------------------
93
94export const rowUsage = (r: AgentRow): Usage => addUsage(r.done, r.cur)
95
96/** Estimated USD, or null when the agent reported no usage or its model has no price. */
97export function rowCost(r: AgentRow, overrides: readonly Price[]): number | null {
98 const u = rowUsage(r)
99 const price = priceFor(r.model, overrides)
100 return hasUsage(u) && price !== null ? costOf(u, price) : null
101}
102
103export const rowHasUsage = (r: AgentRow): boolean => hasUsage(rowUsage(r))
104
105export function contextWindow(model: string): number {
106 return /\[1m\]|-1m\b/i.test(model) ? LONG_WINDOW : DEFAULT_WINDOW
107}
108
109/** Context fill 0..1 from the latest request, or null when nothing was reported. */
110export function rowFill(r: AgentRow): number | null {
111 return r.fill === null ? null : Math.min(1, r.fill / contextWindow(r.model))
112}
113
114export function elapsedOf(r: AgentRow, now: number): number {
115 return r.busyMs + (r.since === null ? 0 : Math.max(0, now - r.since))
116}
117
118// --- summary ---------------------------------------------------------------
119
120export type Summary = {
121 count: number
122 running: number
123 finished: number
124 failed: number
125 tokens: number
126 cost: number
127 /** Agents with tokens but no price: the cost is then a lower bound. */
128 unpriced: number
129 elapsedMs: number
130}
131
132export function summarize(rows: readonly AgentRow[], now: number, overrides: readonly Price[]): Summary {
133 let cost = 0
134 let unpriced = 0
135 let tokens = 0
136 for (const r of rows) {
137 tokens += totalTokens(rowUsage(r))
138 const c = rowCost(r, overrides)
139 if (c !== null) cost += c
140 else if (rowHasUsage(r)) unpriced += 1
141 }
142 const running = rows.filter(r => r.status === 'running').length
143 const first = Math.min(...rows.map(r => r.startedAt))
144 const last = running > 0 ? now : Math.max(...rows.map(r => r.endedAt ?? now))
145 return {
146 count: rows.length,
147 running,
148 finished: rows.filter(r => r.status === 'finished').length,
149 failed: rows.filter(r => r.status === 'failed').length,
150 tokens,
151 cost,
152 unpriced,
153 elapsedMs: rows.length === 0 ? 0 : Math.max(0, last - first),
154 }
155}
156
157// --- formatting ------------------------------------------------------------
158
159export function fmtTokens(n: number): string {
160 if (n < 1000) return String(n)
161 if (n < 1e6) return `${(n / 1000).toFixed(n < 10_000 ? 2 : 1).replace(/\.?0+$/, '')}k`
162 return `${(n / 1e6).toFixed(2).replace(/\.?0+$/, '')}M`
163}
164
165export function fmtCost(usd: number, isLowerBound = false): string {
166 const body = usd > 0 && usd < 0.01 ? usd.toFixed(3) : usd.toFixed(2)
167 return `est. $${body}${isLowerBound ? '+' : ''}`
168}
169
170export function fmtDuration(ms: number): string {
171 const s = Math.floor(ms / 1000)
172 if (s < 60) return `${s}s`
173 const m = Math.floor(s / 60)
174 if (m < 60) return `${m}m${String(s % 60).padStart(2, '0')}s`
175 return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
176}
177
178/** The band's one line, e.g. "helpers: 2 running, 3 done · 41.2k tok · est. $0.38 · 1m12s". */
179export function bandText(s: Summary): string {
180 const parts = [
181 s.running > 0 ? `${s.running} running` : '',
182 s.finished > 0 ? `${s.finished} done` : '',
183 s.failed > 0 ? `${s.failed} failed` : '',
184 ].filter(Boolean)
185 const cost = s.tokens === 0 ? 'no usage yet' : s.cost === 0 && s.unpriced > 0 ? 'est. n/a' : fmtCost(s.cost, s.unpriced > 0)
186 return `helpers: ${parts.join(', ')} · ${fmtTokens(s.tokens)} tok · ${cost} · ${fmtDuration(s.elapsedMs)}`
187}
188
189/** A filled bar for a known context fill. */
190export function fillBar(fill: number, width: number): string {
191 const n = Math.round(Math.max(0, Math.min(1, fill)) * width)
192 return '█'.repeat(n) + '░'.repeat(width - n)
193}
194
195/** A moving block for an agent whose fill is unknown; `seconds` is its elapsed time. */
196export function busyBar(width: number, seconds: number): string {
197 const block = Math.min(3, width)
198 const span = Math.max(1, width - block)
199 const at = seconds % (2 * span)
200 const start = at <= span ? at : 2 * span - at
201 return '░'.repeat(start) + '▓'.repeat(block) + '░'.repeat(width - block - start)
202}
203
204export function emptyBar(width: number): string {
205 return '░'.repeat(width)
206}
207
208// --- row updates (pure: $.state's update may run them twice) ---------------
209
210export function newRow(id: string, init: Partial<AgentRow>, now: number): AgentRow {
211 return {
212 id,
213 label: init.label ?? '',
214 type: init.type ?? '',
215 model: init.model ?? '',
216 status: 'running',
217 startedAt: now,
218 endedAt: null,
219 busyMs: 0,
220 since: now,
221 done: emptyUsage(),
222 cur: emptyUsage(),
223 curTurn: '',
224 fill: null,
225 steps: 0,
226 ...(init.parentId ? { parentId: init.parentId } : {}),
227 tools: 0,
228 lastAt: now,
229 }
230}
231
232/** Counts one tool call of the agent's own loop. Unknown agents are not created here (forks carry ids no list names). */
233export function countTool(rows: readonly AgentRow[], id: string, now: number): AgentRow[] {
234 return rows.map(r => (r.id === id ? { ...r, tools: (r.tools ?? 0) + 1, lastAt: now, stallNoted: false } : r))
235}
236
237/** How long a running helper may say nothing before it counts as stalled (the `stallMinutes` setting; 0 turns it off). */
238export const DEFAULT_STALL_MINUTES = 5
239
240export function stallMsOf(value: unknown): number {
241 const n = typeof value === 'number' && Number.isFinite(value) ? value : DEFAULT_STALL_MINUTES
242 return Math.min(24 * 60, Math.max(0, n)) * 60_000
243}
244
245/** A running helper that has reported nothing for `stallMs` (0 = never). The engine's own status is not consulted: this is only silence. */
246export function isStalled(r: AgentRow, now: number, stallMs: number): boolean {
247 return stallMs > 0 && r.status === 'running' && now - (r.lastAt ?? r.startedAt) >= stallMs
248}
249
250/** Applies `fn` to the row `id`, creating it from `init` first when unknown; drops old rows past the cap. */
251export function touch(
252 rows: readonly AgentRow[],
253 id: string,
254 init: Partial<AgentRow>,
255 now: number,
256 fn: (r: AgentRow) => AgentRow,
257): AgentRow[] {
258 const found = rows.some(r => r.id === id)
259 const stamp = (r: AgentRow): AgentRow => ({ ...r, lastAt: now, stallNoted: false })
260 const next = found ? rows.map(r => (r.id === id ? stamp(fn(r)) : r)) : [...rows, stamp(fn(newRow(id, init, now)))]
261 if (next.length <= MAX_ROWS) return next
262 const drop = next.findIndex(r => r.status !== 'running' && r.id !== id)
263 return drop < 0 ? next : next.filter((_, i) => i !== drop)
264}
265
266/** The agent is (again) working: a new turn started or its first request went out. */
267export function markRunning(r: AgentRow, now: number): AgentRow {
268 return r.status === 'running' && r.since !== null ? r : { ...r, status: 'running', since: now, endedAt: null }
269}
270
271/** One finished request: its tokens join the running turn, its input side is the context in use. */
272export function addStep(r: AgentRow, turnId: string, usage: Usage, model: string): AgentRow {
273 const carried = r.curTurn === turnId || r.curTurn === '' ? r : { ...r, done: addUsage(r.done, r.cur), cur: emptyUsage() }
274 return {
275 ...carried,
276 curTurn: turnId,
277 cur: addUsage(carried.cur, usage),
278 fill: usage.input + usage.cacheRead + usage.cacheWrite,
279 steps: carried.steps + 1,
280 model: model || carried.model,
281 }
282}
283
284/** A turn ended. The turn's own total (when given) replaces the steps' sum, so nothing counts twice. */
285export function endTurn(r: AgentRow, ok: boolean, durationMs: number, total: Usage | null, now: number): AgentRow {
286 return {
287 ...r,
288 status: ok ? 'finished' : 'failed',
289 done: addUsage(r.done, total ?? r.cur),
290 cur: emptyUsage(),
291 curTurn: '',
292 busyMs: r.busyMs + durationMs,
293 since: null,
294 endedAt: now,
295 }
296}
297
298/** The engine says the agent ended without a turn.complete we saw (killed, failed). */
299export function endByStatus(r: AgentRow, ok: boolean, now: number): AgentRow {
300 if (r.status !== 'running') return r
301 return { ...r, status: ok ? 'finished' : 'failed', busyMs: elapsedOf(r, now), since: null, endedAt: now }
302}
303
304// --- progress bars -----------------------------------------------------------
305
306/** "▰▰▰▱▱" for done/total; one cell per item up to 10 cells, so 3/5 reads as three of five. */
307export function progressBar(done: number, total: number): string {
308 const width = Math.max(1, Math.min(10, total))
309 const n = total <= 0 ? 0 : Math.round((Math.min(done, total) / total) * width)
310 return '▰'.repeat(n) + '▱'.repeat(width - n)
311}
312
313export function taskCounts(items: readonly TaskItem[]): { done: number; total: number; active: number } {
314 return {
315 done: items.filter(t => t.status === 'completed').length,
316 total: items.length,
317 active: items.filter(t => t.status === 'in_progress').length,
318 }
319}
320
321/** "▰▰▰▱▱ 3/5 tasks", or '' when Claude has no task list. */
322export function taskText(items: readonly TaskItem[]): string {
323 const { done, total } = taskCounts(items)
324 return total === 0 ? '' : `${progressBar(done, total)} ${done}/${total} tasks`
325}
326
327/** "▰▱▱ 1/3 helpers": helpers that ended (done or failed) of all seen. */
328export function helperText(s: Summary): string {
329 return `${progressBar(s.finished + s.failed, s.count)} ${s.finished + s.failed}/${s.count} helpers`
330}
331
332// --- Claude's task list, from its task tools ---------------------------------
333// Sources (all main-loop tool.call events): TodoWrite carries the whole list (result.newTodos); TaskCreate's
334// result gives the new id; TaskUpdate's input gives id + new status; TaskList's result is a full snapshot.
335// Inputs are read defensively: a shape this does not know leaves the list as it was.
336
337const MAX_TASKS = 200
338const STATUSES: readonly string[] = ['pending', 'in_progress', 'completed']
339
340const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null
341const asStatus = (v: unknown): TaskStatus | undefined => (typeof v === 'string' && STATUSES.includes(v) ? (v as TaskStatus) : undefined)
342const asText = (v: unknown): string => (typeof v === 'string' ? v : '')
343
344/** True for the tools applyTaskTool understands. */
345export const isTaskTool = (tool: string): boolean => /^(TodoWrite|TaskCreate|TaskUpdate|TaskList)$/.test(tool)
346
347export function applyTaskTool(items: readonly TaskItem[], tool: string, input: unknown, result: unknown): TaskItem[] {
348 const inp = isRecord(input) ? input : {}
349 const res = isRecord(result) ? result : {}
350 const cap = (list: TaskItem[]) => list.slice(-MAX_TASKS)
351
352 if (tool === 'TodoWrite') {
353 const list = Array.isArray(res.newTodos) ? res.newTodos : inp.todos
354 if (!Array.isArray(list)) return [...items]
355 return cap(
356 list.flatMap((t, i): TaskItem[] => {
357 const status = isRecord(t) ? asStatus(t.status) : undefined
358 return isRecord(t) && status !== undefined ? [{ id: `todo:${i}`, subject: asText(t.content), status }] : []
359 }),
360 )
361 }
362
363 if (tool === 'TaskList') {
364 if (!Array.isArray(res.tasks)) return [...items]
365 return cap(
366 res.tasks.flatMap((t): TaskItem[] => {
367 const status = isRecord(t) ? asStatus(t.status) : undefined
368 return isRecord(t) && typeof t.id === 'string' && status !== undefined ? [{ id: t.id, subject: asText(t.subject), status }] : []
369 }),
370 )
371 }
372
373 if (tool === 'TaskCreate') {
374 const task = isRecord(res.task) ? res.task : undefined
375 if (task === undefined || typeof task.id !== 'string' || items.some(t => t.id === task.id)) return [...items]
376 return cap([...items, { id: task.id, subject: asText(task.subject) || asText(inp.subject), status: 'pending' }])
377 }
378
379 if (tool === 'TaskUpdate') {
380 const id = typeof inp.taskId === 'string' ? inp.taskId : ''
381 if (id === '' || res.success === false) return [...items]
382 if (inp.status === 'deleted') return items.filter(t => t.id !== id)
383 const status = asStatus(inp.status)
384 const subject = asText(inp.subject)
385 if (!items.some(t => t.id === id)) {
386 // A task made before this mod was watching: count it, so the total stays right.
387 return status === undefined ? [...items] : cap([...items, { id, subject: subject || `task ${id}`, status }])
388 }
389 return items.map(t => (t.id === id ? { ...t, status: status ?? t.status, subject: subject || t.subject } : t))
390 }
391
392 return [...items]
393}
394
395// --- subagent tree -------------------------------------------------------------
396
397export type TreeRow = { row: AgentRow; depth: number }
398
399/**
400 * Rows as a tree: an agent started by another agent sits right under its parent (`parentId`, from agent.spawn's
401 * parentAgentId or the engine's agent list). Roots are the agents the main loop started or whose parent is not
402 * tracked: running first, then newest; children keep start order. Safe against a parent loop.
403 */
404export function treeOrder(rows: readonly AgentRow[]): TreeRow[] {
405 const known = new Set(rows.map(r => r.id))
406 const kids = (id: string) => rows.filter(r => r.parentId === id && r.id !== id).sort((a, b) => a.startedAt - b.startedAt)
407 const roots = rows
408 .filter(r => r.parentId === undefined || !known.has(r.parentId) || r.parentId === r.id)
409 .sort((a, b) => Number(b.status === 'running') - Number(a.status === 'running') || b.startedAt - a.startedAt)
410 const out: TreeRow[] = []
411 const seen = new Set<string>()
412 const walk = (row: AgentRow, depth: number) => {
413 if (seen.has(row.id)) return
414 seen.add(row.id)
415 out.push({ row, depth })
416 for (const child of kids(row.id)) walk(child, depth + 1)
417 }
418 for (const root of roots) walk(root, 0)
419 // Agents that only point at each other (a loop) have no root: list them flat rather than lose them.
420 for (const r of rows) walk(r, 0)
421 return out
422}
423
424/** The tree marker: nothing for a root, an indented "└ " for a nested agent (capped so a deep tree stays narrow). */
425export const indentOf = (depth: number): string => (depth === 0 ? '' : `${' '.repeat(Math.min(depth, 4) - 1)}└ `)
426
427export const toolsText = (r: AgentRow): string => `${r.tools ?? 0} tool${(r.tools ?? 0) === 1 ? '' : 's'}`
428
429/** One line per agent for `/progress tree`: marker, status glyph, name, elapsed, tool count. */
430export function treeLines(rows: readonly AgentRow[], now: number): string[] {
431 return treeOrder(rows).map(({ row, depth }) => {
432 const glyph = row.status === 'running' ? '▶' : row.status === 'finished' ? '✓' : '✗'
433 return `${indentOf(depth)}${glyph} ${row.label || row.id.slice(0, 8)} · ${row.status} · ${fmtDuration(elapsedOf(row, now))} · ${toolsText(row)}`
434 })
435}
436types/index.d.ts 65 lines1export type Usage = {
2 input: number
3 output: number
4 cacheRead: number
5 cacheWrite: number
6}
7
8export type AgentStatus = 'running' | 'finished' | 'failed'
9
10/** One subagent as this mod tracks it. Plain JSON: it lives in $.state. */
11export type AgentRow = {
12 id: string
13 label: string
14 type: string
15 model: string
16 status: AgentStatus
17 /** First time this mod saw the agent (ms since epoch). */
18 startedAt: number
19 /** When its last turn ended; null while it runs. */
20 endedAt: number | null
21 /** Time spent in turns that already ended. */
22 busyMs: number
23 /** Start of the turn running now; null between turns. */
24 since: number | null
25 /** Tokens of the turns that ended. */
26 done: Usage
27 /** Tokens of the turn running now, summed over its steps. */
28 cur: Usage
29 curTurn: string
30 /** Input-side tokens of the latest request: the context in use. */
31 fill: number | null
32 steps: number
33 /** The agent that spawned this one; absent for one the main loop started (and in 0.3.0 rows kept over a hot reload). */
34 parentId?: string
35 /** Tool calls the agent's own loop made (counted from tool.call events that carry its agentId). */
36 tools?: number
37 /** The last time the engine reported anything from this agent (a request, a tool call, its spawn): the stall clock. */
38 lastAt?: number
39 /** True once a toast has said this running helper looks stalled, so it is said once. */
40 stallNoted?: boolean
41}
42
43export type TaskStatus = 'pending' | 'in_progress' | 'completed'
44
45/** One entry of Claude's own task list (TodoWrite or TaskCreate/TaskUpdate). */
46export type TaskItem = {
47 id: string
48 subject: string
49 status: TaskStatus
50}
51
52declare module 'claude-code' {
53 interface PluginState {
54 'progress': {
55 agents: AgentRow[]
56 /** The main loop's task list as its task tools last reported it. */
57 tasks: TaskItem[]
58 /** null until read from $.store. */
59 isHidden: boolean | null
60 /** Bumped by the once-a-second timer so drawings refresh while agents run. */
61 tick: number
62 }
63 }
64}
65