SLOPSHOPPER

agents-progress

Progress bar above the prompt for delegated work, plus an agents panel (/agents-info) that opens for any subagent: running, finished and planned subagents with…

newpanebandrowsguardcommand
v1.1.3MITupdated 2026-10-15fabiopbarbieri/agents-progress
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agents-progress
│ ┃ Agents ✕ › fix the failing auth test and add an audit log call │ ┃ c: Collapse │ ┃ ≈$0.00 · 0 tokens · 0:00 ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ No subagents yet. ⏺ 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 │ │ › /agents-info │ ⎿ agents-progress: Agents panel opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agents
c: Collapse ≈$0.00 · 0 tokens · 0:00 No subagents yet.
README

agents-progress

A Claude Code mod, forked from savvy-progress (MIT): a progress bar above the prompt and a live panel of subagents.

  • Agents panel (/agents-info toggles it): opens by itself when any subagent starts, once per busy spell (a run started while none was running) or once per reported flow, so closing it sticks until then. Shows the session's subagents, across flows: running, the latest ten finished (a +N earlier button opens the rest) and the planned ones, with model, effort, task progress (percent and steps, once the run reports them), context, estimated cost and time; the totals count every run.
  • Pressing a run (its title on the terminal, its View button on the desktop) puts its transcript in the main view, and on the run in view brings the main conversation back; ↩ Main conversation (m) does too. The run in view is marked.
  • A run another subagent started sits right under it, indented.
  • With the pane focused, c collapses it, f folds the finished runs and e opens the earlier ones. Esc closes it, from the pane or from an idle, empty prompt.
  • On the terminal each run takes four rows: name, tier and model, the bar with the task's steps, then context, tokens, cost and time.
  • Progress bar: the flow's title, phase, accepted tasks out of planned, and a button with the crew size that opens the panel. It appears once something calls progress, sits under any other plugin's band, and steps aside 10 seconds after the flow is reported finished.
  • Tiers color each run and pick its crab: an agent type named after a tier (careful, plugin:careful) wins; otherwise the model decides: Fable is fable, Opus is heavy at xhigh or max effort and careful below, Sonnet is medium, Haiku is light.

Tools and skill

  • mcp__agents-progress__progress: the delegating session reports the plan, phase and accepted tasks.
  • mcp__agents-progress__step: a subagent reports its own steps (done, total, note). The mod adds a paragraph asking for it to every subagent's prompt; a run that does not report shows its context fill in grey.
  • Neither tool draws a row in the transcript, as the band and the panel show what they report; a call that failed still shows.
  • Skill agents-progress:reportar-progresso: Claude loads it before any delegation to report the plan and each accepted task. It is not in the / menu.

Cost is a rough estimate from token counts and the PRICES table in hooks/register.tsx, not a bill.

Settings

Set them in /config.

  • language: auto (default), en, pt or ru.
  • stepBrief: on (default) briefs every subagent to report its steps; off leaves prompts as written. Each report is one more model request in the subagent.

Install

At the prompt of a Claude Code session in a terminal:

/plugin install agents-progress --marketplace fabiopbarbieri/agents-progress

Answer y to add the marketplace, then pick a scope. Or from a shell:

claude plugin marketplace add fabiopbarbieri/agents-progress
claude plugin install agents-progress@agents-progress

The Claude desktop app has no /plugin command: install from a terminal, and the mod loads in the app's local sessions too.

The options are optional: the install may say they are not set, and their defaults apply until you change them in /config.

To work on it, add your clone as the marketplace instead (claude plugin marketplace add <path to the clone>): the entry's source is relative, so the session reads the plugin from that folder. Edit, then /reload-plugins.

Checks

claude plugin validate . --strict
claude plugin test .
npx -p typescript@5 tsc -p .

claude plugin test runs tests/*.test.ts against the engine's own host, on the terminal and desktop surfaces; tests/kit.ts stands for the engine beneath the mod. It checks the hooks and the trees they return, not how a surface paints them.

tsc reads the API types the engine lays in .claude-plugin/types/ (ignored by Git) when an interactive session loads the mod from its folder, as claude --plugin-dir . does; a claude -p run does not lay them. Tests stay out of it, as their fixtures are partial.

Versioning

Semantic Versioning 2.0.0, with version in .claude-plugin/plugin.json as the one source:

  • Major: a change that breaks what others rely on: the tools' inputs or answers, the $.state contract in types/index.d.ts, a userConfig option removed or renamed, the /agents-info command.
  • Minor: a feature that keeps all of that working.
  • Patch: a fix, a performance gain or a docs change.

To release: move version, add its section to CHANGELOG.md, commit, then claude plugin tag . --push (tag agents-progress--v<version>) and a GitHub release from that section.

Source 2 files
hooks/register.tsx 1444 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, Timer } from 'claude-code'
3
4import type { AgentRun, Flow, Panel, Phase, PlannedTask } from '../types'
5
6const flow = atom({ plugin: 'agents-progress', key: 'flow' } as const, null)
7const agents = atom({ plugin: 'agents-progress', key: 'agents' } as const, [])
8const panel = atom({ plugin: 'agents-progress', key: 'panel' } as const, {
9  isCompact: false,
10  isDoneCollapsed: false,
11  autoOpenedFor: '',
12})
13const now = atom({ plugin: 'agents-progress', key: 'now' } as const, 0)
14
15// `update` writes, and so redraws every reader, even when the value stays the same:
16// these write only a change. One per value, as the engine's scan reads each by name.
17const same = (a: unknown, b: unknown): boolean => a === b || JSON.stringify(a) === JSON.stringify(b)
18
19async function patchFlow($: EngineInterface, change: (f: Flow | null) => Flow | null): Promise<Flow | null> {
20  const held = await read($, flow)
21  return same(change(held), held) ? held : update($, flow, change)
22}
23
24async function patchAgents($: EngineInterface, change: (list: AgentRun[]) => AgentRun[]): Promise<AgentRun[]> {
25  const held = await read($, agents)
26  return same(change(held), held) ? held : update($, agents, change)
27}
28
29const TOOL = 'mcp__agents-progress__progress'
30const STEP_TOOL = 'mcp__agents-progress__step'
31const PANE = 'agents-progress'
32const QUIET_TOOLS = new Set([TOOL, STEP_TOOL])
33const PHASES: readonly Phase[] = ['plan', 'design', 'delegate', 'review', 'close']
34// How long a finished flow's band stays before it steps aside by itself.
35const FINISHED_LINGER_MS = 10_000
36const ACCENT = '#8f8cf4'
37const DONE = '#5fbf8f'
38
39type ProgressInput = {
40  title?: string
41  total?: number
42  done?: number
43  phase?: Phase
44  finished?: boolean
45  tasks?: { title?: string; tier?: string; after?: number[] }[]
46}
47
48// ---------------------------------------------------------------------------
49// Language: the `language` option, else Claude Code's `language` setting, else the
50// process locale; English when nothing says Portuguese or Russian.
51
52type Lang = 'en' | 'pt' | 'ru'
53
54const STRINGS = {
55  en: {
56    pane: 'Agents',
57    cost: 'Cost',
58    tokens: 'Tokens',
59    time: 'Time',
60    collapse: 'Collapse',
61    expand: 'Expand',
62    running: 'Running',
63    finished: 'Finished',
64    earlier: 'earlier',
65    fewer: 'Show fewer',
66    planned: 'Planned',
67    empty: 'No subagents yet.',
68    round: 'round',
69    failed: 'error',
70    after: 'after',
71    tokensWord: 'tokens',
72    agentsCount: 'agents',
73    isRunning: 'running',
74    isFinished: 'finished',
75    isPlanned: 'planned',
76    opened: 'Agents panel opened.',
77    closed: 'Agents panel closed.',
78    done: 'Done',
79    plan: 'Plan',
80    design: 'Design',
81    tasks: 'Tasks',
82    review: 'Review',
83    busy: 'Tasks running',
84    flow: 'Progress',
85    view: 'View',
86    main: 'Main conversation',
87    inView: 'in view',
88    gone: "This run's transcript is gone: Claude Code lets a run go about 30 seconds after it ends.",
89  },
90  pt: {
91    pane: 'Agentes',
92    cost: 'Custo',
93    tokens: 'Tokens',
94    time: 'Tempo',
95    collapse: 'Recolher',
96    expand: 'Expandir',
97    running: 'Em execução',
98    finished: 'Concluídos',
99    earlier: 'anteriores',
100    fewer: 'Mostrar menos',
101    planned: 'Planejados',
102    empty: 'Nenhum subagente ainda.',
103    round: 'rodada',
104    failed: 'erro',
105    after: 'depois de',
106    tokensWord: 'tokens',
107    agentsCount: 'agentes',
108    isRunning: 'em execução',
109    isFinished: 'concluído',
110    isPlanned: 'planejada',
111    opened: 'Painel de agentes aberto.',
112    closed: 'Painel de agentes fechado.',
113    done: 'Concluído',
114    plan: 'Plano',
115    design: 'Design',
116    tasks: 'Tarefas',
117    review: 'Revisão',
118    busy: 'Tarefas em execução',
119    flow: 'Progresso',
120    view: 'Ver',
121    main: 'Conversa principal',
122    inView: 'em vista',
123    gone: 'O transcript deste run não está mais disponível: o Claude Code libera um run cerca de 30 s depois que ele termina.',
124  },
125  ru: {
126    pane: 'Агенты',
127    cost: 'Стоимость',
128    tokens: 'Токены',
129    time: 'Время',
130    collapse: 'Свернуть',
131    expand: 'Развернуть',
132    running: 'Работают',
133    finished: 'Завершены',
134    earlier: 'ранее',
135    fewer: 'Показать меньше',
136    planned: 'Запланированы',
137    empty: 'Субагентов пока нет.',
138    round: 'раунд',
139    failed: 'ошибка',
140    after: 'после',
141    tokensWord: 'токенов',
142    agentsCount: 'агентов',
143    isRunning: 'работает',
144    isFinished: 'завершён',
145    isPlanned: 'запланирована',
146    opened: 'Панель агентов открыта.',
147    closed: 'Панель агентов закрыта.',
148    done: 'Готово',
149    plan: 'План',
150    design: 'Дизайн',
151    tasks: 'Задачи',
152    review: 'Ревью',
153    busy: 'Задачи в работе',
154    flow: 'Прогресс',
155    view: 'Открыть',
156    main: 'Основной диалог',
157    inView: 'открыт',
158    gone: 'Транскрипт этого запуска больше недоступен: Claude Code отпускает запуск примерно через 30 секунд после завершения.',
159  },
160} as const
161
162// Module scope is fine here: session.start sets it again on every (re)load.
163let lang: Lang = 'en'
164// $.ui.view came with Claude Code 2.1.300; the desktop app runs its own engine, which
165// may lag. Set in session.start, as lang is.
166let canView = true
167
168const VIEW_SINCE = [2, 1, 300]
169
170async function engineHasView($: EngineInterface): Promise<boolean> {
171  try {
172    const { base, version } = await $.session.version()
173    const parts = (base ?? version).split(/[.-]/).slice(0, 3).map(Number)
174    // A version not spelled as a release is a build of its own: assume it has the call.
175    if (parts.length < 3 || parts.some(isNaN)) return true
176    for (const [i, since] of VIEW_SINCE.entries()) if (parts[i] !== since) return parts[i]! > since
177    return true
178  } catch {
179    return false
180  }
181}
182const tr = () => STRINGS[lang]
183
184const langOf = (v: unknown): Lang => {
185  if (typeof v !== 'string') return 'en'
186  if (/^(ru|russian|рус)/i.test(v.trim())) return 'ru'
187  if (/^(pt|portugu)/i.test(v.trim())) return 'pt'
188  return 'en'
189}
190
191async function detectLang($: EngineInterface, option: unknown): Promise<Lang> {
192  if (option === 'en' || option === 'pt' || option === 'ru') return option
193  try {
194    const settings = (await $.settings.read()) as Record<string, unknown>
195    if (typeof settings.language === 'string' && settings.language.trim()) return langOf(settings.language)
196  } catch {
197    // No settings: fall through to the locale.
198  }
199  const locale = (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG'))
200  return langOf(locale)
201}
202
203const blank = (): Flow => ({
204  title: tr().flow,
205  total: 0,
206  done: 0,
207  phase: 'plan',
208  isFinished: false,
209  tasks: [],
210})
211
212const isNewFlow = (prev: Flow | null, input: ProgressInput): boolean =>
213  !prev || prev.isFinished || (input.title !== undefined && input.title.trim() !== prev.title)
214
215const cleanTasks = (tasks: ProgressInput['tasks']): PlannedTask[] | undefined =>
216  tasks
217    ?.filter(t => t.title?.trim())
218    .map(t => ({
219      title: (t.title ?? '').trim(),
220      tier: (t.tier ?? '').trim().toLowerCase(),
221      after: (t.after ?? []).filter(n => Number.isInteger(n) && n > 0),
222    }))
223
224const merge = (prev: Flow | null, input: ProgressInput): Flow => {
225  // A new title means a new flow: never carry counters over from an earlier one.
226  const base = isNewFlow(prev, input) || !prev ? blank() : { ...blank(), ...prev }
227  const tasks = cleanTasks(input.tasks) ?? base.tasks
228  const total = Math.max(0, Math.round(input.total ?? (input.tasks ? tasks.length : base.total)))
229  const done = Math.min(total || Infinity, Math.max(0, Math.round(input.done ?? base.done)))
230  const phase = input.phase && PHASES.includes(input.phase) ? input.phase : base.phase
231  return {
232    ...base,
233    title: input.title?.trim() || base.title,
234    total,
235    done,
236    phase: input.finished ? 'close' : phase,
237    isFinished: input.finished === true,
238    tasks,
239  }
240}
241
242// What every subagent's brief gets, so the pane draws the task's own bar instead of
243// its context fill. In English, as the tools' own descriptions are.
244const STEP_BRIEF =
245  'Report your progress with `mcp__agents-progress__step` (if it is listed only by name, load it first with ' +
246  'ToolSearch `select:mcp__agents-progress__step`): right after reading this brief, call it with `total` (your plan ' +
247  'in 3 to 8 steps) and `done: 0`; call it again with the new `done` and a short `note` as each step finishes.'
248
249const briefed = (prompt: string, isOn: boolean): string =>
250  !isOn || prompt.includes(STEP_TOOL) ? prompt : `${prompt}\n\n${STEP_BRIEF}`
251
252// Runs still at work. The Agent tool answers as soon as a background run starts,
253// so only the runs' own status says how many are out.
254const runningOf = (list: AgentRun[]): number => list.filter(a => a.status === 'running').length
255
256const label = (f: Flow, running: number): string => {
257  const s = tr()
258  if (f.isFinished) return s.done
259  if (f.phase === 'plan') return s.plan
260  if (f.phase === 'design') return s.design
261  const isReview = f.phase === 'review'
262  // Without a plan there is no x/y, only how many run now: "Tarefas em execução: 2".
263  if (!f.total) return `${isReview ? s.review : s.busy}: ${running}`
264  return `${isReview ? s.review : s.tasks} ${f.done}/${f.total}`
265}
266
267const ratio = (f: Flow): number => (f.isFinished ? 1 : f.total ? f.done / f.total : 0)
268
269// Deterministic noise so the dither does not shimmer between redraws.
270const noise = (x: number, y: number): number => {
271  const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
272  return s - Math.floor(s)
273}
274
275// The whole row is one SVG: the desktop wraps sibling elements onto new lines,
276// so title, bar, percent and the crab live in one drawing; only the count and
277// the dismiss are Buttons beside it.
278const H = 22
279const BAR_H = 16
280const CRAB_W = 26
281const CELL = 3
282const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
283
284const xml = (s: string): string =>
285  s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c)
286
287const clip = (s: string, max: number): string => (s.length > max ? s.slice(0, Math.max(1, max - 1)) + '…' : s)
288
289// Rough advance of system UI text, in em; good enough to size the title's slot.
290const charEm = (ch: string): number =>
291  /[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-ZА-ЯЁmwшщжюМШЩЖЮ@%]/.test(ch) ? 0.72 : 0.56
292
293const textWidth = (s: string, size: number): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
294
295// Cuts `s` to fit `maxW` pixels, with an ellipsis when it had to cut.
296const fitText = (s: string, size: number, maxW: number): string => {
297  if (textWidth(s, size) <= maxW) return s
298  let out = ''
299  for (const ch of s) {
300    if (textWidth(out + ch + '…', size) > maxW) break
301    out += ch
302  }
303  return out + '…'
304}
305
306const rowSvg = (f: Flow, W: number, running: number): string => {
307  const isWorking = running > 0
308  // The title takes what it needs, up to 40% of the row; the bar takes the rest.
309  const title = fitText(f.title, 13, Math.max(60, W * 0.4))
310  const BAR_X = Math.round(16 + textWidth(title, 13) + 12)
311  const BAR_W = Math.max(60, W - BAR_X - 46 - CRAB_W)
312  const color = f.isFinished ? DONE : ACCENT
313  const y0 = (H - BAR_H) / 2
314  const fillW = Math.round(BAR_W * ratio(f))
315  const runW = f.total ? Math.round((BAR_W * Math.min(f.total, f.done + running)) / f.total) : 0
316  // Each layer's cells go into four paths, one per twinkle group: four animated
317  // elements however wide the bar, where every cell was one before.
318  const dots = ['', '', '', '']
319  const faint = ['', '', '', '']
320  const cell = (x: number, y: number, size: number) => `M${x} ${y}h${size}v${size}h${-size}z`
321
322  // Dithered fill: sparse at the start, dense toward the head.
323  const cols = Math.floor(fillW / CELL)
324  const rows = Math.floor(BAR_H / CELL)
325  for (let c = 0; c < cols; c++) {
326    const density = 0.35 + 0.6 * Math.pow(c / Math.max(1, cols), 1.2)
327    for (let r = 0; r < rows; r++) {
328      if (noise(c, r) < density) dots[Math.floor(noise(r, c) * 4)] += cell(c * CELL + 1, r * CELL + 1, 2)
329    }
330  }
331  // Handed to workers, not yet accepted: a faint second layer.
332  for (let c = cols; c < Math.floor(runW / CELL); c++) {
333    for (let r = 0; r < rows; r++) {
334      if (noise(c + 7, r + 3) < 0.2) faint[Math.floor(noise(r + 5, c) * 4)] += cell(c * CELL + 1, r * CELL + 1, 1.7)
335    }
336  }
337
338  const ticks: string[] = []
339  for (let i = 1; i < f.total; i++) {
340    const x = Math.round((BAR_W * i) / f.total)
341    if (x > fillW + 4) ticks.push(`<rect x="${x}" y="${BAR_H / 2 - 4}" width="1.5" height="8" rx="0.75"/>`)
342  }
343
344  const text = label(f, running)
345  const pillW = Math.round(18 + text.length * 6.6)
346  const pillX = Math.max(0, Math.min(BAR_W - pillW, fillW - pillW))
347  const percent = `${Math.round(ratio(f) * 100)}%`
348  const walker = crab(W - CRAB_W + 1, 0, 'other', false, isWorking, 0.8)
349  const layer = (groups: string[]) => groups.map((d, k) => (d ? `<path class="t${k}" d="${d}"/>` : '')).join('')
350  // Pixels twinkle in four out-of-phase groups while runs are at work; otherwise the bar stands still.
351  const twinkle =
352    isWorking && !f.isFinished
353      ? `.t0,.t1,.t2,.t3{animation:tw 2.2s ease-in-out infinite}
354.t1{animation-duration:2.8s;animation-delay:-.7s}.t2{animation-duration:1.9s;animation-delay:-1.3s}.t3{animation-duration:3.3s;animation-delay:-.4s}
355@keyframes tw{0%,100%{opacity:1}50%{opacity:0.3}}
356@media (prefers-reduced-motion: reduce){.t0,.t1,.t2,.t3{animation:none}}`
357      : ''
358
359  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
360<style>
361.t{fill:#1f1f1f}.m{fill:#8a8a8a}.k{fill:#e4e4e2}.tk{fill:#b4b4b0}
362@media (prefers-color-scheme: dark){.t{fill:#ececec}.m{fill:#9a9a9a}.k{fill:#2c2c2c}.tk{fill:#5a5a5a}}
363${twinkle}
364</style>
365<defs><clipPath id="c"><rect x="0" y="0" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/></clipPath></defs>
366<circle cx="5" cy="${H / 2}" r="4" fill="${color}"/>
367<text class="t" x="16" y="${H / 2 + 4.5}" font-family="${FONT}" font-size="13" font-weight="500">${xml(title)}</text>
368<g transform="translate(${BAR_X},${y0})">
369<rect class="k" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/>
370<g clip-path="url(#c)">
371<g fill="${color}">${layer(dots)}</g>
372<g fill="${color}" opacity="0.45">${layer(faint)}</g>
373<g class="tk">${ticks.join('')}</g>
374</g>
375<rect x="${pillX}" width="${pillW}" height="${BAR_H}" rx="${BAR_H / 2}" fill="${color}"/>
376<text x="${pillX + pillW / 2}" y="${BAR_H / 2 + 4}" text-anchor="middle" font-family="${FONT}" font-size="11" font-weight="600" fill="#ffffff">${xml(text)}</text>
377</g>
378<text class="m" x="${W - CRAB_W - 6}" y="${H / 2 + 4.5}" text-anchor="end" font-family="${FONT}" font-size="12.5" font-variant-numeric="tabular-nums">${percent}</text>
379${crabCss(walker)}${walker}
380</svg>`
381}
382
383const barText = (f: Flow, width: number): string => {
384  const filled = Math.round(width * ratio(f))
385  return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
386}
387
388// ---------------------------------------------------------------------------
389// Agents panel: every subagent of the session, plus the tasks the flow planned.
390
391const TIER_COLOR: Record<string, string> = {
392  fable: '#7F77DD',
393  heavy: '#D85A30',
394  careful: '#BA7517',
395  medium: '#378ADD',
396  light: '#1D9E75',
397  other: '#888780',
398}
399
400const colorOf = (tier: string): string => TIER_COLOR[tier] ?? '#888780'
401
402const TIER_MODEL: Record<string, string> = {
403  fable: 'Fable',
404  heavy: 'Opus',
405  careful: 'Opus',
406  medium: 'Sonnet',
407  light: 'Haiku',
408}
409
410// USD per million tokens: input, output, cache read, cache write (5-minute TTL).
411// The engine reports tokens, not money, so the panel's cost is an estimate.
412const PRICES: [RegExp, [number, number, number, number]][] = [
413  [/fable|mythos/, [10, 50, 0.25, 12.5]],
414  [/opus-5-5/, [4, 20, 0.2, 5]],
415  [/opus/, [5, 25, 0.5, 6.25]],
416  [/sonnet/, [2, 10, 0.2, 2.5]],
417  [/haiku/, [1, 5, 0.1, 1.25]],
418]
419
420type Usage = {
421  input_tokens: number
422  output_tokens: number
423  cache_read_input_tokens: number
424  cache_creation_input_tokens: number
425}
426
427const priceOf = (model: string): [number, number, number, number] =>
428  PRICES.find(([re]) => re.test(model.toLowerCase()))?.[1] ?? [4, 20, 0.2, 5]
429
430const costOf = (model: string, u: Usage): number => {
431  const [i, o, r, w] = priceOf(model)
432  return (
433    ((u.input_tokens || 0) * i +
434      (u.output_tokens || 0) * o +
435      (u.cache_read_input_tokens || 0) * r +
436      (u.cache_creation_input_tokens || 0) * w) /
437    1e6
438  )
439}
440
441const windowOf = (model: string): number => (/haiku/i.test(model) ? 200_000 : 1_000_000)
442
443// What each tier runs on, for planned tasks that have no run yet.
444// A run takes its tier from an agent type named after one (`careful`, `plugin:careful`), else from its model: Fable is fable, Opus heavy at xhigh or max
445// effort and careful below, Sonnet medium, Haiku light.
446const tierOf = (type: string, model = '', effort = ''): string => {
447  const named = type.replace(/^[^:]*:/, '').toLowerCase()
448  if (named in TIER_MODEL) return named
449  const m = model.toLowerCase()
450  if (/fable|mythos/.test(m)) return 'fable'
451  if (/opus/.test(m)) return /xhigh|max/.test(effort) ? 'heavy' : 'careful'
452  if (/sonnet/.test(m)) return 'medium'
453  if (/haiku/.test(m)) return 'light'
454  return 'other'
455}
456
457const tierOfRun = (a: AgentRun): string => tierOf(a.type, a.model, a.effort)
458
459const modelName = (id: string): string => {
460  const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
461  const [, family = '', major = '', minor] = m ?? []
462  if (!family) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '') || '—'
463  return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()} ${major}${minor ? '.' + minor : ''}`
464}
465
466const norm = (s: string): string => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
467
468const fmtTokens = (n: number): string =>
469  n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${Math.round(n)}`
470
471const fmtCost = (usd: number): string => `$${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`
472
473const fmtTime = (ms: number): string => {
474  const s = Math.max(0, Math.round(ms / 1000))
475  const h = Math.floor(s / 3600)
476  const m = Math.floor((s % 3600) / 60)
477  const ss = String(s % 60).padStart(2, '0')
478  return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
479}
480
481const elapsed = (a: AgentRun, at: number): number => (a.endedAt ?? Math.max(at, a.startedAt)) - a.startedAt
482
483type Planned = PlannedTask & { n: number }
484
485// The runs started since the flow began: earlier runs of the session are not its tasks.
486const runsOf = (f: Flow, list: AgentRun[]): AgentRun[] => list.filter(a => a.startedAt >= (f.startedAt ?? 0))
487
488const plannedOf = (f: Flow | null, list: AgentRun[]): Planned[] => {
489  if (!f || f.isFinished) return []
490  const started = new Set(runsOf(f, list).map(a => norm(a.description)))
491  return (f.tasks ?? []).map((t, i) => ({ ...t, n: i + 1 })).filter(t => !started.has(norm(t.title)))
492}
493
494// The time some run was at work: runs side by side count once, and the idle spells
495// between them (the session keeps its runs across flows) not at all.
496const busyTime = (list: AgentRun[], at: number): number => {
497  const spans = list.map(a => [a.startedAt, a.endedAt ?? Math.max(at, a.startedAt)] as const).sort((x, y) => x[0] - y[0])
498  let total = 0
499  let end = -Infinity
500  for (const [from, to] of spans) {
501    if (to <= end) continue
502    total += to - Math.max(from, end)
503    end = to
504  }
505  return total
506}
507
508const totals = (list: AgentRun[], at: number) => {
509  const cost = list.reduce((s, a) => s + a.costUsd, 0)
510  const tokens = list.reduce((s, a) => s + a.tokens, 0)
511  return { cost, tokens, time: busyTime(list, at) }
512}
513
514// --- desktop drawings: each row is one SVG, as the band above the prompt is.
515
516const PANE_CSS = `<style>
517.t{fill:#1f1f1f}.s{fill:#6b6b68}.m{fill:#9a9a96}.k{fill:#ecebe8}.ln{stroke:#e4e4e1}.tile{fill:#f4f3f0}
518@media (prefers-color-scheme: dark){.t{fill:#ececec}.s{fill:#a8a8a4}.m{fill:#7d7d79}.k{fill:#2c2c2b}.ln{stroke:#333331}.tile{fill:#262625}}
519.live{animation:p 1.6s ease-in-out infinite}@keyframes p{50%{opacity:.3}}
520@media (prefers-reduced-motion: reduce){.live{animation:none}}
521</style>`
522
523// Pixel Clawd from DockCrab (Clawdy): a 24×18 crab on a 30×28 grid, one costume per tier.
524// The body keeps the brand clay; the tier's color lives in the costume's accent.
525const CLAY = '#D97757'
526const INK = '#1F1E1D'
527
528// `cls` puts a pixel in a named group: `bd` (the default) is the body and its
529// costume, `la`/`lb` the leg pairs, anything else a prop with its own motion.
530type Fill = (x: number, y: number, w: number, h: number, c: string, cls?: string) => void
531
532const stamp = (f: Fill, x: number, y: number, rows: string[], map: Record<string, string>, cls?: string): void =>
533  rows.forEach((row, dy) => [...row].forEach((ch, dx) => map[ch] && f(x + dx, y + dy, 1, 1, map[ch] ?? '', cls)))
534
535// `armCls` lets a raised claw travel with the prop it holds.
536const crabBody = (f: Fill, armFront = 0, armCls?: string): void => {
537  f(7, 10, 16, 12, CLAY)
538  f(3, 14, 4, 4, CLAY)
539  f(23, 14 + armFront, 4, 4, CLAY, armCls)
540  f(9, 12, 2, 2, INK)
541  f(19, 12, 2, 2, INK)
542  f(7, 22, 2, 4, CLAY, 'la')
543  f(17, 22, 2, 4, CLAY, 'la')
544  f(11, 22, 2, 4, CLAY, 'lb')
545  f(21, 22, 2, 4, CLAY, 'lb')
546}
547
548// Pure CSS, run by the compositor: no redraws. Periods divide one second, so the
549// once-a-second redraw of a running row restarts them in phase. Every crab walks;
550// each costume adds its prop's own motion on top. A drawing carries only the rules
551// of the costumes walking in it, and none when no crab walks.
552const WALK_CSS = `.run .la{animation:st .5s steps(1) infinite}.run .lb{animation:st .5s steps(1) infinite -.25s}
553.run .bd{animation:bob .5s steps(1) infinite -.125s}
554.run g{transform-box:fill-box}
555@keyframes st{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}
556@keyframes blink{50%{opacity:.15}}
557@media (prefers-reduced-motion: reduce){.run,.run g{animation:none!important}}`
558
559const COSTUME_CSS: Record<string, string> = {
560  fable: `.c-fable.run{animation:float 1s ease-in-out infinite}
561.c-fable.run .la,.c-fable.run .lb,.c-fable.run .bd{animation:none}
562.c-fable.run .ant{animation:blink 1s steps(1) infinite}
563.c-fable.run .star{animation:blink .5s steps(1) infinite -.25s}
564@keyframes float{50%{transform:translateY(-2px)}}`,
565  heavy: `.c-heavy.run .it{animation:scan 1s steps(1) infinite}
566.c-heavy.run .gl{animation:blink 1s steps(1) infinite -.5s}
567@keyframes scan{25%{transform:translate(-1px,1px)}50%{transform:translate(-2px,2px)}75%{transform:translate(-1px,1px)}}`,
568  careful: `.c-careful.run .it{transform-origin:100% 100%;animation:twist .5s ease-in-out infinite}
569@keyframes twist{50%{transform:rotate(-35deg)}}`,
570  medium: `.c-medium.run .pan{transform-origin:0 50%;animation:tilt 1s ease-in-out infinite}
571.c-medium.run .egg{animation:flip 1s ease-in-out infinite}
572@keyframes tilt{20%,40%{transform:rotate(-12deg)}}@keyframes flip{30%{transform:translateY(-5px) scaleY(-1)}60%{transform:translateY(0)}}`,
573  light: `.c-light.run .la{animation-duration:.25s}.c-light.run .lb{animation-duration:.25s;animation-delay:-.125s}
574.c-light.run .flag{transform-origin:0 50%;animation:wave .25s steps(1) infinite}
575@keyframes wave{50%{transform:skewY(-12deg) scaleX(.85)}}`,
576  explore: `.c-explore.run .it{transform-origin:50% 100%;animation:fence .5s ease-in-out infinite}
577@keyframes fence{50%{transform:rotate(25deg)}}`,
578}
579
580const crabCss = (markup: string): string => {
581  const walking = new Set([...markup.matchAll(/class="c-([a-z]+) run"/g)].map(m => m[1] ?? ''))
582  if (!walking.size) return ''
583  return `<style>${WALK_CSS}${[...walking].map(c => COSTUME_CSS[c] ?? '').join('')}</style>`
584}
585
586const COSTUMES: Record<string, (f: Fill, t: string) => void> = {
587  // Fable: astronaut in a glass dome; floats instead of walking, the antenna and the star blink.
588  fable: (f, t) => {
589    crabBody(f)
590    f(6, 7, 18, 1, '#E6E8EE'); f(5, 8, 1, 14, '#E6E8EE'); f(24, 8, 1, 14, '#E6E8EE'); f(6, 22, 18, 1, '#C9CCD2')
591    f(6, 8, 18, 14, 'rgba(169,214,245,.32)'); f(8, 9, 2, 1, '#fff'); f(8, 10, 1, 2, '#fff')
592    f(14, 4, 2, 3, '#C9CCD2'); f(14, 2, 2, 2, t, 'ant'); f(13, 18, 4, 2, t)
593    f(27, 3, 1, 3, '#F5C542', 'star'); f(26, 4, 3, 1, '#F5C542', 'star')
594  },
595  // Heavy: detective with a deerstalker; the magnifier sweeps and glints.
596  heavy: (f, t) => {
597    crabBody(f, -4, 'it')
598    stamp(f, 6, 3, ['......bbbbbb......', '....bbcbbcbbbb....', '...bbbbbbbbbbbb...', '..bcbbcbbcbbcbbb..', '.bbbbbbbbbbbbbbbb.', 'dddddddddddddddddd'], { b: '#7A4A26', c: '#A0703F', d: '#5A3519' })
599    f(6, 9, 18, 1, t)
600    stamp(f, 23, 1, ['.kkk.', 'k...k', 'k...k', 'k...k', '.kkk.'], { k: '#3A3A3C' }, 'it')
601    f(24, 2, 3, 3, 'rgba(169,214,245,.7)', 'it'); f(25, 6, 1, 4, '#7A4A26', 'it'); f(24, 2, 1, 1, '#fff', 'gl')
602  },
603  // Careful: engineer in a hard hat; the wrench turns a bolt.
604  careful: (f, t) => {
605    crabBody(f)
606    stamp(f, 6, 4, ['.....yyyyyyyy.....', '...yyyyyhhyyyyy...', '..yyyyyyhhyyyyyy..', '..yyyyyyhhyyyyyy..', '.yyyyyyyhhyyyyyyy.', 'dddddddddddddddddd'], { y: '#F5C542', h: '#FBE08A', d: '#C99A1E' })
607    f(13, 5, 4, 2, t)
608    stamp(f, 0, 10, ['.s.s', 'sss.', '.s..', '.s..'], { s: '#8E929A' }, 'it')
609  },
610  // Medium: chef, the toque traced from DockCrab's Sprites.chefHat; tosses the omelette.
611  medium: (f, t) => {
612    crabBody(f, -4, 'pan')
613    stamp(f, 6, 0, ['........lll.......', '.......lllll......', '.wwwwgwwwwwwgwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwgwwwwwggwwwww', '.wwwwgwwwwwggwwwww', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.'], { w: '#F4F3EE', l: '#F7F6F2', g: '#D2D1C8', b: t, d: '#B45F43' })
614    f(22, 8, 7, 2, '#4A4A48', 'pan'); f(26, 10, 1, 1, '#4A4A48', 'pan'); f(24, 7, 3, 1, '#F5B731', 'egg')
615  },
616  // Light: racer in a helmet; runs at double pace, the checkered flag flutters.
617  light: (f, t) => {
618    crabBody(f, -4)
619    stamp(f, 6, 5, ['....rrrrrrrrrr....', '..rrrrrrwwrrrrrr..', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.kkkkkkkkkkkkkkkkr'], { r: t, w: '#F8F6F1', k: INK })
620    f(25, 1, 1, 9, '#8E929A')
621    stamp(f, 26, 1, ['wkwk', 'kwkw', 'wkwk'], { w: '#F8F6F1', k: INK }, 'flag')
622  },
623  // Explore: pirate scouting the code; the cutlass fences.
624  explore: f => {
625    crabBody(f)
626    stamp(f, 5, 3, ['.kk..............kk.', '.kkk....kkkk....kkk.', '..kkkkkkkwwkkkkkkk..', '..kkkkkkkkkkkkkkkk..', '.gggggggggggggggggg.'], { k: '#55514C', w: '#F8F6F1', g: '#F5C542' })
627    f(7, 11, 11, 1, INK); f(18, 11, 4, 3, INK)
628    f(27, 6, 1, 9, '#C9CCD2', 'it'); f(26, 15, 3, 1, '#7A4A26', 'it')
629  },
630  other: f => crabBody(f),
631}
632
633const costumeOf = (a: AgentRun): string => (a.type === 'Explore' ? 'explore' : tierOfRun(a))
634
635const CRAB_SCALE = 1.1
636
637// Body and props nest inside `bd` so a prop rides the bob and adds its own motion;
638// legs stay outside it and step on their own.
639const crab = (x: number, y: number, costume: string, dim = false, isWalking = false, scale = CRAB_SCALE): string => {
640  // Cells in a row of one colour become one path, in drawing order, so what
641  // overlaps still overlaps the same way: a few paths instead of ~150 rects.
642  const groups = new Map<string, { c: string; d: string }[]>([['bd', []]])
643  const f: Fill = (cx, cy, w, h, c, cls = 'bd') => {
644    const runs = groups.get(cls) ?? []
645    groups.set(cls, runs)
646    const cell = `M${cx} ${cy}h${w}v${h}h${-w}z`
647    const last = runs[runs.length - 1]
648    if (last?.c === c) last.d += cell
649    else runs.push({ c, d: cell })
650  }
651  const draw = COSTUMES[costume] ?? ((g: Fill) => crabBody(g))
652  draw(f, colorOf(costume))
653  const paths = (cls: string) => (groups.get(cls) ?? []).map(r => `<path fill="${r.c}" d="${r.d}"/>`).join('')
654  const group = (cls: string) => `<g class="${cls}">${paths(cls)}</g>`
655  const props = [...groups.keys()].filter(k => k !== 'bd' && k !== 'la' && k !== 'lb')
656  const body = `<g class="bd">${paths('bd')}${props.map(group).join('')}</g>`
657  return `<g transform="translate(${x},${y}) scale(${scale})" opacity="${dim ? 0.45 : 1}" shape-rendering="crispEdges"><g class="c-${costume}${isWalking ? ' run' : ''}">${body}${group('la')}${group('lb')}</g></g>`
658}
659
660const statusMark = (x: number, y: number, status: string, color: string): string => {
661  if (status === 'running') return `<circle class="live" cx="${x}" cy="${y}" r="3.5" fill="${color}"/>`
662  if (status === 'done') return `<path d="M${x - 5} ${y}l3.5 3.5 6.5-7" fill="none" stroke="#3B9C5F" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>`
663  if (status === 'failed') return `<path d="M${x - 4} ${y - 4}l8 8M${x + 4} ${y - 4}l-8 8" stroke="#D0453F" stroke-width="1.8" stroke-linecap="round"/>`
664  return `<circle cx="${x}" cy="${y}" r="5" fill="none" stroke="#9a9a96" stroke-width="1.4"/><path d="M${x} ${y - 2.5}v2.8l1.8 1.2" fill="none" stroke="#9a9a96" stroke-width="1.4" stroke-linecap="round"/>`
665}
666
667const svg = (W: number, H: number, body: string): string =>
668  `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${PANE_CSS}${crabCss(body)}${body}</svg>`
669
670// The pane's own title already says "Agents": the header names the flow, if any.
671const headerSvg = (W: number, title: string, t: ReturnType<typeof totals>): string => {
672  const s = tr()
673  const gap = 6
674  const tw = (W - gap * 2) / 3
675  const top = title ? 28 : 0
676  const tile = (i: number, k: string, v: string) =>
677    `<rect class="tile" x="${i * (tw + gap)}" y="${top}" width="${tw}" height="40" rx="8"/>
678<text class="s" x="${i * (tw + gap) + 9}" y="${top + 16}" font-family="${FONT}" font-size="11">${k}</text>
679<text class="t" x="${i * (tw + gap) + 9}" y="${top + 33}" font-family="${FONT}" font-size="15" font-weight="600" font-variant-numeric="tabular-nums">${v}</text>`
680  return svg(
681    W,
682    headerHeight(title),
683    `${title ? `<text class="t" x="0" y="15" font-family="${FONT}" font-size="14" font-weight="600">${xml(fitText(title, 14, W))}</text>` : ''}
684${tile(0, s.cost, '≈' + fmtCost(t.cost))}${tile(1, s.tokens, fmtTokens(t.tokens))}${tile(2, s.time, fmtTime(t.time))}`,
685  )
686}
687
688const headerHeight = (title: string): number => (title ? 72 : 44)
689
690// The task's own progress when the worker reports steps; a finished run is full.
691const progressOf = (a: AgentRun): number | null => {
692  if (a.status === 'done') return 1
693  if (a.stepTotal) return Math.min(1, (a.stepDone ?? 0) / a.stepTotal)
694  return null
695}
696
697// The task's own percentage, for a run that reports its steps. A run that answered is
698// full, though it may answer before reporting its last step; a failed one keeps its count.
699const stepsOf = (a: AgentRun, sep: string): string => {
700  if (!a.stepTotal) return ''
701  const done = a.status === 'done' ? a.stepTotal : (a.stepDone ?? 0)
702  return `${Math.round((progressOf(a) ?? 0) * 100)}% · ${done}/${a.stepTotal}${a.stepNote ? sep + a.stepNote : ''}`
703}
704
705const ctxOf = (a: AgentRun): number => (a.contextMax ? Math.min(100, Math.round((a.contextTokens / a.contextMax) * 100)) : 0)
706
707// A run another subagent started sits under it, this many pixels in per level.
708const INDENT = 16
709
710const agentSvg = (full: number, a: AgentRun, at: number, depth = 0, isViewed = false): string => {
711  const s = tr()
712  const ind = Math.min(depth, MAX_DEPTH) * INDENT
713  const W = full - ind
714  const tier = tierOfRun(a)
715  const color = colorOf(tier)
716  const ctx = ctxOf(a)
717  const textW = W - 42 - 22
718  const meta = [a.effort ? `${modelName(a.model)} · ${a.effort}` : modelName(a.model)]
719  if (a.round > 1) meta.push(`${s.round} ${a.round}`)
720  if (a.status === 'failed') meta.push(s.failed)
721  const barW = textW
722  const progress = progressOf(a)
723  const stats = `ctx ${ctx}% · ${fmtTokens(a.contextTokens)}  ≈${fmtCost(a.costUsd)}  ${fmtTime(elapsed(a, at))}`
724  const steps = stepsOf(a, ' · ')
725  const stepsW = Math.max(0, barW - textWidth(stats, 11) - 12)
726  // Without reported steps the bar falls back to the context, drawn grey.
727  const fillW = Math.round(barW * (progress ?? ctx / 100))
728  // The run in the main view sits on a tile; a nested one hangs from an elbow.
729  const tile = isViewed ? `<rect class="tile" x="-4" y="1" width="${W + 4}" height="63" rx="8"/>` : ''
730  const elbow = ind ? `<path class="ln" d="M${ind - 10} 0v24h6" fill="none"/>` : ''
731  return svg(
732    full,
733    66,
734    `${elbow}<g transform="translate(${ind},0)">${tile}${crab(0, 14, costumeOf(a), false, a.status === 'running')}
735<text class="t" x="42" y="18" font-family="${FONT}" font-size="13" font-weight="600">${xml(fitText(a.description || a.type, 13, textW))}</text>
736<text x="42" y="34" font-family="${FONT}" font-size="11"><tspan fill="${color}">${xml(tier === 'other' ? a.type : tier)}</tspan><tspan class="s">  ${xml(meta.join('  ·  '))}</tspan></text>
737${steps && stepsW > 30 ? `<text class="t" x="42" y="49" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${xml(fitText(steps, 11, stepsW))}</text>` : ''}
738<text class="s" x="${42 + barW}" y="49" text-anchor="end" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${stats}</text>
739<rect class="k" x="42" y="55" width="${barW}" height="4" rx="2"/><rect${progress === null ? ' class="m"' : ''} x="42" y="55" width="${fillW}" height="4" rx="2"${progress === null ? '' : ` fill="${color}"`}/>
740${statusMark(W - 8, 16, a.status, color)}
741<line class="ln" x1="0" y1="65.5" x2="${W}" y2="65.5"/></g>`,
742  )
743}
744
745const plannedSvg = (W: number, p: Planned): string => {
746  const tier = p.tier in TIER_COLOR ? p.tier : 'other'
747  const color = colorOf(tier)
748  const textW = W - 42 - 22
749  const meta = [TIER_MODEL[tier] ?? '']
750  if (p.after.length) meta.push(`${tr().after} ${p.after.join(', ')}`)
751  return svg(
752    W,
753    46,
754    `${crab(0, 6, tier, true)}
755<text class="s" x="42" y="18" font-family="${FONT}" font-size="13" font-weight="600">${xml(fitText(`${p.n}. ${p.title}`, 13, textW))}</text>
756<text x="42" y="34" font-family="${FONT}" font-size="11"><tspan fill="${color}">${xml(tier)}</tspan><tspan class="m">  ${xml(meta.filter(Boolean).join('  ·  '))}</tspan></text>
757${statusMark(W - 8, 16, 'planned', color)}
758<line class="ln" x1="0" y1="45.5" x2="${W}" y2="45.5"/>`,
759  )
760}
761
762const compactSvg = (W: number, list: AgentRun[], planned: Planned[], t: ReturnType<typeof totals>): string => {
763  const icons = [
764    ...list.filter(a => a.status === 'running').map(a => ({ k: costumeOf(a), c: colorOf(tierOfRun(a)), s: 'running', dim: false })),
765    ...list.filter(a => a.status !== 'running').map(a => ({ k: costumeOf(a), c: colorOf(tierOfRun(a)), s: a.status, dim: false })),
766    ...planned.map(p => ({ k: p.tier in TIER_COLOR ? p.tier : 'other', c: colorOf(p.tier), s: 'planned', dim: true })),
767  ]
768  const fit = Math.max(1, Math.floor((W - 150) / 36))
769  const shown = icons.slice(0, fit)
770  const more = icons.length - shown.length
771  const body = shown
772    .map((ic, i) => crab(i * 36, 0, ic.k, ic.dim, ic.s === 'running') + (ic.s === 'running' ? `<circle class="live" cx="${i * 36 + 32}" cy="4" r="3" fill="${ic.c}"/>` : ''))
773    .join('')
774  const x = shown.length * 36 + (more ? 4 : 0)
775  return svg(
776    W,
777    32,
778    `${body}${more ? `<text class="s" x="${x}" y="21" font-family="${FONT}" font-size="12">+${more}</text>` : ''}
779<text class="s" x="${W}" y="21" text-anchor="end" font-family="${FONT}" font-size="12" font-variant-numeric="tabular-nums">≈${fmtCost(t.cost)} · ${fmtTokens(t.tokens)} · ${fmtTime(t.time)}</text>`,
780  )
781}
782
783// --- terminal drawing: the same rows in text.
784
785const ctxBar = (pct: number, width: number): string => {
786  const filled = Math.round((width * pct) / 100)
787  return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
788}
789
790// The pane draws the latest finished runs; the totals still count every run.
791const FINISHED_SHOWN = 10
792
793// CSS pixels the desktop keeps beside each run's drawing for its transcript button.
794const VIEW_W = 72
795
796// Claude Code drops a finished subagent from its tasks list 30 s after it ends, and
797// $.ui.view finds only the agents still there. A redraw a little later takes the press
798// off the runs it let go.
799const RELEASED_MS = 31_000
800
801// The ids $.agent.list() still holds; null where it could not say.
802async function listedIds($: EngineInterface): Promise<Set<string> | null> {
803  try {
804    return new Set((await $.agent.list()).map(a => a.id))
805  } catch {
806    return null
807  }
808}
809
810// How deep the pane indents a run started by another subagent; deeper ones stay at it.
811const MAX_DEPTH = 3
812
813type Nested = { a: AgentRun; depth: number }
814
815// Each run followed by the runs it started, a level deeper, when they share its
816// section; one whose parent is drawn elsewhere, or not at all, starts a tree of its own.
817const nest = (rows: AgentRun[]): Nested[] => {
818  const ids = new Set(rows.map(a => a.agentId).filter(Boolean))
819  const out: Nested[] = []
820  const seen = new Set<AgentRun>()
821  const walk = (a: AgentRun, depth: number): void => {
822    if (seen.has(a)) return
823    seen.add(a)
824    out.push({ a, depth })
825    for (const c of rows) if (c.parentId && c.parentId === a.agentId) walk(c, depth + 1)
826  }
827  for (const a of rows) if (!a.parentId || !ids.has(a.parentId)) walk(a, 0)
828  for (const a of rows) walk(a, 0)
829  return out
830}
831
832const STATUS_GLYPH: Record<string, string> = { running: '●', done: '✓', failed: '✗', planned: '◷' }
833
834// Escape closes it, from the pane or an idle, empty prompt; over a turn or text it stays the person's.
835const PANE_OPEN = () => ({ id: PANE, title: tr().pane, closeOnEscape: true }) as const
836
837// Opens the agents pane, or closes it when it is up; true when it ends up open.
838// Up means drawn and in front: one opened unasked on a narrow terminal waits undrawn,
839// and one behind another pane's tab is out of sight, so the person's ask opens those.
840async function togglePane($: EngineInterface): Promise<boolean> {
841  const mine = (await $.ui.panes()).find(p => p.id === PANE)
842  if (mine?.isPlaced && mine.isShown) {
843    await $.ui.close({ id: PANE })
844    return false
845  }
846  const at = await $.clock.now()
847  await update($, now, () => at)
848  await $.ui.open(PANE_OPEN())
849  return true
850}
851
852// Marks the key once the pane opened (or waits for room, which seats it later);
853// an open that fails leaves it unmarked, so the next run tries again.
854async function autoOpen($: EngineInterface, key: string): Promise<void> {
855  const p = await read($, panel)
856  if (p.autoOpenedFor === key) return
857  void $.ui
858    .open(PANE_OPEN())
859    .then(() => update($, panel, prev => ({ ...prev, autoOpenedFor: key })))
860    .catch(() => {})
861}
862
863// Ticks the running runs' clocks once a second, and stops when none runs: an idle
864// session reads nothing. A run that starts or resumes starts it again.
865let ticker: Timer | null = null
866
867function startTicker($: EngineInterface): void {
868  if (ticker) return
869  ticker = $.clock.every(1000, () => {
870    void (async () => {
871      if (!(await read($, agents)).some(a => a.status === 'running')) {
872        ticker?.cancel()
873        ticker = null
874        return
875      }
876      const at = await $.clock.now()
877      await update($, now, () => at)
878    })()
879  })
880}
881
882// A finished flow leaves the band after FINISHED_LINGER_MS, unless a newer one took its place.
883function stepAside($: EngineInterface, f: Flow): void {
884  const finishedAt = f.finishedAt
885  if (finishedAt === undefined) return
886  void (async () => {
887    const wait = Math.max(0, finishedAt + FINISHED_LINGER_MS - (await $.clock.now()))
888    $.clock.after(wait, () => {
889      void patchFlow($, prev => (prev?.isFinished && prev.finishedAt === finishedAt ? null : prev))
890    })
891  })()
892}
893
894// Nothing another plugin drew: core's own band (an `engine` element, which the band
895// replaced before too) or an empty box.
896const isBlank = (el: RenderElement | undefined): boolean =>
897  !el || el.type === 'engine' || (el.type === 'Box' && (el.children ?? []).length === 0)
898
899export const register: Register = (on, options) => {
900  on('session.start', async ($, e, next) => {
901    const started = await next(e)
902    lang = await detectLang($, options.language)
903    canView = await engineHasView($)
904    // A reload drops pending timers: a finished flow held by the host still steps aside.
905    const held = await read($, flow)
906    if (held?.isFinished) stepAside($, held)
907    await $.tool.register({
908      name: 'progress',
909      description:
910        'Report the progress of work delegated to subagents to the progress bar above the prompt and the agents panel. ' +
911        'Call it after presenting the plan (title, total, tasks, phase "delegate"), each time a task is accepted (done), ' +
912        'when switching phase or re-planning (tasks), and once at the end with finished: true. Fields left out keep their previous value.',
913      inputSchema: {
914        type: 'object',
915        properties: {
916          title: { type: 'string', description: 'Short name of the overall task, a few words.' },
917          total: { type: 'integer', minimum: 0, description: 'Number of planned worker tasks.' },
918          done: { type: 'integer', minimum: 0, description: 'Number of tasks accepted after review.' },
919          phase: { type: 'string', enum: [...PHASES] },
920          finished: { type: 'boolean', description: 'True once the flow is closed.' },
921          tasks: {
922            type: 'array',
923            description:
924              'The planned worker tasks in order, numbered from 1. Each title must equal the Agent tool `description` the task will be delegated with, so the panel can match runs to tasks.',
925            items: {
926              type: 'object',
927              properties: {
928                title: { type: 'string', description: 'A few words; reused verbatim as the Agent description.' },
929                tier: {
930                  type: 'string',
931                  enum: ['fable', 'heavy', 'careful', 'medium', 'light'],
932                  description: 'The model the task runs on: fable = Fable, heavy and careful = Opus, medium = Sonnet, light = Haiku.',
933                },
934                after: { type: 'array', items: { type: 'integer' }, description: 'Numbers of the tasks this one waits for.' },
935              },
936              required: ['title', 'tier'],
937            },
938          },
939        },
940      },
941    })
942    await $.tool.register({
943      name: 'step',
944      description:
945        'For subagents: report progress on your own task to the agents panel. ' +
946        'Right after reading the brief, call it with `total` (your plan in 3-8 steps) and `done: 0`; ' +
947        'call it again as each step finishes. Cheap and silent: it only draws a bar.',
948      inputSchema: {
949        type: 'object',
950        properties: {
951          done: { type: 'integer', minimum: 0, description: 'Steps finished so far.' },
952          total: { type: 'integer', minimum: 1, description: 'Steps planned; may change if the plan changes.' },
953          note: { type: 'string', description: 'The step in progress, a few words.' },
954        },
955        required: ['done'],
956      },
957    })
958    await $.command.register({
959      name: 'agents-info',
960      description: 'Show or hide the panel of subagents: running, finished and planned, with model, context, cost and time',
961    })
962
963    // A reload drops the old ticker with the old module: start again for runs still at work.
964    if ((await read($, agents)).some(a => a.status === 'running')) startTicker($)
965    return started
966  })
967
968  on('command.run', { command: 'agents-info' }, async $ => {
969    const isOpen = await togglePane($)
970    return { text: isOpen ? tr().opened : tr().closed }
971  })
972
973  on('tool.call', { tool: TOOL }, async ($, e) => {
974    const input = e as unknown as ProgressInput
975    // The pane keeps every run of the session; a new flow only marks when it began.
976    const at = await $.clock.now()
977    const next = await patchFlow($, p => {
978      const merged = merge(p, input)
979      return {
980        ...merged,
981        ...(isNewFlow(p, input) ? { startedAt: at } : {}),
982        ...(merged.isFinished ? { finishedAt: at } : {}),
983      }
984    })
985    if (next?.isFinished) stepAside($, next)
986    if (next && input.tasks?.length) await autoOpen($, next.title)
987    return { result: `ok: ${label(next ?? blank(), runningOf(await read($, agents)))}` }
988  })
989
990  // A worker's own progress: the call runs in the worker's loop, so agentId names it.
991  on('tool.call', { tool: STEP_TOOL }, async ($, e) => {
992    const input = e as unknown as { done?: number; total?: number; note?: string }
993    const agentId = e.agentId
994    if (!agentId) return { result: 'ignored: only subagents report steps' }
995    await patchAgents($, list =>
996      list.map(a => {
997        if (a.agentId !== agentId) return a
998        const total = Math.max(0, Math.round(input.total ?? a.stepTotal ?? 0))
999        const done = Math.max(0, Math.round(input.done ?? a.stepDone ?? 0))
1000        return { ...a, stepTotal: total, stepDone: total ? Math.min(total, done) : done, stepNote: input.note?.trim() || undefined }
1001      }),
1002    )
1003    return { result: 'ok' }
1004  })
1005
1006  on('agent.spawn', async ($, e, next) => {
1007    // A resume (a parent woken when its background child answers, say) continues a run
1008    // already listed: its prompt is the waking message, which the engine keeps as sent.
1009    const isResume = e.resume !== undefined
1010    const started = await next(isResume ? e : { ...e, prompt: briefed(e.prompt, options.stepBrief !== 'off') })
1011    if (started.deny !== undefined) return started
1012
1013    const at = await $.clock.now()
1014    const id = started.agentId ?? e.resume?.agentId ?? e.tool_use_id
1015    const list = await read($, agents)
1016    const wasIdle = !list.some(a => a.status === 'running')
1017    const held = list.find(a => a.id === id)
1018    if (isResume && held) {
1019      // The same run at work again: its start, totals, steps and round stay.
1020      await update($, agents, all =>
1021        all.map(a => (a.id === id ? { ...a, model: started.model || a.model, status: 'running', endedAt: undefined } : a)),
1022      )
1023      await update($, now, () => at)
1024      startTicker($)
1025      return started
1026    }
1027    await update($, agents, all => {
1028      const round = 1 + all.filter(a => a.id !== id && e.description && norm(a.description) === norm(e.description)).length
1029      const run: AgentRun = {
1030        id,
1031        agentId: started.agentId ?? e.resume?.agentId,
1032        type: e.subagentType,
1033        description: e.description,
1034        ...(e.parentAgentId ? { parentId: e.parentAgentId } : {}),
1035        model: started.model,
1036        status: 'running',
1037        startedAt: at,
1038        contextTokens: 0,
1039        contextMax: windowOf(started.model),
1040        tokens: 0,
1041        costUsd: 0,
1042        steps: 0,
1043        round,
1044      }
1045      return [...all.filter(a => a.id !== run.id), run].slice(-200)
1046    })
1047    await update($, now, () => at)
1048    startTicker($)
1049    // A run started under a flow still planning moves it to delegating.
1050    await patchFlow($, prev => (prev && !prev.isFinished && prev.phase === 'plan' ? { ...prev, phase: 'delegate' } : prev))
1051    // Any subagent opens the panel: once per reported flow, else once per busy spell
1052    // (a run started while none was running), so closing it sticks until the next one.
1053    const f = await read($, flow)
1054    if (f && !f.isFinished) await autoOpen($, f.title)
1055    else if (wasIdle) await autoOpen($, `run:${id}`)
1056    return started
1057  })
1058
1059  // Each model request of a subagent: live context, tokens and cost.
1060  on('turn.step', async function* ($, e, next) {
1061    const result = yield* next(e)
1062    const agentId = e.agentId
1063    const usage = result.usage
1064    if (!agentId || !usage) return result
1065
1066    const model = usage.model || e.model
1067    await patchAgents($, list =>
1068      list.map(a =>
1069        a.agentId !== agentId
1070          ? a
1071          : {
1072              ...a,
1073              model,
1074              effort: typeof e.effort === 'string' ? e.effort : a.effort,
1075              status: 'running',
1076              endedAt: undefined,
1077              contextTokens:
1078                (usage.input_tokens || 0) +
1079                (usage.cache_read_input_tokens || 0) +
1080                (usage.cache_creation_input_tokens || 0) +
1081                (usage.output_tokens || 0),
1082              contextMax: windowOf(model),
1083              tokens:
1084                a.tokens +
1085                (usage.input_tokens || 0) +
1086                (usage.output_tokens || 0) +
1087                (usage.cache_read_input_tokens || 0) +
1088                (usage.cache_creation_input_tokens || 0),
1089              costUsd: a.costUsd + costOf(model, usage),
1090              steps: a.steps + 1,
1091            },
1092      ),
1093    )
1094    // A run resumed after it answered is at work again.
1095    startTicker($)
1096    return result
1097  })
1098
1099  on('turn.complete', async ($, e, next) => {
1100    const agentId = e.agentId
1101    if (agentId) {
1102      const at = await $.clock.now()
1103      const list = await patchAgents($, list =>
1104        list.map(a => {
1105          if (a.agentId !== agentId) return a
1106          // A run whose steps went unseen still gets the turn's own sum.
1107          const fallback = a.steps === 0 && e.usage
1108          return {
1109            ...a,
1110            status: e.reason === 'answer' ? 'done' : 'failed',
1111            endedAt: at,
1112            ...(fallback && e.usage
1113              ? {
1114                  model: e.usage.model || a.model,
1115                  tokens:
1116                    (e.usage.input_tokens || 0) +
1117                    (e.usage.output_tokens || 0) +
1118                    (e.usage.cache_read_input_tokens || 0) +
1119                    (e.usage.cache_creation_input_tokens || 0),
1120                  costUsd: costOf(e.usage.model || a.model, e.usage),
1121                }
1122              : {}),
1123          }
1124        }),
1125      )
1126      if (list.some(a => a.agentId === agentId)) {
1127        await update($, now, () => at)
1128        if (canView) $.clock.after(RELEASED_MS, () => $.ui.invalidate('ui.render'))
1129      }
1130    }
1131    return next(e)
1132  })
1133
1134  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1135    const s = tr()
1136    const ui = $.ui.resolve(e)
1137    const { Box, Text, Button } = ui
1138    const list = await read($, agents)
1139    const f = await read($, flow)
1140    const p: Panel = await read($, panel)
1141    const at = Math.max(await read($, now), ...list.map(a => a.startedAt), 0)
1142
1143    const running = list.filter(a => a.status === 'running').reverse()
1144    const finished = list.filter(a => a.status !== 'running').reverse()
1145    const shownDone = nest(p.isDoneCollapsed ? [] : p.isEarlierShown ? finished : finished.slice(0, FINISHED_SHOWN))
1146    const shownRunning = nest(running)
1147    // The agent whose transcript is in the main view, if any.
1148    const viewed = e.props.view?.agentId
1149    const earlier = p.isDoneCollapsed ? 0 : finished.length - finished.slice(0, FINISHED_SHOWN).length
1150    const planned = plannedOf(f, list)
1151    const t = totals(list, at)
1152    // The pane's title says "Agents"; inside, only the flow's own name.
1153    const title = f && !f.isFinished ? f.title : ''
1154
1155    const toggleCompact = (
1156      <Button
1157        key="compact"
1158        label={p.isCompact ? s.expand : s.collapse}
1159        plain
1160        hotkey="c"
1161        onPress={() => update($, panel, prev => ({ ...prev, isCompact: !prev.isCompact }))}
1162      />
1163    )
1164    const toggleDone = (
1165      <Button
1166        key="done"
1167        label={`${p.isDoneCollapsed ? '▸' : '▾'} ${s.finished} · ${finished.length}`}
1168        plain
1169        hotkey="f"
1170        onPress={() => update($, panel, prev => ({ ...prev, isDoneCollapsed: !prev.isDoneCollapsed }))}
1171      />
1172    )
1173    // Past the latest FINISHED_SHOWN, the rest of the session's runs open on demand.
1174    const toggleEarlier =
1175      earlier > 0 ? (
1176        <Button
1177          key="earlier"
1178          label={p.isEarlierShown ? `▴ ${s.fewer}` : `+${earlier} ${s.earlier}`}
1179          plain
1180          hotkey="e"
1181          onPress={() => update($, panel, prev => ({ ...prev, isEarlierShown: !prev.isEarlierShown }))}
1182        />
1183      ) : null
1184    // A press puts the run's transcript in the main view, or, on the run in view, the
1185    // main conversation back; a run the tasks list no longer holds says why not.
1186    // An engine before $.ui.view draws no such press: it would do nothing and say nothing.
1187    // Nor does a run the tasks list let go (the run in view keeps its way back), and one
1188    // let go since this drawing says so in the panel's language, not the engine's.
1189    const listed = canView ? await listedIds($) : null
1190    const canPress = (a: AgentRun, isViewed: boolean): a is AgentRun & { agentId: string } =>
1191      canView && !!a.agentId && (isViewed || !listed || listed.has(a.agentId))
1192    const show = (agentId?: string) => () => {
1193      void (async () => {
1194        const ids = agentId ? await listedIds($) : null
1195        if (agentId && ids && !ids.has(agentId)) {
1196          $.ui.toast(s.gone)
1197          $.ui.invalidate('ui.render')
1198          return
1199        }
1200        const shown = await $.ui.view(agentId ? { agentId } : {})
types/index.d.ts 66 lines
1export type Phase = 'plan' | 'design' | 'delegate' | 'review' | 'close'
2
3export type PlannedTask = {
4  title: string
5  tier: string
6  after: number[]
7}
8
9export type Flow = {
10  title: string
11  total: number
12  done: number
13  phase: Phase
14  isFinished: boolean
15  /** When the flow began; runs from before it are not its tasks. */
16  startedAt?: number
17  /** When the flow was reported finished; its band steps aside a little after. */
18  finishedAt?: number
19  tasks: PlannedTask[]
20}
21
22export type AgentStatus = 'running' | 'done' | 'failed'
23
24export type AgentRun = {
25  id: string
26  agentId?: string
27  type: string
28  description: string
29  model: string
30  effort?: string
31  status: AgentStatus
32  startedAt: number
33  endedAt?: number
34  contextTokens: number
35  contextMax: number
36  tokens: number
37  costUsd: number
38  steps: number
39  round: number
40  /** The agentId of the subagent that started this one; absent when the main loop did. */
41  parentId?: string
42  /** Self-reported by the worker through the `step` tool. */
43  stepDone?: number
44  stepTotal?: number
45  stepNote?: string
46}
47
48export type Panel = {
49  isCompact: boolean
50  isDoneCollapsed: boolean
51  /** Every finished run drawn, not only the latest ten; absent in a panel held from before. */
52  isEarlierShown?: boolean
53  autoOpenedFor: string
54}
55
56declare module 'claude-code' {
57  interface PluginState {
58    'agents-progress': {
59      flow: Flow | null
60      agents: AgentRun[]
61      panel: Panel
62      now: number
63    }
64  }
65}
66