SLOPSHOPPER

progress

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.

newpanebandguardcommandtoast
★ 1v0.5.3MITupdated 2026-10-14samuelbfernandes/moddeck/mods/progress
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · progress
│ ┃ Subagents ✕ › fix the failing auth test and add an audit log call │ ┃ No subagents have run in this session. │ ⏺ 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 │ │ › /progress │ ⎿ progress: Subagent pane opened (token counts are real, costs are │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Subagents
No subagents have run in this session.
README

progress

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.

  • Band above the prompt, one line when nothing runs: ◈ ▰▰▰▱▱ 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.
  • Tasks bar: counts Claude's own task list (the TodoWrite, or TaskCreate/TaskUpdate tools) in the main conversation.
  • Pane (/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).
  • Tree as text: /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.

How the numbers are made

  • Tokens come from each helper's turn.step usage, replaced by its turn.complete total. "tok" = input + output + cache read + cache write.
  • Cost is an estimate: tokens x a built-in price table (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]).
  • Tasks: the list comes from the tool call results (TodoWrite newTodos, TaskCreate task.id, TaskUpdate status, TaskList). No other source exists.
  • Tree: agent.spawn says which agent started a helper (parentAgentId); the engine's agent list fills gaps. Helpers the main conversation started are top level.
  • Tool count = tool calls whose event carries the helper's id. Elapsed is the engine's turn time.

Turn it off

/plugin disable progress (or /progress band to hide just the band).

Limits

  • Only what this session saw; only the hidden flag is saved. A helper's own task list is not counted (only the main conversation's).
  • Plan-mode steps that are not task tools are not tasks. Not verified live: that every helper kind raises step and tool events, the desktop look, price accuracy for new models.
  • Tested with synthetic events on terminal and desktop; nothing has been run on Windows.
Source 3 files
hooks/register.tsx 436 lines
1import { 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}
436
hooks/lib.ts 436 lines
1import 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}
436
types/index.d.ts 65 lines
1export 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