Agents panel with pixel crabs and a progress strip above the prompt, for any subagents. Based on savvy-progress by johnnyvizz (MIT).

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