A progress bar above the prompt and a live agents panel (/agents-info) for sub-agent work: each sub-agent with its model, progress, the skills it used and a…

A live agents panel for Claude Code, for when you hand work to sub-agents: each one with its model and effort, a progress bar, the skills it used, and a little creature of its own that stays the same for that sub-agent. Totals for cost, tokens and time sit at the top, and a progress bar above the prompt shows a reported round at a glance.
This is a fork of savvy-progress by johnnyvizz (MIT), which made the progress bar, the agents panel and the creatures. This fork adds gate tracking, the skills each sub-agent used, a currency setting, and tidier headings. Credit for the original is johnnyvizz's.

At the prompt of a terminal session of Claude Code:
/plugin install fun-savvy-progress --marketplace couriard/claude-mods
Answer y to add the marketplace, pick a scope, and fill in the settings below when asked (all have defaults). In the Claude desktop app: + next to the prompt box → Plugins → Add marketplace → couriard/claude-mods → Add plugin.
/agents-info opens or closes the agents panel. It also opens by itself at the start of a session unless you turn that off.

All can be changed later with /config → fun-savvy-progress.
| Setting | What it does | Default |
|---|---|---|
| Panel language | auto follows Claude Code's language, then your system's; or en, ru. | auto |
| Open the agents panel at the start of every session | Off keeps it closed until /agents-info. | On |
| Currency | USD, GBP or EUR. Costs are worked out in US dollars from a built-in price table and are a rough estimate, not a bill. | USD |
| Exchange Rate | How much of your currency one US dollar buys, for example 0.76 for pounds. Ignored for USD. | 1 |
The panel watches sub-agents start, work and finish on its own; nothing is required. A workflow can add to it:
Ada — what it does shows "Ada" as the sub-agent's name and "What It Does" as its heading. One with the word "gate" in it is shown as a gate.GATE-RECORD: {"task": "<what it checked>", "gate": "<gate name>", "verdict": "pass|fail|not applicable"} lines, which feed the Gates counter.progress tool (mcp__fun-savvy-progress__progress): an orchestrator reports a round (a title, a phaseLabel such as Building, and total and done) and the progress bar appears above the prompt. Fields left out keep their last value; finished: true closes the round.step tool (mcp__fun-savvy-progress__step): a sub-agent reports its own steps (done, total, note). Without it, a sub-agent's bar moves with the calls it makes.The two tools reach Claude only where Claude Code offers a mod's tools to the model; in some versions of the desktop app it does not, and then the panel still works but the progress bar above the prompt stays hidden.
MIT. Original © johnnyvizz; changes in this fork © couriard. See LICENSE.
hooks/register.tsx 1521 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentRun, Flow, GateRecord, Health, Panel, Phase, PlannedTask } from '../types'
5
6const flow = atom({ plugin: 'fun-savvy-progress', key: 'flow' } as const, null)
7const agents = atom({ plugin: 'fun-savvy-progress', key: 'agents' } as const, [])
8const panel = atom({ plugin: 'fun-savvy-progress', key: 'panel' } as const, {
9 isCompact: false,
10 isDoneCollapsed: false,
11 autoOpenedFor: '',
12})
13const now = atom({ plugin: 'fun-savvy-progress', key: 'now' } as const, 0)
14const skillsUsed = atom({ plugin: 'fun-savvy-progress', key: 'skills' } as const, [])
15
16const TOOL = 'mcp__fun-savvy-progress__progress'
17const STEP_TOOL = 'mcp__fun-savvy-progress__step'
18const PANE = 'savvy-agents'
19const PHASES: readonly Phase[] = ['plan', 'design', 'delegate', 'review', 'close']
20const ACCENT = '#8f8cf4'
21const DONE = '#5fbf8f'
22
23type ProgressInput = {
24 title?: string
25 total?: number
26 done?: number
27 phase?: Phase
28 phaseLabel?: string
29 finished?: boolean
30 tasks?: { title?: string; tier?: string; after?: number[]; id?: string; crew?: string; gates?: string[]; status?: string }[]
31 skills?: string[]
32 health?: Health
33}
34
35// ---------------------------------------------------------------------------
36// Language: the `language` option, else Claude Code's `language` setting, else the
37// process locale; English when nothing says Russian.
38
39type Lang = 'en' | 'ru'
40
41const STRINGS = {
42 en: {
43 pane: 'Agents',
44 cost: 'Cost',
45 tokens: 'Tokens',
46 time: 'Time',
47 collapse: 'Collapse',
48 expand: 'Expand',
49 running: 'Running',
50 finished: 'Finished',
51 planned: 'Planned',
52 empty: 'No Sub-agents Yet',
53 round: 'Round',
54 failed: 'Error',
55 after: 'After',
56 tokensWord: 'Tokens',
57 agentsCount: 'Agents',
58 isRunning: 'Running',
59 isFinished: 'Finished',
60 isPlanned: 'Planned',
61 opened: 'Agents Panel Opened',
62 closed: 'Agents Panel Closed',
63 done: 'Done',
64 plan: 'Plan',
65 design: 'Design',
66 tasks: 'Tasks',
67 review: 'Review',
68 busy: 'Running',
69 tasksHead: 'Tasks',
70 skills: 'Skills',
71 gatesWord: 'Gates',
72 health: 'Health',
73 task: 'Task',
74 model: 'Model',
75 crew: 'Name',
76 gate: 'Gate',
77 context: 'Context',
78 shared: 'Shared',
79 heartbeat: 'Heartbeat',
80 watchdog: 'Watchdog',
81 paused: 'Paused',
82 slots: 'Slots',
83 none: 'None',
84 },
85 ru: {
86 pane: 'Агенты',
87 cost: 'Стоимость',
88 tokens: 'Токены',
89 time: 'Время',
90 collapse: 'Свернуть',
91 expand: 'Развернуть',
92 running: 'Работают',
93 finished: 'Завершены',
94 planned: 'Запланированы',
95 empty: 'Субагентов пока нет.',
96 round: 'раунд',
97 failed: 'ошибка',
98 after: 'после',
99 tokensWord: 'токенов',
100 agentsCount: 'агентов',
101 isRunning: 'работает',
102 isFinished: 'завершён',
103 isPlanned: 'запланирована',
104 opened: 'Панель агентов открыта.',
105 closed: 'Панель агентов закрыта.',
106 done: 'Готово',
107 plan: 'План',
108 design: 'Дизайн',
109 tasks: 'Задачи',
110 review: 'Ревью',
111 busy: 'в работе',
112 tasksHead: 'Задачи',
113 skills: 'Навыки',
114 gatesWord: 'Проверки',
115 health: 'Состояние',
116 task: 'Задача',
117 model: 'Модель',
118 crew: 'Имя',
119 gate: 'Проверка',
120 context: 'контекст',
121 shared: 'общая',
122 heartbeat: 'пульс',
123 watchdog: 'сторож',
124 paused: 'пауза',
125 slots: 'слоты',
126 none: 'Нет',
127 },
128} as const
129
130// Module scope is fine here: session.start sets it again on every (re)load.
131let lang: Lang = 'en'
132const tr = () => STRINGS[lang]
133
134const isRussian = (v: unknown): boolean => typeof v === 'string' && /^(ru|russian|рус)/i.test(v.trim())
135
136async function detectLang($: EngineInterface, option: unknown): Promise<Lang> {
137 if (option === 'en' || option === 'ru') return option
138 try {
139 const settings = (await $.settings.read()) as Record<string, unknown>
140 if (typeof settings.language === 'string' && settings.language.trim()) return isRussian(settings.language) ? 'ru' : 'en'
141 } catch {
142 // No settings: fall through to the locale.
143 }
144 const locale = (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG'))
145 return isRussian(locale) ? 'ru' : 'en'
146}
147
148const blank = (): Flow => ({
149 title: 'savvy-flow',
150 total: 0,
151 done: 0,
152 running: 0,
153 phase: 'plan',
154 phaseLabel: undefined,
155 isFinished: false,
156 tasks: [],
157 skills: undefined,
158 health: undefined,
159})
160
161const isNewFlow = (prev: Flow | null, input: ProgressInput): boolean =>
162 !prev || prev.isFinished || (input.title !== undefined && input.title.trim() !== prev.title)
163
164const cleanTasks = (tasks: ProgressInput['tasks']): PlannedTask[] | undefined =>
165 tasks
166 ?.filter(t => t.title?.trim())
167 .map(t => ({
168 title: (t.title ?? '').trim(),
169 tier: (t.tier ?? '').replace(/^savvy-/, '').trim().toLowerCase(),
170 after: (t.after ?? []).filter(n => Number.isInteger(n) && n > 0),
171 ...(t.id?.trim() ? { id: t.id.trim() } : {}),
172 ...(t.crew?.trim() ? { crew: t.crew.trim() } : {}),
173 ...(t.gates?.length ? { gates: t.gates.map(g => g.trim()).filter(Boolean) } : {}),
174 ...(t.status?.trim() ? { status: t.status.trim().toLowerCase() } : {}),
175 }))
176
177const merge = (prev: Flow | null, input: ProgressInput): Flow => {
178 // A new title means a new flow: never carry counters over from an earlier one.
179 const base = isNewFlow(prev, input) || !prev ? blank() : { ...blank(), ...prev }
180 const tasks = cleanTasks(input.tasks) ?? base.tasks
181 // A count the report leaves out follows the tasks' own statuses when they carry any.
182 const statused = tasks.filter(t => t.status)
183 const doneByStatus = statused.filter(t => t.status === 'done').length
184 const total = Math.max(0, Math.round(input.total ?? (input.tasks ? tasks.length : base.total)))
185 const done = Math.min(total || Infinity, Math.max(0, Math.round(input.done ?? (input.tasks && statused.length ? doneByStatus : base.done))))
186 const phase = input.phase && PHASES.includes(input.phase) ? input.phase : base.phase
187 return {
188 ...base,
189 title: input.title?.trim() || base.title,
190 total,
191 done,
192 phase: input.finished ? 'close' : phase,
193 phaseLabel: input.finished ? undefined : (input.phaseLabel?.trim() ?? base.phaseLabel),
194 isFinished: input.finished === true,
195 tasks,
196 skills: input.skills?.length ? [...new Set(input.skills.map(x => x.trim()).filter(Boolean))] : base.skills,
197 health: input.health ? { ...base.health, ...input.health } : base.health,
198 }
199}
200
201const label = (f: Flow): string => {
202 const s = tr()
203 if (f.isFinished) return s.done
204 const count = f.total ? `${f.done}/${f.total}` : `${f.running} ${s.busy}`
205 if (f.phaseLabel) return `${titleCase(f.phaseLabel)} ${count}`
206 if (f.phase === 'plan') return s.plan
207 if (f.phase === 'design') return s.design
208 return `${f.phase === 'review' ? s.review : s.tasks} ${count}`
209}
210
211const ratio = (f: Flow): number => (f.isFinished ? 1 : f.total ? f.done / f.total : 0)
212
213// Deterministic noise so the dither does not shimmer between redraws.
214const noise = (x: number, y: number): number => {
215 const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
216 return s - Math.floor(s)
217}
218
219// The whole row is one SVG: the desktop wraps sibling elements onto new lines,
220// so title, bar, percent and the crab live in one drawing; only the count and
221// the dismiss are Buttons beside it.
222const H = 22
223const BAR_H = 16
224const CRAB_W = 26
225const CELL = 3
226const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
227
228const xml = (s: string): string =>
229 s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
230
231const clip = (s: string, max: number): string => (s.length > max ? s.slice(0, Math.max(1, max - 1)) + '…' : s)
232
233// Rough advance of system UI text, in em; good enough to size the title's slot.
234const charEm = (ch: string): number =>
235 /[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-ZА-ЯЁmwшщжюМШЩЖЮ@%]/.test(ch) ? 0.72 : 0.56
236
237const textWidth = (s: string, size: number): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
238
239// Cuts `s` to fit `maxW` pixels, with an ellipsis when it had to cut.
240const fitText = (s: string, size: number, maxW: number): string => {
241 if (textWidth(s, size) <= maxW) return s
242 let out = ''
243 for (const ch of s) {
244 if (textWidth(out + ch + '…', size) > maxW) break
245 out += ch
246 }
247 return out + '…'
248}
249
250const rowSvg = (f: Flow, W: number, isWorking: boolean): string => {
251 // The title takes what it needs, up to 40% of the row; the bar takes the rest.
252 const title = fitText(titleCase(f.title), 13, Math.max(60, W * 0.4))
253 const BAR_X = Math.round(16 + textWidth(title, 13) + 12)
254 const BAR_W = Math.max(60, W - BAR_X - 46 - CRAB_W)
255 const color = f.isFinished ? DONE : ACCENT
256 const y0 = (H - BAR_H) / 2
257 const fillW = Math.round(BAR_W * ratio(f))
258 const runW = f.total ? Math.round((BAR_W * Math.min(f.total, f.done + f.running)) / f.total) : 0
259 const dots: string[] = []
260
261 // Dithered fill: sparse at the start, dense toward the head.
262 const cols = Math.floor(fillW / CELL)
263 const rows = Math.floor(BAR_H / CELL)
264 for (let c = 0; c < cols; c++) {
265 const density = 0.35 + 0.6 * Math.pow(c / Math.max(1, cols), 1.2)
266 for (let r = 0; r < rows; r++) {
267 if (noise(c, r) < density) dots.push(`<rect class="t${Math.floor(noise(r, c) * 4)}" x="${c * CELL + 1}" y="${r * CELL + 1}" width="2" height="2"/>`)
268 }
269 }
270 // Handed to workers, not yet accepted: a faint second layer.
271 const faint: string[] = []
272 for (let c = cols; c < Math.floor(runW / CELL); c++) {
273 for (let r = 0; r < rows; r++) {
274 if (noise(c + 7, r + 3) < 0.2) faint.push(`<rect class="t${Math.floor(noise(r + 5, c) * 4)}" x="${c * CELL + 1}" y="${r * CELL + 1}" width="1.7" height="1.7"/>`)
275 }
276 }
277
278 const ticks: string[] = []
279 for (let i = 1; i < f.total; i++) {
280 const x = Math.round((BAR_W * i) / f.total)
281 if (x > fillW + 4) ticks.push(`<rect x="${x}" y="${BAR_H / 2 - 4}" width="1.5" height="8" rx="0.75"/>`)
282 }
283
284 const text = label(f)
285 const pillW = Math.round(18 + text.length * 6.6)
286 const pillX = Math.max(0, Math.min(BAR_W - pillW, fillW - pillW))
287 const percent = `${Math.round(ratio(f) * 100)}%`
288
289 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
290<style>
291.t{fill:#1f1f1f}.m{fill:#8a8a8a}.k{fill:#e4e4e2}.tk{fill:#b4b4b0}
292@media (prefers-color-scheme: dark){.t{fill:#ececec}.m{fill:#9a9a9a}.k{fill:#2c2c2c}.tk{fill:#5a5a5a}}
293/* Pixels twinkle in four out-of-phase groups; a finished bar settles to a slow glow. */
294.t0,.t1,.t2,.t3{animation:tw ${f.isFinished ? 3.2 : 2.2}s ease-in-out infinite}
295.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}
296@keyframes tw{0%,100%{opacity:1}50%{opacity:${f.isFinished ? 0.8 : 0.3}}}
297@media (prefers-reduced-motion: reduce){.t0,.t1,.t2,.t3{animation:none}}
298</style>
299<defs><clipPath id="c"><rect x="0" y="0" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/></clipPath></defs>
300<circle cx="5" cy="${H / 2}" r="4" fill="${color}"/>
301<text class="t" x="16" y="${H / 2 + 4.5}" font-family="${FONT}" font-size="13" font-weight="500">${xml(title)}</text>
302<g transform="translate(${BAR_X},${y0})">
303<rect class="k" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/>
304<g clip-path="url(#c)">
305<g fill="${color}">${dots.join('')}</g>
306<g fill="${color}" opacity="0.45">${faint.join('')}</g>
307<g class="tk">${ticks.join('')}</g>
308</g>
309<rect x="${pillX}" width="${pillW}" height="${BAR_H}" rx="${BAR_H / 2}" fill="${color}"/>
310<text x="${pillX + pillW / 2}" y="${BAR_H / 2 + 4}" text-anchor="middle" font-family="${FONT}" font-size="11" font-weight="600" fill="#ffffff">${xml(text)}</text>
311</g>
312<text class="m" x="${W - CRAB_W - 6}" y="${H / 2 + 4.5}" text-anchor="end" font-family="${FONT}" font-size="12.5" font-variant-numeric="tabular-nums">${percent}</text>
313${CRAB_CSS}${crab(W - CRAB_W + 1, 0, 'other', false, isWorking, 0.8)}
314</svg>`
315}
316
317const barText = (f: Flow, width: number): string => {
318 const filled = Math.round(width * ratio(f))
319 return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
320}
321
322// ---------------------------------------------------------------------------
323// Agents panel: every subagent of the session, plus the tasks the flow planned.
324
325const TIER_COLOR: Record<string, string> = {
326 fable: '#7F77DD',
327 heavy: '#D85A30',
328 careful: '#BA7517',
329 medium: '#378ADD',
330 light: '#1D9E75',
331 other: '#888780',
332}
333
334// What each savvy tier runs on, for planned tasks that have no run yet.
335const colorOf = (tier: string): string => TIER_COLOR[tier] ?? '#888780'
336
337const TIER_MODEL: Record<string, string> = {
338 fable: 'Fable · High',
339 heavy: 'Opus · Extra High',
340 careful: 'Opus · High',
341 medium: 'Opus · Medium',
342 light: 'Opus · Low',
343}
344
345// USD per million tokens: input, output, cache read, cache write (5-minute TTL).
346// The engine reports tokens, not money, so the panel's cost is an estimate.
347const PRICES: [RegExp, [number, number, number, number]][] = [
348 [/fable|mythos/, [10, 50, 0.25, 12.5]],
349 [/opus-5-5/, [4, 20, 0.2, 5]],
350 [/opus/, [5, 25, 0.5, 6.25]],
351 [/sonnet/, [2, 10, 0.2, 2.5]],
352 [/haiku/, [1, 5, 0.1, 1.25]],
353]
354
355type Usage = {
356 input_tokens: number
357 output_tokens: number
358 cache_read_input_tokens: number
359 cache_creation_input_tokens: number
360}
361
362const priceOf = (model: string): [number, number, number, number] =>
363 PRICES.find(([re]) => re.test(model.toLowerCase()))?.[1] ?? [4, 20, 0.2, 5]
364
365const costOf = (model: string, u: Usage): number => {
366 const [i, o, r, w] = priceOf(model)
367 return (
368 ((u.input_tokens || 0) * i +
369 (u.output_tokens || 0) * o +
370 (u.cache_read_input_tokens || 0) * r +
371 (u.cache_creation_input_tokens || 0) * w) /
372 1e6
373 )
374}
375
376const windowOf = (model: string): number => (/haiku/i.test(model) ? 200_000 : 1_000_000)
377
378// `savvy-careful`, or `savvy-flow:savvy-careful` when the agents ship in a plugin.
379const tierOf = (type: string): string => {
380 const bare = type.replace(/^[^:]*:/, '')
381 const t = bare.replace(/^savvy-/, '').toLowerCase()
382 return t in TIER_COLOR && bare.startsWith('savvy-') ? t : 'other'
383}
384
385const modelName = (id: string): string => {
386 const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
387 const [, family = '', major = '', minor] = m ?? []
388 if (!family) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '') || '—'
389 return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()} ${major}${minor ? '.' + minor : ''}`
390}
391
392const norm = (s: string): string => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
393
394// ---------------------------------------------------------------------------
395// Crews, tasks and gates: how a run's description, brief and report name them.
396
397// Gates: a run is a gate when its description carries the word ("Design gate, …", "Gate: …")
398// or its report carries GATE-RECORD lines.
399const GATE_DESC = /\bgates?\b/i
400
401// "Crew A — three asks" → crew "Crew A", lane "three asks".
402// "Design gate, CTA lane" → crew "Design gate", lane "CTA lane".
403const splitDescription = (desc: string): { crew: string; lane: string } => {
404 const d = (desc || '').trim()
405 const m = /^(.*?)\s*(?:—|–|-{1,2}|:|,)\s+(.*)$/.exec(d)
406 if (m && m[1] && m[2]) return { crew: m[1].trim(), lane: m[2].trim() }
407 return { crew: d, lane: '' }
408}
409
410// Task ids in a brief: the ids the round reported through the progress tool, found as whole words.
411// Nothing is assumed about how a board spells its ids.
412const taskIdsOf = (text: string, known: readonly (string | undefined)[]): string[] => {
413 const t = text || ''
414 return [...new Set(known.filter((id): id is string => !!id && new RegExp(`(^|[^A-Za-z0-9])${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}($|[^A-Za-z0-9])`).test(t)))]
415}
416const knownIds = (f: Flow | null): (string | undefined)[] => (f?.tasks ?? []).map(t => t.id)
417
418const SMALL = new Set(['a', 'an', 'and', 'as', 'at', 'by', 'for', 'in', 'of', 'on', 'or', 'the', 'to', 'with', 'its', 'is', 'not'])
419// Words with a spelling of their own, kept as their owners write them.
420const SPELLED: Record<string, string> = { clickup: 'ClickUp', seo: 'SEO', aeo: 'AEO', cro: 'CRO', cta: 'CTA', ui: 'UI', ux: 'UX', ai: 'AI' }
421const titleCase = (s: string): string =>
422 s
423 .split(/\s+/)
424 .map((w, i) => {
425 const lower = w.toLowerCase()
426 if (SPELLED[lower]) return SPELLED[lower]
427 // A small word is lowered only when written lower case already, so a name like "Crew A" keeps its capital.
428 if (i > 0 && SMALL.has(lower) && w === lower) return lower
429 if (/^[A-Z0-9]{2,}$/.test(w)) return w
430 return w.charAt(0).toUpperCase() + w.slice(1)
431 })
432 .join(' ')
433
434// A machine name read as words: "gate-design-system" → "Gate Design System".
435const nameCase = (s: string): string => titleCase(s.replace(/[-_]+/g, ' ').trim())
436
437// Effort as words: "high" → "High", "xhigh" → "Extra High".
438const effortName = (e: string): string => (e.toLowerCase() === 'xhigh' ? 'Extra High' : titleCase(e))
439
440// The heading a run shows: the board task it works (by id, then by crew name), else its lane, else its description.
441const taskOfRun = (a: AgentRun, f: Flow | null): PlannedTask | undefined => {
442 const tasks = f?.tasks ?? []
443 const byId = tasks.filter(t => t.id && a.taskIds?.includes(t.id))
444 if (byId.length === 1) return byId[0]
445 if (byId.length > 1) return undefined
446 if (a.crew) {
447 const byCrew = tasks.filter(t => t.crew && norm(t.crew) === norm(a.crew ?? ''))
448 if (byCrew.length === 1) return byCrew[0]
449 }
450 return undefined
451}
452
453// A gate is one whatever its spawn recorded: the description says so, or its report did.
454const isGateRun = (a: AgentRun): boolean => a.isGate === true || GATE_DESC.test(a.description || '')
455
456// A gate's kind and what it is checking, read from its description in either order:
457// "Design gate, hover green" → Design, "hover green";
458// "Gate: design system check, hover green" → Design System, "hover green".
459// The word "gate" (and "check") never becomes the kind, so a row never reads "Gate Gate" or
460// "Gate: Gate".
461const GATE_FIRST = /^\s*gates?\b\s*(?:—|–|-{1,2}|:|,)?\s*(.*)$/i
462const gateParts = (a: AgentRun): { kind: string; lane: string } => {
463 const named = GATE_FIRST.exec(a.description || '')
464 const { crew, lane } = named ? splitDescription(named[1] ?? '') : splitDescription(a.description)
465 const kind = (crew || '').replace(/\b(?:gates?|checks?)\b/gi, '').replace(/\s+/g, ' ').trim()
466 // No kind named: none is made up; the row then reads "Gate: <task>" alone.
467 return { kind: titleCase(kind), lane }
468}
469
470// "Design gate" → "Design"; the kind of gate, for the heading and the grey line.
471const gateKindOf = (a: AgentRun): string => gateParts(a).kind
472
473// A crew's heading is the task it works; a gate's says what it checks: "Design gate: Scroller Centring".
474const headingOf = (a: AgentRun, f: Flow | null): string => {
475 const task = taskOfRun(a, f)
476 const ids = a.taskIds ?? []
477 const named = (f?.tasks ?? []).filter(t => t.id && ids.includes(t.id)).map(t => titleCase(t.title))
478 const { lane, crew } = isGateRun(a) ? { ...gateParts(a), crew: '' } : splitDescription(a.description)
479 const subject = task ? titleCase(task.title) : named.length > 1 ? named.join(' · ') : titleCase(lane || crew || a.type)
480 if (isGateRun(a)) return gateKindOf(a) ? `${gateKindOf(a)} ${tr().gate}: ${subject}` : `${tr().gate}: ${subject}`
481 return subject
482}
483
484// Every GATE-RECORD line of a report: {task, gate, verdict}.
485const gateRecordsOf = (answer: string): GateRecord[] =>
486 answer
487 .split('\n')
488 .filter(l => l.includes('GATE-RECORD:'))
489 .map(l => {
490 const get = (k: string) => new RegExp(`"${k}"\\s*:\\s*"([^"]*)"`, 'i').exec(l)?.[1]?.trim() ?? ''
491 return { task: get('task'), gate: get('gate'), verdict: get('verdict').toLowerCase() }
492 })
493
494// Every COST-RECORD line's task name.
495const costTasksOf = (answer: string): string[] =>
496 answer
497 .split('\n')
498 .filter(l => l.includes('COST-RECORD:'))
499 .map(l => /"task"\s*:\s*"([^"]*)"/i.exec(l)?.[1]?.trim() ?? '')
500 .filter(Boolean)
501
502
503// Gates by name across the session, for the chip: name → pass/fail/na counts.
504const gatesByName = (list: AgentRun[]): { name: string; pass: number; fail: number; na: number }[] => {
505 const map = new Map<string, { name: string; pass: number; fail: number; na: number }>()
506 for (const a of list) {
507 for (const r of a.gateRecords ?? []) {
508 const key = norm(r.gate)
509 if (!key) continue
510 const g = map.get(key) ?? { name: r.gate, pass: 0, fail: 0, na: 0 }
511 if (r.verdict === 'pass') g.pass++
512 else if (r.verdict === 'fail') g.fail++
513 else g.na++
514 map.set(key, g)
515 }
516 }
517 return [...map.values()]
518}
519
520const fmtTokens = (n: number): string =>
521 n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${Math.round(n)}`
522
523// Costs are worked out in US dollars (the price table above) and shown in the Currency setting,
524// at the Exchange Rate setting (how much of that currency one US dollar buys).
525const CURRENCY_SIGN: Record<string, string> = { USD: '$', GBP: '£', EUR: '€' }
526let currency = { sign: '$', perUsd: 1 }
527const fmtCost = (usd: number): string => {
528 const v = usd * currency.perUsd
529 return `${currency.sign}${v < 10 ? v.toFixed(2) : v.toFixed(1)}`
530}
531
532const fmtTime = (ms: number): string => {
533 const s = Math.max(0, Math.round(ms / 1000))
534 const h = Math.floor(s / 3600)
535 const m = Math.floor((s % 3600) / 60)
536 const ss = String(s % 60).padStart(2, '0')
537 return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
538}
539
540const elapsed = (a: AgentRun, at: number): number => (a.endedAt ?? Math.max(at, a.startedAt)) - a.startedAt
541
542type Planned = PlannedTask & { n: number }
543
544const plannedOf = (f: Flow | null, list: AgentRun[]): Planned[] => {
545 if (!f || f.isFinished) return []
546 // A round whose tasks carry ids is shown in the Tasks list already, with each task's state.
547 if ((f.tasks ?? []).some(t => t.id)) return []
548 const started = new Set(list.map(a => norm(a.description)))
549 return (f.tasks ?? []).map((t, i) => ({ ...t, n: i + 1 })).filter(t => !started.has(norm(t.title)))
550}
551
552const totals = (list: AgentRun[], at: number) => {
553 const cost = list.reduce((s, a) => s + a.costUsd, 0)
554 const tokens = list.reduce((s, a) => s + a.tokens, 0)
555 const start = Math.min(...list.map(a => a.startedAt))
556 const end = Math.max(...list.map(a => a.endedAt ?? Math.max(at, a.startedAt)))
557 return { cost, tokens, time: list.length ? end - start : 0 }
558}
559
560// --- desktop drawings: each row is one SVG, as the band above the prompt is.
561
562const PANE_CSS = `<style>
563.t{fill:#1f1f1f}.s{fill:#6b6b68}.m{fill:#9a9a96}.k{fill:#ecebe8}.ln{stroke:#e4e4e1}.tile{fill:#f4f3f0}
564@media (prefers-color-scheme: dark){.t{fill:#ececec}.s{fill:#a8a8a4}.m{fill:#7d7d79}.k{fill:#2c2c2b}.ln{stroke:#333331}.tile{fill:#262625}}
565.live{animation:p 1.6s ease-in-out infinite}@keyframes p{50%{opacity:.3}}
566@media (prefers-reduced-motion: reduce){.live{animation:none}}
567</style>`
568
569// Pixel Clawd from DockCrab (Clawdy): a 24×18 crab on a 30×28 grid, one costume per tier.
570// The body keeps the brand clay; the tier's color lives in the costume's accent.
571const CLAY = '#D97757'
572const INK = '#1F1E1D'
573
574// `cls` puts a pixel in a named group: `bd` (the default) is the body and its
575// costume, `la`/`lb` the leg pairs, anything else a prop with its own motion.
576type Fill = (x: number, y: number, w: number, h: number, c: string, cls?: string) => void
577
578const stamp = (f: Fill, x: number, y: number, rows: string[], map: Record<string, string>, cls?: string): void =>
579 rows.forEach((row, dy) => [...row].forEach((ch, dx) => map[ch] && f(x + dx, y + dy, 1, 1, map[ch] ?? '', cls)))
580
581// `armCls` lets a raised claw travel with the prop it holds.
582const crabBody = (f: Fill, armFront = 0, armCls?: string): void => {
583 f(7, 10, 16, 12, CLAY)
584 f(3, 14, 4, 4, CLAY)
585 f(23, 14 + armFront, 4, 4, CLAY, armCls)
586 f(9, 12, 2, 2, INK)
587 f(19, 12, 2, 2, INK)
588 f(7, 22, 2, 4, CLAY, 'la')
589 f(17, 22, 2, 4, CLAY, 'la')
590 f(11, 22, 2, 4, CLAY, 'lb')
591 f(21, 22, 2, 4, CLAY, 'lb')
592}
593
594// Pure CSS, run by the compositor: no redraws. Periods divide one second, so the
595// once-a-second redraw of a running row restarts them in phase. Every crab walks;
596// each costume adds its prop's own motion on top.
597const CRAB_CSS = `<style>
598.run .la{animation:st .5s steps(1) infinite}.run .lb{animation:st .5s steps(1) infinite -.25s}
599.run .bd{animation:bob .5s steps(1) infinite -.125s}
600.run g{transform-box:fill-box}
601@keyframes st{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}
602.c-fable.run{animation:float 1s ease-in-out infinite}
603.c-fable.run .la,.c-fable.run .lb,.c-fable.run .bd{animation:none}
604.c-fable.run .ant{animation:blink 1s steps(1) infinite}
605.c-fable.run .star{animation:blink .5s steps(1) infinite -.25s}
606@keyframes float{50%{transform:translateY(-2px)}}@keyframes blink{50%{opacity:.15}}
607.c-heavy.run .it{animation:scan 1s steps(1) infinite}
608.c-heavy.run .gl{animation:blink 1s steps(1) infinite -.5s}
609@keyframes scan{25%{transform:translate(-1px,1px)}50%{transform:translate(-2px,2px)}75%{transform:translate(-1px,1px)}}
610.c-careful.run .it{transform-origin:100% 100%;animation:twist .5s ease-in-out infinite}
611@keyframes twist{50%{transform:rotate(-35deg)}}
612.c-medium.run .pan{transform-origin:0 50%;animation:tilt 1s ease-in-out infinite}
613.c-medium.run .egg{animation:flip 1s ease-in-out infinite}
614@keyframes tilt{20%,40%{transform:rotate(-12deg)}}@keyframes flip{30%{transform:translateY(-5px) scaleY(-1)}60%{transform:translateY(0)}}
615.c-light.run .la{animation-duration:.25s}.c-light.run .lb{animation-duration:.25s;animation-delay:-.125s}
616.c-light.run .flag{transform-origin:0 50%;animation:wave .25s steps(1) infinite}
617@keyframes wave{50%{transform:skewY(-12deg) scaleX(.85)}}
618.c-explore.run .it{transform-origin:50% 100%;animation:fence .5s ease-in-out infinite}
619@keyframes fence{50%{transform:rotate(25deg)}}
620@media (prefers-reduced-motion: reduce){.run,.run g{animation:none!important}}
621</style>`
622
623const COSTUMES: Record<string, (f: Fill, t: string) => void> = {
624 // Fable: astronaut in a glass dome; floats instead of walking, the antenna and the star blink.
625 fable: (f, t) => {
626 crabBody(f)
627 f(6, 7, 18, 1, '#E6E8EE'); f(5, 8, 1, 14, '#E6E8EE'); f(24, 8, 1, 14, '#E6E8EE'); f(6, 22, 18, 1, '#C9CCD2')
628 f(6, 8, 18, 14, 'rgba(169,214,245,.32)'); f(8, 9, 2, 1, '#fff'); f(8, 10, 1, 2, '#fff')
629 f(14, 4, 2, 3, '#C9CCD2'); f(14, 2, 2, 2, t, 'ant'); f(13, 18, 4, 2, t)
630 f(27, 3, 1, 3, '#F5C542', 'star'); f(26, 4, 3, 1, '#F5C542', 'star')
631 },
632 // Heavy: detective with a deerstalker; the magnifier sweeps and glints.
633 heavy: (f, t) => {
634 crabBody(f, -4, 'it')
635 stamp(f, 6, 3, ['......bbbbbb......', '....bbcbbcbbbb....', '...bbbbbbbbbbbb...', '..bcbbcbbcbbcbbb..', '.bbbbbbbbbbbbbbbb.', 'dddddddddddddddddd'], { b: '#7A4A26', c: '#A0703F', d: '#5A3519' })
636 f(6, 9, 18, 1, t)
637 stamp(f, 23, 1, ['.kkk.', 'k...k', 'k...k', 'k...k', '.kkk.'], { k: '#3A3A3C' }, 'it')
638 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')
639 },
640 // Careful: engineer in a hard hat; the wrench turns a bolt.
641 careful: (f, t) => {
642 crabBody(f)
643 stamp(f, 6, 4, ['.....yyyyyyyy.....', '...yyyyyhhyyyyy...', '..yyyyyyhhyyyyyy..', '..yyyyyyhhyyyyyy..', '.yyyyyyyhhyyyyyyy.', 'dddddddddddddddddd'], { y: '#F5C542', h: '#FBE08A', d: '#C99A1E' })
644 f(13, 5, 4, 2, t)
645 stamp(f, 0, 10, ['.s.s', 'sss.', '.s..', '.s..'], { s: '#8E929A' }, 'it')
646 },
647 // Medium: chef, the toque traced from DockCrab's Sprites.chefHat; tosses the omelette.
648 medium: (f, t) => {
649 crabBody(f, -4, 'pan')
650 stamp(f, 6, 0, ['........lll.......', '.......lllll......', '.wwwwgwwwwwwgwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwgwwwwwggwwwww', '.wwwwgwwwwwggwwwww', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.'], { w: '#F4F3EE', l: '#F7F6F2', g: '#D2D1C8', b: t, d: '#B45F43' })
651 f(22, 8, 7, 2, '#4A4A48', 'pan'); f(26, 10, 1, 1, '#4A4A48', 'pan'); f(24, 7, 3, 1, '#F5B731', 'egg')
652 },
653 // Light: racer in a helmet; runs at double pace, the checkered flag flutters.
654 light: (f, t) => {
655 crabBody(f, -4)
656 stamp(f, 6, 5, ['....rrrrrrrrrr....', '..rrrrrrwwrrrrrr..', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.kkkkkkkkkkkkkkkkr'], { r: t, w: '#F8F6F1', k: INK })
657 f(25, 1, 1, 9, '#8E929A')
658 stamp(f, 26, 1, ['wkwk', 'kwkw', 'wkwk'], { w: '#F8F6F1', k: INK }, 'flag')
659 },
660 // Explore: pirate scouting the code; the cutlass fences.
661 explore: f => {
662 crabBody(f)
663 stamp(f, 5, 3, ['.kk..............kk.', '.kkk....kkkk....kkk.', '..kkkkkkkwwkkkkkkk..', '..kkkkkkkkkkkkkkkk..', '.gggggggggggggggggg.'], { k: '#55514C', w: '#F8F6F1', g: '#F5C542' })
664 f(7, 11, 11, 1, INK); f(18, 11, 4, 3, INK)
665 f(27, 6, 1, 9, '#C9CCD2', 'it'); f(26, 15, 3, 1, '#7A4A26', 'it')
666 },
667 other: f => crabBody(f),
668}
669
670const costumeOf = (type: string): string => (type === 'Explore' ? 'explore' : tierOf(type))
671
672// A sub-agent that is not a savvy tier (a crew, a gate, any other type) gets a costume
673// chosen when it spawns: the costume least worn by the runs already in the list, ties
674// broken by its own id, so neighbours differ and a crab keeps its look on every redraw.
675// The plain crab is never used. Its accent colour is its own too,
676// from a palette by id, so two crabs in the same costume still read apart.
677// Sub-agent names: ordinary first names. A description that starts with one ("Ada — the task")
678// keeps it; any other sub-agent is given one from this list, steady for it (picked from its own id)
679// and never one already in use by a running sub-agent.
680const FIRST_NAMES = ['Ada', 'Ben', 'Cleo', 'Dev', 'Eli', 'Freya', 'Gus', 'Hana', 'Iris', 'Jonah', 'Kai', 'Lena', 'Milo', 'Nora', 'Otto', 'Pia', 'Quinn', 'Ravi', 'Sana', 'Theo', 'Uma', 'Vera', 'Wes', 'Yara', 'Zane', 'Alma', 'Bram', 'Cora', 'Dara', 'Ezra', 'Fern', 'Hugo', 'Ines', 'Jude', 'Kira', 'Luca', 'Mara', 'Nils', 'Orla', 'Rhea']
681// Single capitalised words that are not names, so a description like "Explore — the repo" is not read as one.
682const NOT_NAMES = new Set(['gate', 'gates', 'crew', 'explore', 'plan', 'review', 'check', 'fix', 'build', 'test', 'research', 'search', 'find', 'write', 'agent', 'task', 'worker', 'design', 'audit', 'general', 'update', 'add', 'make', 'run', 'read', 'refactor', 'debug', 'docs', 'copy'])
683const firstNameOf = (lead: string): string => {
684 const w = (lead || '').trim()
685 return /^[A-Z][a-z]+(?:[-'][A-Z]?[a-z]+)?$/.test(w) && !NOT_NAMES.has(w.toLowerCase()) ? w : ''
686}
687const pickName = (id: string, taken: readonly string[]): string => {
688 const start = hashOf(id) % FIRST_NAMES.length
689 for (let k = 0; k < FIRST_NAMES.length; k++) {
690 const n = FIRST_NAMES[(start + k) % FIRST_NAMES.length] ?? 'Ada'
691 if (!taken.includes(n)) return n
692 }
693 return FIRST_NAMES[start] ?? 'Ada'
694}
695// A run's name: the one given at spawn, else worked out the same way (runs from before names existed).
696const nameOf = (a: AgentRun): string => a.name || firstNameOf(splitDescription(a.description).crew) || pickName(a.id, [])
697
698const RANDOM_COSTUMES = ['fable', 'heavy', 'careful', 'medium', 'light', 'explore']
699const ACCENTS = ['#7F77DD', '#D85A30', '#BA7517', '#378ADD', '#1D9E75', '#C13584', '#2A9D8F', '#E9A23B', '#5B8DEF', '#8E44AD']
700const hashOf = (s: string): number => {
701 let h = 2166136261
702 for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619)
703 return h >>> 0
704}
705const pickCostume = (id: string, worn: string[]): string => {
706 const counts = new Map(RANDOM_COSTUMES.map(c => [c, 0]))
707 for (const w of worn) if (counts.has(w)) counts.set(w, (counts.get(w) ?? 0) + 1)
708 const least = Math.min(...counts.values())
709 const pool = RANDOM_COSTUMES.filter(c => counts.get(c) === least)
710 return pool[hashOf(id) % pool.length] ?? 'explore'
711}
712const accentFor = (id: string): string => ACCENTS[hashOf(id + ':accent') % ACCENTS.length] ?? ACCENTS[0] ?? '#888780'
713const costumeFor = (a: { id: string; type: string; costume?: string }): string => {
714 const c = costumeOf(a.type)
715 if (c !== 'other') return c
716 return a.costume ?? (RANDOM_COSTUMES[hashOf(a.id) % RANDOM_COSTUMES.length] ?? 'explore')
717}
718
719const verdictsOf = (answer: string): { pass: number; fail: number; na: number } | undefined => {
720 const lines = answer.split('\n').filter(l => l.includes('GATE-RECORD:'))
721 if (lines.length === 0) return undefined
722 const v = { pass: 0, fail: 0, na: 0 }
723 for (const l of lines) {
724 const m = /"verdict"\s*:\s*"([^"]+)"/i.exec(l)
725 const word = (m?.[1] ?? '').toLowerCase()
726 if (word === 'pass') v.pass++
727 else if (word === 'fail') v.fail++
728 else if (word) v.na++
729 }
730 return v
731}
732type GateSum = { running: number; pass: number; fail: number; na: number; runs: number }
733const gatesOf = (list: AgentRun[]): GateSum => {
734 const g: GateSum = { running: 0, pass: 0, fail: 0, na: 0, runs: 0 }
735 for (const a of list) {
736 if (!a.isGate) continue
737 g.runs++
738 if (a.status === 'running') g.running++
739 if (a.verdicts) {
740 g.pass += a.verdicts.pass
741 g.fail += a.verdicts.fail
742 g.na += a.verdicts.na
743 }
744 }
745 return g
746}
747const gateLabel = (g: GateSum): string => {
748 const parts = [`✓${g.pass}`, `✗${g.fail}`]
749 if (g.na) parts.push(`–${g.na}`)
750 if (g.running) parts.push(`◷${g.running}`)
751 return `Gates ${parts.join(' ')}`
752}
753
754const CRAB_SCALE = 1.1
755
756// Body and props nest inside `bd` so a prop rides the bob and adds its own motion;
757// legs stay outside it and step on their own.
758const crab = (x: number, y: number, costume: string, dim = false, isWalking = false, scale = CRAB_SCALE, accent?: string): string => {
759 const groups = new Map<string, string[]>([['bd', []]])
760 const f: Fill = (cx, cy, w, h, c, cls = 'bd') => {
761 if (!groups.has(cls)) groups.set(cls, [])
762 groups.get(cls)?.push(`<rect x="${cx}" y="${cy}" width="${w}" height="${h}" fill="${c}"/>`)
763 }
764 const draw = COSTUMES[costume] ?? ((g: Fill) => crabBody(g))
765 draw(f, accent ?? colorOf(costume))
766 const group = (cls: string) => `<g class="${cls}">${(groups.get(cls) ?? []).join('')}</g>`
767 const props = [...groups.keys()].filter(k => k !== 'bd' && k !== 'la' && k !== 'lb')
768 const body = `<g class="bd">${(groups.get('bd') ?? []).join('')}${props.map(group).join('')}</g>`
769 return `<g transform="translate(${x},${y}) scale(${scale})" opacity="${dim ? 0.45 : 1}" shape-rendering="crispEdges"><g class="c-${costume}${isWalking ? ' run' : ''}">${body}${group('la')}${group('lb')}</g></g>`
770}
771
772const statusMark = (x: number, y: number, status: string, color: string): string => {
773 if (status === 'running') return `<circle class="live" cx="${x}" cy="${y}" r="3.5" fill="${color}"/>`
774 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"/>`
775 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"/>`
776 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"/>`
777}
778
779const svg = (W: number, H: number, body: string): string =>
780 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${PANE_CSS}${CRAB_CSS}${body}</svg>`
781
782// The pane's own title already says "Agents": the header names the flow, if any.
783// Four tiles: the round's tasks (done of total, from the board,
784// whatever the orchestrator reports), then cost, tokens and time.
785const headerSvg = (W: number, title: string, t: ReturnType<typeof totals>, tasks?: { done: number; total: number }): string => {
786 const s = tr()
787 // Tiles spaced apart with room inside them.
788 const gap = TILE_GAP
789 const n = tasks ? 4 : 3
790 const tw = (W - gap * (n - 1)) / n
791 const top = title ? 32 : 0
792 const tile = (i: number, k: string, v: string) =>
793 `<rect class="tile" x="${i * (tw + gap)}" y="${top}" width="${tw}" height="${TILE_H}" rx="10"/>
794<text class="s" x="${i * (tw + gap) + TILE_PAD}" y="${top + 22}" font-family="${FONT}" font-size="11">${k}</text>
795<text class="t" x="${i * (tw + gap) + TILE_PAD}" y="${top + 44}" font-family="${FONT}" font-size="15" font-weight="600" font-variant-numeric="tabular-nums">${v}</text>`
796 const tiles = [
797 ...(tasks ? [tile(0, s.tasksHead, `${tasks.done}/${tasks.total}`)] : []),
798 tile(n - 3, s.cost, fmtCost(t.cost)),
799 tile(n - 2, s.tokens, fmtTokens(t.tokens)),
800 tile(n - 1, s.time, fmtTime(t.time)),
801 ]
802 return svg(
803 W,
804 headerHeight(title),
805 `${title ? `<text class="t" x="0" y="15" font-family="${FONT}" font-size="14" font-weight="600">${xml(fitText(title, 14, W))}</text>` : ''}
806${tiles.join('')}`,
807 )
808}
809
810const TILE_GAP = 12
811const TILE_H = 58
812const TILE_PAD = 16
813// Room under the tiles before the next section.
814const headerHeight = (title: string): number => (title ? 32 : 0) + TILE_H + 8
815
816// The task's own progress when the worker reports steps; a finished run is full.
817const progressOf = (a: AgentRun): number | null => {
818 if (a.status === 'done') return 1
819 if (a.stepTotal) return Math.min(1, (a.stepDone ?? 0) / a.stepTotal)
820 return null
821}
822
823const ctxOf = (a: AgentRun): number => (a.contextMax ? Math.min(100, Math.round((a.contextTokens / a.contextMax) * 100)) : 0)
824
825// The grey lines under a run's heading, one each: who it is, what it runs on, which board task.
826const metaOf = (a: AgentRun, f: Flow | null): string[] => {
827 const s = tr()
828 const { crew } = splitDescription(a.description)
829 const model = a.effort ? `${modelName(a.model)} · ${effortName(a.effort)}` : modelName(a.model)
830 // Every row, gates included, reads Name, Model, then Skills; a gate's kind is in its heading.
831 void crew
832 const parts = [`${s.crew}: ${nameOf(a)}`, `${s.model}: ${model}`]
833 // The skills this sub-agent used, under its task. Every row carries the line, "None" when it used none.
834 parts.push(`${s.skills}: ${a.skills?.length ? a.skills.map(nameCase).join(', ') : s.none}`)
835 if (a.round > 1) parts.push(`${s.round} ${a.round}`)
836 if (a.status === 'failed') parts.push(s.failed)
837 return parts
838}
839
840const META_LINE = 16
841// Heading, then one grey line per meta part, then the bar (and the crew's own step note beside it when it reports one).
842// Room under the bar before the next row's rule.
843const agentHeight = (a: AgentRun, f: Flow | null): number => Math.max(64, 30 + metaOf(a, f).length * META_LINE + 30)
844
845// The row's crab is drawn larger than the band's.
846const ROW_CRAB = 1.9
847const ROW_TEXT_X = 72
848// The row's bar, taller than the band's hairline.
849const ROW_BAR_H = 8
850
851const agentSvg = (W: number, a: AgentRun, at: number, f: Flow | null): string => {
852 const s = tr()
853 const tier = tierOf(a.type)
854 const color = isGateRun(a) ? '#9a9a96' : colorOf(tier)
855 const ctx = ctxOf(a)
856 const textW = W - ROW_TEXT_X - 22
857 const barW = textW
858 const progress = progressOf(a)
859 // Without reported steps the bar falls back to the context, drawn grey. No figures are drawn
860 // on the row, and no step count either; the header's tiles carry the totals.
861 const fillW = Math.round(barW * (progress ?? ctx / 100))
862 const meta = metaOf(a, f)
863 const H = agentHeight(a, f)
864 const barY = 30 + meta.length * META_LINE + 4
865 void at
866 return svg(
867 W,
868 H,
869 `${crab(2, Math.max(2, Math.round(H / 2) - Math.round(14 * ROW_CRAB)), costumeFor(a), false, a.status === 'running', ROW_CRAB, tierOf(a.type) === 'other' ? accentFor(a.id) : undefined)}
870<text class="t" x="${ROW_TEXT_X}" y="22" font-family="${FONT}" font-size="13.5" font-weight="600">${xml(fitText(headingOf(a, f), 13.5, textW))}</text>
871${meta.map((m, i) => `<text class="s" x="${ROW_TEXT_X}" y="${40 + i * META_LINE}" font-family="${FONT}" font-size="11">${xml(fitText(m, 11, textW))}</text>`).join('')}
872<rect class="k" x="${ROW_TEXT_X}" y="${barY}" width="${barW}" height="${ROW_BAR_H}" rx="${ROW_BAR_H / 2}"/><rect${progress === null ? ' class="m"' : ''} x="${ROW_TEXT_X}" y="${barY}" width="${fillW}" height="${ROW_BAR_H}" rx="${ROW_BAR_H / 2}"${progress === null ? '' : ` fill="${color}"`}/>
873${statusMark(W - 8, 20, a.status, color)}
874<line class="ln" x1="0" y1="${H - 0.5}" x2="${W}" y2="${H - 0.5}"/>`,
875 )
876}
877
878// The chips row: skills used, gates by name, and the run's health.
879const chipsSvg = (W: number, skills: string[], gates: ReturnType<typeof gatesByName>, health: Health | undefined): string => {
880 const s = tr()
881 const lines: string[] = []
882 if (skills.length) lines.push(`<tspan class="s">${s.skills}:</tspan> <tspan class="t">${xml(skills.join(' · '))}</tspan>`)
883 // The gates by name are not drawn here; each task row carries its own gates.
884 void gates
885 if (health) {
886 const dot = (ok: boolean | undefined, word: string) =>
887 ok === undefined ? '' : `<tspan fill="${ok ? '#3B9C5F' : '#D0453F'}">●</tspan> <tspan class="t">${word}</tspan>`
888 const bits = [dot(health.heartbeat, s.heartbeat), dot(health.watchdog, s.watchdog), health.paused ? `<tspan fill="#D0453F">●</tspan> <tspan class="t">${s.paused}</tspan>` : '', health.slots ? `<tspan class="t">${xml(health.slots)} ${s.slots}</tspan>` : ''].filter(Boolean)
889 if (bits.length) lines.push(`<tspan class="s">${s.health}:</tspan> ${bits.join('<tspan class="s"> · </tspan>')}`)
890 }
891 const h = lines.length * 20 + 6
892 return svg(W, h, lines.map((l, i) => `<text x="0" y="${16 + i * 20}" font-family="${FONT}" font-size="11.5">${l}</text>`).join(''))
893}
894const chipsHeight = (skills: string[], gates: unknown[], health: Health | undefined): number => {
895 let n = 0
896 void gates
897 if (skills.length) n++
898 if (health && (health.heartbeat !== undefined || health.watchdog !== undefined || health.paused || health.slots)) n++
899 return n * 20 + 6
900}
901
902const plannedSvg = (W: number, p: Planned): string => {
903 const tier = p.tier in TIER_COLOR && p.tier !== 'other' ? p.tier : 'explore'
904 const color = colorOf(tier)
905 const textW = W - 42 - 22
906 const meta = [TIER_MODEL[tier] ?? '']
907 if (p.after.length) meta.push(`${tr().after} ${p.after.join(', ')}`)
908 return svg(
909 W,
910 46,
911 `${crab(0, 6, tier, true)}
912<text class="s" x="42" y="18" font-family="${FONT}" font-size="13" font-weight="600">${xml(fitText(`${p.n}. ${titleCase(p.title)}`, 13, textW))}</text>
913<text x="42" y="34" font-family="${FONT}" font-size="11"><tspan fill="${color}">${xml(titleCase(tier))}</tspan><tspan class="m"> ${xml(meta.filter(Boolean).join(' · '))}</tspan></text>
914${statusMark(W - 8, 16, 'planned', color)}
915<line class="ln" x1="0" y1="45.5" x2="${W}" y2="45.5"/>`,
916 )
917}
918
919const compactSvg = (W: number, list: AgentRun[], planned: Planned[], t: ReturnType<typeof totals>): string => {
920 const icons = [
921 ...list.filter(a => a.status === 'running').map(a => ({ k: costumeFor(a), c: colorOf(tierOf(a.type)), s: 'running', dim: false, ac: tierOf(a.type) === 'other' ? accentFor(a.id) : undefined })),
922 ...list.filter(a => a.status !== 'running').map(a => ({ k: costumeFor(a), c: colorOf(tierOf(a.type)), s: a.status, dim: false, ac: tierOf(a.type) === 'other' ? accentFor(a.id) : undefined })),
923 ...planned.map(p => ({ k: p.tier in TIER_COLOR ? p.tier : 'explore', c: colorOf(p.tier), s: 'planned', dim: true, ac: undefined as string | undefined })),
924 ]
925 const fit = Math.max(1, Math.floor((W - 150) / 36))
926 const shown = icons.slice(0, fit)
927 const more = icons.length - shown.length
928 const body = shown
929 .map((ic, i) => crab(i * 36, 0, ic.k, ic.dim, ic.s === 'running', CRAB_SCALE, ic.ac) + (ic.s === 'running' ? `<circle class="live" cx="${i * 36 + 32}" cy="4" r="3" fill="${ic.c}"/>` : ''))
930 .join('')
931 const x = shown.length * 36 + (more ? 4 : 0)
932 return svg(
933 W,
934 32,
935 `${body}${more ? `<text class="s" x="${x}" y="21" font-family="${FONT}" font-size="12">+${more}</text>` : ''}
936<text class="s" x="${W}" y="21" text-anchor="end" font-family="${FONT}" font-size="12" font-variant-numeric="tabular-nums">${fmtCost(t.cost)} · ${fmtTokens(t.tokens)} · ${fmtTime(t.time)}</text>`,
937 )
938}
939
940// --- terminal drawing: the same rows in text.
941
942const ctxBar = (pct: number, width: number): string => {
943 const filled = Math.round((width * pct) / 100)
944 return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
945}
946
947const STATUS_GLYPH: Record<string, string> = { running: '●', done: '✓', failed: '✗', planned: '◷' }
948
949// Opens the agents pane, or closes it when it is up; true when it ends up open.
950async function togglePane($: EngineInterface): Promise<boolean> {
951 const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
952 if (isOpen) {
953 await $.ui.close({ id: PANE })
954 return false
955 }
956 const at = await $.clock.now()
957 await update($, now, () => at)
958 await $.ui.open({ id: PANE, title: tr().pane })
959 return true
960}
961
962async function autoOpen($: EngineInterface, key: string): Promise<void> {
963 const p = await read($, panel)
964 if (p.autoOpenedFor === key) return
965 await update($, panel, prev => ({ ...prev, autoOpenedFor: key }))
966 void $.ui.open({ id: PANE, title: tr().pane })
967}
968
969// A skill read from its own folder: ".../skills/<name>/SKILL.md" gives <name>. Lower-case "skills"
970// only, so a crew writing a skill back to the vault's "Skills" library is not counted as using it.
971const SKILL_PATH = /\/skills\/([^/\s"']+)\/SKILL\.md\b/
972const skillOfPath = (text: string): string => SKILL_PATH.exec(text)?.[1] ?? ''
973
974// Adds a skill to the sub-agent's own list, once.
975async function noteSkill($: EngineInterface, agentId: string, name: string): Promise<void> {
976 await update($, agents, list =>
977 list.map(a => (a.agentId !== agentId || a.skills?.includes(name) ? a : { ...a, skills: [...(a.skills ?? []), name] })),
978 )
979}
980
981export const register: Register = (on, options) => {
982 // The person's own settings (userConfig in plugin.json).
983 const code = typeof options.currency === 'string' && options.currency in CURRENCY_SIGN ? options.currency : 'USD'
984 const rate = Number.parseFloat(typeof options.exchangeRate === 'string' ? options.exchangeRate : '1')
985 currency = { sign: CURRENCY_SIGN[code] ?? '$', perUsd: code === 'USD' || !(rate > 0) ? 1 : rate }
986
987 on('session.start', async ($, e, next) => {
988 const started = await next(e)
989 lang = await detectLang($, options.language)
990 await $.tool.register({
991 name: 'progress',
992 description:
993 'Report the round to the progress bar above the prompt and the agents panel: a savvy-flow, or a work-through of tasks from a task board. ' +
994 'Call it when the round is planned (title, tasks with their ids, crews and expected gates, phaseLabel), whenever a task changes status or crew, ' +
995 'when the phase changes (phaseLabel: Building, Gating, Merging, Snapshot), and once at the end with finished: true. Fields left out keep their previous value.',
996 inputSchema: {
997 type: 'object',
998 properties: {
999 title: { type: 'string', description: 'Short name of the overall task or the parent task, a few words.' },
1000 total: { type: 'integer', minimum: 0, description: 'Number of planned tasks; left out, the tasks list counts.' },
1001 done: { type: 'integer', minimum: 0, description: 'Number of tasks finished (at review); left out, the tasks with status done count.' },
1002 phase: { type: 'string', enum: [...PHASES] },
1003 phaseLabel: { type: 'string', description: 'Free words for the pill, e.g. Building, Gating, Merging, Snapshot.' },
1004 finished: { type: 'boolean', description: 'True once the round is closed.' },
1005 tasks: {
1006 type: 'array',
1007 description:
1008 'The tasks of the round in order, numbered from 1. For a savvy-flow each title equals the Agent description; for a work-through each carries its board id, so the panel matches crews (by the ids in their briefs), gates (by the GATE-RECORD task field) and costs (COST-RECORD) to it.',
1009 items: {
1010 type: 'object',
1011 properties: {
1012 title: { type: 'string', description: 'The task name, as on the board.' },
1013 tier: { type: 'string', enum: ['fable', 'heavy', 'careful', 'medium', 'light', 'other'] },
1014 after: { type: 'array', items: { type: 'integer' }, description: 'Numbers of the tasks this one waits for.' },
1015 id: { type: 'string', description: 'The task id on the board, as it appears in the briefs handed to sub-agents.' },
1016 crew: { type: 'string', description: 'The crew codename it was handed to.' },
1017 gates: { type: 'array', items: { type: 'string' }, description: 'The gates expected to check it, by name.' },
1018 status: { type: 'string', enum: ['to do', 'building', 'gating', 'done', 'blocked'] },
1019 },
1020 required: ['title'],
1021 },
1022 },
1023 skills: { type: 'array', items: { type: 'string' }, description: 'Skills in use this run, by name.' },
1024 health: {
1025 type: 'object',
1026 description: 'The run\'s health: heartbeat touched, watchdog armed, paused, crew slots in use.',
1027 properties: {
1028 heartbeat: { type: 'boolean' },
1029 watchdog: { type: 'boolean' },
1030 paused: { type: 'boolean' },
1031 slots: { type: 'string', description: 'e.g. "2 of 2"' },
1032 },
1033 },
1034 },
1035 },
1036 })
1037 await $.tool.register({
1038 name: 'step',
1039 description:
1040 'For savvy-flow workers: report progress on your own task to the agents panel. ' +
1041 'Right after reading the brief, call it with `total` (your plan in 3-8 steps) and `done: 0`; ' +
1042 'call it again as each step finishes. Cheap and silent: it only draws a bar.',
1043 inputSchema: {
1044 type: 'object',
1045 properties: {
1046 done: { type: 'integer', minimum: 0, description: 'Steps finished so far.' },
1047 total: { type: 'integer', minimum: 1, description: 'Steps planned; may change if the plan changes.' },
1048 note: { type: 'string', description: 'The step in progress, a few words.' },
1049 },
1050 required: ['done'],
1051 },
1052 })
1053 await $.command.register({
1054 name: 'agents-info',
1055 description: 'Show or hide the panel of subagents: running, finished and planned, with model, context, cost and time',
1056 })
1057
1058 // The panel opens by itself in every session unless the autoOpen option says otherwise. Opened unasked it
1059 // seats from 144 columns and waits below that.
1060 if (options.autoOpen !== false && options.autoOpen !== 'off') void $.ui.open({ id: PANE, title: tr().pane })
1061
1062 // Ticks the running agents' clocks; quiet when nothing runs.
1063 $.clock.every(1000, () => {
1064 void (async () => {
1065 const list = await read($, agents)
1066 if (!list.some(a => a.status === 'running')) return
1067 const at = await $.clock.now()
1068 await update($, now, () => at)
1069 })()
1070 })
1071 return started
1072 })
1073
1074 on('command.run', { command: 'agents-info' }, async $ => {
1075 const isOpen = await togglePane($)
1076 return { text: isOpen ? tr().opened : tr().closed }
1077 })
1078
1079 on('tool.call', { tool: TOOL }, async ($, e) => {
1080 const input = e as unknown as ProgressInput
1081 const prev = await read($, flow)
1082 if (isNewFlow(prev, input) && input.title !== undefined) {
1083 // A new flow starts with a clean list; agents still running stay.
1084 await update($, agents, list => list.filter(a => a.status === 'running'))
1085 }
1086 const next = await update($, flow, p => merge(p, input))
1087 if (next && input.tasks?.length) await autoOpen($, next.title)
1088 return { result: `ok: ${label(next ?? blank())}` }
1089 })
1090
1091 // A worker's own progress: the call runs in the worker's loop, so agentId names it.
1092 on('tool.call', { tool: STEP_TOOL }, async ($, e) => {
1093 const input = e as unknown as { done?: number; total?: number; note?: string }
1094 const agentId = e.agentId
1095 if (!agentId) return { result: 'ignored: only subagents report steps' }
1096 await update($, agents, list =>
1097 list.map(a => {
1098 if (a.agentId !== agentId) return a
1099 const total = Math.max(0, Math.round(input.total ?? a.stepTotal ?? 0))
1100 const done = Math.max(0, Math.round(input.done ?? a.stepDone ?? 0))
1101 return { ...a, stepTotal: total, stepDone: total ? Math.min(total, done) : done, stepNote: input.note?.trim() || undefined, stepSource: 'self' }
1102 }),
1103 )
1104 return { result: 'ok' }
1105 })
1106
1107 // Skills by name: the main session's for the chips row, a sub-agent's on its own row.
1108 on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
1109 const raw = e as unknown as Record<string, unknown>
1110 const name = String(raw.skill ?? raw.name ?? '').trim()
1111 if (name && !e.agentId) await update($, skillsUsed, list => (list.includes(name) ? list : [...list, name]))
1112 if (name && e.agentId) await noteSkill($, e.agentId, name)
1113 return next(e)
1114 })
1115
1116 // A sub-agent also uses a skill by reading its SKILL.md, as crews are briefed to.
1117 on('tool.call', { tool: 'Read' }, async ($, e, next) => {
1118 const name = skillOfPath(String((e as unknown as { file_path?: string }).file_path ?? ''))
1119 if (name && e.agentId) await noteSkill($, e.agentId, name)
1120 return next(e)
1121 })
1122
1123 // A crew's progress when it does not report its own steps (sub-agents may not get the step tool):
1124 // every tool call it makes nudges its bar, a typical crew making about eighty, so the bar moves
1125 // from its first minute and never fills before the crew finishes.
1126 on('tool.call', async ($, e, next) => {
1127 const agentId = e.agentId
1128 if (!agentId || e.tool === STEP_TOOL) return next(e)
1129 await update($, agents, list =>
1130 list.map(a => {
1131 if (a.agentId !== agentId || a.isGate || a.stepSource === 'self') return a
1132 const calls = (a.calls ?? 0) + 1
1133 const n = Math.max(1, a.taskIds?.length ?? 1)
1134 const total = n * 5
1135 const byCalls = Math.min(total - 1, Math.floor((calls / (80 * n)) * total))
1136 return { ...a, calls, stepTotal: total, stepDone: byCalls, stepSource: 'watched' }
1137 }),
1138 )
1139 return next(e)
1140 })
1141
1142 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
1143 const agentId = e.agentId
1144 const cmd = String((e as unknown as { command?: string }).command ?? '')
1145 if (!agentId || !cmd) return next(e)
1146 const skill = skillOfPath(cmd)
1147 if (skill) await noteSkill($, agentId, skill)
1148 return next(e)
1149 })
1150
1151 // Safety net: worker launches move the faint layer even if the orchestrator forgets to report.
1152 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
1153 const type = String(e.subagent_type ?? '')
1154 if (!type.startsWith('savvy-')) return next(e)
1155
1156 await update($, flow, prev => {
1157 const base = prev && !prev.isFinished ? { ...blank(), ...prev } : blank()
1158 return { ...base, running: base.running + 1, phase: base.phase === 'plan' ? 'delegate' : base.phase }
1159 })
1160 try {
1161 return await next(e)
1162 } finally {
1163 await update($, flow, prev => (prev ? { ...prev, running: Math.max(0, prev.running - 1) } : prev))
1164 }
1165 })
1166
1167 on('agent.spawn', async ($, e, next) => {
1168 const started = await next(e)
1169 if (started.deny !== undefined) return started
1170
1171 const at = await $.clock.now()
1172 const ids = knownIds(await read($, flow))
1173 await update($, agents, list => {
1174 const round = 1 + list.filter(a => norm(a.description) === norm(e.description) && e.description).length
1175 const { crew, lane } = splitDescription(e.description ?? '')
1176 const run: AgentRun = {
1177 id: started.agentId ?? e.tool_use_id,
1178 agentId: started.agentId,
1179 type: e.subagentType,
1180 description: e.description,
1181 model: started.model,
1182 status: 'running',
1183 startedAt: at,
1184 contextTokens: 0,
1185 contextMax: windowOf(started.model),
1186 tokens: 0,
1187 costUsd: 0,
1188 steps: 0,
1189 round,
1190 isGate: GATE_DESC.test(e.description ?? ''),
1191 taskIds: taskIdsOf(e.prompt ?? '', ids),
1192 crew,
1193 lane,
1194 name: firstNameOf(crew) || pickName(started.agentId ?? e.tool_use_id, list.filter(r => r.status === 'running').map(nameOf)),
1195 costume: costumeOf(e.subagentType) === 'other' ? pickCostume(started.agentId ?? e.tool_use_id, list.slice(-RANDOM_COSTUMES.length + 1).map(r => costumeFor(r))) : undefined,
1196 }
1197 return [...list.filter(a => a.id !== run.id), run].slice(-200)
1198 })
1199 await update($, now, () => at)
1200 if (e.subagentType.startsWith('savvy-')) {types/index.d.ts 108 lines1export type Phase = 'plan' | 'design' | 'delegate' | 'review' | 'close'
2
3/** A task of the round, as the orchestrator reports it. */
4export type PlannedTask = {
5 title: string
6 tier: string
7 after: number[]
8 /** The task's own id on the board, used to match crews, gates and costs to it. */
9 id?: string
10 /** The crew it was handed to, by codename. */
11 crew?: string
12 /** The gates expected to check it, by name. */
13 gates?: string[]
14 /** Where it is: to do, building, gating, done, blocked. */
15 status?: string
16}
17
18export type Health = {
19 heartbeat?: boolean
20 watchdog?: boolean
21 paused?: boolean
22 /** Crew slots in use, as words, e.g. "2 of 2". */
23 slots?: string
24}
25
26export type Flow = {
27 title: string
28 total: number
29 done: number
30 running: number
31 phase: Phase
32 /** Free words for the pill, e.g. "Building", "Gating", "Merging"; the phase's own label when empty. */
33 phaseLabel?: string
34 isFinished: boolean
35 tasks: PlannedTask[]
36 skills?: string[]
37 health?: Health
38}
39
40export type AgentStatus = 'running' | 'done' | 'failed'
41
42/** One verdict line from a gate's report. */
43export type GateRecord = { task: string; gate: string; verdict: string }
44
45export type AgentRun = {
46 id: string
47 agentId?: string
48 type: string
49 description: string
50 model: string
51 effort?: string
52 status: AgentStatus
53 startedAt: number
54 endedAt?: number
55 contextTokens: number
56 contextMax: number
57 tokens: number
58 costUsd: number
59 steps: number
60 round: number
61 /** Self-reported by the worker through the `step` tool, or read off its commands (stepSource says which). */
62 stepDone?: number
63 stepTotal?: number
64 stepNote?: string
65 stepSource?: 'self' | 'watched'
66 /** Tool calls the crew has made, which nudge its bar between stages. */
67 calls?: number
68 /** A gate run: its description starts with "Gate", or its report carried a GATE-RECORD line. */
69 isGate?: boolean
70 /** Verdicts read from the GATE-RECORD lines of a gate run's report. */
71 verdicts?: { pass: number; fail: number; na: number }
72 /** Every GATE-RECORD line of the report, one entry each. */
73 gateRecords?: GateRecord[]
74 /** The task names from the report's COST-RECORD lines. */
75 costTasks?: string[]
76 /** Board task ids found in the brief the crew was given. */
77 taskIds?: string[]
78 /** Skills this sub-agent used, by name: its Skill tool calls and the SKILL.md files it read. */
79 skills?: string[]
80 /** The crew's name, read from its description ("Crew A — …"). */
81 crew?: string
82 /** The rest of the description after the codename. */
83 lane?: string
84 /** The costume picked at spawn for a run that is not a savvy tier; never the plain crab. */
85 costume?: string
86 /** The sub-agent's first name: from its description when it starts with one, else given from a list. */
87 name?: string
88}
89
90export type Panel = {
91 isCompact: boolean
92 isDoneCollapsed: boolean
93 autoOpenedFor: string
94}
95
96declare module 'claude-code' {
97 interface PluginState {
98 'fun-savvy-progress': {
99 flow: Flow | null
100 agents: AgentRun[]
101 panel: Panel
102 now: number
103 /** Skills the main session has invoked, by name, in order of first use. */
104 skills: string[]
105 }
106 }
107}
108