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

A Claude Code mod: a progress bar above the prompt and a live panel of subagents, each drawn as a crab whose costume follows the model and effort it runs on. Forked from johnnyvizz's savvy-progress 1.2.0 (MIT, see LICENSE) and renamed from savvy-progress in 2.0.0, since it shows any subagent work, with or without the savvy-flow skill.
a presses it while the band holds the focus). It appears when a subagent of any type starts, or when something reports progress. When the work ends it turns into a green Done row that stays until you dismiss it with ✕./agents-info toggles it): running, finished and planned subagents with model, effort, task progress, context, estimated cost and time. It opens by itself whenever a new batch of subagent work starts. Collapse shrinks it to one line per agent.prefers-reduced-motion stops them. The crabs are SVG, so they draw on the desktop; the terminal gets text rows in the costume's color.The costume follows what the agent runs on, so every subagent gets one without any special agent definitions:
| Costume | Who | Its prop |
|---|---|---|
| astronaut | Fable at any effort; Opus at high, xhigh or max | floats in a glass dome; the antenna and the star blink |
| detective | Opus at medium or low | the magnifier sweeps and glints |
| engineer | Sonnet at high, xhigh or max | the wrench turns a bolt |
| chef | Sonnet at medium or low | tosses the omelette |
| racer | Haiku | runs at double pace; the checkered flag flutters |
| pirate | the Explore agent, whatever it runs on | the cutlass fences |
| plain crab | any other model | walks |
turn.step), so a general-purpose agent switched to Haiku turns into a racer. An effort not known yet (before the run's first request) or a numeric budget counts as below high.sonnet), full ids and a [1m] suffix all work.savvy-* agent from upstream's savvy-flow follows the same rule from its model and effort. Only while its model is unknown does it fall back to its tier (savvy-fable Fable/high, savvy-heavy Opus/xhigh, savvy-careful Opus/high, savvy-medium Opus/medium, savvy-light Opus/low).general-purpose, savvy-careful), then model and effort.The mapping lives in hooks/crew.ts, a pure module with its own tests.
Upstream shows the bar only for savvy-* workers or an explicit progress report. Here:
Explore, general-purpose, a custom one, ...) opens an auto flow titled "Subagents". Its counters come from spawns and completions, in hooks/auto.ts: every start adds to the total, every finish adds to done, and once every agent of the batch has finished the row closes. Only the batch's own agents count, so an agent left "running" by an interrupted earlier batch cannot hold it open.savvy-* workers as before). Close it mid-batch and it stays closed until the next batch; /agents-info or the crew button reopens it any time.progress tool, or a savvy-* worker) is never touched by this: its counters stay the orchestrator's. Once an explicit report arrives it takes the flow over.While a flow was active, the bar took the whole band above the prompt: it never drew what the plugins beneath it draw there, so another mod's box (agent-pulse, say) disappeared under it. The bar now stacks above them.
A breaking release: the plugin's name is its tool and settings namespace, so see Moving from savvy-progress.
savvy-progress to subagent-progress: the plugin, the folder, the tool names (mcp__subagent-progress__progress, mcp__subagent-progress__step), the state, the pane and the pluginConfigs key. The /agents-info command is unchanged.savvy-* agents got a prop, and with savvy-flow not installed only the Explore pirate did. Now every subagent does.progress takes a model and effort per task: tier is optional now, and a planned task can say model (sonnet, haiku, a model id) and effort. Planned rows show "Sonnet · high" and wear the matching costume. A savvy tier, or a costume name, still works.progress and step no longer say they are for savvy-flow, and an untitled report is "Subagents".hooks/crew.test.ts (15 tests) and four costume render tests in hooks/render.test.ts.The desktop drawings are unchanged; this release is the terminal's text rendering.
'Svg' in ui too, which sent the band down its desktop path; the plugin test kit drew it as ×N ✕ alone. On the terminal it now always draws its text row: title, bar, phase, percent and crew.1:20 · ≈$0.42 · ctx 12% · 120k, then the step count and its note. A line cut at the pane's edge loses the note, not the cost and time. The panel's totals read the same way.◷ on planned rows (they sit under "Planned" already).▏▎▍▌▋▊▉), so a 6-cell bar moves in about 2% steps. It needs a font with block elements, as Cascadia and Nerd Fonts have.▣ use the theme keys success, error and claude, so they follow the theme and skin. Costume colors stay as they are.✓ in place of ● once the work ends.a, drawn a: ×N.Tasks 1 running, or the Russian labels), so the row no longer wraps at 80 columns.hooks/render.test.ts: the pane and the band drawn on the terminal at several widths, and the band's SVG on desktop (12 tests).The fork itself; see What this fork changes.
mcp__subagent-progress__progress: an orchestrator reports the plan, phase and accepted tasks. Optional: without it the bar follows spawns and completions. Each planned task is a title (the Agent description it will be delegated with) and, optionally, model, effort, a savvy tier, and after.mcp__subagent-progress__step: a worker reports its own steps (done, total, note). A worker that does not report shows its context fill in grey instead. A subagent only calls it if its prompt tells it to.Cost is a rough estimate from token counts and a built-in per-model price table (PRICES in hooks/register.tsx), not a bill.
language: auto (default), en or ru. auto follows Claude Code's language setting, then the system locale, and falls back to English. Set it in /config, or, for a mod loaded by hand, in ~/.claude/settings.json:
{ "pluginConfigs": { "subagent-progress": { "options": { "language": "ru" } } } }
/plugin install subagent-progress --marketplace afterever/claude-mods
Install instructions for the whole repository are in the repository README.
claude plugin uninstall savvy-progress@afterever-mods
claude plugin marketplace update afterever-mods
claude plugin install subagent-progress@afterever-mods
Then restart Claude Code.
savvy-progress: both register /agents-info. This applies to upstream's savvy-progress@claude-kit too.language setting under pluginConfigs.savvy-progress has to move to pluginConfigs.subagent-progress.mcp__savvy-progress__progress and __step, which no longer exist; they skip those calls silently, so the flow still runs and the bar follows the spawns. To keep its step reporting, change those two tool names in its skills/ and agents/ files.hooks/register.tsx 1120 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentRun, Flow, Panel, Phase, PlannedTask } from '../types'
5import { AUTO_TITLE, isCounted, isSavvyType, onFinish, onSpawn, openKey, startsBatch } from './auto'
6import { COSTUME_COLOR, bareType, costumeOf, plannedCostume, plannedProfile } from './crew'
7import type { Costume } from './crew'
8
9const flow = atom({ plugin: 'subagent-progress', key: 'flow' } as const, null)
10const agents = atom({ plugin: 'subagent-progress', key: 'agents' } as const, [])
11const panel = atom({ plugin: 'subagent-progress', key: 'panel' } as const, {
12 isCompact: false,
13 isDoneCollapsed: false,
14 autoOpenedFor: '',
15})
16const now = atom({ plugin: 'subagent-progress', key: 'now' } as const, 0)
17
18const TOOL = 'mcp__subagent-progress__progress'
19const STEP_TOOL = 'mcp__subagent-progress__step'
20const PANE = 'subagents'
21const PHASES: readonly Phase[] = ['plan', 'design', 'delegate', 'review', 'close']
22const ACCENT = '#8f8cf4'
23const DONE = '#5fbf8f'
24
25type ProgressInput = {
26 title?: string
27 total?: number
28 done?: number
29 phase?: Phase
30 finished?: boolean
31 tasks?: { title?: string; tier?: string; model?: string; effort?: string; after?: number[] }[]
32}
33
34// ---------------------------------------------------------------------------
35// Language: the `language` option, else Claude Code's `language` setting, else the
36// process locale; English when nothing says Russian.
37
38type Lang = 'en' | 'ru'
39
40const STRINGS = {
41 en: {
42 pane: 'Agents',
43 cost: 'Cost',
44 tokens: 'Tokens',
45 time: 'Time',
46 collapse: 'Collapse',
47 expand: 'Expand',
48 running: 'Running',
49 finished: 'Finished',
50 planned: 'Planned',
51 empty: 'No subagents yet.',
52 round: 'round',
53 failed: 'error',
54 after: 'after',
55 tokensWord: 'tokens',
56 agentsCount: 'agents',
57 isRunning: 'running',
58 isFinished: 'finished',
59 isPlanned: 'planned',
60 opened: 'Agents panel opened.',
61 closed: 'Agents panel closed.',
62 done: 'Done',
63 plan: 'Plan',
64 design: 'Design',
65 tasks: 'Tasks',
66 review: 'Review',
67 busy: 'running',
68 },
69 ru: {
70 pane: 'Агенты',
71 cost: 'Стоимость',
72 tokens: 'Токены',
73 time: 'Время',
74 collapse: 'Свернуть',
75 expand: 'Развернуть',
76 running: 'Работают',
77 finished: 'Завершены',
78 planned: 'Запланированы',
79 empty: 'Субагентов пока нет.',
80 round: 'раунд',
81 failed: 'ошибка',
82 after: 'после',
83 tokensWord: 'токенов',
84 agentsCount: 'агентов',
85 isRunning: 'работает',
86 isFinished: 'завершён',
87 isPlanned: 'запланирована',
88 opened: 'Панель агентов открыта.',
89 closed: 'Панель агентов закрыта.',
90 done: 'Готово',
91 plan: 'План',
92 design: 'Дизайн',
93 tasks: 'Задачи',
94 review: 'Ревью',
95 busy: 'в работе',
96 },
97} as const
98
99// Module scope is fine here: session.start sets it again on every (re)load.
100let lang: Lang = 'en'
101const tr = () => STRINGS[lang]
102
103const isRussian = (v: unknown): boolean => typeof v === 'string' && /^(ru|russian|рус)/i.test(v.trim())
104
105async function detectLang($: EngineInterface, option: unknown): Promise<Lang> {
106 if (option === 'en' || option === 'ru') return option
107 try {
108 const settings = (await $.settings.read()) as Record<string, unknown>
109 if (typeof settings.language === 'string' && settings.language.trim()) return isRussian(settings.language) ? 'ru' : 'en'
110 } catch {
111 // No settings: fall through to the locale.
112 }
113 const locale = (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG'))
114 return isRussian(locale) ? 'ru' : 'en'
115}
116
117const blank = (): Flow => ({
118 title: AUTO_TITLE,
119 total: 0,
120 done: 0,
121 running: 0,
122 phase: 'plan',
123 isFinished: false,
124 tasks: [],
125})
126
127const isNewFlow = (prev: Flow | null, input: ProgressInput): boolean =>
128 !prev || prev.isFinished || (input.title !== undefined && input.title.trim() !== prev.title)
129
130const cleanTasks = (tasks: ProgressInput['tasks']): PlannedTask[] | undefined =>
131 tasks
132 ?.filter(t => t.title?.trim())
133 .map(t => ({
134 title: (t.title ?? '').trim(),
135 tier: (t.tier ?? '').replace(/^savvy-/, '').trim().toLowerCase(),
136 model: t.model?.trim() || undefined,
137 effort: t.effort?.trim().toLowerCase() || undefined,
138 after: (t.after ?? []).filter(n => Number.isInteger(n) && n > 0),
139 }))
140
141const merge = (prev: Flow | null, input: ProgressInput): Flow => {
142 // A new title means a new flow: never carry counters over from an earlier one.
143 const base = isNewFlow(prev, input) || !prev ? blank() : { ...blank(), ...prev }
144 const tasks = cleanTasks(input.tasks) ?? base.tasks
145 const total = Math.max(0, Math.round(input.total ?? (input.tasks ? tasks.length : base.total)))
146 const done = Math.min(total || Infinity, Math.max(0, Math.round(input.done ?? base.done)))
147 const phase = input.phase && PHASES.includes(input.phase) ? input.phase : base.phase
148 return {
149 ...base,
150 title: input.title?.trim() || base.title,
151 total,
152 done,
153 phase: input.finished ? 'close' : phase,
154 isFinished: input.finished === true,
155 tasks,
156 // An explicit report takes the flow over from the spawn-derived one.
157 isAuto: false,
158 }
159}
160
161const label = (f: Flow): string => {
162 const s = tr()
163 if (f.isFinished) return s.done
164 if (f.phase === 'plan') return s.plan
165 if (f.phase === 'design') return s.design
166 const count = f.total ? `${f.done}/${f.total}` : `${f.running} ${s.busy}`
167 return `${f.phase === 'review' ? s.review : s.tasks} ${count}`
168}
169
170const ratio = (f: Flow): number => (f.isFinished ? 1 : f.total ? f.done / f.total : 0)
171
172// Deterministic noise so the dither does not shimmer between redraws.
173const noise = (x: number, y: number): number => {
174 const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
175 return s - Math.floor(s)
176}
177
178// The whole row is one SVG: the desktop wraps sibling elements onto new lines,
179// so title, bar, percent and the crab live in one drawing; only the count and
180// the dismiss are Buttons beside it.
181const H = 22
182const BAR_H = 16
183const CRAB_W = 26
184const CELL = 3
185const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
186
187const xml = (s: string): string =>
188 s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
189
190const clip = (s: string, max: number): string => (s.length > max ? s.slice(0, Math.max(1, max - 1)) + '…' : s)
191
192// Rough advance of system UI text, in em; good enough to size the title's slot.
193const charEm = (ch: string): number =>
194 /[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-ZА-ЯЁmwшщжюМШЩЖЮ@%]/.test(ch) ? 0.72 : 0.56
195
196const textWidth = (s: string, size: number): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
197
198// Cuts `s` to fit `maxW` pixels, with an ellipsis when it had to cut.
199const fitText = (s: string, size: number, maxW: number): string => {
200 if (textWidth(s, size) <= maxW) return s
201 let out = ''
202 for (const ch of s) {
203 if (textWidth(out + ch + '…', size) > maxW) break
204 out += ch
205 }
206 return out + '…'
207}
208
209const rowSvg = (f: Flow, W: number, isWorking: boolean): string => {
210 // The title takes what it needs, up to 40% of the row; the bar takes the rest.
211 const title = fitText(f.title, 13, Math.max(60, W * 0.4))
212 const BAR_X = Math.round(16 + textWidth(title, 13) + 12)
213 const BAR_W = Math.max(60, W - BAR_X - 46 - CRAB_W)
214 const color = f.isFinished ? DONE : ACCENT
215 const y0 = (H - BAR_H) / 2
216 const fillW = Math.round(BAR_W * ratio(f))
217 const runW = f.total ? Math.round((BAR_W * Math.min(f.total, f.done + f.running)) / f.total) : 0
218 const dots: string[] = []
219
220 // Dithered fill: sparse at the start, dense toward the head.
221 const cols = Math.floor(fillW / CELL)
222 const rows = Math.floor(BAR_H / CELL)
223 for (let c = 0; c < cols; c++) {
224 const density = 0.35 + 0.6 * Math.pow(c / Math.max(1, cols), 1.2)
225 for (let r = 0; r < rows; r++) {
226 if (noise(c, r) < density) dots.push(`<rect class="t${Math.floor(noise(r, c) * 4)}" x="${c * CELL + 1}" y="${r * CELL + 1}" width="2" height="2"/>`)
227 }
228 }
229 // Handed to workers, not yet accepted: a faint second layer.
230 const faint: string[] = []
231 for (let c = cols; c < Math.floor(runW / CELL); c++) {
232 for (let r = 0; r < rows; r++) {
233 if (noise(c + 7, r + 3) < 0.2) faint.push(`<rect class="t${Math.floor(noise(r + 5, c) * 4)}" x="${c * CELL + 1}" y="${r * CELL + 1}" width="1.7" height="1.7"/>`)
234 }
235 }
236
237 const ticks: string[] = []
238 for (let i = 1; i < f.total; i++) {
239 const x = Math.round((BAR_W * i) / f.total)
240 if (x > fillW + 4) ticks.push(`<rect x="${x}" y="${BAR_H / 2 - 4}" width="1.5" height="8" rx="0.75"/>`)
241 }
242
243 const text = label(f)
244 const pillW = Math.round(18 + text.length * 6.6)
245 const pillX = Math.max(0, Math.min(BAR_W - pillW, fillW - pillW))
246 const percent = `${Math.round(ratio(f) * 100)}%`
247
248 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
249<style>
250.t{fill:#1f1f1f}.m{fill:#8a8a8a}.k{fill:#e4e4e2}.tk{fill:#b4b4b0}
251@media (prefers-color-scheme: dark){.t{fill:#ececec}.m{fill:#9a9a9a}.k{fill:#2c2c2c}.tk{fill:#5a5a5a}}
252/* Pixels twinkle in four out-of-phase groups; a finished bar settles to a slow glow. */
253.t0,.t1,.t2,.t3{animation:tw ${f.isFinished ? 3.2 : 2.2}s ease-in-out infinite}
254.t1{animation-duration:${f.isFinished ? 3.8 : 2.8}s;animation-delay:-.7s}.t2{animation-duration:${f.isFinished ? 4.4 : 1.9}s;animation-delay:-1.3s}.t3{animation-duration:${f.isFinished ? 3.5 : 3.3}s;animation-delay:-.4s}
255@keyframes tw{0%,100%{opacity:1}50%{opacity:${f.isFinished ? 0.8 : 0.3}}}
256@media (prefers-reduced-motion: reduce){.t0,.t1,.t2,.t3{animation:none}}
257</style>
258<defs><clipPath id="c"><rect x="0" y="0" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/></clipPath></defs>
259<circle cx="5" cy="${H / 2}" r="4" fill="${color}"/>
260<text class="t" x="16" y="${H / 2 + 4.5}" font-family="${FONT}" font-size="13" font-weight="500">${xml(title)}</text>
261<g transform="translate(${BAR_X},${y0})">
262<rect class="k" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/>
263<g clip-path="url(#c)">
264<g fill="${color}">${dots.join('')}</g>
265<g fill="${color}" opacity="0.45">${faint.join('')}</g>
266<g class="tk">${ticks.join('')}</g>
267</g>
268<rect x="${pillX}" width="${pillW}" height="${BAR_H}" rx="${BAR_H / 2}" fill="${color}"/>
269<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>
270</g>
271<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>
272${CRAB_CSS}${crab(W - CRAB_W + 1, 0, 'other', false, isWorking, 0.8)}
273</svg>`
274}
275
276// Text bar for the terminal, in eighths of a cell: a 6-cell bar still moves in ~2% steps.
277const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
278const bar = (fraction: number, width: number): string => {
279 const eighths = Math.round(Math.max(0, Math.min(1, fraction)) * width * 8)
280 const full = Math.floor(eighths / 8)
281 const part = EIGHTHS[eighths % 8]
282 return '█'.repeat(full) + part + '░'.repeat(Math.max(0, width - full - (part ? 1 : 0)))
283}
284
285// ---------------------------------------------------------------------------
286// Agents panel: every subagent of the session, plus the tasks the flow planned.
287
288const colorOf = (costume: Costume): string => COSTUME_COLOR[costume]
289
290// "Opus · xhigh": what a planned task will run on, when it says (its model, or its savvy tier).
291const profileLabel = (p: PlannedTask): string => {
292 const { model, effort } = plannedProfile(p)
293 if (!model) return ''
294 const name = modelName(model)
295 return [name.charAt(0).toUpperCase() + name.slice(1), effort].filter(Boolean).join(' · ')
296}
297
298// USD per million tokens: input, output, cache read, cache write (5-minute TTL).
299// The engine reports tokens, not money, so the panel's cost is an estimate.
300const PRICES: [RegExp, [number, number, number, number]][] = [
301 [/fable|mythos/, [10, 50, 0.25, 12.5]],
302 [/opus-5-5/, [4, 20, 0.2, 5]],
303 [/opus/, [5, 25, 0.5, 6.25]],
304 [/sonnet/, [2, 10, 0.2, 2.5]],
305 [/haiku/, [1, 5, 0.1, 1.25]],
306]
307
308type Usage = {
309 input_tokens: number
310 output_tokens: number
311 cache_read_input_tokens: number
312 cache_creation_input_tokens: number
313}
314
315const priceOf = (model: string): [number, number, number, number] =>
316 PRICES.find(([re]) => re.test(model.toLowerCase()))?.[1] ?? [4, 20, 0.2, 5]
317
318const costOf = (model: string, u: Usage): number => {
319 const [i, o, r, w] = priceOf(model)
320 return (
321 ((u.input_tokens || 0) * i +
322 (u.output_tokens || 0) * o +
323 (u.cache_read_input_tokens || 0) * r +
324 (u.cache_creation_input_tokens || 0) * w) /
325 1e6
326 )
327}
328
329const windowOf = (model: string): number => (/haiku/i.test(model) ? 200_000 : 1_000_000)
330
331const modelName = (id: string): string => {
332 const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
333 const [, family = '', major = '', minor] = m ?? []
334 if (!family) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '') || '—'
335 return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()} ${major}${minor ? '.' + minor : ''}`
336}
337
338const norm = (s: string): string => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
339
340const fmtTokens = (n: number): string =>
341 n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${Math.round(n)}`
342
343const fmtCost = (usd: number): string => `$${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`
344
345const fmtTime = (ms: number): string => {
346 const s = Math.max(0, Math.round(ms / 1000))
347 const h = Math.floor(s / 3600)
348 const m = Math.floor((s % 3600) / 60)
349 const ss = String(s % 60).padStart(2, '0')
350 return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
351}
352
353const elapsed = (a: AgentRun, at: number): number => (a.endedAt ?? Math.max(at, a.startedAt)) - a.startedAt
354
355type Planned = PlannedTask & { n: number }
356
357const plannedOf = (f: Flow | null, list: AgentRun[]): Planned[] => {
358 if (!f || f.isFinished) return []
359 const started = new Set(list.map(a => norm(a.description)))
360 return (f.tasks ?? []).map((t, i) => ({ ...t, n: i + 1 })).filter(t => !started.has(norm(t.title)))
361}
362
363const totals = (list: AgentRun[], at: number) => {
364 const cost = list.reduce((s, a) => s + a.costUsd, 0)
365 const tokens = list.reduce((s, a) => s + a.tokens, 0)
366 const start = Math.min(...list.map(a => a.startedAt))
367 const end = Math.max(...list.map(a => a.endedAt ?? Math.max(at, a.startedAt)))
368 return { cost, tokens, time: list.length ? end - start : 0 }
369}
370
371// --- desktop drawings: each row is one SVG, as the band above the prompt is.
372
373const PANE_CSS = `<style>
374.t{fill:#1f1f1f}.s{fill:#6b6b68}.m{fill:#9a9a96}.k{fill:#ecebe8}.ln{stroke:#e4e4e1}.tile{fill:#f4f3f0}
375@media (prefers-color-scheme: dark){.t{fill:#ececec}.s{fill:#a8a8a4}.m{fill:#7d7d79}.k{fill:#2c2c2b}.ln{stroke:#333331}.tile{fill:#262625}}
376.live{animation:p 1.6s ease-in-out infinite}@keyframes p{50%{opacity:.3}}
377@media (prefers-reduced-motion: reduce){.live{animation:none}}
378</style>`
379
380// Pixel Clawd from DockCrab (Clawdy): a 24×18 crab on a 30×28 grid, one costume per kind
381// of agent (hooks/crew.ts). The body keeps the brand clay; the costume's color lives in its accent.
382const CLAY = '#D97757'
383const INK = '#1F1E1D'
384
385// `cls` puts a pixel in a named group: `bd` (the default) is the body and its
386// costume, `la`/`lb` the leg pairs, anything else a prop with its own motion.
387type Fill = (x: number, y: number, w: number, h: number, c: string, cls?: string) => void
388
389const stamp = (f: Fill, x: number, y: number, rows: string[], map: Record<string, string>, cls?: string): void =>
390 rows.forEach((row, dy) => [...row].forEach((ch, dx) => map[ch] && f(x + dx, y + dy, 1, 1, map[ch] ?? '', cls)))
391
392// `armCls` lets a raised claw travel with the prop it holds.
393const crabBody = (f: Fill, armFront = 0, armCls?: string): void => {
394 f(7, 10, 16, 12, CLAY)
395 f(3, 14, 4, 4, CLAY)
396 f(23, 14 + armFront, 4, 4, CLAY, armCls)
397 f(9, 12, 2, 2, INK)
398 f(19, 12, 2, 2, INK)
399 f(7, 22, 2, 4, CLAY, 'la')
400 f(17, 22, 2, 4, CLAY, 'la')
401 f(11, 22, 2, 4, CLAY, 'lb')
402 f(21, 22, 2, 4, CLAY, 'lb')
403}
404
405// Pure CSS, run by the compositor: no redraws. Periods divide one second, so the
406// once-a-second redraw of a running row restarts them in phase. Every crab walks;
407// each costume adds its prop's own motion on top.
408const CRAB_CSS = `<style>
409.run .la{animation:st .5s steps(1) infinite}.run .lb{animation:st .5s steps(1) infinite -.25s}
410.run .bd{animation:bob .5s steps(1) infinite -.125s}
411.run g{transform-box:fill-box}
412@keyframes st{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}
413.c-astronaut.run{animation:float 1s ease-in-out infinite}
414.c-astronaut.run .la,.c-astronaut.run .lb,.c-astronaut.run .bd{animation:none}
415.c-astronaut.run .ant{animation:blink 1s steps(1) infinite}
416.c-astronaut.run .star{animation:blink .5s steps(1) infinite -.25s}
417@keyframes float{50%{transform:translateY(-2px)}}@keyframes blink{50%{opacity:.15}}
418.c-detective.run .it{animation:scan 1s steps(1) infinite}
419.c-detective.run .gl{animation:blink 1s steps(1) infinite -.5s}
420@keyframes scan{25%{transform:translate(-1px,1px)}50%{transform:translate(-2px,2px)}75%{transform:translate(-1px,1px)}}
421.c-engineer.run .it{transform-origin:100% 100%;animation:twist .5s ease-in-out infinite}
422@keyframes twist{50%{transform:rotate(-35deg)}}
423.c-chef.run .pan{transform-origin:0 50%;animation:tilt 1s ease-in-out infinite}
424.c-chef.run .egg{animation:flip 1s ease-in-out infinite}
425@keyframes tilt{20%,40%{transform:rotate(-12deg)}}@keyframes flip{30%{transform:translateY(-5px) scaleY(-1)}60%{transform:translateY(0)}}
426.c-racer.run .la{animation-duration:.25s}.c-racer.run .lb{animation-duration:.25s;animation-delay:-.125s}
427.c-racer.run .flag{transform-origin:0 50%;animation:wave .25s steps(1) infinite}
428@keyframes wave{50%{transform:skewY(-12deg) scaleX(.85)}}
429.c-pirate.run .it{transform-origin:50% 100%;animation:fence .5s ease-in-out infinite}
430@keyframes fence{50%{transform:rotate(25deg)}}
431@media (prefers-reduced-motion: reduce){.run,.run g{animation:none!important}}
432</style>`
433
434const WARDROBE: Record<Costume, (f: Fill, t: string) => void> = {
435 // Fable, Opus at high or above: astronaut in a glass dome; floats instead of walking, the antenna and the star blink.
436 astronaut: (f, t) => {
437 crabBody(f)
438 f(6, 7, 18, 1, '#E6E8EE'); f(5, 8, 1, 14, '#E6E8EE'); f(24, 8, 1, 14, '#E6E8EE'); f(6, 22, 18, 1, '#C9CCD2')
439 f(6, 8, 18, 14, 'rgba(169,214,245,.32)'); f(8, 9, 2, 1, '#fff'); f(8, 10, 1, 2, '#fff')
440 f(14, 4, 2, 3, '#C9CCD2'); f(14, 2, 2, 2, t, 'ant'); f(13, 18, 4, 2, t)
441 f(27, 3, 1, 3, '#F5C542', 'star'); f(26, 4, 3, 1, '#F5C542', 'star')
442 },
443 // Opus below high: detective with a deerstalker; the magnifier sweeps and glints.
444 detective: (f, t) => {
445 crabBody(f, -4, 'it')
446 stamp(f, 6, 3, ['......bbbbbb......', '....bbcbbcbbbb....', '...bbbbbbbbbbbb...', '..bcbbcbbcbbcbbb..', '.bbbbbbbbbbbbbbbb.', 'dddddddddddddddddd'], { b: '#7A4A26', c: '#A0703F', d: '#5A3519' })
447 f(6, 9, 18, 1, t)
448 stamp(f, 23, 1, ['.kkk.', 'k...k', 'k...k', 'k...k', '.kkk.'], { k: '#3A3A3C' }, 'it')
449 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')
450 },
451 // Sonnet at high or above: engineer in a hard hat; the wrench turns a bolt.
452 engineer: (f, t) => {
453 crabBody(f)
454 stamp(f, 6, 4, ['.....yyyyyyyy.....', '...yyyyyhhyyyyy...', '..yyyyyyhhyyyyyy..', '..yyyyyyhhyyyyyy..', '.yyyyyyyhhyyyyyyy.', 'dddddddddddddddddd'], { y: '#F5C542', h: '#FBE08A', d: '#C99A1E' })
455 f(13, 5, 4, 2, t)
456 stamp(f, 0, 10, ['.s.s', 'sss.', '.s..', '.s..'], { s: '#8E929A' }, 'it')
457 },
458 // Sonnet below high: chef, the toque traced from DockCrab's Sprites.chefHat; tosses the omelette.
459 chef: (f, t) => {
460 crabBody(f, -4, 'pan')
461 stamp(f, 6, 0, ['........lll.......', '.......lllll......', '.wwwwgwwwwwwgwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwgwwwwwggwwwww', '.wwwwgwwwwwggwwwww', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.'], { w: '#F4F3EE', l: '#F7F6F2', g: '#D2D1C8', b: t, d: '#B45F43' })
462 f(22, 8, 7, 2, '#4A4A48', 'pan'); f(26, 10, 1, 1, '#4A4A48', 'pan'); f(24, 7, 3, 1, '#F5B731', 'egg')
463 },
464 // Haiku: racer in a helmet; runs at double pace, the checkered flag flutters.
465 racer: (f, t) => {
466 crabBody(f, -4)
467 stamp(f, 6, 5, ['....rrrrrrrrrr....', '..rrrrrrwwrrrrrr..', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.kkkkkkkkkkkkkkkkr'], { r: t, w: '#F8F6F1', k: INK })
468 f(25, 1, 1, 9, '#8E929A')
469 stamp(f, 26, 1, ['wkwk', 'kwkw', 'wkwk'], { w: '#F8F6F1', k: INK }, 'flag')
470 },
471 // The Explore agent: pirate scouting the code; the cutlass fences.
472 pirate: f => {
473 crabBody(f)
474 stamp(f, 5, 3, ['.kk..............kk.', '.kkk....kkkk....kkk.', '..kkkkkkkwwkkkkkkk..', '..kkkkkkkkkkkkkkkk..', '.gggggggggggggggggg.'], { k: '#55514C', w: '#F8F6F1', g: '#F5C542' })
475 f(7, 11, 11, 1, INK); f(18, 11, 4, 3, INK)
476 f(27, 6, 1, 9, '#C9CCD2', 'it'); f(26, 15, 3, 1, '#7A4A26', 'it')
477 },
478 other: f => crabBody(f),
479}
480
481const CRAB_SCALE = 1.1
482
483// Body and props nest inside `bd` so a prop rides the bob and adds its own motion;
484// legs stay outside it and step on their own.
485const crab = (x: number, y: number, costume: Costume, dim = false, isWalking = false, scale = CRAB_SCALE): string => {
486 const groups = new Map<string, string[]>([['bd', []]])
487 const f: Fill = (cx, cy, w, h, c, cls = 'bd') => {
488 if (!groups.has(cls)) groups.set(cls, [])
489 groups.get(cls)?.push(`<rect x="${cx}" y="${cy}" width="${w}" height="${h}" fill="${c}"/>`)
490 }
491 const draw = WARDROBE[costume] ?? ((g: Fill) => crabBody(g))
492 draw(f, colorOf(costume))
493 const group = (cls: string) => `<g class="${cls}">${(groups.get(cls) ?? []).join('')}</g>`
494 const props = [...groups.keys()].filter(k => k !== 'bd' && k !== 'la' && k !== 'lb')
495 const body = `<g class="bd">${(groups.get('bd') ?? []).join('')}${props.map(group).join('')}</g>`
496 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>`
497}
498
499const statusMark = (x: number, y: number, status: string, color: string): string => {
500 if (status === 'running') return `<circle class="live" cx="${x}" cy="${y}" r="3.5" fill="${color}"/>`
501 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"/>`
502 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"/>`
503 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"/>`
504}
505
506const svg = (W: number, H: number, body: string): string =>
507 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${PANE_CSS}${CRAB_CSS}${body}</svg>`
508
509// The pane's own title already says "Agents": the header names the flow, if any.
510const headerSvg = (W: number, title: string, t: ReturnType<typeof totals>): string => {
511 const s = tr()
512 const gap = 6
513 const tw = (W - gap * 2) / 3
514 const top = title ? 28 : 0
515 const tile = (i: number, k: string, v: string) =>
516 `<rect class="tile" x="${i * (tw + gap)}" y="${top}" width="${tw}" height="40" rx="8"/>
517<text class="s" x="${i * (tw + gap) + 9}" y="${top + 16}" font-family="${FONT}" font-size="11">${k}</text>
518<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>`
519 return svg(
520 W,
521 headerHeight(title),
522 `${title ? `<text class="t" x="0" y="15" font-family="${FONT}" font-size="14" font-weight="600">${xml(fitText(title, 14, W))}</text>` : ''}
523${tile(0, s.cost, '≈' + fmtCost(t.cost))}${tile(1, s.tokens, fmtTokens(t.tokens))}${tile(2, s.time, fmtTime(t.time))}`,
524 )
525}
526
527const headerHeight = (title: string): number => (title ? 72 : 44)
528
529// The task's own progress when the worker reports steps; a finished run is full.
530const progressOf = (a: AgentRun): number | null => {
531 if (a.status === 'done') return 1
532 if (a.stepTotal) return Math.min(1, (a.stepDone ?? 0) / a.stepTotal)
533 return null
534}
535
536const ctxOf = (a: AgentRun): number => (a.contextMax ? Math.min(100, Math.round((a.contextTokens / a.contextMax) * 100)) : 0)
537
538const agentSvg = (W: number, a: AgentRun, at: number): string => {
539 const s = tr()
540 const costume = costumeOf(a)
541 const color = colorOf(costume)
542 const ctx = ctxOf(a)
543 const textW = W - 42 - 22
544 const meta = [a.effort ? `${modelName(a.model)} · ${a.effort}` : modelName(a.model)]
545 if (a.round > 1) meta.push(`${s.round} ${a.round}`)
546 if (a.status === 'failed') meta.push(s.failed)
547 const barW = textW
548 const progress = progressOf(a)
549 const stats = `ctx ${ctx}% · ${fmtTokens(a.contextTokens)} ≈${fmtCost(a.costUsd)} ${fmtTime(elapsed(a, at))}`
550 const steps = a.stepTotal ? `${a.stepDone ?? 0}/${a.stepTotal}${a.stepNote ? ' · ' + a.stepNote : ''}` : ''
551 const stepsW = Math.max(0, barW - textWidth(stats, 11) - 12)
552 // Without reported steps the bar falls back to the context, drawn grey.
553 const fillW = Math.round(barW * (progress ?? ctx / 100))
554 return svg(
555 W,
556 66,
557 `${crab(0, 14, costume, false, a.status === 'running')}
558<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>
559<text x="42" y="34" font-family="${FONT}" font-size="11"><tspan fill="${color}">${xml(bareType(a.type))}</tspan><tspan class="s"> ${xml(meta.join(' · '))}</tspan></text>
560${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>` : ''}
561<text class="s" x="${42 + barW}" y="49" text-anchor="end" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${stats}</text>
562<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}"`}/>
563${statusMark(W - 8, 16, a.status, color)}
564<line class="ln" x1="0" y1="65.5" x2="${W}" y2="65.5"/>`,
565 )
566}
567
568const plannedSvg = (W: number, p: Planned): string => {
569 const costume = plannedCostume(p)
570 const color = colorOf(costume)
571 const textW = W - 42 - 22
572 const meta = [profileLabel(p)]
573 if (p.after.length) meta.push(`${tr().after} ${p.after.join(', ')}`)
574 return svg(
575 W,
576 46,
577 `${crab(0, 6, costume, true)}
578<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>
579<text x="42" y="34" font-family="${FONT}" font-size="11"><tspan fill="${color}">${xml(p.tier)}</tspan><tspan class="m"> ${xml(meta.filter(Boolean).join(' · '))}</tspan></text>
580${statusMark(W - 8, 16, 'planned', color)}
581<line class="ln" x1="0" y1="45.5" x2="${W}" y2="45.5"/>`,
582 )
583}
584
585const compactSvg = (W: number, list: AgentRun[], planned: Planned[], t: ReturnType<typeof totals>): string => {
586 const icon = (k: Costume, s: string, dim: boolean) => ({ k, c: colorOf(k), s, dim })
587 const icons = [
588 ...list.filter(a => a.status === 'running').map(a => icon(costumeOf(a), 'running', false)),
589 ...list.filter(a => a.status !== 'running').map(a => icon(costumeOf(a), a.status, false)),
590 ...planned.map(p => icon(plannedCostume(p), 'planned', true)),
591 ]
592 const fit = Math.max(1, Math.floor((W - 150) / 36))
593 const shown = icons.slice(0, fit)
594 const more = icons.length - shown.length
595 const body = shown
596 .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}"/>` : ''))
597 .join('')
598 const x = shown.length * 36 + (more ? 4 : 0)
599 return svg(
600 W,
601 32,
602 `${body}${more ? `<text class="s" x="${x}" y="21" font-family="${FONT}" font-size="12">+${more}</text>` : ''}
603<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>`,
604 )
605}
606
607// --- terminal drawing: the same rows in text.
608
609const STATUS_GLYPH: Record<string, string> = { running: '●', done: '✓', failed: '✗', planned: '◷' }
610
611// Opens the agents pane, or closes it when it is up; true when it ends up open.
612async function togglePane($: EngineInterface): Promise<boolean> {
613 const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
614 if (isOpen) {
615 await $.ui.close({ id: PANE })
616 return false
617 }
618 const at = await $.clock.now()
619 await update($, now, () => at)
620 await $.ui.open({ id: PANE, title: tr().pane })
621 return true
622}
623
624async function autoOpen($: EngineInterface, key: string): Promise<void> {
625 const p = await read($, panel)
626 if (p.autoOpenedFor === key) return
627 await update($, panel, prev => ({ ...prev, autoOpenedFor: key }))
628 void $.ui.open({ id: PANE, title: tr().pane })
629}
630
631export const register: Register = (on, options) => {
632 on('session.start', async ($, e, next) => {
633 const started = await next(e)
634 lang = await detectLang($, options.language)
635 await $.tool.register({
636 name: 'progress',
637 description:
638 "Report a multi-agent plan to the progress bar above the prompt and the agents panel. " +
639 'Call it after presenting the plan (title, total, tasks, phase "delegate"), each time a task is accepted (done), ' +
640 'when switching phase or re-planning (tasks), and once at the end with finished: true. Fields left out keep their previous value. ' +
641 'Optional: without it the bar follows subagent spawns and completions by itself.',
642 inputSchema: {
643 type: 'object',
644 properties: {
645 title: { type: 'string', description: 'Short name of the overall task, a few words.' },
646 total: { type: 'integer', minimum: 0, description: 'Number of planned worker tasks.' },
647 done: { type: 'integer', minimum: 0, description: 'Number of tasks accepted after review.' },
648 phase: { type: 'string', enum: [...PHASES] },
649 finished: { type: 'boolean', description: 'True once the flow is closed.' },
650 tasks: {
651 type: 'array',
652 description:
653 '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.',
654 items: {
655 type: 'object',
656 properties: {
657 title: { type: 'string', description: 'A few words; reused verbatim as the Agent description.' },
658 model: {
659 type: 'string',
660 description: 'What the task will run on: `opus`, `sonnet`, `haiku`, `fable` or a model id. With `effort` it picks the crab\'s costume.',
661 },
662 effort: { type: 'string', enum: ['low', 'medium', 'high', 'xhigh', 'max'] },
663 tier: {
664 type: 'string',
665 enum: ['fable', 'heavy', 'careful', 'medium', 'light'],
666 description: 'A savvy-flow worker tier, for when `model` is left out.',
667 },
668 after: { type: 'array', items: { type: 'integer' }, description: 'Numbers of the tasks this one waits for.' },
669 },
670 required: ['title'],
671 },
672 },
673 },
674 },
675 })
676 await $.tool.register({
677 name: 'step',
678 description:
679 'For subagents: report progress on your own task to the agents panel. ' +
680 'Right after reading the brief, call it with `total` (your plan in 3-8 steps) and `done: 0`; ' +
681 'call it again as each step finishes. Cheap and silent: it only draws a bar.',
682 inputSchema: {
683 type: 'object',
684 properties: {
685 done: { type: 'integer', minimum: 0, description: 'Steps finished so far.' },
686 total: { type: 'integer', minimum: 1, description: 'Steps planned; may change if the plan changes.' },
687 note: { type: 'string', description: 'The step in progress, a few words.' },
688 },
689 required: ['done'],
690 },
691 })
692 await $.command.register({
693 name: 'agents-info',
694 description: 'Show or hide the panel of subagents: running, finished and planned, with model, context, cost and time',
695 })
696
697 // Ticks the running agents' clocks; quiet when nothing runs.
698 $.clock.every(1000, () => {
699 void (async () => {
700 const list = await read($, agents)
701 if (!list.some(a => a.status === 'running')) return
702 const at = await $.clock.now()
703 await update($, now, () => at)
704 })()
705 })
706 return started
707 })
708
709 on('command.run', { command: 'agents-info' }, async $ => {
710 const isOpen = await togglePane($)
711 return { text: isOpen ? tr().opened : tr().closed }
712 })
713
714 on('tool.call', { tool: TOOL }, async ($, e) => {
715 const input = e as unknown as ProgressInput
716 const prev = await read($, flow)
717 if (isNewFlow(prev, input) && input.title !== undefined) {
718 // A new flow starts with a clean list; agents still running stay.
719 await update($, agents, list => list.filter(a => a.status === 'running'))
720 }
721 const next = await update($, flow, p => merge(p, input))
722 if (next && input.tasks?.length) await autoOpen($, next.title)
723 return { result: `ok: ${label(next ?? blank())}` }
724 })
725
726 // A worker's own progress: the call runs in the worker's loop, so agentId names it.
727 on('tool.call', { tool: STEP_TOOL }, async ($, e) => {
728 const input = e as unknown as { done?: number; total?: number; note?: string }
729 const agentId = e.agentId
730 if (!agentId) return { result: 'ignored: only subagents report steps' }
731 await update($, agents, list =>
732 list.map(a => {
733 if (a.agentId !== agentId) return a
734 const total = Math.max(0, Math.round(input.total ?? a.stepTotal ?? 0))
735 const done = Math.max(0, Math.round(input.done ?? a.stepDone ?? 0))
736 return { ...a, stepTotal: total, stepDone: total ? Math.min(total, done) : done, stepNote: input.note?.trim() || undefined }
737 }),
738 )
739 return { result: 'ok' }
740 })
741
742 // Safety net: worker launches move the faint layer even if the orchestrator forgets to report.
743 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
744 const type = String(e.subagent_type ?? '')
745 if (!type.startsWith('savvy-')) return next(e)
746
747 await update($, flow, prev => {
748 const base = prev && !prev.isFinished ? { ...blank(), ...prev } : blank()
749 return { ...base, running: base.running + 1, phase: base.phase === 'plan' ? 'delegate' : base.phase }
750 })
751 try {
752 return await next(e)
753 } finally {
754 await update($, flow, prev => (prev ? { ...prev, running: Math.max(0, prev.running - 1) } : prev))
755 }
756 })
757
758 on('agent.spawn', async ($, e, next) => {
759 const started = await next(e)
760 if (started.deny !== undefined) return started
761
762 const at = await $.clock.now()
763 // Outside a /savvy-flow, every non-savvy agent drives the flow row itself (auto.ts).
764 const isSavvy = isSavvyType(e.subagentType)
765 const isFreshBatch = !isSavvy && startsBatch(await read($, flow))
766 const runId = started.agentId ?? e.tool_use_id
767 await update($, agents, list => {
768 const round = 1 + list.filter(a => norm(a.description) === norm(e.description) && e.description).length
769 const run: AgentRun = {
770 id: runId,
771 agentId: started.agentId,
772 type: e.subagentType,
773 description: e.description,
774 model: started.model,
775 status: 'running',
776 startedAt: at,
777 contextTokens: 0,
778 contextMax: windowOf(started.model),
779 tokens: 0,
780 costUsd: 0,
781 steps: 0,
782 round,
783 }
784 // A new batch starts with a clean list, as a new flow does; agents still running stay.
785 const kept = isFreshBatch ? list.filter(a => a.status === 'running') : list
786 return [...kept.filter(a => a.id !== run.id), run].slice(-200)
787 })
788 await update($, now, () => at)
789 if (!isSavvy) await update($, flow, prev => onSpawn(prev, runId))
790 const f = await read($, flow)
791 await autoOpen($, f && !f.isFinished ? openKey(f) : isSavvy ? 'flow' : AUTO_TITLE)
792 return started
793 })
794
795 // Each model request of a subagent: live context, tokens and cost.
796 on('turn.step', async function* ($, e, next) {
797 const result = yield* next(e)
798 const agentId = e.agentId
799 const usage = result.usage
800 if (!agentId || !usage) return result
801
802 const model = usage.model || e.model
803 await update($, agents, list =>
804 list.map(a =>
805 a.agentId !== agentId
806 ? a
807 : {
808 ...a,
809 model,
810 effort: typeof e.effort === 'string' ? e.effort : a.effort,
811 status: 'running',
812 endedAt: undefined,
813 contextTokens:
814 (usage.input_tokens || 0) +
815 (usage.cache_read_input_tokens || 0) +
816 (usage.cache_creation_input_tokens || 0) +
817 (usage.output_tokens || 0),
818 contextMax: windowOf(model),
819 tokens:
820 a.tokens +
821 (usage.input_tokens || 0) +
822 (usage.output_tokens || 0) +
823 (usage.cache_read_input_tokens || 0) +
824 (usage.cache_creation_input_tokens || 0),
825 costUsd: a.costUsd + costOf(model, usage),
826 steps: a.steps + 1,
827 },
828 ),
829 )
830 return result
831 })
832
833 on('turn.complete', async ($, e, next) => {
834 const agentId = e.agentId
835 if (agentId) {
836 const at = await $.clock.now()
837 const counted = isCounted((await read($, agents)).find(a => a.agentId === agentId))
838 await update($, agents, list =>
839 list.map(a => {
840 if (a.agentId !== agentId) return a
841 // A run whose steps went unseen still gets the turn's own sum.
842 const fallback = a.steps === 0 && e.usage
843 return {
844 ...a,
845 status: e.reason === 'answer' ? 'done' : 'failed',
846 endedAt: at,
847 ...(fallback && e.usage
848 ? {
849 model: e.usage.model || a.model,
850 tokens:
851 e.usage.input_tokens +
852 e.usage.output_tokens +
853 e.usage.cache_read_input_tokens +
854 e.usage.cache_creation_input_tokens,
855 costUsd: costOf(e.usage.model || a.model, e.usage),
856 }
857 : {}),
858 }
859 }),
860 )
861 await update($, now, () => at)
862 // The batch's own agents all finished: a spawn-derived flow closes into the green "Done" row.
863 const runs = await read($, agents)
864 await update($, flow, prev => onFinish(prev, counted, runs))
865 }
866 return next(e)
867 })
868
869 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
870 const s = tr()
871 const ui = $.ui.resolve(e)
872 const { Box, Text, Button } = ui
873 const list = await read($, agents)
874 const f = await read($, flow)
875 const p: Panel = await read($, panel)
876 const at = Math.max(await read($, now), ...list.map(a => a.startedAt), 0)
877
878 const running = list.filter(a => a.status === 'running').reverse()
879 const finished = list.filter(a => a.status !== 'running').reverse()
880 const planned = plannedOf(f, list)
881 const t = totals(list, at)
882 // The pane's title says "Agents"; inside, only the flow's own name.
883 const title = f && !f.isFinished ? f.title : ''
884
885 const toggleCompact = (
886 <Button
887 key="compact"
888 label={p.isCompact ? s.expand : s.collapse}
889 plain
890 onPress={() => update($, panel, prev => ({ ...prev, isCompact: !prev.isCompact }))}
891 />
892 )
893 const toggleDone = (
894 <Button
895 key="done"
896 label={`${p.isDoneCollapsed ? '▸' : '▾'} ${s.finished} · ${finished.length}`}
897 plain
898 onPress={() => update($, panel, prev => ({ ...prev, isDoneCollapsed: !prev.isDoneCollapsed }))}
899 />
900 )
901 const isEmpty = list.length === 0 && planned.length === 0
902 const summary = `≈${fmtCost(t.cost)}, ${fmtTokens(t.tokens)} ${s.tokensWord}, ${fmtTime(t.time)}`
903
904 if (e.surface === 'desktop' && 'Svg' in ui) {
905 const { Svg } = ui
906 const W = Math.max(240, Math.min(900, (e.props.bodyColumns || 40) * 8 - 8))
907 const section = (key: string, text: string) => (
908 <Text key={key} dimColor>
909 {text}
910 </Text>
911 )
912
913 if (p.isCompact) {
914 return (
915 <Box flexDirection="column" gap={1}>
916 <Svg source={compactSvg(W, list, planned, t)} alt={`${list.length} ${s.agentsCount}, ${summary}`} width={W} height={32} />
917 {toggleCompact}
918 </Box>
919 )
920 }
921 return (
922 <Box flexDirection="column">
923 <Svg source={headerSvg(W, title, t)} alt={title ? `${title}: ${summary}` : summary} width={W} height={headerHeight(title)} />
924 {toggleCompact}
925 {isEmpty && <Text dimColor>{s.empty}</Text>}
926 {running.length > 0 && section('h-run', `${s.running} · ${running.length}`)}
927 {running.map(a => (
928 <Svg key={a.id} source={agentSvg(W, a, at)} alt={`${a.description}: ${modelName(a.model)}, ${s.isRunning}`} width={W} height={66} />
929 ))}
930 {finished.length > 0 && toggleDone}
931 {!p.isDoneCollapsed &&
932 finished.map(a => (
933 <Svg key={a.id} source={agentSvg(W, a, at)} alt={`${a.description}: ${modelName(a.model)}, ${s.isFinished}`} width={W} height={66} />
934 ))}
935 {planned.length > 0 && section('h-plan', `${s.planned} · ${planned.length}`)}
936 {planned.map(pl => (
937 <Svg key={`plan-${pl.n}`} source={plannedSvg(W, pl)} alt={`${pl.n}. ${pl.title}: ${s.isPlanned}`} width={W} height={46} />
938 ))}
939 </Box>
940 )
941 }
942
943 // Terminal: the same content in text rows.
944 const cols = Math.max(24, e.props.bodyColumns || 40)
945 const barW = Math.max(6, Math.min(20, cols - 34))
946 // Theme keys, so done and failed follow the person's theme; running keeps the costume's color.
947 const statusColor = (a: AgentRun, color: string) => (a.status === 'failed' ? 'error' : a.status === 'done' ? 'success' : color)
948 // The bar shows steps when the worker reports them, else its context fill in grey.
949 const runBar = (a: AgentRun, color: string, width: number) => {
950 const progress = progressOf(a)
951 return progress === null ? <Text dimColor>{bar(ctxOf(a) / 100, width)}</Text> : <Text color={color}>{bar(progress, width)}</Text>
952 }
953 const row = (a: AgentRun) => {
954 const color = colorOf(costumeOf(a))
955 const model = a.effort ? `${modelName(a.model)} · ${a.effort}` : modelName(a.model)
956 // Steps and their free-text note go last: a cut line loses them, not the time and cost.
957 const steps = a.stepTotal ? ` · ${a.stepDone ?? 0}/${a.stepTotal}${a.stepNote ? ' ' + a.stepNote : ''}` : ''
958 return (
959 <Box key={a.id} flexDirection="column">
960 <Box flexDirection="row" gap={1}>
961 <Text color={color}>▣</Text>
962 <Text bold wrap="truncate-end">
963 {a.description || a.type}
964 </Text>
965 <Text color={statusColor(a, color)}>{STATUS_GLYPH[a.status]}</Text>
966 </Box>
967 <Text dimColor wrap="truncate-end">
968 {' '}
969 {bareType(a.type)} · {model}
970 {a.round > 1 ? ` · ${s.round} ${a.round}` : ''}
971 </Text>
972 <Text wrap="truncate-end">
973 {' '}
974 {runBar(a, color, barW)}
975 <Text dimColor>
976 {' '}
977 {fmtTime(elapsed(a, at))} · ≈{fmtCost(a.costUsd)} · ctx {ctxOf(a)}% · {fmtTokens(a.contextTokens)}
978 {steps}
979 </Text>
980 </Text>
981 </Box>
982 )
983 }
984 // Compact: one line per agent, the bar only where it fits.
985 const lineBarW = cols >= 40 ? Math.min(10, cols - 30) : 0
986 const line = (a: AgentRun) => {
987 const color = colorOf(costumeOf(a))
988 return (
989 <Box key={a.id} flexDirection="row" gap={1}>
990 <Text color={statusColor(a, color)}>{STATUS_GLYPH[a.status]}</Text>
991 <Box flexGrow={1} flexShrink={1}>
992 <Text wrap="truncate-end">{a.description || a.type}</Text>
993 </Box>
994 {lineBarW > 0 && runBar(a, color, lineBarW)}
995 <Text dimColor>
996 {fmtTime(elapsed(a, at))} ≈{fmtCost(a.costUsd)}
997 </Text>
998 </Box>
999 )
1000 }
1001
1002 return (
1003 <Box flexDirection="column">
1004 <Box flexDirection="row" justifyContent="space-between">
1005 <Text bold wrap="truncate-end">
1006 {title}
1007 </Text>
1008 {toggleCompact}
1009 </Box>
1010 <Text dimColor>
1011 {fmtTime(t.time)} · ≈{fmtCost(t.cost)} · {fmtTokens(t.tokens)} {s.tokensWord}
1012 </Text>
1013 {p.isCompact ? (
1014 <Box flexDirection="column">
1015 {running.map(line)}
1016 {finished.length > 0 && toggleDone}
1017 {!p.isDoneCollapsed && finished.map(line)}
1018 {planned.map(pl => (
1019 <Text key={`plan-${pl.n}`} dimColor wrap="truncate-end">
1020 {STATUS_GLYPH.planned} {pl.n}. {pl.title}
1021 </Text>
1022 ))}
1023 </Box>
1024 ) : (
1025 <Box flexDirection="column" marginTop={1}>
1026 {isEmpty && <Text dimColor>{s.empty}</Text>}
1027 {running.length > 0 && <Text dimColor>{s.running} · {running.length}</Text>}
1028 {running.map(row)}
1029 {finished.length > 0 && toggleDone}
1030 {!p.isDoneCollapsed && finished.map(row)}
1031 {planned.length > 0 && <Text dimColor>{s.planned} · {planned.length}</Text>}
1032 {planned.map(pl => (
1033 <Box key={`plan-${pl.n}`} flexDirection="column">
1034 <Text dimColor wrap="truncate-end">
1035 <Text color={colorOf(plannedCostume(pl))}>▢</Text> {pl.n}. {pl.title}
1036 </Text>
1037 <Text dimColor wrap="truncate-end">
1038 {' '}
1039 {[pl.tier, profileLabel(pl)].filter(Boolean).join(' · ')}
1040 {pl.after.length ? ` · ${s.after} ${pl.after.join(', ')}` : ''}
1041 </Text>
1042 </Box>
1043 ))}
1044 </Box>
1045 )}
1046 </Box>
1047 )
1048 })
1049
1050 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1051 const f = await read($, flow)
1052 if (f === null || e.props.hasSurvey) return next(e)
1053
1054 // The band is one site for every plugin: what the plugins beneath draw
1055 // stays, under the bar, so another mod's band is never hidden.
1056 const below = await next(e)
1057 const ui = $.ui.resolve(e)
1058 const { Box, Text, Button } = ui
1059 const list = await read($, agents)
1060 const crew = list.length + plannedOf(f, list).length
1061 const isWorking = list.some(a => a.status === 'running')
1062 const crewButton = (
1063 <Button key="subagent-crew" label={`×${crew}`} plain hotkey="a" onPress={() => void togglePane($)} />
1064 )
1065 const percent = `${Math.round(ratio(f) * 100)}%`
1066 const dismiss = (
1067 <Button
1068 key="subagent-dismiss"
1069 label="✕"
1070 plain
1071 role="dismiss"
1072 onPress={() => update($, flow, () => null)}
1073 />
1074 )
1075
1076 // The terminal's table answers `'Svg' in ui` too but draws it empty: the text row is its own.
1077 if (e.surface !== 'terminal' && 'Svg' in ui) {
1078 const { Svg } = ui
1079 // About 8 CSS px per reported column; the rest is the count, the dismiss
1080 // and their gaps. No floor above the slot: a row wider than it would wrap.
1081 const width = Math.max(180, Math.min(1600, (e.props.bodyColumns || 100) * 8 - 96))
1082 return (
1083 <Box flexDirection="column">
1084 <Box flexDirection="row" alignItems="center" gap={1}>
1085 <Svg source={rowSvg(f, width, isWorking)} alt={`${f.title}: ${label(f)}, ${percent}`} width={width} height={H} />
1086 {crewButton}
1087 {dismiss}
1088 </Box>
1089 {below}
1090 </Box>
1091 )
1092 }
1093
1094 const cols = e.props.bodyColumns
1095 const titleW = Math.max(8, Math.min(30, f.title.length + 2, Math.floor(cols / 3)))
1096 // What follows the bar, measured: label, percent, the crew button (`a: ×N`), ▣ and ✕,
1097 // and the row's six gaps of 2. The bar takes what is left.
1098 const tail = label(f).length + percent.length + `a: ×${crew}`.length + 2 + 12
1099 const width = Math.max(6, Math.min(40, cols - titleW - tail))
1100 const color = f.isFinished ? 'success' : ACCENT
1101 return (
1102 <Box flexDirection="column">
1103 <Box flexDirection="row" gap={2}>
1104 <Box width={titleW} flexShrink={0}>
1105 <Text color={color}>{f.isFinished ? '✓' : '●'} </Text>
1106 <Text wrap="truncate-end">{f.title}</Text>
1107 </Box>
1108 <Text color={color}>{bar(ratio(f), width)}</Text>
1109 <Text bold>{label(f)}</Text>
1110 <Text dimColor>{percent}</Text>
1111 <Text color="claude">▣</Text>
1112 {crewButton}
1113 {dismiss}
1114 </Box>
1115 {below}
1116 </Box>
1117 )
1118 })
1119}
1120hooks/auto.ts 73 lines1import type { AgentRun, Flow } from '../types'
2
3// Subagent work nobody reports a plan for: the flow row is derived from spawns and
4// completions. An orchestrated flow (the `progress` tool, or a `savvy-*` worker of an
5// upstream savvy-flow) is never touched here.
6
7export const AUTO_TITLE = 'Subagents'
8
9/** `savvy-careful`, or `savvy-flow:savvy-careful` when the agents ship in a plugin. */
10export const isSavvyType = (type: string): boolean => type.startsWith('savvy-')
11
12const fresh = (): Flow => ({
13 title: AUTO_TITLE,
14 total: 0,
15 done: 0,
16 running: 0,
17 phase: 'delegate',
18 isFinished: false,
19 tasks: [],
20 isAuto: true,
21 ids: [],
22})
23
24/**
25 * What `autoOpen` dedupes on: the panel opens again whenever this changes. An auto
26 * flow keys on its first agent, so every new batch opens it, but the agents joining
27 * a batch do not reopen what the person closed; an orchestrated flow keys on its title.
28 */
29export const openKey = (f: Flow): string => (f.isAuto ? (f.ids?.[0] ?? AUTO_TITLE) : f.title)
30
31/** No flow, or the last one is closed: the next spawn opens a new batch. */
32export const startsBatch = (prev: Flow | null): boolean => prev === null || prev.isFinished
33
34/** A non-savvy agent `id` started: join the running auto flow, or open a new one. */
35export const onSpawn = (prev: Flow | null, id: string): Flow | null => {
36 if (prev && !prev.isFinished && !prev.isAuto) return prev
37 const base = prev && !prev.isFinished ? prev : fresh()
38 const ids = base.ids ?? []
39 return {
40 ...base,
41 total: base.total + 1,
42 running: base.running + 1,
43 ids: ids.includes(id) ? ids : [...ids, id],
44 }
45}
46
47/** Whether a run's completion counts toward an auto flow: it was running and not a savvy worker. */
48export const isCounted = (run: Pick<AgentRun, 'status' | 'type'> | undefined): boolean =>
49 run !== undefined && run.status === 'running' && !isSavvyType(run.type)
50
51/**
52 * Only this batch's own agents decide it is over: a run left `running` by an earlier
53 * batch (an interrupted agent never reports back) must not hold it open.
54 */
55const isBatchIdle = (f: Flow, runs: Pick<AgentRun, 'id' | 'status'>[]): boolean =>
56 (f.ids ?? []).every(id => runs.find(r => r.id === id)?.status !== 'running')
57
58/**
59 * An agent finished. Count it when it was one this flow spawned; once none of the
60 * batch is running any more, close the flow, which draws as the green "Done" row
61 * until dismissed.
62 */
63export const onFinish = (
64 prev: Flow | null,
65 counted: boolean,
66 runs: Pick<AgentRun, 'id' | 'status'>[],
67): Flow | null => {
68 if (!prev || !prev.isAuto || prev.isFinished) return prev
69 if (isBatchIdle(prev, runs)) return { ...prev, done: prev.total, running: 0, phase: 'close', isFinished: true }
70 if (!counted) return prev
71 return { ...prev, done: Math.min(prev.total, prev.done + 1), running: Math.max(0, prev.running - 1) }
72}
73hooks/crew.ts 98 lines1// Which costume a subagent's crab wears. The costume follows what the agent runs on, so
2// every subagent gets a prop, with or without savvy-flow:
3//
4// Fable (any effort), Opus at high or above astronaut
5// Opus below high detective
6// Sonnet at high or above engineer
7// Sonnet below high chef
8// Haiku racer
9// the Explore agent pirate
10// anything else a plain crab
11//
12// `high` and above is `high`, `xhigh` and `max`. An effort not known yet (a run's first
13// request has not gone out) or a numeric budget counts as below high.
14
15export type Costume = 'astronaut' | 'detective' | 'engineer' | 'chef' | 'racer' | 'pirate' | 'other'
16
17export const COSTUMES: readonly Costume[] = ['astronaut', 'detective', 'engineer', 'chef', 'racer', 'pirate', 'other']
18
19export const COSTUME_COLOR: Record<Costume, string> = {
20 astronaut: '#7F77DD',
21 detective: '#D85A30',
22 engineer: '#BA7517',
23 chef: '#378ADD',
24 racer: '#1D9E75',
25 pirate: '#888780',
26 other: '#888780',
27}
28
29type Family = 'fable' | 'opus' | 'sonnet' | 'haiku'
30
31/** `claude-sonnet-5-5`, `sonnet`, `claude-opus-5-5[1m]`: the family by substring; `inherit` and unknown ids are none. */
32export const familyOf = (model: string | undefined): Family | undefined => {
33 const m = (model ?? '').toLowerCase()
34 if (/fable|mythos/.test(m)) return 'fable'
35 if (/opus/.test(m)) return 'opus'
36 if (/sonnet/.test(m)) return 'sonnet'
37 if (/haiku/.test(m)) return 'haiku'
38 return undefined
39}
40
41const HIGH = new Set(['high', 'xhigh', 'max'])
42
43export const isHighEffort = (effort: unknown): boolean => typeof effort === 'string' && HIGH.has(effort.trim().toLowerCase())
44
45const byFamily = (family: Family, effort: unknown): Costume => {
46 if (family === 'fable') return 'astronaut'
47 if (family === 'haiku') return 'racer'
48 if (family === 'opus') return isHighEffort(effort) ? 'astronaut' : 'detective'
49 return isHighEffort(effort) ? 'engineer' : 'chef'
50}
51
52/** `savvy-flow:savvy-careful` is `savvy-careful`; `general-purpose` is itself. */
53export const bareType = (type: string): string => type.replace(/^[^:]*:/, '')
54
55// savvy-flow's workers are a model and an effort each (its agents/*.md frontmatter).
56// Kept for what has no model yet: a planned task named by tier, a `savvy-*` run before
57// its first request. A run's own model and effort always decide first.
58export const LEGACY_TIERS: Record<string, { model: string; effort: string }> = {
59 fable: { model: 'fable', effort: 'high' },
60 heavy: { model: 'opus', effort: 'xhigh' },
61 careful: { model: 'opus', effort: 'high' },
62 medium: { model: 'opus', effort: 'medium' },
63 light: { model: 'opus', effort: 'low' },
64}
65
66const legacyOf = (name: string): { model: string; effort: string } | undefined =>
67 Object.prototype.hasOwnProperty.call(LEGACY_TIERS, name) ? LEGACY_TIERS[name] : undefined
68
69/** A run's costume: the Explore pirate, else its model and effort, else its savvy tier, else plain. */
70export const costumeOf = (run: { type: string; model?: string; effort?: string }): Costume => {
71 const bare = bareType(run.type)
72 if (bare === 'Explore') return 'pirate'
73 const family = familyOf(run.model)
74 if (family) return byFamily(family, run.effort)
75 const legacy = bare.startsWith('savvy-') ? legacyOf(bare.slice('savvy-'.length).toLowerCase()) : undefined
76 const legacyFamily = legacy && familyOf(legacy.model)
77 return legacy && legacyFamily ? byFamily(legacyFamily, legacy.effort) : 'other'
78}
79
80export type PlannedLike = { tier?: string; model?: string; effort?: string }
81
82const isCostume = (name: string): name is Costume => (COSTUMES as readonly string[]).includes(name)
83
84/** What a planned task will run on: its own model and effort, else its tier (a savvy tier or a costume name). */
85export const plannedProfile = (p: PlannedLike): { model?: string; effort?: string } => {
86 if (p.model && familyOf(p.model)) return { model: p.model, effort: p.effort }
87 const legacy = legacyOf((p.tier ?? '').trim().toLowerCase())
88 return legacy ? { ...legacy } : {}
89}
90
91export const plannedCostume = (p: PlannedLike): Costume => {
92 const profile = plannedProfile(p)
93 const family = familyOf(profile.model)
94 if (family) return byFamily(family, profile.effort)
95 const name = (p.tier ?? '').trim().toLowerCase()
96 return isCostume(name) ? name : 'other'
97}
98types/index.d.ts 67 lines1export type Phase = 'plan' | 'design' | 'delegate' | 'review' | 'close'
2
3export type PlannedTask = {
4 title: string
5 /** A savvy-flow tier or a costume name; empty when the plan names a model instead. */
6 tier: string
7 /** What the task will run on: an alias or a model id. Picks the costume with `effort`. */
8 model?: string
9 effort?: string
10 after: number[]
11}
12
13export type Flow = {
14 title: string
15 total: number
16 done: number
17 running: number
18 phase: Phase
19 isFinished: boolean
20 tasks: PlannedTask[]
21 /** Derived from subagent spawns and completions, not reported by an orchestrator. */
22 isAuto?: boolean
23 /** Ids of the agents an auto flow spawned: only these decide it is over. */
24 ids?: string[]
25}
26
27export type AgentStatus = 'running' | 'done' | 'failed'
28
29export type AgentRun = {
30 id: string
31 agentId?: string
32 type: string
33 description: string
34 model: string
35 effort?: string
36 status: AgentStatus
37 startedAt: number
38 endedAt?: number
39 contextTokens: number
40 contextMax: number
41 tokens: number
42 costUsd: number
43 steps: number
44 round: number
45 /** Self-reported by the worker through the `step` tool. */
46 stepDone?: number
47 stepTotal?: number
48 stepNote?: string
49}
50
51export type Panel = {
52 isCompact: boolean
53 isDoneCollapsed: boolean
54 autoOpenedFor: string
55}
56
57declare module 'claude-code' {
58 interface PluginState {
59 'subagent-progress': {
60 flow: Flow | null
61 agents: AgentRun[]
62 panel: Panel
63 now: number
64 }
65 }
66}
67