SLOPSHOPPER

agent-progress

Progress bar above the prompt for /savvy-flow or any delegated plan, plus an agents panel (/agents-info): running, finished and planned subagents with model…

newpanebandguardcommandprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-progress
│ ┃ Agents ✕ › fix the failing auth test and add an audit log call │ ┃ ≈$0.00 · 0 tokens · 0:00 Collapse │ ┃ ⏺ Read(src/auth.ts) │ ┃ No subagents yet. ⎿ Read 6 lines │ ⏺ 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 │ ⎿ agent-progress: Agents panel opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

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

claude-mods

Claude Code mods: plugins built on function hooks that run inside Claude Code.

Install

/plugin marketplace add nguyenngoctoan/claude-mods
/plugin install agent-progress@claude-mods

Then run /reload-plugins.

Mods

agent-progress

A progress bar above the prompt for a delegated plan, plus an agents panel (/agents-info) that lists running, finished and planned subagents with model, task progress, context, cost and time. The bar shows tokens used, the cache hit and tokens per second.

Based on savvy-progress from JohnnyVizz/claude-kit (MIT).

Develop

claude plugin validate agent-progress
claude plugin test agent-progress

License

MIT. See agent-progress/LICENSE, which keeps the upstream copyright notice.

Source 2 files
hooks/register.tsx 1175 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentRun, Flow, Panel, Phase, PlannedTask, Stats } from '../types'
5
6const flow = atom({ plugin: 'agent-progress', key: 'flow' } as const, null)
7const agents = atom({ plugin: 'agent-progress', key: 'agents' } as const, [])
8const panel = atom({ plugin: 'agent-progress', key: 'panel' } as const, {
9  isCompact: false,
10  isDoneCollapsed: false,
11  autoOpenedFor: '',
12})
13const now = atom({ plugin: 'agent-progress', key: 'now' } as const, 0)
14const stats = atom({ plugin: 'agent-progress', key: 'stats' } as const, { tokens: 0, tps: 0, cached: 0, prompt: 0 })
15
16const TOOL = 'mcp__agent-progress__progress'
17const STEP_TOOL = 'mcp__agent-progress__step'
18const PANE = 'savvy-agents'
19const PHASES: readonly Phase[] = ['plan', 'design', 'delegate', 'review', 'close']
20const ACCENT = '#8f8cf4'
21const DONE = '#5fbf8f'
22
23type ProgressInput = {
24  title?: string
25  total?: number
26  done?: number
27  phase?: Phase
28  finished?: boolean
29  tasks?: { title?: string; tier?: string; after?: number[] }[]
30}
31
32// ---------------------------------------------------------------------------
33// Language: the `language` option, else Claude Code's `language` setting, else the
34// process locale; English when nothing says Russian.
35
36type Lang = 'en' | 'ru'
37
38const STRINGS = {
39  en: {
40    pane: 'Agents',
41    cost: 'Cost',
42    tokens: 'Tokens',
43    time: 'Time',
44    collapse: 'Collapse',
45    expand: 'Expand',
46    running: 'Running',
47    finished: 'Finished',
48    planned: 'Planned',
49    empty: 'No subagents yet.',
50    round: 'round',
51    failed: 'error',
52    after: 'after',
53    tokensWord: 'tokens',
54    agentsCount: 'agents',
55    isRunning: 'running',
56    isFinished: 'finished',
57    isPlanned: 'planned',
58    opened: 'Agents panel opened.',
59    closed: 'Agents panel closed.',
60    done: 'Done',
61    plan: 'Plan',
62    design: 'Design',
63    tasks: 'Tasks',
64    review: 'Review',
65    busy: 'running',
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  },
95} as const
96
97// Module scope is fine here: session.start sets it again on every (re)load.
98let lang: Lang = 'en'
99const tr = () => STRINGS[lang]
100
101const isRussian = (v: unknown): boolean => typeof v === 'string' && /^(ru|russian|рус)/i.test(v.trim())
102
103async function detectLang($: EngineInterface, option: unknown): Promise<Lang> {
104  if (option === 'en' || option === 'ru') return option
105  try {
106    const settings = (await $.settings.read()) as Record<string, unknown>
107    if (typeof settings.language === 'string' && settings.language.trim()) return isRussian(settings.language) ? 'ru' : 'en'
108  } catch {
109    // No settings: fall through to the locale.
110  }
111  const locale = (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG'))
112  return isRussian(locale) ? 'ru' : 'en'
113}
114
115const blank = (): Flow => ({
116  title: 'savvy-flow',
117  total: 0,
118  done: 0,
119  running: 0,
120  phase: 'plan',
121  isFinished: false,
122  tasks: [],
123})
124
125const isNewFlow = (prev: Flow | null, input: ProgressInput): boolean =>
126  !prev || prev.isFinished || (input.title !== undefined && input.title.trim() !== prev.title)
127
128const cleanTasks = (tasks: ProgressInput['tasks']): PlannedTask[] | undefined =>
129  tasks
130    ?.filter(t => t.title?.trim())
131    .map(t => ({
132      title: (t.title ?? '').trim(),
133      tier: (t.tier ?? '').replace(/^savvy-/, '').trim().toLowerCase(),
134      after: (t.after ?? []).filter(n => Number.isInteger(n) && n > 0),
135    }))
136
137const merge = (prev: Flow | null, input: ProgressInput): Flow => {
138  // A new title means a new flow: never carry counters over from an earlier one.
139  const base = isNewFlow(prev, input) || !prev ? blank() : { ...blank(), ...prev }
140  const tasks = cleanTasks(input.tasks) ?? base.tasks
141  const total = Math.max(0, Math.round(input.total ?? (input.tasks ? tasks.length : base.total)))
142  const done = Math.min(total || Infinity, Math.max(0, Math.round(input.done ?? base.done)))
143  const phase = input.phase && PHASES.includes(input.phase) ? input.phase : base.phase
144  return {
145    ...base,
146    title: input.title?.trim() || base.title,
147    total,
148    done,
149    phase: input.finished ? 'close' : phase,
150    isFinished: input.finished === true,
151    tasks,
152  }
153}
154
155const label = (f: Flow): string => {
156  const s = tr()
157  if (f.isFinished) return s.done
158  if (f.phase === 'plan') return s.plan
159  if (f.phase === 'design') return s.design
160  const count = f.total ? `${f.done}/${f.total}` : `${f.running} ${s.busy}`
161  return `${f.phase === 'review' ? s.review : s.tasks} ${count}`
162}
163
164const ratio = (f: Flow): number => (f.isFinished ? 1 : f.total ? f.done / f.total : 0)
165
166// Deterministic noise so the dither does not shimmer between redraws.
167const noise = (x: number, y: number): number => {
168  const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
169  return s - Math.floor(s)
170}
171
172// The whole row is one SVG: the desktop wraps sibling elements onto new lines,
173// so title, bar, percent and the crab live in one drawing; only the count and
174// the dismiss are Buttons beside it.
175const H = 22
176const BAR_H = 16
177const CRAB_W = 26
178const CELL = 3
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, extra = ''): 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(16 + textWidth(title, 13) + 12)
207  const extraW = extra ? Math.round(textWidth(extra, 12.5)) + 14 : 0
208  const BAR_W = Math.max(60, W - BAR_X - 46 - CRAB_W - extraW)
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:#1f1f1f}.m{fill:#8a8a8a}.k{fill:#e4e4e2}.tk{fill:#b4b4b0}
246@media (prefers-color-scheme: dark){.t{fill:#ececec}.m{fill:#9a9a9a}.k{fill:#2c2c2c}.tk{fill:#5a5a5a}}
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="#ffffff">${xml(text)}</text>
265</g>
266<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>
267${extra ? `<text class="m" x="${W - CRAB_W - 6 - 40}" y="${H / 2 + 4.5}" text-anchor="end" font-family="${FONT}" font-size="12.5" font-variant-numeric="tabular-nums">${xml(extra)}</text>` : ''}
268${CRAB_CSS}${crab(W - CRAB_W + 1, 0, 'other', false, isWorking, 0.8)}
269</svg>`
270}
271
272const barText = (f: Flow, width: number): string => {
273  const filled = Math.round(width * ratio(f))
274  return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
275}
276
277// ---------------------------------------------------------------------------
278// Agents panel: every subagent of the session, plus the tasks the flow planned.
279
280const TIER_COLOR: Record<string, string> = {
281  fable: '#7F77DD',
282  heavy: '#D85A30',
283  careful: '#BA7517',
284  medium: '#378ADD',
285  light: '#1D9E75',
286  other: '#888780',
287}
288
289// What each savvy tier runs on, for planned tasks that have no run yet.
290const colorOf = (tier: string): string => TIER_COLOR[tier] ?? '#888780'
291
292const TIER_MODEL: Record<string, string> = {
293  fable: 'Fable · high',
294  heavy: 'Opus · xhigh',
295  careful: 'Opus · high',
296  medium: 'Opus · medium',
297  light: 'Opus · low',
298}
299
300// USD per million tokens: input, output, cache read, cache write (5-minute TTL).
301// The engine reports tokens, not money, so the panel's cost is an estimate.
302const PRICES: [RegExp, [number, number, number, number]][] = [
303  [/fable-5-1|mythos-5-1/, [10, 50, 0.25, 12.5]],
304  [/fable|mythos/, [10, 50, 1, 12.5]],
305  [/opus-5-5/, [4, 20, 0.2, 5]],
306  [/opus/, [5, 25, 0.5, 6.25]],
307  [/sonnet-4/, [3, 15, 0.3, 3.75]],
308  [/sonnet/, [2, 10, 0.2, 2.5]],
309  [/haiku-5-5/, [0.1, 0.5, 0.01, 0.125]],
310  [/haiku/, [1, 5, 0.1, 1.25]],
311]
312
313// Haiku 5.5 bills a prompt past 100K tokens at a higher tier.
314const HAIKU_55_LONG: [number, number, number, number] = [0.5, 2.5, 0.05, 0.625]
315
316type Usage = {
317  input_tokens: number
318  output_tokens: number
319  cache_read_input_tokens: number
320  cache_creation_input_tokens: number
321}
322
323const priceOf = (model: string, promptTokens: number): [number, number, number, number] =>
324  /haiku-5-5/i.test(model) && promptTokens > 100_000
325    ? HAIKU_55_LONG
326    : (PRICES.find(([re]) => re.test(model.toLowerCase()))?.[1] ?? [4, 20, 0.2, 5])
327
328const costOf = (model: string, u: Usage): number => {
329  const prompt = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
330  const [i, o, r, w] = priceOf(model, prompt)
331  return (
332    ((u.input_tokens || 0) * i +
333      (u.output_tokens || 0) * o +
334      (u.cache_read_input_tokens || 0) * r +
335      (u.cache_creation_input_tokens || 0) * w) /
336    1e6
337  )
338}
339
340const windowOf = (model: string): number => (/haiku-[34]/i.test(model) ? 200_000 : 1_000_000)
341
342// `savvy-careful`, or `savvy-flow:savvy-careful` when the agents ship in a plugin.
343const tierOf = (type: string): string => {
344  const bare = type.replace(/^[^:]*:/, '')
345  const t = bare.replace(/^savvy-/, '').toLowerCase()
346  return t in TIER_COLOR && bare.startsWith('savvy-') ? t : 'other'
347}
348
349const modelName = (id: string): string => {
350  const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
351  const [, family = '', major = '', minor] = m ?? []
352  if (!family) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '') || '—'
353  return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()} ${major}${minor ? '.' + minor : ''}`
354}
355
356const norm = (s: string): string => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
357
358const fmtTokens = (n: number): string =>
359  n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${Math.round(n)}`
360
361// "4k tok · 92% cache · 100 tps" for the bar; nothing until a request has been counted,
362// and no cache figure until a request has read from the cache.
363const fmtStats = (st: Stats): string =>
364  st.tokens
365    ? `${fmtTokens(st.tokens)} tok${st.cached ? ` · ${Math.round((st.cached / st.prompt) * 100)}% cache` : ''}${st.tps ? ` · ${st.tps} tps` : ''}`
366    : ''
367
368const fmtCost = (usd: number): string => `$${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`
369
370const fmtTime = (ms: number): string => {
371  const s = Math.max(0, Math.round(ms / 1000))
372  const h = Math.floor(s / 3600)
373  const m = Math.floor((s % 3600) / 60)
374  const ss = String(s % 60).padStart(2, '0')
375  return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
376}
377
378const elapsed = (a: AgentRun, at: number): number => (a.endedAt ?? Math.max(at, a.startedAt)) - a.startedAt
379
380type Planned = PlannedTask & { n: number }
381
382const plannedOf = (f: Flow | null, list: AgentRun[]): Planned[] => {
383  if (!f || f.isFinished) return []
384  const started = new Set(list.map(a => norm(a.description)))
385  return (f.tasks ?? []).map((t, i) => ({ ...t, n: i + 1 })).filter(t => !started.has(norm(t.title)))
386}
387
388const totals = (list: AgentRun[], at: number) => {
389  const cost = list.reduce((s, a) => s + a.costUsd, 0)
390  const tokens = list.reduce((s, a) => s + a.tokens, 0)
391  const start = Math.min(...list.map(a => a.startedAt))
392  const end = Math.max(...list.map(a => a.endedAt ?? Math.max(at, a.startedAt)))
393  return { cost, tokens, time: list.length ? end - start : 0 }
394}
395
396// --- desktop drawings: each row is one SVG, as the band above the prompt is.
397
398const PANE_CSS = `<style>
399.t{fill:#1f1f1f}.s{fill:#6b6b68}.m{fill:#9a9a96}.k{fill:#ecebe8}.ln{stroke:#e4e4e1}.tile{fill:#f4f3f0}
400@media (prefers-color-scheme: dark){.t{fill:#ececec}.s{fill:#a8a8a4}.m{fill:#7d7d79}.k{fill:#2c2c2b}.ln{stroke:#333331}.tile{fill:#262625}}
401.live{animation:p 1.6s ease-in-out infinite}@keyframes p{50%{opacity:.3}}
402@media (prefers-reduced-motion: reduce){.live{animation:none}}
403</style>`
404
405// Pixel Clawd from DockCrab (Clawdy): a 24×18 crab on a 30×28 grid, one costume per tier.
406// The body keeps the brand clay; the tier's color lives in the costume's accent.
407const CLAY = '#D97757'
408const INK = '#1F1E1D'
409
410// `cls` puts a pixel in a named group: `bd` (the default) is the body and its
411// costume, `la`/`lb` the leg pairs, anything else a prop with its own motion.
412type Fill = (x: number, y: number, w: number, h: number, c: string, cls?: string) => void
413
414const stamp = (f: Fill, x: number, y: number, rows: string[], map: Record<string, string>, cls?: string): void =>
415  rows.forEach((row, dy) => [...row].forEach((ch, dx) => map[ch] && f(x + dx, y + dy, 1, 1, map[ch] ?? '', cls)))
416
417// `armCls` lets a raised claw travel with the prop it holds.
418const crabBody = (f: Fill, armFront = 0, armCls?: string): void => {
419  f(7, 10, 16, 12, CLAY)
420  f(3, 14, 4, 4, CLAY)
421  f(23, 14 + armFront, 4, 4, CLAY, armCls)
422  f(9, 12, 2, 2, INK)
423  f(19, 12, 2, 2, INK)
424  f(7, 22, 2, 4, CLAY, 'la')
425  f(17, 22, 2, 4, CLAY, 'la')
426  f(11, 22, 2, 4, CLAY, 'lb')
427  f(21, 22, 2, 4, CLAY, 'lb')
428}
429
430// Pure CSS, run by the compositor: no redraws. Periods divide one second, so the
431// once-a-second redraw of a running row restarts them in phase. Every crab walks;
432// each costume adds its prop's own motion on top.
433const CRAB_CSS = `<style>
434.run .la{animation:st .5s steps(1) infinite}.run .lb{animation:st .5s steps(1) infinite -.25s}
435.run .bd{animation:bob .5s steps(1) infinite -.125s}
436.run g{transform-box:fill-box}
437@keyframes st{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}
438.c-fable.run{animation:float 1s ease-in-out infinite}
439.c-fable.run .la,.c-fable.run .lb,.c-fable.run .bd{animation:none}
440.c-fable.run .ant{animation:blink 1s steps(1) infinite}
441.c-fable.run .star{animation:blink .5s steps(1) infinite -.25s}
442@keyframes float{50%{transform:translateY(-2px)}}@keyframes blink{50%{opacity:.15}}
443.c-heavy.run .it{animation:scan 1s steps(1) infinite}
444.c-heavy.run .gl{animation:blink 1s steps(1) infinite -.5s}
445@keyframes scan{25%{transform:translate(-1px,1px)}50%{transform:translate(-2px,2px)}75%{transform:translate(-1px,1px)}}
446.c-careful.run .it{transform-origin:100% 100%;animation:twist .5s ease-in-out infinite}
447@keyframes twist{50%{transform:rotate(-35deg)}}
448.c-medium.run .pan{transform-origin:0 50%;animation:tilt 1s ease-in-out infinite}
449.c-medium.run .egg{animation:flip 1s ease-in-out infinite}
450@keyframes tilt{20%,40%{transform:rotate(-12deg)}}@keyframes flip{30%{transform:translateY(-5px) scaleY(-1)}60%{transform:translateY(0)}}
451.c-light.run .la{animation-duration:.25s}.c-light.run .lb{animation-duration:.25s;animation-delay:-.125s}
452.c-light.run .flag{transform-origin:0 50%;animation:wave .25s steps(1) infinite}
453@keyframes wave{50%{transform:skewY(-12deg) scaleX(.85)}}
454.c-explore.run .it{transform-origin:50% 100%;animation:fence .5s ease-in-out infinite}
455@keyframes fence{50%{transform:rotate(25deg)}}
456@media (prefers-reduced-motion: reduce){.run,.run g{animation:none!important}}
457</style>`
458
459const COSTUMES: Record<string, (f: Fill, t: string) => void> = {
460  // Fable: astronaut in a glass dome; floats instead of walking, the antenna and the star blink.
461  fable: (f, t) => {
462    crabBody(f)
463    f(6, 7, 18, 1, '#E6E8EE'); f(5, 8, 1, 14, '#E6E8EE'); f(24, 8, 1, 14, '#E6E8EE'); f(6, 22, 18, 1, '#C9CCD2')
464    f(6, 8, 18, 14, 'rgba(169,214,245,.32)'); f(8, 9, 2, 1, '#fff'); f(8, 10, 1, 2, '#fff')
465    f(14, 4, 2, 3, '#C9CCD2'); f(14, 2, 2, 2, t, 'ant'); f(13, 18, 4, 2, t)
466    f(27, 3, 1, 3, '#F5C542', 'star'); f(26, 4, 3, 1, '#F5C542', 'star')
467  },
468  // Heavy: detective with a deerstalker; the magnifier sweeps and glints.
469  heavy: (f, t) => {
470    crabBody(f, -4, 'it')
471    stamp(f, 6, 3, ['......bbbbbb......', '....bbcbbcbbbb....', '...bbbbbbbbbbbb...', '..bcbbcbbcbbcbbb..', '.bbbbbbbbbbbbbbbb.', 'dddddddddddddddddd'], { b: '#7A4A26', c: '#A0703F', d: '#5A3519' })
472    f(6, 9, 18, 1, t)
473    stamp(f, 23, 1, ['.kkk.', 'k...k', 'k...k', 'k...k', '.kkk.'], { k: '#3A3A3C' }, 'it')
474    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')
475  },
476  // Careful: engineer in a hard hat; the wrench turns a bolt.
477  careful: (f, t) => {
478    crabBody(f)
479    stamp(f, 6, 4, ['.....yyyyyyyy.....', '...yyyyyhhyyyyy...', '..yyyyyyhhyyyyyy..', '..yyyyyyhhyyyyyy..', '.yyyyyyyhhyyyyyyy.', 'dddddddddddddddddd'], { y: '#F5C542', h: '#FBE08A', d: '#C99A1E' })
480    f(13, 5, 4, 2, t)
481    stamp(f, 0, 10, ['.s.s', 'sss.', '.s..', '.s..'], { s: '#8E929A' }, 'it')
482  },
483  // Medium: chef, the toque traced from DockCrab's Sprites.chefHat; tosses the omelette.
484  medium: (f, t) => {
485    crabBody(f, -4, 'pan')
486    stamp(f, 6, 0, ['........lll.......', '.......lllll......', '.wwwwgwwwwwwgwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwgwwwwwggwwwww', '.wwwwgwwwwwggwwwww', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.'], { w: '#F4F3EE', l: '#F7F6F2', g: '#D2D1C8', b: t, d: '#B45F43' })
487    f(22, 8, 7, 2, '#4A4A48', 'pan'); f(26, 10, 1, 1, '#4A4A48', 'pan'); f(24, 7, 3, 1, '#F5B731', 'egg')
488  },
489  // Light: racer in a helmet; runs at double pace, the checkered flag flutters.
490  light: (f, t) => {
491    crabBody(f, -4)
492    stamp(f, 6, 5, ['....rrrrrrrrrr....', '..rrrrrrwwrrrrrr..', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.kkkkkkkkkkkkkkkkr'], { r: t, w: '#F8F6F1', k: INK })
493    f(25, 1, 1, 9, '#8E929A')
494    stamp(f, 26, 1, ['wkwk', 'kwkw', 'wkwk'], { w: '#F8F6F1', k: INK }, 'flag')
495  },
496  // Explore: pirate scouting the code; the cutlass fences.
497  explore: f => {
498    crabBody(f)
499    stamp(f, 5, 3, ['.kk..............kk.', '.kkk....kkkk....kkk.', '..kkkkkkkwwkkkkkkk..', '..kkkkkkkkkkkkkkkk..', '.gggggggggggggggggg.'], { k: '#55514C', w: '#F8F6F1', g: '#F5C542' })
500    f(7, 11, 11, 1, INK); f(18, 11, 4, 3, INK)
501    f(27, 6, 1, 9, '#C9CCD2', 'it'); f(26, 15, 3, 1, '#7A4A26', 'it')
502  },
503  other: f => crabBody(f),
504}
505
506const costumeOf = (type: string): string => (type === 'Explore' ? 'explore' : tierOf(type))
507
508const CRAB_SCALE = 1.1
509
510// Body and props nest inside `bd` so a prop rides the bob and adds its own motion;
511// legs stay outside it and step on their own.
512const crab = (x: number, y: number, costume: string, dim = false, isWalking = false, scale = CRAB_SCALE): string => {
513  const groups = new Map<string, string[]>([['bd', []]])
514  const f: Fill = (cx, cy, w, h, c, cls = 'bd') => {
515    if (!groups.has(cls)) groups.set(cls, [])
516    groups.get(cls)?.push(`<rect x="${cx}" y="${cy}" width="${w}" height="${h}" fill="${c}"/>`)
517  }
518  const draw = COSTUMES[costume] ?? ((g: Fill) => crabBody(g))
519  draw(f, colorOf(costume))
520  const group = (cls: string) => `<g class="${cls}">${(groups.get(cls) ?? []).join('')}</g>`
521  const props = [...groups.keys()].filter(k => k !== 'bd' && k !== 'la' && k !== 'lb')
522  const body = `<g class="bd">${(groups.get('bd') ?? []).join('')}${props.map(group).join('')}</g>`
523  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>`
524}
525
526const statusMark = (x: number, y: number, status: string, color: string): string => {
527  if (status === 'running') return `<circle class="live" cx="${x}" cy="${y}" r="3.5" fill="${color}"/>`
528  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"/>`
529  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"/>`
530  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"/>`
531}
532
533const svg = (W: number, H: number, body: string): string =>
534  `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${PANE_CSS}${CRAB_CSS}${body}</svg>`
535
536// The pane's own title already says "Agents": the header names the flow, if any.
537const headerSvg = (W: number, title: string, t: ReturnType<typeof totals>): string => {
538  const s = tr()
539  const gap = 6
540  const tw = (W - gap * 2) / 3
541  const top = title ? 28 : 0
542  const tile = (i: number, k: string, v: string) =>
543    `<rect class="tile" x="${i * (tw + gap)}" y="${top}" width="${tw}" height="40" rx="8"/>
544<text class="s" x="${i * (tw + gap) + 9}" y="${top + 16}" font-family="${FONT}" font-size="11">${k}</text>
545<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>`
546  return svg(
547    W,
548    headerHeight(title),
549    `${title ? `<text class="t" x="0" y="15" font-family="${FONT}" font-size="14" font-weight="600">${xml(fitText(title, 14, W))}</text>` : ''}
550${tile(0, s.cost, '≈' + fmtCost(t.cost))}${tile(1, s.tokens, fmtTokens(t.tokens))}${tile(2, s.time, fmtTime(t.time))}`,
551  )
552}
553
554const headerHeight = (title: string): number => (title ? 72 : 44)
555
556// The task's own progress when the worker reports steps; a finished run is full.
557const progressOf = (a: AgentRun): number | null => {
558  if (a.status === 'done') return 1
559  if (a.stepTotal) return Math.min(1, (a.stepDone ?? 0) / a.stepTotal)
560  return null
561}
562
563const ctxOf = (a: AgentRun): number => (a.contextMax ? Math.min(100, Math.round((a.contextTokens / a.contextMax) * 100)) : 0)
564
565const agentSvg = (W: number, a: AgentRun, at: number): string => {
566  const s = tr()
567  const tier = tierOf(a.type)
568  const color = colorOf(tier)
569  const ctx = ctxOf(a)
570  const textW = W - 42 - 22
571  const meta = [a.effort ? `${modelName(a.model)} · ${a.effort}` : modelName(a.model)]
572  if (a.round > 1) meta.push(`${s.round} ${a.round}`)
573  if (a.status === 'failed') meta.push(s.failed)
574  const barW = textW
575  const progress = progressOf(a)
576  const stats = `ctx ${ctx}% · ${fmtTokens(a.contextTokens)}  ≈${fmtCost(a.costUsd)}  ${fmtTime(elapsed(a, at))}`
577  const steps = a.stepTotal ? `${a.stepDone ?? 0}/${a.stepTotal}${a.stepNote ? ' · ' + a.stepNote : ''}` : ''
578  const stepsW = Math.max(0, barW - textWidth(stats, 11) - 12)
579  // Without reported steps the bar falls back to the context, drawn grey.
580  const fillW = Math.round(barW * (progress ?? ctx / 100))
581  return svg(
582    W,
583    66,
584    `${crab(0, 14, costumeOf(a.type), false, a.status === 'running')}
585<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>
586<text x="42" y="34" font-family="${FONT}" font-size="11"><tspan fill="${color}">${xml(tier === 'other' ? a.type : tier)}</tspan><tspan class="s">  ${xml(meta.join('  ·  '))}</tspan></text>
587${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>` : ''}
588<text class="s" x="${42 + barW}" y="49" text-anchor="end" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${stats}</text>
589<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}"`}/>
590${statusMark(W - 8, 16, a.status, color)}
591<line class="ln" x1="0" y1="65.5" x2="${W}" y2="65.5"/>`,
592  )
593}
594
595const plannedSvg = (W: number, p: Planned): string => {
596  const tier = p.tier in TIER_COLOR ? p.tier : 'other'
597  const color = colorOf(tier)
598  const textW = W - 42 - 22
599  const meta = [TIER_MODEL[tier] ?? '']
600  if (p.after.length) meta.push(`${tr().after} ${p.after.join(', ')}`)
601  return svg(
602    W,
603    46,
604    `${crab(0, 6, tier, true)}
605<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>
606<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>
607${statusMark(W - 8, 16, 'planned', color)}
608<line class="ln" x1="0" y1="45.5" x2="${W}" y2="45.5"/>`,
609  )
610}
611
612const compactSvg = (W: number, list: AgentRun[], planned: Planned[], t: ReturnType<typeof totals>): string => {
613  const icons = [
614    ...list.filter(a => a.status === 'running').map(a => ({ k: costumeOf(a.type), c: colorOf(tierOf(a.type)), s: 'running', dim: false })),
615    ...list.filter(a => a.status !== 'running').map(a => ({ k: costumeOf(a.type), c: colorOf(tierOf(a.type)), s: a.status, dim: false })),
616    ...planned.map(p => ({ k: p.tier in TIER_COLOR ? p.tier : 'other', c: colorOf(p.tier), s: 'planned', dim: true })),
617  ]
618  const fit = Math.max(1, Math.floor((W - 150) / 36))
619  const shown = icons.slice(0, fit)
620  const more = icons.length - shown.length
621  const body = shown
622    .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}"/>` : ''))
623    .join('')
624  const x = shown.length * 36 + (more ? 4 : 0)
625  return svg(
626    W,
627    32,
628    `${body}${more ? `<text class="s" x="${x}" y="21" font-family="${FONT}" font-size="12">+${more}</text>` : ''}
629<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>`,
630  )
631}
632
633// --- terminal drawing: the same rows in text.
634
635const ctxBar = (pct: number, width: number): string => {
636  const filled = Math.round((width * pct) / 100)
637  return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
638}
639
640const STATUS_GLYPH: Record<string, string> = { running: '●', done: '✓', failed: '✗', planned: '◷' }
641
642// The terminal has no Svg, so running work spins a braille frame per tick
643// instead; `animations: off` keeps the still glyphs.
644const TICK_MS = 500
645const SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
646let isAnimated = true
647const spinAt = (at: number, offset = 0): string => SPIN[(Math.floor(at / TICK_MS) + offset) % SPIN.length] ?? '●'
648const offsetOf = (id: string): number => [...id].reduce((sum, ch) => sum + ch.charCodeAt(0), 0)
649const glyphOf = (a: AgentRun, at: number): string =>
650  isAnimated && a.status === 'running' ? spinAt(at, offsetOf(a.id)) : (STATUS_GLYPH[a.status] ?? '')
651
652// Opens the agents pane, or closes it when it is up; true when it ends up open.
653async function togglePane($: EngineInterface): Promise<boolean> {
654  const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
655  if (isOpen) {
656    await $.ui.close({ id: PANE })
657    return false
658  }
659  const at = await $.clock.now()
660  await update($, now, () => at)
661  await $.ui.open({ id: PANE, title: tr().pane })
662  return true
663}
664
665async function autoOpen($: EngineInterface, key: string): Promise<void> {
666  const p = await read($, panel)
667  if (p.autoOpenedFor === key) return
668  await update($, panel, prev => ({ ...prev, autoOpenedFor: key }))
669  void $.ui.open({ id: PANE, title: tr().pane })
670}
671
672// A pane the plugin opened for subagents closes when none is left, and the next one opens it
673// again; a pane the person opened by hand (`autoOpenedFor` empty) stays.
674async function closeIfEmpty($: EngineInterface): Promise<void> {
675  if ((await read($, agents)).length || !(await read($, panel)).autoOpenedFor) return
676  await update($, panel, prev => ({ ...prev, autoOpenedFor: '' }))
677  if ((await $.ui.panes()).some(p => p.id === PANE)) await $.ui.close({ id: PANE })
678}
679
680export const register: Register = (on, options) => {
681  isAnimated = options.animations !== 'off'
682  on('session.start', async ($, e, next) => {
683    const started = await next(e)
684    lang = await detectLang($, options.language)
685    await $.tool.register({
686      name: 'progress',
687      description:
688        'Report progress of a /savvy-flow run, or of any plan you delegate to 3 or more subagents, to the progress bar above the prompt and the agents panel. ' +
689        'Call it after presenting the plan (title, total, tasks, phase "delegate"), each time a task is accepted (done), ' +
690        'when switching phase or re-planning (tasks), and once at the end with finished: true. Fields left out keep their previous value. ' +
691        'Skip it for one-off subagents.',
692      isDeferred: false,
693      inputSchema: {
694        type: 'object',
695        properties: {
696          title: { type: 'string', description: 'Short name of the overall task, a few words.' },
697          total: { type: 'integer', minimum: 0, description: 'Number of planned worker tasks.' },
698          done: { type: 'integer', minimum: 0, description: 'Number of tasks accepted after review.' },
699          phase: { type: 'string', enum: [...PHASES] },
700          finished: { type: 'boolean', description: 'True once the flow is closed.' },
701          tasks: {
702            type: 'array',
703            description:
704              '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.',
705            items: {
706              type: 'object',
707              properties: {
708                title: { type: 'string', description: 'A few words; reused verbatim as the Agent description.' },
709                tier: {
710                  type: 'string',
711                  enum: ['fable', 'heavy', 'careful', 'medium', 'light'],
712                  description: 'The savvy-flow tier, when the task runs as a savvy-* agent; leave it out otherwise.',
713                },
714                after: { type: 'array', items: { type: 'integer' }, description: 'Numbers of the tasks this one waits for.' },
715              },
716              required: ['title'],
717            },
718          },
719        },
720      },
721    })
722    await $.tool.register({
723      name: 'step',
724      description:
725        'For subagents (savvy-flow workers, or any delegated task): report progress on your own task to the agents panel. ' +
726        'Right after reading the brief, call it with `total` (your plan in 3-8 steps) and `done: 0`; ' +
727        'call it again as each step finishes. Cheap and silent: it only draws a bar.',
728      inputSchema: {
729        type: 'object',
730        properties: {
731          done: { type: 'integer', minimum: 0, description: 'Steps finished so far.' },
732          total: { type: 'integer', minimum: 1, description: 'Steps planned; may change if the plan changes.' },
733          note: { type: 'string', description: 'The step in progress, a few words.' },
734        },
735        required: ['done'],
736      },
737    })
738    await $.command.register({
739      name: 'agents-info',
740      description: 'Show or hide the panel of subagents: running, finished and planned, with model, context, cost and time',
741    })
742
743    // Ticks the running agents' clocks and the terminal's spinner frames; quiet when nothing runs.
744    $.clock.every(TICK_MS, async () => {
745      const list = await read($, agents)
746      if (!list.some(a => a.status === 'running')) return
747      const at = await $.clock.now()
748      await update($, now, () => at)
749    })
750    return started
751  })
752
753  on('command.run', { command: 'agents-info' }, async $ => {
754    const isOpen = await togglePane($)
755    return { text: isOpen ? tr().opened : tr().closed }
756  })
757
758  on('tool.call', { tool: TOOL }, async ($, e) => {
759    const input = e as unknown as ProgressInput
760    const prev = await read($, flow)
761    if (isNewFlow(prev, input) && input.title !== undefined) {
762      // A new flow starts with a clean list; agents still running stay.
763      await update($, agents, list => list.filter(a => a.status === 'running'))
764      await update($, stats, s => ({ ...s, tokens: 0, cached: 0, prompt: 0 }))
765      await closeIfEmpty($)
766    }
767    const next = await update($, flow, p => merge(p, input))
768    return { result: `ok: ${label(next ?? blank())}` }
769  })
770
771  // A worker's own progress: the call runs in the worker's loop, so agentId names it.
772  on('tool.call', { tool: STEP_TOOL }, async ($, e) => {
773    const input = e as unknown as { done?: number; total?: number; note?: string }
774    const agentId = e.agentId
775    if (!agentId) return { result: 'ignored: only subagents report steps' }
776    await update($, agents, list =>
777      list.map(a => {
778        if (a.agentId !== agentId) return a
779        const total = Math.max(0, Math.round(input.total ?? a.stepTotal ?? 0))
780        const done = Math.max(0, Math.round(input.done ?? a.stepDone ?? 0))
781        return { ...a, stepTotal: total, stepDone: total ? Math.min(total, done) : done, stepNote: input.note?.trim() || undefined }
782      }),
783    )
784    return { result: 'ok' }
785  })
786
787  // Safety net: worker launches move the faint layer even if the orchestrator forgets to report.
788  on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
789    const type = String(e.subagent_type ?? '')
790    if (!type.startsWith('savvy-')) return next(e)
791
792    // A launch after a finished or cleared flow starts a new one: the count starts over too.
793    const before = await read($, flow)
794    if (!before || before.isFinished) await update($, stats, s => ({ ...s, tokens: 0, cached: 0, prompt: 0 }))
795    await update($, flow, prev => {
796      const base = prev && !prev.isFinished ? { ...blank(), ...prev } : blank()
797      return { ...base, running: base.running + 1, phase: base.phase === 'plan' ? 'delegate' : base.phase }
798    })
799    try {
800      return await next(e)
801    } finally {
802      await update($, flow, prev => (prev ? { ...prev, running: Math.max(0, prev.running - 1) } : prev))
803    }
804  })
805
806  // A finished flow's bar goes with the person's next prompt; the next plan draws a new one.
807  on('prompt.submit', async ($, e, next) => {
808    if (e.origin.kind === 'composer' || e.origin.kind === 'bridge') {
809      const f = await read($, flow)
810      if (f?.isFinished) await update($, flow, () => null)
811    }
812    return next(e)
813  })
814
815  on('agent.spawn', async ($, e, next) => {
816    const started = await next(e)
817    if (started.deny !== undefined) return started
818
819    const at = await $.clock.now()
820    await update($, agents, list => {
821      const round = 1 + list.filter(a => norm(a.description) === norm(e.description) && e.description).length
822      const run: AgentRun = {
823        id: started.agentId ?? e.tool_use_id,
824        agentId: started.agentId,
825        type: e.subagentType,
826        description: e.description,
827        model: started.model,
828        status: 'running',
829        startedAt: at,
830        contextTokens: 0,
831        contextMax: windowOf(started.model),
832        tokens: 0,
833        costUsd: 0,
834        steps: 0,
835        round,
836      }
837      return [...list.filter(a => a.id !== run.id), run].slice(-200)
838    })
839    await update($, now, () => at)
840    const f = await read($, flow)
841    await autoOpen($, f && !f.isFinished ? f.title : 'savvy-flow')
842    return started
843  })
844
845  // Each model request of a subagent: live context, tokens and cost.
846  on('turn.step', async function* ($, e, next) {
847    const t0 = await $.clock.now()
848    const result = yield* next(e)
849    const agentId = e.agentId
850    const usage = result.usage
851    // The count belongs to the open flow: a finished flow keeps what it used.
852    const open = await read($, flow)
853    if (usage && open && !open.isFinished) {
854      // A request's wall time includes the wait for its first token, so the speed reads a little low.
855      const ms = (await $.clock.now()) - t0
856      const prompt = usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
857      const used = prompt + usage.output_tokens
858      await update($, stats, s => ({
859        tokens: s.tokens + used,
860        cached: s.cached + usage.cache_read_input_tokens,
861        prompt: s.prompt + prompt,
862        tps: usage.output_tokens > 0 && ms > 0 ? Math.round((usage.output_tokens * 1000) / ms) : s.tps,
863      }))
864    }
865    if (!agentId || !usage) return result
866
867    const model = usage.model || e.model
868    await update($, agents, list =>
869      list.map(a =>
870        a.agentId !== agentId
871          ? a
872          : {
873              ...a,
874              model,
875              effort: typeof e.effort === 'string' ? e.effort : a.effort,
876              status: 'running',
877              endedAt: undefined,
878              contextTokens:
879                (usage.input_tokens || 0) +
880                (usage.cache_read_input_tokens || 0) +
881                (usage.cache_creation_input_tokens || 0) +
882                (usage.output_tokens || 0),
883              contextMax: windowOf(model),
884              tokens:
885                a.tokens +
886                (usage.input_tokens || 0) +
887                (usage.output_tokens || 0) +
888                (usage.cache_read_input_tokens || 0) +
889                (usage.cache_creation_input_tokens || 0),
890              costUsd: a.costUsd + costOf(model, usage),
891              steps: a.steps + 1,
892            },
893      ),
894    )
895    return result
896  })
897
898  on('turn.complete', async ($, e, next) => {
899    const agentId = e.agentId
900    if (agentId) {
901      const at = await $.clock.now()
902      await update($, agents, list =>
903        list.map(a => {
904          if (a.agentId !== agentId) return a
905          // A run whose steps went unseen still gets the turn's own sum.
906          const fallback = a.steps === 0 && e.usage
907          return {
908            ...a,
909            status: e.reason === 'answer' ? 'done' : 'failed',
910            endedAt: at,
911            ...(fallback && e.usage
912              ? {
913                  model: e.usage.model || a.model,
914                  tokens:
915                    e.usage.input_tokens +
916                    e.usage.output_tokens +
917                    e.usage.cache_read_input_tokens +
918                    e.usage.cache_creation_input_tokens,
919                  costUsd: costOf(e.usage.model || a.model, e.usage),
920                }
921              : {}),
922          }
923        }),
924      )
925      await update($, now, () => at)
926    }
927    return next(e)
928  })
929
930  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
931    const s = tr()
932    const ui = $.ui.resolve(e)
933    const { Box, Text, Button } = ui
934    const list = await read($, agents)
935    const f = await read($, flow)
936    const p: Panel = await read($, panel)
937    const at = Math.max(await read($, now), ...list.map(a => a.startedAt), 0)
938
939    const running = list.filter(a => a.status === 'running').reverse()
940    const finished = list.filter(a => a.status !== 'running').reverse()
941    const planned = plannedOf(f, list)
942    const t = totals(list, at)
943    // The pane's title says "Agents"; inside, only the flow's own name.
944    const title = f && !f.isFinished ? f.title : ''
945
946    const toggleCompact = (
947      <Button
948        key="compact"
949        label={p.isCompact ? s.expand : s.collapse}
950        plain
951        onPress={() => update($, panel, prev => ({ ...prev, isCompact: !prev.isCompact }))}
952      />
953    )
954    const toggleDone = (
955      <Button
956        key="done"
957        label={`${p.isDoneCollapsed ? '▸' : '▾'} ${s.finished} · ${finished.length}`}
958        plain
959        onPress={() => update($, panel, prev => ({ ...prev, isDoneCollapsed: !prev.isDoneCollapsed }))}
960      />
961    )
962    const isEmpty = list.length === 0 && planned.length === 0
963    const summary = `≈${fmtCost(t.cost)}, ${fmtTokens(t.tokens)} ${s.tokensWord}, ${fmtTime(t.time)}`
964
965    if (e.surface === 'desktop' && 'Svg' in ui) {
966      const { Svg } = ui
967      const W = Math.max(240, Math.min(900, (e.props.bodyColumns || 40) * 8 - 8))
968      const section = (key: string, text: string) => (
969        <Text key={key} dimColor>
970          {text}
971        </Text>
972      )
973
974      if (p.isCompact) {
975        return (
976          <Box flexDirection="column" gap={1}>
977            <Svg
978              source={compactSvg(W, list, planned, t)}
979              alt={`${list.length} ${s.agentsCount}, ${summary}`}
980              width={W}
981              height={32}
982              isInteractive={isAnimated && running.length > 0 ? true : undefined}
983            />
984            {toggleCompact}
985          </Box>
986        )
987      }
988      return (
989        <Box flexDirection="column">
990          <Svg source={headerSvg(W, title, t)} alt={title ? `${title}: ${summary}` : summary} width={W} height={headerHeight(title)} />
991          {toggleCompact}
992          {isEmpty && <Text dimColor>{s.empty}</Text>}
993          {running.length > 0 && section('h-run', `${s.running} · ${running.length}`)}
994          {running.map(a => (
995            <Svg
996              key={a.id}
997              source={agentSvg(W, a, at)}
998              alt={`${a.description}: ${modelName(a.model)}, ${s.isRunning}`}
999              width={W}
1000              height={66}
1001              isInteractive={isAnimated || undefined}
1002            />
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)
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}>{glyphOf(a, at)}</Text>
1035          </Box>
1036          <Text dimColor wrap="truncate-end">
1037            {'  '}
1038            {tier === 'other' ? a.type : 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        {title ? (
1056          <Text bold wrap="truncate-end">
1057            {title}
1058          </Text>
1059        ) : null}
1060        <Box flexDirection="row" gap={2}>
1061          <Text dimColor>
1062            ≈{fmtCost(t.cost)} · {fmtTokens(t.tokens)} {s.tokensWord} · {fmtTime(t.time)}
1063          </Text>
1064          {toggleCompact}
1065        </Box>
1066        {p.isCompact ? (
1067          <Text wrap="truncate-end">
1068            {[...running, ...finished].map(a => (
1069              <Text key={a.id} color={colorOf(tierOf(a.type))}>
1070                {glyphOf(a, at)}{' '}
1071              </Text>
1072            ))}
1073            {planned.map(pl => (
1074              <Text key={`plan-${pl.n}`} dimColor>
1075                ◷{' '}
1076              </Text>
1077            ))}
1078          </Text>
1079        ) : (
1080          <Box flexDirection="column" marginTop={1}>
1081            {isEmpty && <Text dimColor>{s.empty}</Text>}
1082            {running.length > 0 && <Text dimColor>{s.running} · {running.length}</Text>}
1083            {running.map(row)}
1084            {finished.length > 0 && toggleDone}
1085            {!p.isDoneCollapsed && finished.map(row)}
1086            {planned.length > 0 && <Text dimColor>{s.planned} · {planned.length}</Text>}
1087            {planned.map(pl => {
1088              const tier = pl.tier in TIER_COLOR ? pl.tier : 'other'
1089              return (
1090                <Box key={`plan-${pl.n}`} flexDirection="column" marginBottom={1}>
1091                  <Text dimColor wrap="truncate-end">
1092                    <Text color={colorOf(tier)}>▢</Text> {pl.n}. {pl.title} ◷
1093                  </Text>
1094                  <Text dimColor wrap="truncate-end">
1095                    {'  '}
1096                    {tier} · {TIER_MODEL[tier] ?? ''}
1097                    {pl.after.length ? ` · ${s.after} ${pl.after.join(', ')}` : ''}
1098                  </Text>
1099                </Box>
1100              )
1101            })}
1102          </Box>
1103        )}
1104      </Box>
1105    )
1106  })
1107
1108  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1109    const f = await read($, flow)
1110    if (f === null || e.props.hasSurvey) return next(e)
1111
1112    const ui = $.ui.resolve(e)
1113    const { Box, Text, Button } = ui
1114    const list = await read($, agents)
1115    const crew = list.length + plannedOf(f, list).length
1116    const isWorking = list.some(a => a.status === 'running')
1117    const at = await read($, now)
1118    const dot = isAnimated && isWorking && !f.isFinished ? spinAt(at) : '●'
1119    const crewButton = (
1120      <Button key="savvy-agents" label={`×${crew}`} plain onPress={() => void togglePane($)} />
1121    )
1122    const percent = `${Math.round(ratio(f) * 100)}%`
1123    const used = fmtStats(await read($, stats))
1124    const dismiss = (
1125      <Button
1126        key="savvy-dismiss"
1127        label="✕"
1128        plain
1129        role="dismiss"
1130        onPress={() => update($, flow, () => null)}
1131      />
1132    )
1133
1134    // The terminal has no `Svg` (it spins text frames instead): ask the surface.
1135    if (e.surface !== 'terminal' && 'Svg' in ui) {
1136      const { Svg } = ui
1137      // About 8 CSS px per reported column; the rest is the count, the dismiss
1138      // and their gaps. No floor above the slot: a row wider than it would wrap.
1139      const width = Math.max(180, Math.min(1600, (e.props.bodyColumns || 100) * 8 - 96))
1140      return (
1141        <Box flexDirection="row" alignItems="center" gap={1}>
1142          <Svg
1143            source={rowSvg(f, width, isWorking, used)}
1144            alt={`${f.title}: ${label(f)}, ${percent}${used && `, ${used}`}`}
1145            width={width}
1146            height={H}
1147            isInteractive={isAnimated || undefined}
1148          />
1149          {crewButton}
1150          {dismiss}
1151        </Box>
1152      )
1153    }
1154
1155    const cols = e.props.bodyColumns
1156    const titleW = Math.max(8, Math.min(30, f.title.length + 2, Math.floor(cols / 3)))
1157    const width = Math.max(6, Math.min(40, cols - titleW - 32 - (used ? used.length + 2 : 0)))
1158    return (
1159      <Box flexDirection="row" gap={2}>
1160        <Box width={titleW} flexShrink={0}>
1161          <Text color={f.isFinished ? DONE : ACCENT}>{dot} </Text>
1162          <Text wrap="truncate-end">{f.title}</Text>
1163        </Box>
1164        <Text color={f.isFinished ? DONE : ACCENT}>{barText(f, width)}</Text>
1165        <Text bold>{label(f)}</Text>
1166        <Text dimColor>{percent}</Text>
1167        {used ? <Text dimColor>{used}</Text> : null}
1168        <Text color={CLAY}>▣</Text>
1169        {crewButton}
1170        {dismiss}
1171      </Box>
1172    )
1173  })
1174}
1175
types/index.d.ts 72 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
43/**
44 * Tokens used while the current flow was open (every model request, subagents included;
45 * a finished flow stops counting), the latest request's output speed, and the cache hit:
46 * prompt tokens read from the cache (`cached`) out of all prompt tokens (`prompt`).
47 */
48export type Stats = {
49  tokens: number
50  tps: number
51  cached: number
52  prompt: number
53}
54
55export type Panel = {
56  isCompact: boolean
57  isDoneCollapsed: boolean
58  autoOpenedFor: string
59}
60
61declare module 'claude-code' {
62  interface PluginState {
63    'agent-progress': {
64      flow: Flow | null
65      agents: AgentRun[]
66      panel: Panel
67      stats: Stats
68      now: number
69    }
70  }
71}
72