Progress bar above the prompt for /savvy-flow or any delegated plan, plus an agents panel (/agents-info): running, finished and planned subagents with model…

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