SLOPSHOPPER

crew

Agents panel with pixel crabs and a progress strip above the prompt, for any subagents. Based on savvy-progress by johnnyvizz (MIT).

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

Draws

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

crew

A Claude Code mod: a sidebar of pixel crabs, one per subagent, and a progress strip above the prompt.

  • Agents panel (/agents-info toggles it): running and finished subagents with model, effort, steps, context, tokens and time. Working crabs walk, and each costume animates its prop.
  • Costume by task: builder (racer with a flag), designer (chef), researcher (hard hat), reviewer (detective), planner (astronaut), Explore (pirate), director (video, scenes, shorts), miner (transcripts, extraction), hunter (hooks, teasers), scientist (tests, verification), guard (security).
  • Codex: every codex exec run in the shell shows up as its own row with OpenAI's blue cloud bot.
  • Strip above the prompt: tasks done out of started, with a dithered pixel bar; ×N opens the panel, ✕ hides the strip. With the panel closed, a small crab button brings it back.

Based on savvy-progress by johnnyvizz (MIT): its panel, crabs and strip, plus the costumes and the Codex pet above. Cost per row is an estimate from token counts and a built-in price table.

Install

In a Claude Code terminal session:

/plugin install crew --marketplace sstani-bgv/claude-crew

Answer y to add the marketplace, then pick the user scope.

Settings

language: auto (default), en or ru.

Source 2 files
hooks/register.tsx 1327 lines
1// Based on savvy-progress by johnnyvizz (MIT): https://github.com/johnnyvizz/claude-kit/tree/main/plugins/savvy-progress
2// Changed: plain subagents get a costume by their task, and drive the strip above the prompt.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, Register, RenderElement } from 'claude-code'
5
6import type { AgentRun, Flow, Panel, Phase, PlannedTask } from '../types'
7
8const flow = atom({ plugin: 'crew', key: 'flow' } as const, null)
9const agents = atom({ plugin: 'crew', key: 'runs' } as const, [])
10const panel = atom({ plugin: 'crew', key: 'panel' } as const, {
11  isCompact: false,
12  isDoneCollapsed: false,
13  autoOpenedFor: '',
14  batchStart: 0,
15  bandDismissedFor: -1,
16  reopenHiddenFor: -1,
17})
18const now = atom({ plugin: 'crew', key: 'now' } as const, 0)
19
20const TOOL = 'mcp__crew__progress'
21const STEP_TOOL = 'mcp__crew__step'
22const PANE = 'savvy-agents'
23const PHASES: readonly Phase[] = ['plan', 'design', 'delegate', 'review', 'close']
24const ACCENT = '#8f8cf4'
25const DONE = '#5fbf8f'
26
27type ProgressInput = {
28  title?: string
29  total?: number
30  done?: number
31  phase?: Phase
32  finished?: boolean
33  tasks?: { title?: string; tier?: string; after?: number[] }[]
34}
35
36// ---------------------------------------------------------------------------
37// Language: the `language` option, else Claude Code's `language` setting, else the
38// process locale; English when nothing says Russian.
39
40type Lang = 'en' | 'ru'
41
42const STRINGS = {
43  en: {
44    pane: 'Agents',
45    cost: 'Cost',
46    tokens: 'Tokens',
47    time: 'Time',
48    collapse: 'Collapse',
49    expand: 'Expand',
50    running: 'Running',
51    finished: 'Finished',
52    planned: 'Planned',
53    empty: 'No subagents yet.',
54    round: 'round',
55    failed: 'error',
56    after: 'after',
57    tokensWord: 'tokens',
58    agentsCount: 'agents',
59    isRunning: 'running',
60    isFinished: 'finished',
61    isPlanned: 'planned',
62    opened: 'Agents panel opened.',
63    closed: 'Agents panel closed.',
64    done: 'Done',
65    plan: 'Plan',
66    design: 'Design',
67    tasks: 'Tasks',
68    review: 'Review',
69    busy: 'running',
70    subagents: 'Subagents',
71  },
72  ru: {
73    pane: 'Агенты',
74    cost: 'Стоимость',
75    tokens: 'Токены',
76    time: 'Время',
77    collapse: 'Свернуть',
78    expand: 'Развернуть',
79    running: 'Работают',
80    finished: 'Завершены',
81    planned: 'Запланированы',
82    empty: 'Субагентов пока нет.',
83    round: 'раунд',
84    failed: 'ошибка',
85    after: 'после',
86    tokensWord: 'токенов',
87    agentsCount: 'агентов',
88    isRunning: 'работает',
89    isFinished: 'завершён',
90    isPlanned: 'запланирована',
91    opened: 'Панель агентов открыта.',
92    closed: 'Панель агентов закрыта.',
93    done: 'Готово',
94    plan: 'План',
95    design: 'Дизайн',
96    tasks: 'Задачи',
97    review: 'Ревью',
98    busy: 'в работе',
99    subagents: 'Субагенты',
100  },
101} as const
102
103// Module scope is fine here: session.start sets it again on every (re)load.
104let lang: Lang = 'en'
105const tr = () => STRINGS[lang]
106
107const isRussian = (v: unknown): boolean => typeof v === 'string' && /^(ru|russian|рус)/i.test(v.trim())
108
109async function detectLang($: EngineInterface, option: unknown): Promise<Lang> {
110  if (option === 'en' || option === 'ru') return option
111  try {
112    const settings = (await $.settings.read()) as Record<string, unknown>
113    if (typeof settings.language === 'string' && settings.language.trim()) return isRussian(settings.language) ? 'ru' : 'en'
114  } catch {
115    // No settings: fall through to the locale.
116  }
117  const locale = (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG'))
118  return isRussian(locale) ? 'ru' : 'en'
119}
120
121const blank = (): Flow => ({
122  title: 'savvy-flow',
123  total: 0,
124  done: 0,
125  running: 0,
126  phase: 'plan',
127  isFinished: false,
128  tasks: [],
129})
130
131const isNewFlow = (prev: Flow | null, input: ProgressInput): boolean =>
132  !prev || prev.isFinished || (input.title !== undefined && input.title.trim() !== prev.title)
133
134const cleanTasks = (tasks: ProgressInput['tasks']): PlannedTask[] | undefined =>
135  tasks
136    ?.filter(t => t.title?.trim())
137    .map(t => ({
138      title: (t.title ?? '').trim(),
139      tier: (t.tier ?? '').replace(/^savvy-/, '').trim().toLowerCase(),
140      after: (t.after ?? []).filter(n => Number.isInteger(n) && n > 0),
141    }))
142
143const merge = (prev: Flow | null, input: ProgressInput): Flow => {
144  // A new title means a new flow: never carry counters over from an earlier one.
145  const base = isNewFlow(prev, input) || !prev ? blank() : { ...blank(), ...prev }
146  const tasks = cleanTasks(input.tasks) ?? base.tasks
147  const total = Math.max(0, Math.round(input.total ?? (input.tasks ? tasks.length : base.total)))
148  const done = Math.min(total || Infinity, Math.max(0, Math.round(input.done ?? base.done)))
149  const phase = input.phase && PHASES.includes(input.phase) ? input.phase : base.phase
150  return {
151    ...base,
152    title: input.title?.trim() || base.title,
153    total,
154    done,
155    phase: input.finished ? 'close' : phase,
156    isFinished: input.finished === true,
157    tasks,
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, costume = 'other'): 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, costume, false, isWorking, 0.8)}
273</svg>`
274}
275
276const barText = (f: Flow, width: number): string => {
277  const filled = Math.round(width * ratio(f))
278  return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
279}
280
281// ---------------------------------------------------------------------------
282// Agents panel: every subagent of the session, plus the tasks the flow planned.
283
284const TIER_COLOR: Record<string, string> = {
285  fable: '#7F77DD',
286  heavy: '#D85A30',
287  careful: '#BA7517',
288  medium: '#378ADD',
289  light: '#1D9E75',
290  other: '#888780',
291  explore: '#2F9FB0',
292  codex: '#4B6BF5',
293  director: '#D9534F',
294  miner: '#C98A1E',
295  hunter: '#5E8C2A',
296  scientist: '#14A3A0',
297  guard: '#6B7FA3',
298}
299
300// What each savvy tier runs on, for planned tasks that have no run yet.
301const colorOf = (tier: string): string => TIER_COLOR[tier] ?? '#888780'
302
303const TIER_MODEL: Record<string, string> = {
304  fable: 'Fable · high',
305  heavy: 'Opus · xhigh',
306  careful: 'Opus · high',
307  medium: 'Opus · medium',
308  light: 'Opus · low',
309}
310
311// USD per million tokens: input, output, cache read, cache write (5-minute TTL).
312// The engine reports tokens, not money, so the panel's cost is an estimate.
313const PRICES: [RegExp, [number, number, number, number]][] = [
314  [/fable|mythos/, [10, 50, 0.25, 12.5]],
315  [/opus-5-5/, [4, 20, 0.2, 5]],
316  [/opus/, [5, 25, 0.5, 6.25]],
317  [/sonnet/, [2, 10, 0.2, 2.5]],
318  [/haiku/, [1, 5, 0.1, 1.25]],
319]
320
321type Usage = {
322  input_tokens: number
323  output_tokens: number
324  cache_read_input_tokens: number
325  cache_creation_input_tokens: number
326}
327
328const priceOf = (model: string): [number, number, number, number] =>
329  PRICES.find(([re]) => re.test(model.toLowerCase()))?.[1] ?? [4, 20, 0.2, 5]
330
331const costOf = (model: string, u: Usage): number => {
332  const [i, o, r, w] = priceOf(model)
333  return (
334    ((u.input_tokens || 0) * i +
335      (u.output_tokens || 0) * o +
336      (u.cache_read_input_tokens || 0) * r +
337      (u.cache_creation_input_tokens || 0) * w) /
338    1e6
339  )
340}
341
342const windowOf = (model: string): number => (/haiku/i.test(model) ? 200_000 : 1_000_000)
343
344// `savvy-careful`, or `savvy-flow:savvy-careful` when the agents ship in a plugin.
345const tierOf = (type: string): string => {
346  const bare = type.replace(/^[^:]*:/, '')
347  const t = bare.replace(/^savvy-/, '').toLowerCase()
348  return t in TIER_COLOR && bare.startsWith('savvy-') ? t : 'other'
349}
350
351const modelName = (id: string): string => {
352  const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
353  const [, family = '', major = '', minor] = m ?? []
354  if (!family) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '') || '—'
355  return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()} ${major}${minor ? '.' + minor : ''}`
356}
357
358const norm = (s: string): string => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
359
360const fmtTokens = (n: number): string =>
361  n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${Math.round(n)}`
362
363const fmtCost = (usd: number): string => `$${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`
364
365const fmtTime = (ms: number): string => {
366  const s = Math.max(0, Math.round(ms / 1000))
367  const h = Math.floor(s / 3600)
368  const m = Math.floor((s % 3600) / 60)
369  const ss = String(s % 60).padStart(2, '0')
370  return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
371}
372
373const elapsed = (a: AgentRun, at: number): number => (a.endedAt ?? Math.max(at, a.startedAt)) - a.startedAt
374
375type Planned = PlannedTask & { n: number }
376
377const plannedOf = (f: Flow | null, list: AgentRun[]): Planned[] => {
378  if (!f || f.isFinished) return []
379  const started = new Set(list.map(a => norm(a.description)))
380  return (f.tasks ?? []).map((t, i) => ({ ...t, n: i + 1 })).filter(t => !started.has(norm(t.title)))
381}
382
383// Plain subagents with no savvy flow: the current batch drives the strip, until dismissed.
384const batchFlow = (list: AgentRun[], p: Panel): Flow | null => {
385  if (p.bandDismissedFor === p.batchStart) return null
386  const batch = list.filter(a => a.startedAt >= p.batchStart)
387  if (!batch.length) return null
388  const running = batch.filter(a => a.status === 'running').length
389  return {
390    ...blank(),
391    title: tr().subagents,
392    total: batch.length,
393    done: batch.length - running,
394    running,
395    phase: 'delegate',
396    isFinished: running === 0,
397  }
398}
399
400const totals = (list: AgentRun[], at: number) => {
401  const cost = list.reduce((s, a) => s + a.costUsd, 0)
402  const tokens = list.reduce((s, a) => s + a.tokens, 0)
403  const start = Math.min(...list.map(a => a.startedAt))
404  const end = Math.max(...list.map(a => a.endedAt ?? Math.max(at, a.startedAt)))
405  return { cost, tokens, time: list.length ? end - start : 0 }
406}
407
408// --- desktop drawings: each row is one SVG, as the band above the prompt is.
409
410const PANE_CSS = `<style>
411.t{fill:#1f1f1f}.s{fill:#6b6b68}.m{fill:#9a9a96}.k{fill:#ecebe8}.ln{stroke:#e4e4e1}.tile{fill:#f4f3f0}
412@media (prefers-color-scheme: dark){.t{fill:#ececec}.s{fill:#a8a8a4}.m{fill:#7d7d79}.k{fill:#2c2c2b}.ln{stroke:#333331}.tile{fill:#262625}}
413.live{animation:p 1.6s ease-in-out infinite}@keyframes p{50%{opacity:.3}}
414@media (prefers-reduced-motion: reduce){.live{animation:none}}
415</style>`
416
417// Pixel Clawd from DockCrab (Clawdy): a 24×18 crab on a 30×28 grid, one costume per tier.
418// The body keeps the brand clay; the tier's color lives in the costume's accent.
419const CLAY = '#D97757'
420const INK = '#1F1E1D'
421
422// `cls` puts a pixel in a named group: `bd` (the default) is the body and its
423// costume, `la`/`lb` the leg pairs, anything else a prop with its own motion.
424type Fill = (x: number, y: number, w: number, h: number, c: string, cls?: string) => void
425
426const stamp = (f: Fill, x: number, y: number, rows: string[], map: Record<string, string>, cls?: string): void =>
427  rows.forEach((row, dy) => [...row].forEach((ch, dx) => map[ch] && f(x + dx, y + dy, 1, 1, map[ch] ?? '', cls)))
428
429// `armCls` lets a raised claw travel with the prop it holds.
430const crabBody = (f: Fill, armFront = 0, armCls?: string): void => {
431  f(7, 10, 16, 12, CLAY)
432  f(3, 14, 4, 4, CLAY)
433  f(23, 14 + armFront, 4, 4, CLAY, armCls)
434  f(9, 12, 2, 2, INK)
435  f(19, 12, 2, 2, INK)
436  f(7, 22, 2, 4, CLAY, 'la')
437  f(17, 22, 2, 4, CLAY, 'la')
438  f(11, 22, 2, 4, CLAY, 'lb')
439  f(21, 22, 2, 4, CLAY, 'lb')
440}
441
442// Pure CSS, run by the compositor: no redraws. Periods divide one second, so the
443// once-a-second redraw of a running row restarts them in phase. Every crab walks;
444// each costume adds its prop's own motion on top.
445const CRAB_CSS = `<style>
446.run .la{animation:st .5s steps(1) infinite}.run .lb{animation:st .5s steps(1) infinite -.25s}
447.run .bd{animation:bob .5s steps(1) infinite -.125s}
448.run g{transform-box:fill-box}
449@keyframes st{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}
450.c-fable.run{animation:float 1s ease-in-out infinite}
451.c-fable.run .la,.c-fable.run .lb,.c-fable.run .bd{animation:none}
452.c-fable.run .ant{animation:blink 1s steps(1) infinite}
453.c-fable.run .star{animation:blink .5s steps(1) infinite -.25s}
454@keyframes float{50%{transform:translateY(-2px)}}@keyframes blink{50%{opacity:.15}}
455.c-heavy.run .it{animation:scan 1s steps(1) infinite}
456.c-heavy.run .gl{animation:blink 1s steps(1) infinite -.5s}
457@keyframes scan{25%{transform:translate(-1px,1px)}50%{transform:translate(-2px,2px)}75%{transform:translate(-1px,1px)}}
458.c-careful.run .it{transform-origin:100% 100%;animation:twist .5s ease-in-out infinite}
459@keyframes twist{50%{transform:rotate(-35deg)}}
460.c-medium.run .pan{transform-origin:0 50%;animation:tilt 1s ease-in-out infinite}
461.c-medium.run .egg{animation:flip 1s ease-in-out infinite}
462@keyframes tilt{20%,40%{transform:rotate(-12deg)}}@keyframes flip{30%{transform:translateY(-5px) scaleY(-1)}60%{transform:translateY(0)}}
463.c-light.run .la{animation-duration:.25s}.c-light.run .lb{animation-duration:.25s;animation-delay:-.125s}
464.c-light.run .flag{transform-origin:0 50%;animation:wave .25s steps(1) infinite}
465@keyframes wave{50%{transform:skewY(-12deg) scaleX(.85)}}
466.c-explore.run .it{transform-origin:50% 100%;animation:fence .5s ease-in-out infinite}
467@keyframes fence{50%{transform:rotate(25deg)}}
468.c-director.run .clap{transform-origin:0 100%;animation:clap .5s steps(1) infinite}
469@keyframes clap{50%{transform:rotate(-30deg)}}
470.c-miner.run .it{transform-origin:50% 100%;animation:dig .5s ease-in-out infinite}
471.c-miner.run .lamp{animation:blink .5s steps(1) infinite}
472@keyframes dig{50%{transform:rotate(-28deg)}}
473.c-hunter.run .it{animation:look 1s steps(1) infinite}
474@keyframes look{50%{transform:translateY(-3px)}}
475.c-scientist.run .b1{animation:rise 1s steps(4) infinite}.c-scientist.run .b2{animation:rise 1s steps(4) infinite -.5s}
476@keyframes rise{0%{transform:translateY(2px);opacity:1}100%{transform:translateY(-3px);opacity:0}}
477.c-guard.run .plume{transform-origin:50% 100%;animation:sway .5s steps(1) infinite}
478.c-guard.run .it{animation:bob .5s steps(1) infinite -.25s}
479@keyframes sway{50%{transform:skewX(-20deg)}}
480.c-codex .pr{opacity:0}
481.c-codex.run .ey{animation:sw 1s steps(1) infinite}.c-codex.run .pr{animation:sw 1s steps(1) infinite -.5s}
482@keyframes sw{0%{opacity:1}50%{opacity:0}}
483@media (prefers-reduced-motion: reduce){.run,.run g{animation:none!important}}
484</style>`
485
486const COSTUMES: Record<string, (f: Fill, t: string) => void> = {
487  // Fable: astronaut in a glass dome; floats instead of walking, the antenna and the star blink.
488  fable: (f, t) => {
489    crabBody(f)
490    f(6, 7, 18, 1, '#E6E8EE'); f(5, 8, 1, 14, '#E6E8EE'); f(24, 8, 1, 14, '#E6E8EE'); f(6, 22, 18, 1, '#C9CCD2')
491    f(6, 8, 18, 14, 'rgba(169,214,245,.32)'); f(8, 9, 2, 1, '#fff'); f(8, 10, 1, 2, '#fff')
492    f(14, 4, 2, 3, '#C9CCD2'); f(14, 2, 2, 2, t, 'ant'); f(13, 18, 4, 2, t)
493    f(27, 3, 1, 3, '#F5C542', 'star'); f(26, 4, 3, 1, '#F5C542', 'star')
494  },
495  // Heavy: detective with a deerstalker; the magnifier sweeps and glints.
496  heavy: (f, t) => {
497    crabBody(f, -4, 'it')
498    stamp(f, 6, 3, ['......bbbbbb......', '....bbcbbcbbbb....', '...bbbbbbbbbbbb...', '..bcbbcbbcbbcbbb..', '.bbbbbbbbbbbbbbbb.', 'dddddddddddddddddd'], { b: '#7A4A26', c: '#A0703F', d: '#5A3519' })
499    f(6, 9, 18, 1, t)
500    stamp(f, 23, 1, ['.kkk.', 'k...k', 'k...k', 'k...k', '.kkk.'], { k: '#3A3A3C' }, 'it')
501    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')
502  },
503  // Careful: engineer in a hard hat; the wrench turns a bolt.
504  careful: (f, t) => {
505    crabBody(f)
506    stamp(f, 6, 4, ['.....yyyyyyyy.....', '...yyyyyhhyyyyy...', '..yyyyyyhhyyyyyy..', '..yyyyyyhhyyyyyy..', '.yyyyyyyhhyyyyyyy.', 'dddddddddddddddddd'], { y: '#F5C542', h: '#FBE08A', d: '#C99A1E' })
507    f(13, 5, 4, 2, t)
508    stamp(f, 0, 10, ['.s.s', 'sss.', '.s..', '.s..'], { s: '#8E929A' }, 'it')
509  },
510  // Medium: chef, the toque traced from DockCrab's Sprites.chefHat; tosses the omelette.
511  medium: (f, t) => {
512    crabBody(f, -4, 'pan')
513    stamp(f, 6, 0, ['........lll.......', '.......lllll......', '.wwwwgwwwwwwgwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwgwwwwwggwwwww', '.wwwwgwwwwwggwwwww', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.'], { w: '#F4F3EE', l: '#F7F6F2', g: '#D2D1C8', b: t, d: '#B45F43' })
514    f(22, 8, 7, 2, '#4A4A48', 'pan'); f(26, 10, 1, 1, '#4A4A48', 'pan'); f(24, 7, 3, 1, '#F5B731', 'egg')
515  },
516  // Light: racer in a helmet; runs at double pace, the checkered flag flutters.
517  light: (f, t) => {
518    crabBody(f, -4)
519    stamp(f, 6, 5, ['....rrrrrrrrrr....', '..rrrrrrwwrrrrrr..', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.kkkkkkkkkkkkkkkkr'], { r: t, w: '#F8F6F1', k: INK })
520    f(25, 1, 1, 9, '#8E929A')
521    stamp(f, 26, 1, ['wkwk', 'kwkw', 'wkwk'], { w: '#F8F6F1', k: INK }, 'flag')
522  },
523  // Explore: pirate scouting the code; the cutlass fences.
524  explore: f => {
525    crabBody(f)
526    stamp(f, 5, 3, ['.kk..............kk.', '.kkk....kkkk....kkk.', '..kkkkkkkwwkkkkkkk..', '..kkkkkkkkkkkkkkkk..', '.gggggggggggggggggg.'], { k: '#55514C', w: '#F8F6F1', g: '#F5C542' })
527    f(7, 11, 11, 1, INK); f(18, 11, 4, 3, INK)
528    f(27, 6, 1, 9, '#C9CCD2', 'it'); f(26, 15, 3, 1, '#7A4A26', 'it')
529  },
530  // Director: black beret; the clapperboard snaps.
531  director: (f, t) => {
532    crabBody(f, -4, 'it')
533    f(15, 5, 2, 1, '#2B2B2E')
534    stamp(f, 7, 6, ['....kkkkkkkk....', '..kkkkkkkkkkkk..', '.kkkkkkkkkkkkkkk', 'kkkkkkkkkkkkkkk.'], { k: '#2B2B2E' })
535    f(23, 5, 6, 5, '#2B2B2E', 'it'); f(24, 7, 4, 1, '#F8F6F1', 'it')
536    stamp(f, 23, 3, ['wkwkwk', 'kwkwkw'], { w: '#F8F6F1', k: t }, 'clap')
537  },
538  // Miner: helmet with a blinking lamp; the pickaxe digs.
539  miner: (f, t) => {
540    crabBody(f, -4, 'it')
541    stamp(f, 6, 4, ['.....oooooooo.....', '...oooooooooooo...', '..oooooooooooooo..', '..oooooooooooooo..', 'dddddddddddddddddd'], { o: t, d: '#7A5214' })
542    f(13, 5, 4, 3, '#FFF3B0', 'lamp')
543    f(25, 2, 1, 9, '#7A4A26', 'it')
544    stamp(f, 22, 1, ['.kkkkkk', 'kk....k'], { k: '#8E929A' }, 'it')
545  },
546  // Hunter: pith helmet; the binoculars go up to the eyes and down again.
547  hunter: (f, t) => {
548    crabBody(f)
549    stamp(f, 6, 3, ['.....hhhhhhhh.....', '...hhhhhhhhhhhh...', '..hhhhhhhhhhhhhh..', '..bbbbbbbbbbbbbbbb', 'hhhhhhhhhhhhhhhhhh'], { h: '#D8C38A', b: t })
550    f(8, 14, 4, 3, '#2B2B2E', 'it'); f(18, 14, 4, 3, '#2B2B2E', 'it'); f(12, 15, 6, 1, '#2B2B2E', 'it')
551    f(9, 15, 2, 1, '#7FB8E6', 'it'); f(19, 15, 2, 1, '#7FB8E6', 'it')
552  },
553  // Scientist: goggles up on the head; the flask bubbles.
554  scientist: (f, t) => {
555    crabBody(f, -4, 'it')
556    f(7, 8, 16, 1, '#3A3A3C')
557    stamp(f, 8, 6, ['.gggg....gggg.', 'gccccg..gccccg', '.gggg....gggg.'], { g: '#3A3A3C', c: '#BDEFF7' })
558    stamp(f, 23, 3, ['.kk.', '.kk.', 'kllk', 'llll', 'llll'], { k: '#C9CCD2', l: t }, 'it')
559    f(24, 1, 1, 1, '#ffffff', 'b1'); f(26, 0, 1, 1, '#ffffff', 'b2')
560  },
561  // Guard: knight's helmet with a plume, a shield in hand.
562  guard: (f, t) => {
563    crabBody(f)
564    stamp(f, 13, 0, ['.pp.', 'pppp'], { p: '#D0453F' }, 'plume')
565    stamp(f, 6, 2, ['......mmmmmm......', '....mmmmmmmmmm....', '...mmmmmmmmmmmm...', '..mmmmmmmmmmmmmm..', '..mmkkkkkkkkkkmm..', '..mmmmmmmmmmmmmm..', '.dddddddddddddddd.'], { m: '#B8BCC4', k: INK, d: '#8E929A' })
566    stamp(f, 0, 12, ['ssss', 'sees', 'sees', 'sees', '.ss.'], { s: '#8E929A', e: t }, 'it')
567  },
568  // Codex: OpenAI's little blue cloud bot; dozes, then wakes to a prompt.
569  codex: () => {},
570  other: f => crabBody(f),
571}
572
573// Codex is no crab: its own body on the same 30x28 grid.
574COSTUMES.codex = f => {
575  const B = '#4B6BF5'
576  const L = '#7E97FF'
577  const D = '#3550D0'
578  const V = '#1E2A5A'
579  const C = '#7FE3F0'
580  f(8, 1, 5, 3, B); f(15, 1, 7, 3, B); f(9, 1, 3, 1, L); f(16, 1, 4, 1, L)
581  f(6, 3, 18, 12, B); f(4, 5, 22, 8, B); f(3, 7, 1, 4, B); f(26, 7, 1, 4, B)
582  f(6, 3, 6, 1, L); f(6, 14, 18, 1, D)
583  f(8, 6, 14, 7, V)
584  stamp(f, 10, 9, ['x..x.x..x', '.xx...xx.'], { x: C }, 'ey')
585  stamp(f, 11, 7, ['x.....', '.x....', '..x...', '.x....', 'x..xxx'], { x: C }, 'pr')
586  f(10, 15, 10, 6, B); f(10, 20, 10, 1, D)
587  stamp(f, 12, 16, ['x....', '.x.xx', 'x....'], { x: C })
588  f(7, 16, 3, 2, B); f(20, 16, 3, 2, B)
589  f(11, 21, 3, 4, B, 'la'); f(16, 21, 3, 4, B, 'lb')
590}
591
592// Plain subagents get a costume by what their task is about; savvy tiers keep their own.
593// Pattern, costume, the word shown in the costume's color.
594const KINDS: [RegExp, string, string][] = [
595  [/codex|кодекс/, 'codex', 'codex'],
596  [/secur|money|vulnerab|безопас/, 'guard', 'guard'],
597  [/mine|extract|signal|transcript|майн|извлеч|транскрипт/, 'miner', 'miner'],
598  [/hunter|hook|teaser|story|payoff|contra|хук|тизер/, 'hunter', 'hunter'],
599  [/scene|vertical|montage|shorts?\b|video|overlay|render|сцен|монтаж|шортс|видео/, 'director', 'director'],
600  [/test|verif|e2e|compliance|тест|провер/, 'scientist', 'scientist'],
601  [/review|audit|check|ревью|аудит/, 'heavy', 'reviewer'],
602  [/research|search|find|investigat|competitor|history|learn|analy|ресерч|исслед|найд|поиск|изуч|анализ/, 'careful', 'researcher'],
603  [/design|graphic|palette|colou?r|ui\b|ux|css|style|icon|дизайн|граф|палитр|цвет/, 'medium', 'designer'],
604  [/build|implement|creat|make|add|code|script|landing|page|fix|refactor|сдел|созд|напиш|собер|почин/, 'light', 'builder'],
605  [/plan|architect|strateg|roadmap|план|архит/, 'fable', 'planner'],
606]
607
608type Look = { costume: string; color: string; word: string }
609
610const lookOf = (type: string, description: string): Look => {
611  const tier = tierOf(type)
612  if (tier !== 'other') return { costume: tier, color: colorOf(tier), word: tier }
613  if (type === 'Explore') return { costume: 'explore', color: '#2F9FB0', word: 'explorer' }
614  const text = `${type} ${description}`.toLowerCase()
615  const hit = KINDS.find(([re]) => re.test(text))
616  return hit ? { costume: hit[1], color: colorOf(hit[1]), word: hit[2] } : { costume: 'other', color: colorOf('other'), word: type }
617}
618
619const CRAB_SCALE = 1.1
620
621// Body and props nest inside `bd` so a prop rides the bob and adds its own motion;
622// legs stay outside it and step on their own.
623const crab = (x: number, y: number, costume: string, dim = false, isWalking = false, scale = CRAB_SCALE): string => {
624  const groups = new Map<string, string[]>([['bd', []]])
625  const f: Fill = (cx, cy, w, h, c, cls = 'bd') => {
626    if (!groups.has(cls)) groups.set(cls, [])
627    groups.get(cls)?.push(`<rect x="${cx}" y="${cy}" width="${w}" height="${h}" fill="${c}"/>`)
628  }
629  const draw = COSTUMES[costume] ?? ((g: Fill) => crabBody(g))
630  draw(f, colorOf(costume))
631  const group = (cls: string) => `<g class="${cls}">${(groups.get(cls) ?? []).join('')}</g>`
632  const props = [...groups.keys()].filter(k => k !== 'bd' && k !== 'la' && k !== 'lb')
633  const body = `<g class="bd">${(groups.get('bd') ?? []).join('')}${props.map(group).join('')}</g>`
634  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>`
635}
636
637const statusMark = (x: number, y: number, status: string, color: string): string => {
638  if (status === 'running') return `<circle class="live" cx="${x}" cy="${y}" r="3.5" fill="${color}"/>`
639  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"/>`
640  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"/>`
641  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"/>`
642}
643
644const svg = (W: number, H: number, body: string): string =>
645  `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${PANE_CSS}${CRAB_CSS}${body}</svg>`
646
647// The pane's own title already says "Agents": the header names the flow, if any.
648const headerSvg = (W: number, title: string, t: ReturnType<typeof totals>): string => {
649  const s = tr()
650  const gap = 6
651  const tw = (W - gap) / 2
652  const top = title ? 28 : 0
653  const tile = (i: number, k: string, v: string) =>
654    `<rect class="tile" x="${i * (tw + gap)}" y="${top}" width="${tw}" height="40" rx="8"/>
655<text class="s" x="${i * (tw + gap) + 9}" y="${top + 16}" font-family="${FONT}" font-size="11">${k}</text>
656<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>`
657  return svg(
658    W,
659    headerHeight(title),
660    `${title ? `<text class="t" x="0" y="15" font-family="${FONT}" font-size="14" font-weight="600">${xml(fitText(title, 14, W))}</text>` : ''}
661${tile(0, s.tokens, fmtTokens(t.tokens))}${tile(1, s.time, fmtTime(t.time))}`,
662  )
663}
664
665const headerHeight = (title: string): number => (title ? 72 : 44)
666
667// The task's own progress when the worker reports steps; a finished run is full.
668const progressOf = (a: AgentRun): number | null => {
669  if (a.status === 'done') return 1
670  if (a.stepTotal) return Math.min(1, (a.stepDone ?? 0) / a.stepTotal)
671  return null
672}
673
674const ctxOf = (a: AgentRun): number => (a.contextMax ? Math.min(100, Math.round((a.contextTokens / a.contextMax) * 100)) : 0)
675
676const agentSvg = (W: number, a: AgentRun, at: number): string => {
677  const s = tr()
678  const look = lookOf(a.type, a.description)
679  const color = look.color
680  const ctx = ctxOf(a)
681  const textW = W - 42 - 22
682  const meta = [a.effort ? `${modelName(a.model)} · ${a.effort}` : modelName(a.model)]
683  if (a.round > 1) meta.push(`${s.round} ${a.round}`)
684  if (a.status === 'failed') meta.push(s.failed)
685  const barW = textW
686  const progress = progressOf(a)
687  const stats = `ctx ${ctx}% · ${fmtTokens(a.contextTokens)}  ≈${fmtCost(a.costUsd)}  ${fmtTime(elapsed(a, at))}`
688  const steps = a.stepTotal ? `${a.stepDone ?? 0}/${a.stepTotal}${a.stepNote ? ' · ' + a.stepNote : ''}` : ''
689  const stepsW = Math.max(0, barW - textWidth(stats, 11) - 12)
690  // Without reported steps the bar falls back to the context, drawn grey.
691  const fillW = Math.round(barW * (progress ?? ctx / 100))
692  return svg(
693    W,
694    66,
695    `${crab(0, 14, look.costume, false, a.status === 'running')}
696<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>
697<text x="42" y="34" font-family="${FONT}" font-size="11"><tspan fill="${color}">${xml(look.word)}</tspan><tspan class="s">  ${xml(meta.join('  ·  '))}</tspan></text>
698${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>` : ''}
699<text class="s" x="${42 + barW}" y="49" text-anchor="end" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${stats}</text>
700<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}"`}/>
701${statusMark(W - 8, 16, a.status, color)}
702<line class="ln" x1="0" y1="65.5" x2="${W}" y2="65.5"/>`,
703  )
704}
705
706const plannedSvg = (W: number, p: Planned): string => {
707  const tier = p.tier in TIER_COLOR ? p.tier : 'other'
708  const color = colorOf(tier)
709  const textW = W - 42 - 22
710  const meta = [TIER_MODEL[tier] ?? '']
711  if (p.after.length) meta.push(`${tr().after} ${p.after.join(', ')}`)
712  return svg(
713    W,
714    46,
715    `${crab(0, 6, tier, true)}
716<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>
717<text x="42" y="34" font-family="${FONT}" font-size="11"><tspan fill="${color}">${xml(tier)}</tspan><tspan class="m">  ${xml(meta.filter(Boolean).join('  ·  '))}</tspan></text>
718${statusMark(W - 8, 16, 'planned', color)}
719<line class="ln" x1="0" y1="45.5" x2="${W}" y2="45.5"/>`,
720  )
721}
722
723const compactSvg = (W: number, list: AgentRun[], planned: Planned[], t: ReturnType<typeof totals>): string => {
724  const icons = [
725    ...list.filter(a => a.status === 'running').map(a => ({ k: lookOf(a.type, a.description).costume, c: lookOf(a.type, a.description).color, s: 'running', dim: false })),
726    ...list.filter(a => a.status !== 'running').map(a => ({ k: lookOf(a.type, a.description).costume, c: lookOf(a.type, a.description).color, s: a.status, dim: false })),
727    ...planned.map(p => ({ k: p.tier in TIER_COLOR ? p.tier : 'other', c: colorOf(p.tier), s: 'planned', dim: true })),
728  ]
729  const fit = Math.max(1, Math.floor((W - 150) / 36))
730  const shown = icons.slice(0, fit)
731  const more = icons.length - shown.length
732  const body = shown
733    .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}"/>` : ''))
734    .join('')
735  const x = shown.length * 36 + (more ? 4 : 0)
736  return svg(
737    W,
738    32,
739    `${body}${more ? `<text class="s" x="${x}" y="21" font-family="${FONT}" font-size="12">+${more}</text>` : ''}
740<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>`,
741  )
742}
743
744// --- terminal drawing: the same rows in text.
745
746const ctxBar = (pct: number, width: number): string => {
747  const filled = Math.round((width * pct) / 100)
748  return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
749}
750
751const STATUS_GLYPH: Record<string, string> = { running: '●', done: '✓', failed: '✗', planned: '◷' }
752
753// Opens the agents pane, or closes it when it is up; true when it ends up open.
754async function togglePane($: EngineInterface): Promise<boolean> {
755  const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
756  if (isOpen) {
757    await $.ui.close({ id: PANE })
758    return false
759  }
760  const at = await $.clock.now()
761  await update($, now, () => at)
762  await $.ui.open({ id: PANE, title: tr().pane })
763  return true
764}
765
766async function autoOpen($: EngineInterface, key: string): Promise<void> {
767  const p = await read($, panel)
768  if (p.autoOpenedFor === key) return
769  await update($, panel, prev => ({ ...prev, autoOpenedFor: key }))
770  void $.ui.open({ id: PANE, title: tr().pane }).catch(() => {})
771}
772
773export const register: Register = (on, options) => {
774  on('session.start', async ($, e, next) => {
775    const started = await next(e)
776    lang = await detectLang($, options.language)
777    await $.tool.register({
778      name: 'progress',
779      description:
780        'Report /savvy-flow progress to the progress bar above the prompt and the agents panel. ' +
781        'Call it after presenting the plan (title, total, tasks, phase "delegate"), each time a task is accepted (done), ' +
782        'when switching phase or re-planning (tasks), and once at the end with finished: true. Fields left out keep their previous value.',
783      inputSchema: {
784        type: 'object',
785        properties: {
786          title: { type: 'string', description: 'Short name of the overall task, a few words.' },
787          total: { type: 'integer', minimum: 0, description: 'Number of planned worker tasks.' },
788          done: { type: 'integer', minimum: 0, description: 'Number of tasks accepted after review.' },
789          phase: { type: 'string', enum: [...PHASES] },
790          finished: { type: 'boolean', description: 'True once the flow is closed.' },
791          tasks: {
792            type: 'array',
793            description:
794              '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.',
795            items: {
796              type: 'object',
797              properties: {
798                title: { type: 'string', description: 'A few words; reused verbatim as the Agent description.' },
799                tier: { type: 'string', enum: ['fable', 'heavy', 'careful', 'medium', 'light'] },
800                after: { type: 'array', items: { type: 'integer' }, description: 'Numbers of the tasks this one waits for.' },
801              },
802              required: ['title', 'tier'],
803            },
804          },
805        },
806      },
807    })
808    await $.tool.register({
809      name: 'step',
810      description:
811        'For savvy-flow workers: report progress on your own task to the agents panel. ' +
812        'Right after reading the brief, call it with `total` (your plan in 3-8 steps) and `done: 0`; ' +
813        'call it again as each step finishes. Cheap and silent: it only draws a bar.',
814      inputSchema: {
815        type: 'object',
816        properties: {
817          done: { type: 'integer', minimum: 0, description: 'Steps finished so far.' },
818          total: { type: 'integer', minimum: 1, description: 'Steps planned; may change if the plan changes.' },
819          note: { type: 'string', description: 'The step in progress, a few words.' },
820        },
821        required: ['done'],
822      },
823    })
824    await $.command.register({
825      name: 'agents-info',
826      description: 'Show or hide the panel of subagents: running, finished and planned, with model, context, cost and time',
827    })
828
829    // Ticks the running agents' clocks; quiet when nothing runs.
830    $.clock.every(1000, () => {
831      void (async () => {
832        const list = await read($, agents)
833        if (!list.some(a => a.status === 'running')) return
834        const at = await $.clock.now()
835        await update($, now, () => at)
836      })()
837    })
838    return started
839  })
840
841  on('command.run', { command: 'agents-info' }, async $ => {
842    const isOpen = await togglePane($)
843    return { text: isOpen ? tr().opened : tr().closed }
844  })
845
846  on('tool.call', { tool: TOOL }, async ($, e) => {
847    const input = e as unknown as ProgressInput
848    const prev = await read($, flow)
849    if (isNewFlow(prev, input) && input.title !== undefined) {
850      // A new flow starts with a clean list; agents still running stay.
851      await update($, agents, list => list.filter(a => a.status === 'running'))
852    }
853    const next = await update($, flow, p => merge(p, input))
854    if (next && input.tasks?.length) await autoOpen($, next.title)
855    return { result: `ok: ${label(next ?? blank())}` }
856  })
857
858  // A worker's own progress: the call runs in the worker's loop, so agentId names it.
859  on('tool.call', { tool: STEP_TOOL }, async ($, e) => {
860    const input = e as unknown as { done?: number; total?: number; note?: string }
861    const agentId = e.agentId
862    if (!agentId) return { result: 'ignored: only subagents report steps' }
863    await update($, agents, list =>
864      list.map(a => {
865        if (a.agentId !== agentId) return a
866        const total = Math.max(0, Math.round(input.total ?? a.stepTotal ?? 0))
867        const done = Math.max(0, Math.round(input.done ?? a.stepDone ?? 0))
868        return { ...a, stepTotal: total, stepDone: total ? Math.min(total, done) : done, stepNote: input.note?.trim() || undefined }
869      }),
870    )
871    return { result: 'ok' }
872  })
873
874  // Safety net: worker launches move the faint layer even if the orchestrator forgets to report.
875  // `codex exec ...` in the shell: a Codex run with its own pet in the panel.
876  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
877    const cmd = String(e.command ?? '')
878    if (!/(^|[;&|(\s])codex\s+(exec|e)\b/.test(cmd) || /--help|--version/.test(cmd)) return next(e)
879    // ponytail: a background `codex exec` returns at once, so it shows as finished right away.
880    const at = await $.clock.now()
881    if (!(await read($, agents)).some(a => a.status === 'running')) {
882      await update($, panel, prev => ({ ...prev, batchStart: at }))
883    }
884    const id = `codex-${e.tool_use_id}`
885    const run: AgentRun = {
886      id,
887      type: 'codex',
888      description: String(e.description ?? '') || 'Codex',
889      model: 'Codex',
890      status: 'running',
891      startedAt: at,
892      contextTokens: 0,
893      contextMax: 0,
894      tokens: 0,
895      costUsd: 0,
896      steps: 0,
897      round: 1,
898    }
899    await update($, agents, list => [...list, run].slice(-200))
900    await update($, now, () => at)
901    await autoOpen($, `batch-${(await read($, panel)).batchStart}`)
902    let isFailed = true
903    try {
904      const result = await next(e)
905      isFailed = result.isError === true
906      return result
907    } finally {
908      const end = await $.clock.now()
909      await update($, agents, list =>
910        list.map(a => (a.id === id ? { ...a, status: isFailed ? ('failed' as const) : ('done' as const), endedAt: end } : a)),
911      )
912      await update($, now, () => end)
913    }
914  })
915
916  on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
917    const type = String(e.subagent_type ?? '')
918    if (!type.startsWith('savvy-')) return next(e)
919
920    await update($, flow, prev => {
921      const base = prev && !prev.isFinished ? { ...blank(), ...prev } : blank()
922      return { ...base, running: base.running + 1, phase: base.phase === 'plan' ? 'delegate' : base.phase }
923    })
924    try {
925      return await next(e)
926    } finally {
927      await update($, flow, prev => (prev ? { ...prev, running: Math.max(0, prev.running - 1) } : prev))
928    }
929  })
930
931  on('agent.spawn', async ($, e, next) => {
932    const started = await next(e)
933    if (started.deny !== undefined) return started
934
935    const at = await $.clock.now()
936    // Nothing was running: this spawn starts a new batch for the strip above the prompt.
937    if (!(await read($, agents)).some(a => a.status === 'running')) {
938      await update($, panel, prev => ({ ...prev, batchStart: at }))
939    }
940    await update($, agents, list => {
941      const round = 1 + list.filter(a => norm(a.description) === norm(e.description) && e.description).length
942      const run: AgentRun = {
943        id: started.agentId ?? e.tool_use_id,
944        agentId: started.agentId,
945        type: e.subagentType,
946        description: e.description,
947        model: started.model,
948        status: 'running',
949        startedAt: at,
950        contextTokens: 0,
951        contextMax: windowOf(started.model),
952        tokens: 0,
953        costUsd: 0,
954        steps: 0,
955        round,
956      }
957      return [...list.filter(a => a.id !== run.id), run].slice(-200)
958    })
959    await update($, now, () => at)
960    if (e.subagentType.startsWith('savvy-')) {
961      const f = await read($, flow)
962      await autoOpen($, f && !f.isFinished ? f.title : 'savvy-flow')
963    } else {
964      await autoOpen($, `batch-${(await read($, panel)).batchStart}`)
965    }
966    return started
967  })
968
969  // Each model request of a subagent: live context, tokens and cost.
970  on('turn.step', async function* ($, e, next) {
971    const result = yield* next(e)
972    const agentId = e.agentId
973    const usage = result.usage
974    if (!agentId || !usage) return result
975
976    const model = usage.model || e.model
977    await update($, agents, list =>
978      list.map(a =>
979        a.agentId !== agentId
980          ? a
981          : {
982              ...a,
983              model,
984              effort: typeof e.effort === 'string' ? e.effort : a.effort,
985              status: 'running' as const,
986              endedAt: undefined,
987              contextTokens:
988                (usage.input_tokens || 0) +
989                (usage.cache_read_input_tokens || 0) +
990                (usage.cache_creation_input_tokens || 0) +
991                (usage.output_tokens || 0),
992              contextMax: windowOf(model),
993              tokens:
994                a.tokens +
995                (usage.input_tokens || 0) +
996                (usage.output_tokens || 0) +
997                (usage.cache_read_input_tokens || 0) +
998                (usage.cache_creation_input_tokens || 0),
999              costUsd: a.costUsd + costOf(model, usage),
1000              steps: a.steps + 1,
1001            },
1002      ),
1003    )
1004    return result
1005  })
1006
1007  on('turn.complete', async ($, e, next) => {
1008    const agentId = e.agentId
1009    if (agentId) {
1010      const at = await $.clock.now()
1011      await update($, agents, list =>
1012        list.map(a => {
1013          if (a.agentId !== agentId) return a
1014          // A run whose steps went unseen still gets the turn's own sum.
1015          const fallback = a.steps === 0 && e.usage
1016          return {
1017            ...a,
1018            status: e.reason === 'answer' ? ('done' as const) : ('failed' as const),
1019            endedAt: at,
1020            ...(fallback && e.usage
1021              ? {
1022                  model: e.usage.model || a.model,
1023                  tokens:
1024                    e.usage.input_tokens +
1025                    e.usage.output_tokens +
1026                    e.usage.cache_read_input_tokens +
1027                    e.usage.cache_creation_input_tokens,
1028                  costUsd: costOf(e.usage.model || a.model, e.usage),
1029                }
1030              : {}),
1031          }
1032        }),
1033      )
1034      await update($, now, () => at)
1035    }
1036    return next(e)
1037  })
1038
1039  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1040    const s = tr()
1041    const ui = $.ui.resolve(e)
1042    const { Box, Text, Button } = ui
1043    const list = await read($, agents)
1044    const f = await read($, flow)
1045    const p: Panel = await read($, panel)
1046    const at = Math.max(await read($, now), ...list.map(a => a.startedAt), 0)
1047
1048    const running = list.filter(a => a.status === 'running').reverse()
1049    const finished = list.filter(a => a.status !== 'running').reverse()
1050    const planned = plannedOf(f, list)
1051    const t = totals(list, at)
1052    // The pane's title says "Agents"; inside, only the flow's own name.
1053    const title = f && !f.isFinished ? f.title : ''
1054
1055    const toggleCompact = (
1056      <Button
1057        key="compact"
1058        label={p.isCompact ? s.expand : s.collapse}
1059        plain
1060        onPress={() => update($, panel, prev => ({ ...prev, isCompact: !prev.isCompact }))}
1061      />
1062    )
1063    const toggleDone = (
1064      <Button
1065        key="done"
1066        label={`${p.isDoneCollapsed ? '▸' : '▾'} ${s.finished} · ${finished.length}`}
1067        plain
1068        onPress={() => update($, panel, prev => ({ ...prev, isDoneCollapsed: !prev.isDoneCollapsed }))}
1069      />
1070    )
1071    const isEmpty = list.length === 0 && planned.length === 0
1072    const summary = `≈${fmtCost(t.cost)}, ${fmtTokens(t.tokens)} ${s.tokensWord}, ${fmtTime(t.time)}`
1073
1074    if (e.surface === 'desktop' && 'Svg' in ui) {
1075      const { Svg } = ui
1076      const W = Math.max(240, Math.min(900, (e.props.bodyColumns || 40) * 8 - 8))
1077      const section = (key: string, text: string) => (
1078        <Text key={key} dimColor>
1079          {text}
1080        </Text>
1081      )
1082
1083      if (p.isCompact) {
1084        return (
1085          <Box flexDirection="column" gap={1}>
1086            <Svg source={compactSvg(W, list, planned, t)} alt={`${list.length} ${s.agentsCount}, ${summary}`} width={W} height={32} />
1087            {toggleCompact}
1088          </Box>
1089        )
1090      }
1091      return (
1092        <Box flexDirection="column">
1093          <Svg source={headerSvg(W, title, t)} alt={title ? `${title}: ${summary}` : summary} width={W} height={headerHeight(title)} />
1094          {toggleCompact}
1095          {isEmpty && <Text dimColor>{s.empty}</Text>}
1096          {running.length > 0 && section('h-run', `${s.running} · ${running.length}`)}
1097          {running.map(a => (
1098            <Svg key={a.id} source={agentSvg(W, a, at)} alt={`${a.description}: ${modelName(a.model)}, ${s.isRunning}`} width={W} height={66} />
1099          ))}
1100          {finished.length > 0 && toggleDone}
1101          {!p.isDoneCollapsed &&
1102            finished.map(a => (
1103              <Svg key={a.id} source={agentSvg(W, a, at)} alt={`${a.description}: ${modelName(a.model)}, ${s.isFinished}`} width={W} height={66} />
1104            ))}
1105          {planned.length > 0 && section('h-plan', `${s.planned} · ${planned.length}`)}
1106          {planned.map(pl => (
1107            <Svg key={`plan-${pl.n}`} source={plannedSvg(W, pl)} alt={`${pl.n}. ${pl.title}: ${s.isPlanned}`} width={W} height={46} />
1108          ))}
1109        </Box>
1110      )
1111    }
1112
1113    // Terminal: the same content in text rows.
1114    const cols = Math.max(24, e.props.bodyColumns || 40)
1115    const barW = Math.max(6, Math.min(20, cols - 34))
1116    const row = (a: AgentRun) => {
1117      const look = lookOf(a.type, a.description)
1118      const color = look.color
1119      const ctx = ctxOf(a)
1120      const progress = progressOf(a)
1121      const model = a.effort ? `${modelName(a.model)} · ${a.effort}` : modelName(a.model)
1122      const steps = a.stepTotal ? `${a.stepDone ?? 0}/${a.stepTotal}${a.stepNote ? ' ' + a.stepNote : ''} · ` : ''
1123      return (
1124        <Box key={a.id} flexDirection="column" marginBottom={1}>
1125          <Box flexDirection="row" gap={1}>
1126            <Text color={color}>▣</Text>
1127            <Text bold wrap="truncate-end">
1128              {a.description || a.type}
1129            </Text>
1130            <Text color={a.status === 'failed' ? 'red' : a.status === 'done' ? 'green' : color}>{STATUS_GLYPH[a.status]}</Text>
1131          </Box>
1132          <Text dimColor wrap="truncate-end">
1133            {'  '}
1134            {look.word} · {model}
1135            {a.round > 1 ? ` · ${s.round} ${a.round}` : ''}
1136          </Text>
1137          <Text wrap="truncate-end">
1138            {'  '}
1139            {progress === null ? <Text dimColor>{ctxBar(ctx, barW)}</Text> : <Text color={color}>{ctxBar(progress * 100, barW)}</Text>}
1140            <Text dimColor>
1141              {' '}
1142              {steps}ctx {ctx}% · {fmtTokens(a.contextTokens)} ≈{fmtCost(a.costUsd)} {fmtTime(elapsed(a, at))}
1143            </Text>
1144          </Text>
1145        </Box>
1146      )
1147    }
1148
1149    return (
1150      <Box flexDirection="column">
1151        <Box flexDirection="row" justifyContent="space-between">
1152          <Text bold wrap="truncate-end">
1153            {title}
1154          </Text>
1155          {toggleCompact}
1156        </Box>
1157        <Text dimColor>
1158          ≈{fmtCost(t.cost)} · {fmtTokens(t.tokens)} {s.tokensWord} · {fmtTime(t.time)}
1159        </Text>
1160        {p.isCompact ? (
1161          <Text wrap="truncate-end">
1162            {[...running, ...finished].map(a => (
1163              <Text key={a.id} color={lookOf(a.type, a.description).color}>
1164                {STATUS_GLYPH[a.status]}{' '}
1165              </Text>
1166            ))}
1167            {planned.map(pl => (
1168              <Text key={`plan-${pl.n}`} dimColor>
1169                ◷{' '}
1170              </Text>
1171            ))}
1172          </Text>
1173        ) : (
1174          <Box flexDirection="column" marginTop={1}>
1175            {isEmpty && <Text dimColor>{s.empty}</Text>}
1176            {running.length > 0 && <Text dimColor>{s.running} · {running.length}</Text>}
1177            {running.map(row)}
1178            {finished.length > 0 && toggleDone}
1179            {!p.isDoneCollapsed && finished.map(row)}
1180            {planned.length > 0 && <Text dimColor>{s.planned} · {planned.length}</Text>}
1181            {planned.map(pl => {
1182              const tier = pl.tier in TIER_COLOR ? pl.tier : 'other'
1183              return (
1184                <Box key={`plan-${pl.n}`} flexDirection="column" marginBottom={1}>
1185                  <Text dimColor wrap="truncate-end">
1186                    <Text color={colorOf(tier)}>▢</Text> {pl.n}. {pl.title} ◷
1187                  </Text>
1188                  <Text dimColor wrap="truncate-end">
1189                    {'  '}
1190                    {tier} · {TIER_MODEL[tier] ?? ''}
1191                    {pl.after.length ? ` · ${s.after} ${pl.after.join(', ')}` : ''}
1192                  </Text>
1193                </Box>
1194              )
1195            })}
1196          </Box>
1197        )}
1198      </Box>
1199    )
1200  })
types/index.d.ts 65 lines
1export type Phase = 'plan' | 'design' | 'delegate' | 'review' | 'close'
2
3export type PlannedTask = {
4  title: string
5  tier: string
6  after: number[]
7}
8
9export type Flow = {
10  title: string
11  total: number
12  done: number
13  running: number
14  phase: Phase
15  isFinished: boolean
16  tasks: PlannedTask[]
17}
18
19export type AgentStatus = 'running' | 'done' | 'failed'
20
21export type AgentRun = {
22  id: string
23  agentId?: string
24  type: string
25  description: string
26  model: string
27  effort?: string
28  status: AgentStatus
29  startedAt: number
30  endedAt?: number
31  contextTokens: number
32  contextMax: number
33  tokens: number
34  costUsd: number
35  steps: number
36  round: number
37  /** Self-reported by the worker through the `step` tool. */
38  stepDone?: number
39  stepTotal?: number
40  stepNote?: string
41}
42
43export type Panel = {
44  isCompact: boolean
45  isDoneCollapsed: boolean
46  autoOpenedFor: string
47  /** When the current batch of plain (non-savvy) subagents began. */
48  batchStart: number
49  /** The batchStart whose strip above the prompt was dismissed; -1 for none. */
50  bandDismissedFor: number
51  /** The batchStart whose reopen strip was hidden; -1 for none. */
52  reopenHiddenFor: number
53}
54
55declare module 'claude-code' {
56  interface PluginState {
57    'crew': {
58      flow: Flow | null
59      runs: AgentRun[]
60      panel: Panel
61      now: number
62    }
63  }
64}
65