SLOPSHOPPER

savvy-progress

Progress bar above the prompt for /savvy-flow, plus an agents panel (/agents-info): running, finished and planned subagents with model, task progress, context…

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

Draws

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

savvy-progress

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

  • Progress bar: the flow's title, phase, accepted tasks out of planned, and a button with the crew size that opens the panel. It appears once something reports progress (savvy-flow does) or a savvy-* worker starts.
  • Agents panel (/agents-info toggles it): running, finished and planned subagents with model, effort, task progress, context, estimated cost and time. Working crabs walk, and each savvy tier 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: the orchestrator reports the plan, phase and accepted tasks.
  • mcp__savvy-progress__step: a worker reports its own steps (done, total, note). A worker that does not report shows its context fill in grey instead.

Cost is a rough estimate from token counts and a built-in per-model price table (PRICES in hooks/register.tsx), not a bill.

Settings

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