Progress bar above the prompt for any work in several parts, /savvy-flow included, plus an agents panel (/agents-info): running, finished and planned subagents…

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