A progress bar above the prompt and a live agents panel for any subagents Claude starts, with model, context, cost and time. Fork of johnnyvizz's…

A Claude Code mod that shows what your subagents are up to: a progress bar above the prompt box, and a live panel listing every subagent with its model, context, tokens, cost and time.
The bar while four subagents run:

The same bar once they've all finished:

The panel, which the crew button opens, with every subagent's model, effort, context, tokens, cost and time:

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