Pink copy of johnnyvizz/savvy-progress: progress bar above the prompt plus an agents panel (/agents-info) of running, finished and planned subagents with…

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