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…

A Claude Code mod, forked from savvy-progress (MIT): a progress bar above the prompt and a live panel of subagents.
/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.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.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.progress, sits under any other plugin's band, and steps aside 10 seconds after the flow is reported finished.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.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.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.
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.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.
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.
Semantic Versioning 2.0.0, with version in .claude-plugin/plugin.json as the one source:
$.state contract in types/index.d.ts, a userConfig option removed or renamed, the /agents-info command.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.
hooks/register.tsx 1444 lines1import { 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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 lines1export 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