SLOPSHOPPER

subagent-progress

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

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

Draws

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

subagent-progress

A Claude Code mod: a progress bar above the prompt and a live panel of subagents, each drawn as a crab whose costume follows the model and effort it runs on. Forked from johnnyvizz's savvy-progress 1.2.0 (MIT, see LICENSE) and renamed from savvy-progress in 2.0.0, since it shows any subagent work, with or without the savvy-flow skill.

  • Progress bar: the flow's title, phase, finished agents out of started, and a button with the crew size that opens the panel (a presses it while the band holds the focus). It appears when a subagent of any type starts, or when something reports progress. When the work ends it turns into a green Done row that stays until you dismiss it with ✕.
  • Agents panel (/agents-info toggles it): running, finished and planned subagents with model, effort, task progress, context, estimated cost and time. It opens by itself whenever a new batch of subagent work starts. Collapse shrinks it to one line per agent.
  • Costumes: working crabs walk, and each costume animates its own prop. prefers-reduced-motion stops them. The crabs are SVG, so they draw on the desktop; the terminal gets text rows in the costume's color.

Costumes

The costume follows what the agent runs on, so every subagent gets one without any special agent definitions:

CostumeWhoIts prop
astronautFable at any effort; Opus at high, xhigh or maxfloats in a glass dome; the antenna and the star blink
detectiveOpus at medium or lowthe magnifier sweeps and glints
engineerSonnet at high, xhigh or maxthe wrench turns a bolt
chefSonnet at medium or lowtosses the omelette
racerHaikuruns at double pace; the checkered flag flutters
piratethe Explore agent, whatever it runs onthe cutlass fences
plain crabany other modelwalks
  • The rule reads the model and effort the run reports (turn.step), so a general-purpose agent switched to Haiku turns into a racer. An effort not known yet (before the run's first request) or a numeric budget counts as below high.
  • Model names match by family, so aliases (sonnet), full ids and a [1m] suffix all work.
  • A savvy-* agent from upstream's savvy-flow follows the same rule from its model and effort. Only while its model is unknown does it fall back to its tier (savvy-fable Fable/high, savvy-heavy Opus/xhigh, savvy-careful Opus/high, savvy-medium Opus/medium, savvy-light Opus/low).
  • The colors are the old tier colors: purple, orange, amber, blue and green for astronaut, detective, engineer, chef and racer.
  • The row under each agent's name shows its agent type (general-purpose, savvy-careful), then model and effort.

The mapping lives in hooks/crew.ts, a pure module with its own tests.

What this fork changes

Upstream shows the bar only for savvy-* workers or an explicit progress report. Here:

  • Any subagent (Explore, general-purpose, a custom one, ...) opens an auto flow titled "Subagents". Its counters come from spawns and completions, in hooks/auto.ts: every start adds to the total, every finish adds to done, and once every agent of the batch has finished the row closes. Only the batch's own agents count, so an agent left "running" by an interrupted earlier batch cannot hold it open.
  • The panel auto-opens when each batch starts (and for savvy-* workers as before). Close it mid-batch and it stays closed until the next batch; /agents-info or the crew button reopens it any time.
  • A new batch of work starts with a clean list; agents still running stay.
  • An orchestrated flow (the progress tool, or a savvy-* worker) is never touched by this: its counters stay the orchestrator's. Once an explicit report arrives it takes the flow over.
  • The costumes follow model and effort, not agent names (2.0.0).

Changes

2.0.1: other mods' bands stay in sight

While a flow was active, the bar took the whole band above the prompt: it never drew what the plugins beneath it draw there, so another mod's box (agent-pulse, say) disappeared under it. The bar now stacks above them.

2.0.0: renamed to subagent-progress, costumes by model and effort

A breaking release: the plugin's name is its tool and settings namespace, so see Moving from savvy-progress.

  • Renamed from savvy-progress to subagent-progress: the plugin, the folder, the tool names (mcp__subagent-progress__progress, mcp__subagent-progress__step), the state, the pane and the pluginConfigs key. The /agents-info command is unchanged.
  • Costumes by model and effort (see Costumes). In 1.x only savvy-* agents got a prop, and with savvy-flow not installed only the Explore pirate did. Now every subagent does.
  • The row names the agent type instead of a savvy tier, in the panel and in the terminal.
  • progress takes a model and effort per task: tier is optional now, and a planned task can say model (sonnet, haiku, a model id) and effort. Planned rows show "Sonnet · high" and wear the matching costume. A savvy tier, or a costume name, still works.
  • Generic tool texts: progress and step no longer say they are for savvy-flow, and an untitled report is "Subagents".
  • hooks/crew.test.ts (15 tests) and four costume render tests in hooks/render.test.ts.

1.4.0: terminal look

The desktop drawings are unchanged; this release is the terminal's text rendering.

  • The progress bar checks the surface before drawing SVG. The terminal's element table answers 'Svg' in ui too, which sent the band down its desktop path; the plugin test kit drew it as ×N ✕ alone. On the terminal it now always draws its text row: title, bar, phase, percent and crew.
  • Time and cost lead each agent's stats line: 1:20 · ≈$0.42 · ctx 12% · 120k, then the step count and its note. A line cut at the pane's edge loses the note, not the cost and time. The panel's totals read the same way.
  • Shorter panel: no blank line after each agent or planned task, and no trailing ◷ on planned rows (they sit under "Planned" already).
  • Compact is one line per agent: status, name, a small bar where the pane is 40 columns or wider, time and cost. Its Finished fold works as in the full view.
  • Finer bars: one bar for the panel and the band, filled in eighths of a cell (▏▎▍▌▋▊▉), so a 6-cell bar moves in about 2% steps. It needs a font with block elements, as Cascadia and Nerd Fonts have.
  • Theme colours: done and failed agents, the Done row and the ▣ use the theme keys success, error and claude, so they follow the theme and skin. Costume colors stay as they are.
  • Done row: ✓ in place of ● once the work ends.
  • Crew button hotkey: a, drawn a: ×N.
  • Band widths are measured: the bar gives up the cells a longer phase label needs (Tasks 1 running, or the Russian labels), so the row no longer wraps at 80 columns.
  • hooks/render.test.ts: the pane and the band drawn on the terminal at several widths, and the band's SVG on desktop (12 tests).

1.3.0: any subagent work

The fork itself; see What this fork changes.

Tools it adds

  • mcp__subagent-progress__progress: an orchestrator reports the plan, phase and accepted tasks. Optional: without it the bar follows spawns and completions. Each planned task is a title (the Agent description it will be delegated with) and, optionally, model, effort, a savvy tier, and after.
  • mcp__subagent-progress__step: a worker reports its own steps (done, total, note). A worker that does not report shows its context fill in grey instead. A subagent only calls it if its prompt tells it to.

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

Settings

language: auto (default), en or ru. auto follows Claude Code's language setting, then the system locale, and falls back to English. Set it in /config, or, for a mod loaded by hand, in ~/.claude/settings.json:

{ "pluginConfigs": { "subagent-progress": { "options": { "language": "ru" } } } }

Install

/plugin install subagent-progress --marketplace afterever/claude-mods

Install instructions for the whole repository are in the repository README.

Moving from savvy-progress

claude plugin uninstall savvy-progress@afterever-mods
claude plugin marketplace update afterever-mods
claude plugin install subagent-progress@afterever-mods

Then restart Claude Code.

  • Install only one of this and savvy-progress: both register /agents-info. This applies to upstream's savvy-progress@claude-kit too.
  • A language setting under pluginConfigs.savvy-progress has to move to pluginConfigs.subagent-progress.
  • Using upstream's savvy-flow? Its skill and agents call mcp__savvy-progress__progress and __step, which no longer exist; they skip those calls silently, so the flow still runs and the bar follows the spawns. To keep its step reporting, change those two tool names in its skills/ and agents/ files.
Source 4 files
hooks/register.tsx 1120 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentRun, Flow, Panel, Phase, PlannedTask } from '../types'
5import { AUTO_TITLE, isCounted, isSavvyType, onFinish, onSpawn, openKey, startsBatch } from './auto'
6import { COSTUME_COLOR, bareType, costumeOf, plannedCostume, plannedProfile } from './crew'
7import type { Costume } from './crew'
8
9const flow = atom({ plugin: 'subagent-progress', key: 'flow' } as const, null)
10const agents = atom({ plugin: 'subagent-progress', key: 'agents' } as const, [])
11const panel = atom({ plugin: 'subagent-progress', key: 'panel' } as const, {
12  isCompact: false,
13  isDoneCollapsed: false,
14  autoOpenedFor: '',
15})
16const now = atom({ plugin: 'subagent-progress', key: 'now' } as const, 0)
17
18const TOOL = 'mcp__subagent-progress__progress'
19const STEP_TOOL = 'mcp__subagent-progress__step'
20const PANE = 'subagents'
21const PHASES: readonly Phase[] = ['plan', 'design', 'delegate', 'review', 'close']
22const ACCENT = '#8f8cf4'
23const DONE = '#5fbf8f'
24
25type ProgressInput = {
26  title?: string
27  total?: number
28  done?: number
29  phase?: Phase
30  finished?: boolean
31  tasks?: { title?: string; tier?: string; model?: string; effort?: string; after?: number[] }[]
32}
33
34// ---------------------------------------------------------------------------
35// Language: the `language` option, else Claude Code's `language` setting, else the
36// process locale; English when nothing says Russian.
37
38type Lang = 'en' | 'ru'
39
40const STRINGS = {
41  en: {
42    pane: 'Agents',
43    cost: 'Cost',
44    tokens: 'Tokens',
45    time: 'Time',
46    collapse: 'Collapse',
47    expand: 'Expand',
48    running: 'Running',
49    finished: 'Finished',
50    planned: 'Planned',
51    empty: 'No subagents yet.',
52    round: 'round',
53    failed: 'error',
54    after: 'after',
55    tokensWord: 'tokens',
56    agentsCount: 'agents',
57    isRunning: 'running',
58    isFinished: 'finished',
59    isPlanned: 'planned',
60    opened: 'Agents panel opened.',
61    closed: 'Agents panel closed.',
62    done: 'Done',
63    plan: 'Plan',
64    design: 'Design',
65    tasks: 'Tasks',
66    review: 'Review',
67    busy: 'running',
68  },
69  ru: {
70    pane: 'Агенты',
71    cost: 'Стоимость',
72    tokens: 'Токены',
73    time: 'Время',
74    collapse: 'Свернуть',
75    expand: 'Развернуть',
76    running: 'Работают',
77    finished: 'Завершены',
78    planned: 'Запланированы',
79    empty: 'Субагентов пока нет.',
80    round: 'раунд',
81    failed: 'ошибка',
82    after: 'после',
83    tokensWord: 'токенов',
84    agentsCount: 'агентов',
85    isRunning: 'работает',
86    isFinished: 'завершён',
87    isPlanned: 'запланирована',
88    opened: 'Панель агентов открыта.',
89    closed: 'Панель агентов закрыта.',
90    done: 'Готово',
91    plan: 'План',
92    design: 'Дизайн',
93    tasks: 'Задачи',
94    review: 'Ревью',
95    busy: 'в работе',
96  },
97} as const
98
99// Module scope is fine here: session.start sets it again on every (re)load.
100let lang: Lang = 'en'
101const tr = () => STRINGS[lang]
102
103const isRussian = (v: unknown): boolean => typeof v === 'string' && /^(ru|russian|рус)/i.test(v.trim())
104
105async function detectLang($: EngineInterface, option: unknown): Promise<Lang> {
106  if (option === 'en' || option === 'ru') return option
107  try {
108    const settings = (await $.settings.read()) as Record<string, unknown>
109    if (typeof settings.language === 'string' && settings.language.trim()) return isRussian(settings.language) ? 'ru' : 'en'
110  } catch {
111    // No settings: fall through to the locale.
112  }
113  const locale = (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG'))
114  return isRussian(locale) ? 'ru' : 'en'
115}
116
117const blank = (): Flow => ({
118  title: AUTO_TITLE,
119  total: 0,
120  done: 0,
121  running: 0,
122  phase: 'plan',
123  isFinished: false,
124  tasks: [],
125})
126
127const isNewFlow = (prev: Flow | null, input: ProgressInput): boolean =>
128  !prev || prev.isFinished || (input.title !== undefined && input.title.trim() !== prev.title)
129
130const cleanTasks = (tasks: ProgressInput['tasks']): PlannedTask[] | undefined =>
131  tasks
132    ?.filter(t => t.title?.trim())
133    .map(t => ({
134      title: (t.title ?? '').trim(),
135      tier: (t.tier ?? '').replace(/^savvy-/, '').trim().toLowerCase(),
136      model: t.model?.trim() || undefined,
137      effort: t.effort?.trim().toLowerCase() || undefined,
138      after: (t.after ?? []).filter(n => Number.isInteger(n) && n > 0),
139    }))
140
141const merge = (prev: Flow | null, input: ProgressInput): Flow => {
142  // A new title means a new flow: never carry counters over from an earlier one.
143  const base = isNewFlow(prev, input) || !prev ? blank() : { ...blank(), ...prev }
144  const tasks = cleanTasks(input.tasks) ?? base.tasks
145  const total = Math.max(0, Math.round(input.total ?? (input.tasks ? tasks.length : base.total)))
146  const done = Math.min(total || Infinity, Math.max(0, Math.round(input.done ?? base.done)))
147  const phase = input.phase && PHASES.includes(input.phase) ? input.phase : base.phase
148  return {
149    ...base,
150    title: input.title?.trim() || base.title,
151    total,
152    done,
153    phase: input.finished ? 'close' : phase,
154    isFinished: input.finished === true,
155    tasks,
156    // An explicit report takes the flow over from the spawn-derived one.
157    isAuto: false,
158  }
159}
160
161const label = (f: Flow): string => {
162  const s = tr()
163  if (f.isFinished) return s.done
164  if (f.phase === 'plan') return s.plan
165  if (f.phase === 'design') return s.design
166  const count = f.total ? `${f.done}/${f.total}` : `${f.running} ${s.busy}`
167  return `${f.phase === 'review' ? s.review : s.tasks} ${count}`
168}
169
170const ratio = (f: Flow): number => (f.isFinished ? 1 : f.total ? f.done / f.total : 0)
171
172// Deterministic noise so the dither does not shimmer between redraws.
173const noise = (x: number, y: number): number => {
174  const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
175  return s - Math.floor(s)
176}
177
178// The whole row is one SVG: the desktop wraps sibling elements onto new lines,
179// so title, bar, percent and the crab live in one drawing; only the count and
180// the dismiss are Buttons beside it.
181const H = 22
182const BAR_H = 16
183const CRAB_W = 26
184const CELL = 3
185const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
186
187const xml = (s: string): string =>
188  s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c)
189
190const clip = (s: string, max: number): string => (s.length > max ? s.slice(0, Math.max(1, max - 1)) + '…' : s)
191
192// Rough advance of system UI text, in em; good enough to size the title's slot.
193const charEm = (ch: string): number =>
194  /[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-ZА-ЯЁmwшщжюМШЩЖЮ@%]/.test(ch) ? 0.72 : 0.56
195
196const textWidth = (s: string, size: number): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
197
198// Cuts `s` to fit `maxW` pixels, with an ellipsis when it had to cut.
199const fitText = (s: string, size: number, maxW: number): string => {
200  if (textWidth(s, size) <= maxW) return s
201  let out = ''
202  for (const ch of s) {
203    if (textWidth(out + ch + '…', size) > maxW) break
204    out += ch
205  }
206  return out + '…'
207}
208
209const rowSvg = (f: Flow, W: number, isWorking: boolean): string => {
210  // The title takes what it needs, up to 40% of the row; the bar takes the rest.
211  const title = fitText(f.title, 13, Math.max(60, W * 0.4))
212  const BAR_X = Math.round(16 + textWidth(title, 13) + 12)
213  const BAR_W = Math.max(60, W - BAR_X - 46 - CRAB_W)
214  const color = f.isFinished ? DONE : ACCENT
215  const y0 = (H - BAR_H) / 2
216  const fillW = Math.round(BAR_W * ratio(f))
217  const runW = f.total ? Math.round((BAR_W * Math.min(f.total, f.done + f.running)) / f.total) : 0
218  const dots: string[] = []
219
220  // Dithered fill: sparse at the start, dense toward the head.
221  const cols = Math.floor(fillW / CELL)
222  const rows = Math.floor(BAR_H / CELL)
223  for (let c = 0; c < cols; c++) {
224    const density = 0.35 + 0.6 * Math.pow(c / Math.max(1, cols), 1.2)
225    for (let r = 0; r < rows; r++) {
226      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"/>`)
227    }
228  }
229  // Handed to workers, not yet accepted: a faint second layer.
230  const faint: string[] = []
231  for (let c = cols; c < Math.floor(runW / CELL); c++) {
232    for (let r = 0; r < rows; r++) {
233      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"/>`)
234    }
235  }
236
237  const ticks: string[] = []
238  for (let i = 1; i < f.total; i++) {
239    const x = Math.round((BAR_W * i) / f.total)
240    if (x > fillW + 4) ticks.push(`<rect x="${x}" y="${BAR_H / 2 - 4}" width="1.5" height="8" rx="0.75"/>`)
241  }
242
243  const text = label(f)
244  const pillW = Math.round(18 + text.length * 6.6)
245  const pillX = Math.max(0, Math.min(BAR_W - pillW, fillW - pillW))
246  const percent = `${Math.round(ratio(f) * 100)}%`
247
248  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
249<style>
250.t{fill:#1f1f1f}.m{fill:#8a8a8a}.k{fill:#e4e4e2}.tk{fill:#b4b4b0}
251@media (prefers-color-scheme: dark){.t{fill:#ececec}.m{fill:#9a9a9a}.k{fill:#2c2c2c}.tk{fill:#5a5a5a}}
252/* Pixels twinkle in four out-of-phase groups; a finished bar settles to a slow glow. */
253.t0,.t1,.t2,.t3{animation:tw ${f.isFinished ? 3.2 : 2.2}s ease-in-out infinite}
254.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}
255@keyframes tw{0%,100%{opacity:1}50%{opacity:${f.isFinished ? 0.8 : 0.3}}}
256@media (prefers-reduced-motion: reduce){.t0,.t1,.t2,.t3{animation:none}}
257</style>
258<defs><clipPath id="c"><rect x="0" y="0" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/></clipPath></defs>
259<circle cx="5" cy="${H / 2}" r="4" fill="${color}"/>
260<text class="t" x="16" y="${H / 2 + 4.5}" font-family="${FONT}" font-size="13" font-weight="500">${xml(title)}</text>
261<g transform="translate(${BAR_X},${y0})">
262<rect class="k" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/>
263<g clip-path="url(#c)">
264<g fill="${color}">${dots.join('')}</g>
265<g fill="${color}" opacity="0.45">${faint.join('')}</g>
266<g class="tk">${ticks.join('')}</g>
267</g>
268<rect x="${pillX}" width="${pillW}" height="${BAR_H}" rx="${BAR_H / 2}" fill="${color}"/>
269<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>
270</g>
271<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>
272${CRAB_CSS}${crab(W - CRAB_W + 1, 0, 'other', false, isWorking, 0.8)}
273</svg>`
274}
275
276// Text bar for the terminal, in eighths of a cell: a 6-cell bar still moves in ~2% steps.
277const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
278const bar = (fraction: number, width: number): string => {
279  const eighths = Math.round(Math.max(0, Math.min(1, fraction)) * width * 8)
280  const full = Math.floor(eighths / 8)
281  const part = EIGHTHS[eighths % 8]
282  return '█'.repeat(full) + part + '░'.repeat(Math.max(0, width - full - (part ? 1 : 0)))
283}
284
285// ---------------------------------------------------------------------------
286// Agents panel: every subagent of the session, plus the tasks the flow planned.
287
288const colorOf = (costume: Costume): string => COSTUME_COLOR[costume]
289
290// "Opus · xhigh": what a planned task will run on, when it says (its model, or its savvy tier).
291const profileLabel = (p: PlannedTask): string => {
292  const { model, effort } = plannedProfile(p)
293  if (!model) return ''
294  const name = modelName(model)
295  return [name.charAt(0).toUpperCase() + name.slice(1), effort].filter(Boolean).join(' · ')
296}
297
298// USD per million tokens: input, output, cache read, cache write (5-minute TTL).
299// The engine reports tokens, not money, so the panel's cost is an estimate.
300const PRICES: [RegExp, [number, number, number, number]][] = [
301  [/fable|mythos/, [10, 50, 0.25, 12.5]],
302  [/opus-5-5/, [4, 20, 0.2, 5]],
303  [/opus/, [5, 25, 0.5, 6.25]],
304  [/sonnet/, [2, 10, 0.2, 2.5]],
305  [/haiku/, [1, 5, 0.1, 1.25]],
306]
307
308type Usage = {
309  input_tokens: number
310  output_tokens: number
311  cache_read_input_tokens: number
312  cache_creation_input_tokens: number
313}
314
315const priceOf = (model: string): [number, number, number, number] =>
316  PRICES.find(([re]) => re.test(model.toLowerCase()))?.[1] ?? [4, 20, 0.2, 5]
317
318const costOf = (model: string, u: Usage): number => {
319  const [i, o, r, w] = priceOf(model)
320  return (
321    ((u.input_tokens || 0) * i +
322      (u.output_tokens || 0) * o +
323      (u.cache_read_input_tokens || 0) * r +
324      (u.cache_creation_input_tokens || 0) * w) /
325    1e6
326  )
327}
328
329const windowOf = (model: string): number => (/haiku/i.test(model) ? 200_000 : 1_000_000)
330
331const modelName = (id: string): string => {
332  const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
333  const [, family = '', major = '', minor] = m ?? []
334  if (!family) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '') || '—'
335  return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()} ${major}${minor ? '.' + minor : ''}`
336}
337
338const norm = (s: string): string => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
339
340const fmtTokens = (n: number): string =>
341  n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${Math.round(n)}`
342
343const fmtCost = (usd: number): string => `$${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`
344
345const fmtTime = (ms: number): string => {
346  const s = Math.max(0, Math.round(ms / 1000))
347  const h = Math.floor(s / 3600)
348  const m = Math.floor((s % 3600) / 60)
349  const ss = String(s % 60).padStart(2, '0')
350  return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
351}
352
353const elapsed = (a: AgentRun, at: number): number => (a.endedAt ?? Math.max(at, a.startedAt)) - a.startedAt
354
355type Planned = PlannedTask & { n: number }
356
357const plannedOf = (f: Flow | null, list: AgentRun[]): Planned[] => {
358  if (!f || f.isFinished) return []
359  const started = new Set(list.map(a => norm(a.description)))
360  return (f.tasks ?? []).map((t, i) => ({ ...t, n: i + 1 })).filter(t => !started.has(norm(t.title)))
361}
362
363const totals = (list: AgentRun[], at: number) => {
364  const cost = list.reduce((s, a) => s + a.costUsd, 0)
365  const tokens = list.reduce((s, a) => s + a.tokens, 0)
366  const start = Math.min(...list.map(a => a.startedAt))
367  const end = Math.max(...list.map(a => a.endedAt ?? Math.max(at, a.startedAt)))
368  return { cost, tokens, time: list.length ? end - start : 0 }
369}
370
371// --- desktop drawings: each row is one SVG, as the band above the prompt is.
372
373const PANE_CSS = `<style>
374.t{fill:#1f1f1f}.s{fill:#6b6b68}.m{fill:#9a9a96}.k{fill:#ecebe8}.ln{stroke:#e4e4e1}.tile{fill:#f4f3f0}
375@media (prefers-color-scheme: dark){.t{fill:#ececec}.s{fill:#a8a8a4}.m{fill:#7d7d79}.k{fill:#2c2c2b}.ln{stroke:#333331}.tile{fill:#262625}}
376.live{animation:p 1.6s ease-in-out infinite}@keyframes p{50%{opacity:.3}}
377@media (prefers-reduced-motion: reduce){.live{animation:none}}
378</style>`
379
380// Pixel Clawd from DockCrab (Clawdy): a 24×18 crab on a 30×28 grid, one costume per kind
381// of agent (hooks/crew.ts). The body keeps the brand clay; the costume's color lives in its accent.
382const CLAY = '#D97757'
383const INK = '#1F1E1D'
384
385// `cls` puts a pixel in a named group: `bd` (the default) is the body and its
386// costume, `la`/`lb` the leg pairs, anything else a prop with its own motion.
387type Fill = (x: number, y: number, w: number, h: number, c: string, cls?: string) => void
388
389const stamp = (f: Fill, x: number, y: number, rows: string[], map: Record<string, string>, cls?: string): void =>
390  rows.forEach((row, dy) => [...row].forEach((ch, dx) => map[ch] && f(x + dx, y + dy, 1, 1, map[ch] ?? '', cls)))
391
392// `armCls` lets a raised claw travel with the prop it holds.
393const crabBody = (f: Fill, armFront = 0, armCls?: string): void => {
394  f(7, 10, 16, 12, CLAY)
395  f(3, 14, 4, 4, CLAY)
396  f(23, 14 + armFront, 4, 4, CLAY, armCls)
397  f(9, 12, 2, 2, INK)
398  f(19, 12, 2, 2, INK)
399  f(7, 22, 2, 4, CLAY, 'la')
400  f(17, 22, 2, 4, CLAY, 'la')
401  f(11, 22, 2, 4, CLAY, 'lb')
402  f(21, 22, 2, 4, CLAY, 'lb')
403}
404
405// Pure CSS, run by the compositor: no redraws. Periods divide one second, so the
406// once-a-second redraw of a running row restarts them in phase. Every crab walks;
407// each costume adds its prop's own motion on top.
408const CRAB_CSS = `<style>
409.run .la{animation:st .5s steps(1) infinite}.run .lb{animation:st .5s steps(1) infinite -.25s}
410.run .bd{animation:bob .5s steps(1) infinite -.125s}
411.run g{transform-box:fill-box}
412@keyframes st{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}
413.c-astronaut.run{animation:float 1s ease-in-out infinite}
414.c-astronaut.run .la,.c-astronaut.run .lb,.c-astronaut.run .bd{animation:none}
415.c-astronaut.run .ant{animation:blink 1s steps(1) infinite}
416.c-astronaut.run .star{animation:blink .5s steps(1) infinite -.25s}
417@keyframes float{50%{transform:translateY(-2px)}}@keyframes blink{50%{opacity:.15}}
418.c-detective.run .it{animation:scan 1s steps(1) infinite}
419.c-detective.run .gl{animation:blink 1s steps(1) infinite -.5s}
420@keyframes scan{25%{transform:translate(-1px,1px)}50%{transform:translate(-2px,2px)}75%{transform:translate(-1px,1px)}}
421.c-engineer.run .it{transform-origin:100% 100%;animation:twist .5s ease-in-out infinite}
422@keyframes twist{50%{transform:rotate(-35deg)}}
423.c-chef.run .pan{transform-origin:0 50%;animation:tilt 1s ease-in-out infinite}
424.c-chef.run .egg{animation:flip 1s ease-in-out infinite}
425@keyframes tilt{20%,40%{transform:rotate(-12deg)}}@keyframes flip{30%{transform:translateY(-5px) scaleY(-1)}60%{transform:translateY(0)}}
426.c-racer.run .la{animation-duration:.25s}.c-racer.run .lb{animation-duration:.25s;animation-delay:-.125s}
427.c-racer.run .flag{transform-origin:0 50%;animation:wave .25s steps(1) infinite}
428@keyframes wave{50%{transform:skewY(-12deg) scaleX(.85)}}
429.c-pirate.run .it{transform-origin:50% 100%;animation:fence .5s ease-in-out infinite}
430@keyframes fence{50%{transform:rotate(25deg)}}
431@media (prefers-reduced-motion: reduce){.run,.run g{animation:none!important}}
432</style>`
433
434const WARDROBE: Record<Costume, (f: Fill, t: string) => void> = {
435  // Fable, Opus at high or above: astronaut in a glass dome; floats instead of walking, the antenna and the star blink.
436  astronaut: (f, t) => {
437    crabBody(f)
438    f(6, 7, 18, 1, '#E6E8EE'); f(5, 8, 1, 14, '#E6E8EE'); f(24, 8, 1, 14, '#E6E8EE'); f(6, 22, 18, 1, '#C9CCD2')
439    f(6, 8, 18, 14, 'rgba(169,214,245,.32)'); f(8, 9, 2, 1, '#fff'); f(8, 10, 1, 2, '#fff')
440    f(14, 4, 2, 3, '#C9CCD2'); f(14, 2, 2, 2, t, 'ant'); f(13, 18, 4, 2, t)
441    f(27, 3, 1, 3, '#F5C542', 'star'); f(26, 4, 3, 1, '#F5C542', 'star')
442  },
443  // Opus below high: detective with a deerstalker; the magnifier sweeps and glints.
444  detective: (f, t) => {
445    crabBody(f, -4, 'it')
446    stamp(f, 6, 3, ['......bbbbbb......', '....bbcbbcbbbb....', '...bbbbbbbbbbbb...', '..bcbbcbbcbbcbbb..', '.bbbbbbbbbbbbbbbb.', 'dddddddddddddddddd'], { b: '#7A4A26', c: '#A0703F', d: '#5A3519' })
447    f(6, 9, 18, 1, t)
448    stamp(f, 23, 1, ['.kkk.', 'k...k', 'k...k', 'k...k', '.kkk.'], { k: '#3A3A3C' }, 'it')
449    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')
450  },
451  // Sonnet at high or above: engineer in a hard hat; the wrench turns a bolt.
452  engineer: (f, t) => {
453    crabBody(f)
454    stamp(f, 6, 4, ['.....yyyyyyyy.....', '...yyyyyhhyyyyy...', '..yyyyyyhhyyyyyy..', '..yyyyyyhhyyyyyy..', '.yyyyyyyhhyyyyyyy.', 'dddddddddddddddddd'], { y: '#F5C542', h: '#FBE08A', d: '#C99A1E' })
455    f(13, 5, 4, 2, t)
456    stamp(f, 0, 10, ['.s.s', 'sss.', '.s..', '.s..'], { s: '#8E929A' }, 'it')
457  },
458  // Sonnet below high: chef, the toque traced from DockCrab's Sprites.chefHat; tosses the omelette.
459  chef: (f, t) => {
460    crabBody(f, -4, 'pan')
461    stamp(f, 6, 0, ['........lll.......', '.......lllll......', '.wwwwgwwwwwwgwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwgwwwwwggwwwww', '.wwwwgwwwwwggwwwww', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.'], { w: '#F4F3EE', l: '#F7F6F2', g: '#D2D1C8', b: t, d: '#B45F43' })
462    f(22, 8, 7, 2, '#4A4A48', 'pan'); f(26, 10, 1, 1, '#4A4A48', 'pan'); f(24, 7, 3, 1, '#F5B731', 'egg')
463  },
464  // Haiku: racer in a helmet; runs at double pace, the checkered flag flutters.
465  racer: (f, t) => {
466    crabBody(f, -4)
467    stamp(f, 6, 5, ['....rrrrrrrrrr....', '..rrrrrrwwrrrrrr..', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.kkkkkkkkkkkkkkkkr'], { r: t, w: '#F8F6F1', k: INK })
468    f(25, 1, 1, 9, '#8E929A')
469    stamp(f, 26, 1, ['wkwk', 'kwkw', 'wkwk'], { w: '#F8F6F1', k: INK }, 'flag')
470  },
471  // The Explore agent: pirate scouting the code; the cutlass fences.
472  pirate: f => {
473    crabBody(f)
474    stamp(f, 5, 3, ['.kk..............kk.', '.kkk....kkkk....kkk.', '..kkkkkkkwwkkkkkkk..', '..kkkkkkkkkkkkkkkk..', '.gggggggggggggggggg.'], { k: '#55514C', w: '#F8F6F1', g: '#F5C542' })
475    f(7, 11, 11, 1, INK); f(18, 11, 4, 3, INK)
476    f(27, 6, 1, 9, '#C9CCD2', 'it'); f(26, 15, 3, 1, '#7A4A26', 'it')
477  },
478  other: f => crabBody(f),
479}
480
481const CRAB_SCALE = 1.1
482
483// Body and props nest inside `bd` so a prop rides the bob and adds its own motion;
484// legs stay outside it and step on their own.
485const crab = (x: number, y: number, costume: Costume, dim = false, isWalking = false, scale = CRAB_SCALE): string => {
486  const groups = new Map<string, string[]>([['bd', []]])
487  const f: Fill = (cx, cy, w, h, c, cls = 'bd') => {
488    if (!groups.has(cls)) groups.set(cls, [])
489    groups.get(cls)?.push(`<rect x="${cx}" y="${cy}" width="${w}" height="${h}" fill="${c}"/>`)
490  }
491  const draw = WARDROBE[costume] ?? ((g: Fill) => crabBody(g))
492  draw(f, colorOf(costume))
493  const group = (cls: string) => `<g class="${cls}">${(groups.get(cls) ?? []).join('')}</g>`
494  const props = [...groups.keys()].filter(k => k !== 'bd' && k !== 'la' && k !== 'lb')
495  const body = `<g class="bd">${(groups.get('bd') ?? []).join('')}${props.map(group).join('')}</g>`
496  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>`
497}
498
499const statusMark = (x: number, y: number, status: string, color: string): string => {
500  if (status === 'running') return `<circle class="live" cx="${x}" cy="${y}" r="3.5" fill="${color}"/>`
501  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"/>`
502  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"/>`
503  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"/>`
504}
505
506const svg = (W: number, H: number, body: string): string =>
507  `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${PANE_CSS}${CRAB_CSS}${body}</svg>`
508
509// The pane's own title already says "Agents": the header names the flow, if any.
510const headerSvg = (W: number, title: string, t: ReturnType<typeof totals>): string => {
511  const s = tr()
512  const gap = 6
513  const tw = (W - gap * 2) / 3
514  const top = title ? 28 : 0
515  const tile = (i: number, k: string, v: string) =>
516    `<rect class="tile" x="${i * (tw + gap)}" y="${top}" width="${tw}" height="40" rx="8"/>
517<text class="s" x="${i * (tw + gap) + 9}" y="${top + 16}" font-family="${FONT}" font-size="11">${k}</text>
518<text class="t" x="${i * (tw + gap) + 9}" y="${top + 33}" font-family="${FONT}" font-size="15" font-weight="600" font-variant-numeric="tabular-nums">${v}</text>`
519  return svg(
520    W,
521    headerHeight(title),
522    `${title ? `<text class="t" x="0" y="15" font-family="${FONT}" font-size="14" font-weight="600">${xml(fitText(title, 14, W))}</text>` : ''}
523${tile(0, s.cost, '≈' + fmtCost(t.cost))}${tile(1, s.tokens, fmtTokens(t.tokens))}${tile(2, s.time, fmtTime(t.time))}`,
524  )
525}
526
527const headerHeight = (title: string): number => (title ? 72 : 44)
528
529// The task's own progress when the worker reports steps; a finished run is full.
530const progressOf = (a: AgentRun): number | null => {
531  if (a.status === 'done') return 1
532  if (a.stepTotal) return Math.min(1, (a.stepDone ?? 0) / a.stepTotal)
533  return null
534}
535
536const ctxOf = (a: AgentRun): number => (a.contextMax ? Math.min(100, Math.round((a.contextTokens / a.contextMax) * 100)) : 0)
537
538const agentSvg = (W: number, a: AgentRun, at: number): string => {
539  const s = tr()
540  const costume = costumeOf(a)
541  const color = colorOf(costume)
542  const ctx = ctxOf(a)
543  const textW = W - 42 - 22
544  const meta = [a.effort ? `${modelName(a.model)} · ${a.effort}` : modelName(a.model)]
545  if (a.round > 1) meta.push(`${s.round} ${a.round}`)
546  if (a.status === 'failed') meta.push(s.failed)
547  const barW = textW
548  const progress = progressOf(a)
549  const stats = `ctx ${ctx}% · ${fmtTokens(a.contextTokens)}  ≈${fmtCost(a.costUsd)}  ${fmtTime(elapsed(a, at))}`
550  const steps = a.stepTotal ? `${a.stepDone ?? 0}/${a.stepTotal}${a.stepNote ? ' · ' + a.stepNote : ''}` : ''
551  const stepsW = Math.max(0, barW - textWidth(stats, 11) - 12)
552  // Without reported steps the bar falls back to the context, drawn grey.
553  const fillW = Math.round(barW * (progress ?? ctx / 100))
554  return svg(
555    W,
556    66,
557    `${crab(0, 14, costume, false, a.status === 'running')}
558<text class="t" x="42" y="18" font-family="${FONT}" font-size="13" font-weight="600">${xml(fitText(a.description || a.type, 13, textW))}</text>
559<text x="42" y="34" font-family="${FONT}" font-size="11"><tspan fill="${color}">${xml(bareType(a.type))}</tspan><tspan class="s">  ${xml(meta.join('  ·  '))}</tspan></text>
560${steps && stepsW > 30 ? `<text class="t" x="42" y="49" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${xml(fitText(steps, 11, stepsW))}</text>` : ''}
561<text class="s" x="${42 + barW}" y="49" text-anchor="end" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${stats}</text>
562<rect class="k" x="42" y="55" width="${barW}" height="4" rx="2"/><rect${progress === null ? ' class="m"' : ''} x="42" y="55" width="${fillW}" height="4" rx="2"${progress === null ? '' : ` fill="${color}"`}/>
563${statusMark(W - 8, 16, a.status, color)}
564<line class="ln" x1="0" y1="65.5" x2="${W}" y2="65.5"/>`,
565  )
566}
567
568const plannedSvg = (W: number, p: Planned): string => {
569  const costume = plannedCostume(p)
570  const color = colorOf(costume)
571  const textW = W - 42 - 22
572  const meta = [profileLabel(p)]
573  if (p.after.length) meta.push(`${tr().after} ${p.after.join(', ')}`)
574  return svg(
575    W,
576    46,
577    `${crab(0, 6, costume, true)}
578<text class="s" x="42" y="18" font-family="${FONT}" font-size="13" font-weight="600">${xml(fitText(`${p.n}. ${p.title}`, 13, textW))}</text>
579<text x="42" y="34" font-family="${FONT}" font-size="11"><tspan fill="${color}">${xml(p.tier)}</tspan><tspan class="m">  ${xml(meta.filter(Boolean).join('  ·  '))}</tspan></text>
580${statusMark(W - 8, 16, 'planned', color)}
581<line class="ln" x1="0" y1="45.5" x2="${W}" y2="45.5"/>`,
582  )
583}
584
585const compactSvg = (W: number, list: AgentRun[], planned: Planned[], t: ReturnType<typeof totals>): string => {
586  const icon = (k: Costume, s: string, dim: boolean) => ({ k, c: colorOf(k), s, dim })
587  const icons = [
588    ...list.filter(a => a.status === 'running').map(a => icon(costumeOf(a), 'running', false)),
589    ...list.filter(a => a.status !== 'running').map(a => icon(costumeOf(a), a.status, false)),
590    ...planned.map(p => icon(plannedCostume(p), 'planned', true)),
591  ]
592  const fit = Math.max(1, Math.floor((W - 150) / 36))
593  const shown = icons.slice(0, fit)
594  const more = icons.length - shown.length
595  const body = shown
596    .map((ic, i) => crab(i * 36, 0, ic.k, ic.dim, ic.s === 'running') + (ic.s === 'running' ? `<circle class="live" cx="${i * 36 + 32}" cy="4" r="3" fill="${ic.c}"/>` : ''))
597    .join('')
598  const x = shown.length * 36 + (more ? 4 : 0)
599  return svg(
600    W,
601    32,
602    `${body}${more ? `<text class="s" x="${x}" y="21" font-family="${FONT}" font-size="12">+${more}</text>` : ''}
603<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>`,
604  )
605}
606
607// --- terminal drawing: the same rows in text.
608
609const STATUS_GLYPH: Record<string, string> = { running: '●', done: '✓', failed: '✗', planned: '◷' }
610
611// Opens the agents pane, or closes it when it is up; true when it ends up open.
612async function togglePane($: EngineInterface): Promise<boolean> {
613  const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
614  if (isOpen) {
615    await $.ui.close({ id: PANE })
616    return false
617  }
618  const at = await $.clock.now()
619  await update($, now, () => at)
620  await $.ui.open({ id: PANE, title: tr().pane })
621  return true
622}
623
624async function autoOpen($: EngineInterface, key: string): Promise<void> {
625  const p = await read($, panel)
626  if (p.autoOpenedFor === key) return
627  await update($, panel, prev => ({ ...prev, autoOpenedFor: key }))
628  void $.ui.open({ id: PANE, title: tr().pane })
629}
630
631export const register: Register = (on, options) => {
632  on('session.start', async ($, e, next) => {
633    const started = await next(e)
634    lang = await detectLang($, options.language)
635    await $.tool.register({
636      name: 'progress',
637      description:
638        "Report a multi-agent plan to the progress bar above the prompt and the agents panel. " +
639        'Call it after presenting the plan (title, total, tasks, phase "delegate"), each time a task is accepted (done), ' +
640        'when switching phase or re-planning (tasks), and once at the end with finished: true. Fields left out keep their previous value. ' +
641        'Optional: without it the bar follows subagent spawns and completions by itself.',
642      inputSchema: {
643        type: 'object',
644        properties: {
645          title: { type: 'string', description: 'Short name of the overall task, a few words.' },
646          total: { type: 'integer', minimum: 0, description: 'Number of planned worker tasks.' },
647          done: { type: 'integer', minimum: 0, description: 'Number of tasks accepted after review.' },
648          phase: { type: 'string', enum: [...PHASES] },
649          finished: { type: 'boolean', description: 'True once the flow is closed.' },
650          tasks: {
651            type: 'array',
652            description:
653              'The planned worker tasks in order, numbered from 1. Each title must equal the Agent tool `description` the task will be delegated with, so the panel can match runs to tasks.',
654            items: {
655              type: 'object',
656              properties: {
657                title: { type: 'string', description: 'A few words; reused verbatim as the Agent description.' },
658                model: {
659                  type: 'string',
660                  description: 'What the task will run on: `opus`, `sonnet`, `haiku`, `fable` or a model id. With `effort` it picks the crab\'s costume.',
661                },
662                effort: { type: 'string', enum: ['low', 'medium', 'high', 'xhigh', 'max'] },
663                tier: {
664                  type: 'string',
665                  enum: ['fable', 'heavy', 'careful', 'medium', 'light'],
666                  description: 'A savvy-flow worker tier, for when `model` is left out.',
667                },
668                after: { type: 'array', items: { type: 'integer' }, description: 'Numbers of the tasks this one waits for.' },
669              },
670              required: ['title'],
671            },
672          },
673        },
674      },
675    })
676    await $.tool.register({
677      name: 'step',
678      description:
679        'For subagents: report progress on your own task to the agents panel. ' +
680        'Right after reading the brief, call it with `total` (your plan in 3-8 steps) and `done: 0`; ' +
681        'call it again as each step finishes. Cheap and silent: it only draws a bar.',
682      inputSchema: {
683        type: 'object',
684        properties: {
685          done: { type: 'integer', minimum: 0, description: 'Steps finished so far.' },
686          total: { type: 'integer', minimum: 1, description: 'Steps planned; may change if the plan changes.' },
687          note: { type: 'string', description: 'The step in progress, a few words.' },
688        },
689        required: ['done'],
690      },
691    })
692    await $.command.register({
693      name: 'agents-info',
694      description: 'Show or hide the panel of subagents: running, finished and planned, with model, context, cost and time',
695    })
696
697    // Ticks the running agents' clocks; quiet when nothing runs.
698    $.clock.every(1000, () => {
699      void (async () => {
700        const list = await read($, agents)
701        if (!list.some(a => a.status === 'running')) return
702        const at = await $.clock.now()
703        await update($, now, () => at)
704      })()
705    })
706    return started
707  })
708
709  on('command.run', { command: 'agents-info' }, async $ => {
710    const isOpen = await togglePane($)
711    return { text: isOpen ? tr().opened : tr().closed }
712  })
713
714  on('tool.call', { tool: TOOL }, async ($, e) => {
715    const input = e as unknown as ProgressInput
716    const prev = await read($, flow)
717    if (isNewFlow(prev, input) && input.title !== undefined) {
718      // A new flow starts with a clean list; agents still running stay.
719      await update($, agents, list => list.filter(a => a.status === 'running'))
720    }
721    const next = await update($, flow, p => merge(p, input))
722    if (next && input.tasks?.length) await autoOpen($, next.title)
723    return { result: `ok: ${label(next ?? blank())}` }
724  })
725
726  // A worker's own progress: the call runs in the worker's loop, so agentId names it.
727  on('tool.call', { tool: STEP_TOOL }, async ($, e) => {
728    const input = e as unknown as { done?: number; total?: number; note?: string }
729    const agentId = e.agentId
730    if (!agentId) return { result: 'ignored: only subagents report steps' }
731    await update($, agents, list =>
732      list.map(a => {
733        if (a.agentId !== agentId) return a
734        const total = Math.max(0, Math.round(input.total ?? a.stepTotal ?? 0))
735        const done = Math.max(0, Math.round(input.done ?? a.stepDone ?? 0))
736        return { ...a, stepTotal: total, stepDone: total ? Math.min(total, done) : done, stepNote: input.note?.trim() || undefined }
737      }),
738    )
739    return { result: 'ok' }
740  })
741
742  // Safety net: worker launches move the faint layer even if the orchestrator forgets to report.
743  on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
744    const type = String(e.subagent_type ?? '')
745    if (!type.startsWith('savvy-')) return next(e)
746
747    await update($, flow, prev => {
748      const base = prev && !prev.isFinished ? { ...blank(), ...prev } : blank()
749      return { ...base, running: base.running + 1, phase: base.phase === 'plan' ? 'delegate' : base.phase }
750    })
751    try {
752      return await next(e)
753    } finally {
754      await update($, flow, prev => (prev ? { ...prev, running: Math.max(0, prev.running - 1) } : prev))
755    }
756  })
757
758  on('agent.spawn', async ($, e, next) => {
759    const started = await next(e)
760    if (started.deny !== undefined) return started
761
762    const at = await $.clock.now()
763    // Outside a /savvy-flow, every non-savvy agent drives the flow row itself (auto.ts).
764    const isSavvy = isSavvyType(e.subagentType)
765    const isFreshBatch = !isSavvy && startsBatch(await read($, flow))
766    const runId = started.agentId ?? e.tool_use_id
767    await update($, agents, list => {
768      const round = 1 + list.filter(a => norm(a.description) === norm(e.description) && e.description).length
769      const run: AgentRun = {
770        id: runId,
771        agentId: started.agentId,
772        type: e.subagentType,
773        description: e.description,
774        model: started.model,
775        status: 'running',
776        startedAt: at,
777        contextTokens: 0,
778        contextMax: windowOf(started.model),
779        tokens: 0,
780        costUsd: 0,
781        steps: 0,
782        round,
783      }
784      // A new batch starts with a clean list, as a new flow does; agents still running stay.
785      const kept = isFreshBatch ? list.filter(a => a.status === 'running') : list
786      return [...kept.filter(a => a.id !== run.id), run].slice(-200)
787    })
788    await update($, now, () => at)
789    if (!isSavvy) await update($, flow, prev => onSpawn(prev, runId))
790    const f = await read($, flow)
791    await autoOpen($, f && !f.isFinished ? openKey(f) : isSavvy ? 'flow' : AUTO_TITLE)
792    return started
793  })
794
795  // Each model request of a subagent: live context, tokens and cost.
796  on('turn.step', async function* ($, e, next) {
797    const result = yield* next(e)
798    const agentId = e.agentId
799    const usage = result.usage
800    if (!agentId || !usage) return result
801
802    const model = usage.model || e.model
803    await update($, agents, list =>
804      list.map(a =>
805        a.agentId !== agentId
806          ? a
807          : {
808              ...a,
809              model,
810              effort: typeof e.effort === 'string' ? e.effort : a.effort,
811              status: 'running',
812              endedAt: undefined,
813              contextTokens:
814                (usage.input_tokens || 0) +
815                (usage.cache_read_input_tokens || 0) +
816                (usage.cache_creation_input_tokens || 0) +
817                (usage.output_tokens || 0),
818              contextMax: windowOf(model),
819              tokens:
820                a.tokens +
821                (usage.input_tokens || 0) +
822                (usage.output_tokens || 0) +
823                (usage.cache_read_input_tokens || 0) +
824                (usage.cache_creation_input_tokens || 0),
825              costUsd: a.costUsd + costOf(model, usage),
826              steps: a.steps + 1,
827            },
828      ),
829    )
830    return result
831  })
832
833  on('turn.complete', async ($, e, next) => {
834    const agentId = e.agentId
835    if (agentId) {
836      const at = await $.clock.now()
837      const counted = isCounted((await read($, agents)).find(a => a.agentId === agentId))
838      await update($, agents, list =>
839        list.map(a => {
840          if (a.agentId !== agentId) return a
841          // A run whose steps went unseen still gets the turn's own sum.
842          const fallback = a.steps === 0 && e.usage
843          return {
844            ...a,
845            status: e.reason === 'answer' ? 'done' : 'failed',
846            endedAt: at,
847            ...(fallback && e.usage
848              ? {
849                  model: e.usage.model || a.model,
850                  tokens:
851                    e.usage.input_tokens +
852                    e.usage.output_tokens +
853                    e.usage.cache_read_input_tokens +
854                    e.usage.cache_creation_input_tokens,
855                  costUsd: costOf(e.usage.model || a.model, e.usage),
856                }
857              : {}),
858          }
859        }),
860      )
861      await update($, now, () => at)
862      // The batch's own agents all finished: a spawn-derived flow closes into the green "Done" row.
863      const runs = await read($, agents)
864      await update($, flow, prev => onFinish(prev, counted, runs))
865    }
866    return next(e)
867  })
868
869  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
870    const s = tr()
871    const ui = $.ui.resolve(e)
872    const { Box, Text, Button } = ui
873    const list = await read($, agents)
874    const f = await read($, flow)
875    const p: Panel = await read($, panel)
876    const at = Math.max(await read($, now), ...list.map(a => a.startedAt), 0)
877
878    const running = list.filter(a => a.status === 'running').reverse()
879    const finished = list.filter(a => a.status !== 'running').reverse()
880    const planned = plannedOf(f, list)
881    const t = totals(list, at)
882    // The pane's title says "Agents"; inside, only the flow's own name.
883    const title = f && !f.isFinished ? f.title : ''
884
885    const toggleCompact = (
886      <Button
887        key="compact"
888        label={p.isCompact ? s.expand : s.collapse}
889        plain
890        onPress={() => update($, panel, prev => ({ ...prev, isCompact: !prev.isCompact }))}
891      />
892    )
893    const toggleDone = (
894      <Button
895        key="done"
896        label={`${p.isDoneCollapsed ? '▸' : '▾'} ${s.finished} · ${finished.length}`}
897        plain
898        onPress={() => update($, panel, prev => ({ ...prev, isDoneCollapsed: !prev.isDoneCollapsed }))}
899      />
900    )
901    const isEmpty = list.length === 0 && planned.length === 0
902    const summary = `≈${fmtCost(t.cost)}, ${fmtTokens(t.tokens)} ${s.tokensWord}, ${fmtTime(t.time)}`
903
904    if (e.surface === 'desktop' && 'Svg' in ui) {
905      const { Svg } = ui
906      const W = Math.max(240, Math.min(900, (e.props.bodyColumns || 40) * 8 - 8))
907      const section = (key: string, text: string) => (
908        <Text key={key} dimColor>
909          {text}
910        </Text>
911      )
912
913      if (p.isCompact) {
914        return (
915          <Box flexDirection="column" gap={1}>
916            <Svg source={compactSvg(W, list, planned, t)} alt={`${list.length} ${s.agentsCount}, ${summary}`} width={W} height={32} />
917            {toggleCompact}
918          </Box>
919        )
920      }
921      return (
922        <Box flexDirection="column">
923          <Svg source={headerSvg(W, title, t)} alt={title ? `${title}: ${summary}` : summary} width={W} height={headerHeight(title)} />
924          {toggleCompact}
925          {isEmpty && <Text dimColor>{s.empty}</Text>}
926          {running.length > 0 && section('h-run', `${s.running} · ${running.length}`)}
927          {running.map(a => (
928            <Svg key={a.id} source={agentSvg(W, a, at)} alt={`${a.description}: ${modelName(a.model)}, ${s.isRunning}`} width={W} height={66} />
929          ))}
930          {finished.length > 0 && toggleDone}
931          {!p.isDoneCollapsed &&
932            finished.map(a => (
933              <Svg key={a.id} source={agentSvg(W, a, at)} alt={`${a.description}: ${modelName(a.model)}, ${s.isFinished}`} width={W} height={66} />
934            ))}
935          {planned.length > 0 && section('h-plan', `${s.planned} · ${planned.length}`)}
936          {planned.map(pl => (
937            <Svg key={`plan-${pl.n}`} source={plannedSvg(W, pl)} alt={`${pl.n}. ${pl.title}: ${s.isPlanned}`} width={W} height={46} />
938          ))}
939        </Box>
940      )
941    }
942
943    // Terminal: the same content in text rows.
944    const cols = Math.max(24, e.props.bodyColumns || 40)
945    const barW = Math.max(6, Math.min(20, cols - 34))
946    // Theme keys, so done and failed follow the person's theme; running keeps the costume's color.
947    const statusColor = (a: AgentRun, color: string) => (a.status === 'failed' ? 'error' : a.status === 'done' ? 'success' : color)
948    // The bar shows steps when the worker reports them, else its context fill in grey.
949    const runBar = (a: AgentRun, color: string, width: number) => {
950      const progress = progressOf(a)
951      return progress === null ? <Text dimColor>{bar(ctxOf(a) / 100, width)}</Text> : <Text color={color}>{bar(progress, width)}</Text>
952    }
953    const row = (a: AgentRun) => {
954      const color = colorOf(costumeOf(a))
955      const model = a.effort ? `${modelName(a.model)} · ${a.effort}` : modelName(a.model)
956      // Steps and their free-text note go last: a cut line loses them, not the time and cost.
957      const steps = a.stepTotal ? ` · ${a.stepDone ?? 0}/${a.stepTotal}${a.stepNote ? ' ' + a.stepNote : ''}` : ''
958      return (
959        <Box key={a.id} flexDirection="column">
960          <Box flexDirection="row" gap={1}>
961            <Text color={color}>▣</Text>
962            <Text bold wrap="truncate-end">
963              {a.description || a.type}
964            </Text>
965            <Text color={statusColor(a, color)}>{STATUS_GLYPH[a.status]}</Text>
966          </Box>
967          <Text dimColor wrap="truncate-end">
968            {'  '}
969            {bareType(a.type)} · {model}
970            {a.round > 1 ? ` · ${s.round} ${a.round}` : ''}
971          </Text>
972          <Text wrap="truncate-end">
973            {'  '}
974            {runBar(a, color, barW)}
975            <Text dimColor>
976              {' '}
977              {fmtTime(elapsed(a, at))} · ≈{fmtCost(a.costUsd)} · ctx {ctxOf(a)}% · {fmtTokens(a.contextTokens)}
978              {steps}
979            </Text>
980          </Text>
981        </Box>
982      )
983    }
984    // Compact: one line per agent, the bar only where it fits.
985    const lineBarW = cols >= 40 ? Math.min(10, cols - 30) : 0
986    const line = (a: AgentRun) => {
987      const color = colorOf(costumeOf(a))
988      return (
989        <Box key={a.id} flexDirection="row" gap={1}>
990          <Text color={statusColor(a, color)}>{STATUS_GLYPH[a.status]}</Text>
991          <Box flexGrow={1} flexShrink={1}>
992            <Text wrap="truncate-end">{a.description || a.type}</Text>
993          </Box>
994          {lineBarW > 0 && runBar(a, color, lineBarW)}
995          <Text dimColor>
996            {fmtTime(elapsed(a, at))} ≈{fmtCost(a.costUsd)}
997          </Text>
998        </Box>
999      )
1000    }
1001
1002    return (
1003      <Box flexDirection="column">
1004        <Box flexDirection="row" justifyContent="space-between">
1005          <Text bold wrap="truncate-end">
1006            {title}
1007          </Text>
1008          {toggleCompact}
1009        </Box>
1010        <Text dimColor>
1011          {fmtTime(t.time)} · ≈{fmtCost(t.cost)} · {fmtTokens(t.tokens)} {s.tokensWord}
1012        </Text>
1013        {p.isCompact ? (
1014          <Box flexDirection="column">
1015            {running.map(line)}
1016            {finished.length > 0 && toggleDone}
1017            {!p.isDoneCollapsed && finished.map(line)}
1018            {planned.map(pl => (
1019              <Text key={`plan-${pl.n}`} dimColor wrap="truncate-end">
1020                {STATUS_GLYPH.planned} {pl.n}. {pl.title}
1021              </Text>
1022            ))}
1023          </Box>
1024        ) : (
1025          <Box flexDirection="column" marginTop={1}>
1026            {isEmpty && <Text dimColor>{s.empty}</Text>}
1027            {running.length > 0 && <Text dimColor>{s.running} · {running.length}</Text>}
1028            {running.map(row)}
1029            {finished.length > 0 && toggleDone}
1030            {!p.isDoneCollapsed && finished.map(row)}
1031            {planned.length > 0 && <Text dimColor>{s.planned} · {planned.length}</Text>}
1032            {planned.map(pl => (
1033              <Box key={`plan-${pl.n}`} flexDirection="column">
1034                <Text dimColor wrap="truncate-end">
1035                  <Text color={colorOf(plannedCostume(pl))}>▢</Text> {pl.n}. {pl.title}
1036                </Text>
1037                <Text dimColor wrap="truncate-end">
1038                  {'  '}
1039                  {[pl.tier, profileLabel(pl)].filter(Boolean).join(' · ')}
1040                  {pl.after.length ? ` · ${s.after} ${pl.after.join(', ')}` : ''}
1041                </Text>
1042              </Box>
1043            ))}
1044          </Box>
1045        )}
1046      </Box>
1047    )
1048  })
1049
1050  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1051    const f = await read($, flow)
1052    if (f === null || e.props.hasSurvey) return next(e)
1053
1054    // The band is one site for every plugin: what the plugins beneath draw
1055    // stays, under the bar, so another mod's band is never hidden.
1056    const below = await next(e)
1057    const ui = $.ui.resolve(e)
1058    const { Box, Text, Button } = ui
1059    const list = await read($, agents)
1060    const crew = list.length + plannedOf(f, list).length
1061    const isWorking = list.some(a => a.status === 'running')
1062    const crewButton = (
1063      <Button key="subagent-crew" label={`×${crew}`} plain hotkey="a" onPress={() => void togglePane($)} />
1064    )
1065    const percent = `${Math.round(ratio(f) * 100)}%`
1066    const dismiss = (
1067      <Button
1068        key="subagent-dismiss"
1069        label="✕"
1070        plain
1071        role="dismiss"
1072        onPress={() => update($, flow, () => null)}
1073      />
1074    )
1075
1076    // The terminal's table answers `'Svg' in ui` too but draws it empty: the text row is its own.
1077    if (e.surface !== 'terminal' && 'Svg' in ui) {
1078      const { Svg } = ui
1079      // About 8 CSS px per reported column; the rest is the count, the dismiss
1080      // and their gaps. No floor above the slot: a row wider than it would wrap.
1081      const width = Math.max(180, Math.min(1600, (e.props.bodyColumns || 100) * 8 - 96))
1082      return (
1083        <Box flexDirection="column">
1084          <Box flexDirection="row" alignItems="center" gap={1}>
1085            <Svg source={rowSvg(f, width, isWorking)} alt={`${f.title}: ${label(f)}, ${percent}`} width={width} height={H} />
1086            {crewButton}
1087            {dismiss}
1088          </Box>
1089          {below}
1090        </Box>
1091      )
1092    }
1093
1094    const cols = e.props.bodyColumns
1095    const titleW = Math.max(8, Math.min(30, f.title.length + 2, Math.floor(cols / 3)))
1096    // What follows the bar, measured: label, percent, the crew button (`a: ×N`), ▣ and ✕,
1097    // and the row's six gaps of 2. The bar takes what is left.
1098    const tail = label(f).length + percent.length + `a: ×${crew}`.length + 2 + 12
1099    const width = Math.max(6, Math.min(40, cols - titleW - tail))
1100    const color = f.isFinished ? 'success' : ACCENT
1101    return (
1102      <Box flexDirection="column">
1103        <Box flexDirection="row" gap={2}>
1104          <Box width={titleW} flexShrink={0}>
1105            <Text color={color}>{f.isFinished ? '✓' : '●'} </Text>
1106            <Text wrap="truncate-end">{f.title}</Text>
1107          </Box>
1108          <Text color={color}>{bar(ratio(f), width)}</Text>
1109          <Text bold>{label(f)}</Text>
1110          <Text dimColor>{percent}</Text>
1111          <Text color="claude">▣</Text>
1112          {crewButton}
1113          {dismiss}
1114        </Box>
1115        {below}
1116      </Box>
1117    )
1118  })
1119}
1120
hooks/auto.ts 73 lines
1import type { AgentRun, Flow } from '../types'
2
3// Subagent work nobody reports a plan for: the flow row is derived from spawns and
4// completions. An orchestrated flow (the `progress` tool, or a `savvy-*` worker of an
5// upstream savvy-flow) is never touched here.
6
7export const AUTO_TITLE = 'Subagents'
8
9/** `savvy-careful`, or `savvy-flow:savvy-careful` when the agents ship in a plugin. */
10export const isSavvyType = (type: string): boolean => type.startsWith('savvy-')
11
12const fresh = (): Flow => ({
13  title: AUTO_TITLE,
14  total: 0,
15  done: 0,
16  running: 0,
17  phase: 'delegate',
18  isFinished: false,
19  tasks: [],
20  isAuto: true,
21  ids: [],
22})
23
24/**
25 * What `autoOpen` dedupes on: the panel opens again whenever this changes. An auto
26 * flow keys on its first agent, so every new batch opens it, but the agents joining
27 * a batch do not reopen what the person closed; an orchestrated flow keys on its title.
28 */
29export const openKey = (f: Flow): string => (f.isAuto ? (f.ids?.[0] ?? AUTO_TITLE) : f.title)
30
31/** No flow, or the last one is closed: the next spawn opens a new batch. */
32export const startsBatch = (prev: Flow | null): boolean => prev === null || prev.isFinished
33
34/** A non-savvy agent `id` started: join the running auto flow, or open a new one. */
35export const onSpawn = (prev: Flow | null, id: string): Flow | null => {
36  if (prev && !prev.isFinished && !prev.isAuto) return prev
37  const base = prev && !prev.isFinished ? prev : fresh()
38  const ids = base.ids ?? []
39  return {
40    ...base,
41    total: base.total + 1,
42    running: base.running + 1,
43    ids: ids.includes(id) ? ids : [...ids, id],
44  }
45}
46
47/** Whether a run's completion counts toward an auto flow: it was running and not a savvy worker. */
48export const isCounted = (run: Pick<AgentRun, 'status' | 'type'> | undefined): boolean =>
49  run !== undefined && run.status === 'running' && !isSavvyType(run.type)
50
51/**
52 * Only this batch's own agents decide it is over: a run left `running` by an earlier
53 * batch (an interrupted agent never reports back) must not hold it open.
54 */
55const isBatchIdle = (f: Flow, runs: Pick<AgentRun, 'id' | 'status'>[]): boolean =>
56  (f.ids ?? []).every(id => runs.find(r => r.id === id)?.status !== 'running')
57
58/**
59 * An agent finished. Count it when it was one this flow spawned; once none of the
60 * batch is running any more, close the flow, which draws as the green "Done" row
61 * until dismissed.
62 */
63export const onFinish = (
64  prev: Flow | null,
65  counted: boolean,
66  runs: Pick<AgentRun, 'id' | 'status'>[],
67): Flow | null => {
68  if (!prev || !prev.isAuto || prev.isFinished) return prev
69  if (isBatchIdle(prev, runs)) return { ...prev, done: prev.total, running: 0, phase: 'close', isFinished: true }
70  if (!counted) return prev
71  return { ...prev, done: Math.min(prev.total, prev.done + 1), running: Math.max(0, prev.running - 1) }
72}
73
hooks/crew.ts 98 lines
1// Which costume a subagent's crab wears. The costume follows what the agent runs on, so
2// every subagent gets a prop, with or without savvy-flow:
3//
4//   Fable (any effort), Opus at high or above   astronaut
5//   Opus below high                             detective
6//   Sonnet at high or above                     engineer
7//   Sonnet below high                           chef
8//   Haiku                                       racer
9//   the Explore agent                           pirate
10//   anything else                               a plain crab
11//
12// `high` and above is `high`, `xhigh` and `max`. An effort not known yet (a run's first
13// request has not gone out) or a numeric budget counts as below high.
14
15export type Costume = 'astronaut' | 'detective' | 'engineer' | 'chef' | 'racer' | 'pirate' | 'other'
16
17export const COSTUMES: readonly Costume[] = ['astronaut', 'detective', 'engineer', 'chef', 'racer', 'pirate', 'other']
18
19export const COSTUME_COLOR: Record<Costume, string> = {
20  astronaut: '#7F77DD',
21  detective: '#D85A30',
22  engineer: '#BA7517',
23  chef: '#378ADD',
24  racer: '#1D9E75',
25  pirate: '#888780',
26  other: '#888780',
27}
28
29type Family = 'fable' | 'opus' | 'sonnet' | 'haiku'
30
31/** `claude-sonnet-5-5`, `sonnet`, `claude-opus-5-5[1m]`: the family by substring; `inherit` and unknown ids are none. */
32export const familyOf = (model: string | undefined): Family | undefined => {
33  const m = (model ?? '').toLowerCase()
34  if (/fable|mythos/.test(m)) return 'fable'
35  if (/opus/.test(m)) return 'opus'
36  if (/sonnet/.test(m)) return 'sonnet'
37  if (/haiku/.test(m)) return 'haiku'
38  return undefined
39}
40
41const HIGH = new Set(['high', 'xhigh', 'max'])
42
43export const isHighEffort = (effort: unknown): boolean => typeof effort === 'string' && HIGH.has(effort.trim().toLowerCase())
44
45const byFamily = (family: Family, effort: unknown): Costume => {
46  if (family === 'fable') return 'astronaut'
47  if (family === 'haiku') return 'racer'
48  if (family === 'opus') return isHighEffort(effort) ? 'astronaut' : 'detective'
49  return isHighEffort(effort) ? 'engineer' : 'chef'
50}
51
52/** `savvy-flow:savvy-careful` is `savvy-careful`; `general-purpose` is itself. */
53export const bareType = (type: string): string => type.replace(/^[^:]*:/, '')
54
55// savvy-flow's workers are a model and an effort each (its agents/*.md frontmatter).
56// Kept for what has no model yet: a planned task named by tier, a `savvy-*` run before
57// its first request. A run's own model and effort always decide first.
58export const LEGACY_TIERS: Record<string, { model: string; effort: string }> = {
59  fable: { model: 'fable', effort: 'high' },
60  heavy: { model: 'opus', effort: 'xhigh' },
61  careful: { model: 'opus', effort: 'high' },
62  medium: { model: 'opus', effort: 'medium' },
63  light: { model: 'opus', effort: 'low' },
64}
65
66const legacyOf = (name: string): { model: string; effort: string } | undefined =>
67  Object.prototype.hasOwnProperty.call(LEGACY_TIERS, name) ? LEGACY_TIERS[name] : undefined
68
69/** A run's costume: the Explore pirate, else its model and effort, else its savvy tier, else plain. */
70export const costumeOf = (run: { type: string; model?: string; effort?: string }): Costume => {
71  const bare = bareType(run.type)
72  if (bare === 'Explore') return 'pirate'
73  const family = familyOf(run.model)
74  if (family) return byFamily(family, run.effort)
75  const legacy = bare.startsWith('savvy-') ? legacyOf(bare.slice('savvy-'.length).toLowerCase()) : undefined
76  const legacyFamily = legacy && familyOf(legacy.model)
77  return legacy && legacyFamily ? byFamily(legacyFamily, legacy.effort) : 'other'
78}
79
80export type PlannedLike = { tier?: string; model?: string; effort?: string }
81
82const isCostume = (name: string): name is Costume => (COSTUMES as readonly string[]).includes(name)
83
84/** What a planned task will run on: its own model and effort, else its tier (a savvy tier or a costume name). */
85export const plannedProfile = (p: PlannedLike): { model?: string; effort?: string } => {
86  if (p.model && familyOf(p.model)) return { model: p.model, effort: p.effort }
87  const legacy = legacyOf((p.tier ?? '').trim().toLowerCase())
88  return legacy ? { ...legacy } : {}
89}
90
91export const plannedCostume = (p: PlannedLike): Costume => {
92  const profile = plannedProfile(p)
93  const family = familyOf(profile.model)
94  if (family) return byFamily(family, profile.effort)
95  const name = (p.tier ?? '').trim().toLowerCase()
96  return isCostume(name) ? name : 'other'
97}
98
types/index.d.ts 67 lines
1export type Phase = 'plan' | 'design' | 'delegate' | 'review' | 'close'
2
3export type PlannedTask = {
4  title: string
5  /** A savvy-flow tier or a costume name; empty when the plan names a model instead. */
6  tier: string
7  /** What the task will run on: an alias or a model id. Picks the costume with `effort`. */
8  model?: string
9  effort?: string
10  after: number[]
11}
12
13export type Flow = {
14  title: string
15  total: number
16  done: number
17  running: number
18  phase: Phase
19  isFinished: boolean
20  tasks: PlannedTask[]
21  /** Derived from subagent spawns and completions, not reported by an orchestrator. */
22  isAuto?: boolean
23  /** Ids of the agents an auto flow spawned: only these decide it is over. */
24  ids?: string[]
25}
26
27export type AgentStatus = 'running' | 'done' | 'failed'
28
29export type AgentRun = {
30  id: string
31  agentId?: string
32  type: string
33  description: string
34  model: string
35  effort?: string
36  status: AgentStatus
37  startedAt: number
38  endedAt?: number
39  contextTokens: number
40  contextMax: number
41  tokens: number
42  costUsd: number
43  steps: number
44  round: number
45  /** Self-reported by the worker through the `step` tool. */
46  stepDone?: number
47  stepTotal?: number
48  stepNote?: string
49}
50
51export type Panel = {
52  isCompact: boolean
53  isDoneCollapsed: boolean
54  autoOpenedFor: string
55}
56
57declare module 'claude-code' {
58  interface PluginState {
59    'subagent-progress': {
60      flow: Flow | null
61      agents: AgentRun[]
62      panel: Panel
63      now: number
64    }
65  }
66}
67