SLOPSHOPPER

savvy-progress

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

newpanebandguardcommandtool
v1.2.4MITupdated 2026-10-15joaopasbento/claude-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 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 tokens · 0:00 No subagents yet.
README

<!-- Modified by joaopasbento in 2026: a copy of savvy-progress 1.2.0 by johnnyvizz (MIT), from github.com/JohnnyVizz/claude-kit at commit 272a877, changed in 1.2.1, 1.2.2, 1.2.3 and 1.2.4. -->

savvy-progress

This is a modified copy. It copies savvy-progress 1.2.0 by johnnyvizz, from JohnnyVizz/claude-kit, folder plugins/savvy-progress at commit 272a877, under the MIT License (LICENSE). What changed from the author's 1.2.0, in 1.2.1:

  • The band is shared. The progress bar goes on top of whatever the other mods draw above the prompt, instead of replacing it. Alone, it draws as before. The manifest lists token-weather as a dependency, so this mod's hooks run before token-weather's: the bar always sits first, the context line right under it, and anything else (another mod's notes) below both. When the band is taller than its rows, Claude Code shows its top, so the bar and the line stay in view. Installing savvy-progress installs token-weather with it.
  • No cost. The estimated cost is gone from the bar's panel, its views and the descriptions, along with the price table: with a subscription, API prices are not what you pay. Tokens and time stay.
  • Any work in several parts. The progress tool's description now fits any multi-part task, and still serves savvy-flow.
  • The bar shows in the terminal. Claude Code's terminal now lists Svg among its elements but draws none, so the bar showed only its buttons there. The SVG row is now drawn on the desktop alone, as the panel already did, and the terminal gets the text bar.

And in 1.2.2:

  • The bar lines up with token-weather's line. The title starts where the forecast word does under it: in the terminal after the dot and two spaces (column 3), in the app at x=19 inside the SVG, with the bar after it. The dot stays where it was.
  • Every agent type is dressed. The panel picks each agent's costume and color by its exact type, from one table (AGENT_LOOKS in hooks/register.tsx): general-purpose the engineer, Explore the pirate, Plan the chef, claude-code-guide the detective, claude the astronaut, fork the racer. Any other type, or one a plugin prefixes, keeps the plain crab. savvy-flow's workers and planned tasks are dressed by their tier, as before, and the bar's crab stays plain. In the terminal, the type's ▣ and name take its color.

And in 1.2.3:

  • Wrong calls are refused. The progress and step tools refuse a call with an argument their input schema does not list, or a value it does not accept, instead of answering "ok" and leaving the bar where it was. The refusal names the field and the right one, as in Unknown field "completed". Use "done" (integer): the number of parts finished., so the model corrects itself on the next try; a refused call changes neither the bar nor the panel. Right calls work as before.

And in 1.2.4:

  • Only known slips are refused. An unknown argument makes the call fail only when it is a known slip for a real field, such as completed for done. Any other unknown key is let through, as up to 1.2.2: the tool.call event carries keys of its own beside the arguments, and a newer Claude Code may add more, which 1.2.3 would have refused on every call. Values the schema does not take and missing required fields are still refused, and so is a call with none of the tool's fields, which would change nothing; its refusal lists the fields.

The savvy-flow skill this mod was made for is not part of this marketplace.

A Claude Code mod: a progress bar above the prompt and a live panel of subagents. Made for the savvy-flow skill, and useful with any subagents.

  • 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, tokens and time. Working crabs walk, and each costume animates its prop: the astronaut floats, the detective sweeps the magnifier, the engineer turns the wrench, the chef tosses the omelette, the racer runs with a fluttering flag. prefers-reduced-motion stops them.

Tools it adds

  • mcp__savvy-progress__progress: reports the progress of any work in several parts: the title, the parts planned and finished, and the phase. With savvy-flow, the orchestrator reports the plan, phase and accepted tasks.
  • mcp__savvy-progress__step: a worker reports its own steps (done, total, note). A worker that does not report shows its context fill in grey instead.

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 /config, or, for a mod loaded by hand, in ~/.claude/settings.json:

{ "pluginConfigs": { "savvy-progress": { "options": { "language": "ru" } } } }

Install instructions are in the repository README.

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