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

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.
savvy-* worker starts./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.mcp__savvy-progress__progress: the orchestrator reports the plan, phase and accepted tasks.mcp__savvy-progress__step: a worker reports its own steps (done, total, note). A worker that does not report shows its context fill in grey instead.Cost is a rough estimate from token counts and a built-in per-model price table (PRICES in hooks/register.tsx), not a bill.
language: auto (default), en or ru. auto follows Claude Code's language setting, then the system locale, and falls back to English. Set it in /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.
hooks/register.tsx 1272 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentRun, Flow, Panel, Phase, PlannedTask } from '../types'
5
6const flow = atom({ plugin: 'savvy-progress', key: 'flow' } as const, null)
7const agents = atom({ plugin: 'savvy-progress', key: 'agents' } as const, [])
8const panel = atom({ plugin: 'savvy-progress', key: 'panel' } as const, {
9 isCompact: false,
10 isDoneCollapsed: false,
11 autoOpenedFor: '',
12})
13const now = atom({ plugin: 'savvy-progress', key: 'now' } as const, 0)
14
15const TOOL = 'mcp__savvy-progress__progress'
16const STEP_TOOL = 'mcp__savvy-progress__step'
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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 lines1export type Phase = 'plan' | 'design' | 'delegate' | 'review' | 'close'
2
3export type PlannedTask = {
4 title: string
5 tier: string
6 after: number[]
7}
8
9export type Flow = {
10 title: string
11 total: number
12 done: number
13 running: number
14 phase: Phase
15 isFinished: boolean
16 tasks: PlannedTask[]
17}
18
19export type AgentStatus = 'running' | 'done' | 'failed'
20
21export type AgentRun = {
22 id: string
23 agentId?: string
24 type: string
25 description: string
26 model: string
27 effort?: string
28 status: AgentStatus
29 startedAt: number
30 endedAt?: number
31 contextTokens: number
32 contextMax: number
33 tokens: number
34 costUsd: number
35 steps: number
36 round: number
37 /** Self-reported by the worker through the `step` tool. */
38 stepDone?: number
39 stepTotal?: number
40 stepNote?: string
41}
42
43export type Panel = {
44 isCompact: boolean
45 isDoneCollapsed: boolean
46 autoOpenedFor: string
47}
48
49declare module 'claude-code' {
50 interface PluginState {
51 'savvy-progress': {
52 flow: Flow | null
53 agents: AgentRun[]
54 panel: Panel
55 now: number
56 }
57 }
58}
59