SLOPSHOPPER

savvy-progress

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

newpanebandguardcommandtool
v1.2.0-pink.3MITupdated 2026-10-11jkhaynes/ai-agent-skills/mods/savvy-progress
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · savvy-progress
│ ┃ Agents ✕ › fix the failing auth test and add an audit log call │ ┃ Collapse │ ┃ ≈$0.00 · 0 tokens · 0:00 ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ No subagents yet. ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /agents-info │ ⎿ savvy-progress: Agents panel opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agents
Collapse ≈$0.00 · 0 tokens · 0:00 No subagents yet.
README

savvy-progress

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).

  • Progress bar: the flow's title, phase, accepted tasks out of planned, and a button with the crew size that opens the panel. It appears once something reports progress (savvy-flow does) or a savvy-* worker starts.
  • Agents panel (/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.

What this copy changes

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

Superpowers dispatches every worker as general-purpose, so this copy reads each one's role from the description its prompts give it:

DescriptionRoleCostume
Implement Task N: <name>implementengineer (turning wrench)
Review Task N (spec + quality), Re-review Task N fix round Rreviewdetective (sweeping magnifier)
Review code changesfinal reviewastronaut

Any other agent keeps its type and stays grey; Explore agents get the explore costume, as upstream.

  • Rounds: runs of the same role on the same task number count as rounds, so a re-review shows as review · round 2. Other agents still count rounds by identical descriptions.
  • The bar: an 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.
  • The plan: the 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.

Colours

PartUpstreamThis copy
Bar and accent#8f8cf4 (purple)#e8b4d0 (dusty pink)
Finished bar#5fbf8f#a6d9a8
Agent figurea 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, tileneutral greys#f2e1e8, #b39ca8, #2f2430, #29202a
Light-mode neutralsneutral greysthe 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.

Tools it adds

  • 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.

Settings

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" } } } }

Install

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.

Develop

claude plugin validate mods/savvy-progress
claude plugin test mods/savvy-progress

Limits

  • The terminal surface also takes the bar's SVG branch. Under 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.
  • While a flow is reported, the band hook answers without passing on to the hooks beneath it, so another mod's band above the prompt (the skins mod's usage rings) can be hidden until you press ✕. Which one shows depends on load order.
Source 2 files
hooks/register.tsx 1162 lines
1import { 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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}
1162
types/index.d.ts 59 lines
1export 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