SLOPSHOPPER

fun-savvy-progress

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…

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

Draws

Pane · savvy-agents
$0.00 · 0 Tokens · 0:00 No Sub-agents Yet
README

Fun Savvy Progress

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.

The agents panel: totals, two sub-agents running and one finished, each with its model and skills

Install

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.

What it shows

  • Totals: cost, tokens and time across the session's sub-agents.
  • Running and Finished sub-agents: each headed by what it is working on, then the same three lines on every one, Name, Model (with effort) and Skills ("None" when it used none), and a progress bar. Names are ordinary first names: a sub-agent whose description starts with one keeps it, and any other is given one (Ada, Ben, Cleo…), steady for it and never shared with another running at the same time. Gates (checking sub-agents) say which kind of gate they are in their heading.
  • Skills and health: the skills the session used, and the run's health when an orchestrator reports it.
  • The progress bar above the prompt, once a workflow reports a round (see below): its name, phase and progress, a Gates counter (✓ passed, ✗ failed, ◷ running) and the crew size, which opens the panel.

The progress bar above the prompt: a round's name, its progress, a gates counter and the crew size

Settings

All can be changed later with /config → fun-savvy-progress.

SettingWhat it doesDefault
Panel languageauto follows Claude Code's language, then your system's; or en, ru.auto
Open the agents panel at the start of every sessionOff keeps it closed until /agents-info.On
CurrencyUSD, 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 RateHow much of your currency one US dollar buys, for example 0.76 for pounds. Ignored for USD.1

How a workflow feeds it

The panel watches sub-agents start, work and finish on its own; nothing is required. A workflow can add to it:

  • Sub-agent descriptions: a description written 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 verdicts: a gate's final report can carry GATE-RECORD: {"task": "<what it checked>", "gate": "<gate name>", "verdict": "pass|fail|not applicable"} lines, which feed the Gates counter.
  • The 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.
  • The 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.

Licence

MIT. Original © johnnyvizz; changes in this fork © couriard. See LICENSE.

Source 2 files
hooks/register.tsx 1521 lines
1import { 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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 lines
1export 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