SLOPSHOPPER

savvy-progress

Progress bar above the prompt and an agents panel (/agents-info): running, finished and planned subagents with model, progress, context, cost and time. Crabs…

newpanebandguardcommandtool
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · savvy-progress
│ ┃ 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 │ ⎿ savvy-progress: Agents panel opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

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

savvy-progress

berkays-mods copy of johnnyvizz/claude-kit 1.2.0 (MIT, johnnyvizz). Added: the claude-config agents scout (Haiku), builder (Sonnet) and reviewer (Opus) are tiers of their own, with their own crabs, and count for the bar and the panel's auto-open the way savvy-* workers do. The progress bar's crab is the orchestrator (the main session): a conductor in a top hat whose baton keeps time. The panel also lists the related background sessions (workers started with the CLI's background flag, in a worktree of this repository or launched by this session), with their state and branch, and in a terminal without graphics the crabs are drawn as half-block text from the same costume data. Install with claude plugin install savvy-progress@berkays-mods.

A Claude Code mod: a progress bar above the prompt and a live panel of subagents. Made for the savvy-flow skill, and useful with any subagents.

  • Progress bar: the flow's title, phase, accepted tasks out of planned, and a button with the crew size that opens the panel. It appears once something reports progress (savvy-flow does) or a savvy-* worker starts.
  • Agents panel (/agents-info toggles it): running, finished and planned subagents with model, effort, task progress, context, estimated cost and time. Working crabs walk, and each savvy tier animates its prop: the astronaut floats, the detective sweeps the magnifier, the engineer turns the wrench, the chef tosses the omelette, the racer runs with a fluttering flag. prefers-reduced-motion stops them.
  • Background sessions: a Background section after Running and Finished (and in the bar's crew count) for background sessions that run in a linked worktree of this repository or that this session launched. Each row has the crab, name, tier, model and effort, state (busy, idle, waiting for you) and branch. It polls claude agents --json and git worktree list every 5 s, only while the panel is open or a flow's bar is up. Model and effort are known only for sessions this session launched with --name (read off the Bash or PowerShell command); the crab comes from --agent scout|builder|reviewer, else the model (Haiku scout, Sonnet builder, Opus reviewer), else the plain crab. No cost or tokens: they are not visible from here.
  • Terminal crabs: where there are no graphics, each subagent and background row, and the bar, shows its crab as text: the costume is drawn into a 30x28 grid, shrunk to 12x10 pixels and printed with half-blocks (▀ in the top pixel's color over the bottom pixel's background). No animation.

Tools it adds

  • mcp__savvy-progress__progress: the orchestrator reports the plan, phase and accepted tasks.
  • mcp__savvy-progress__step: a worker reports its own steps (done, total, note). A worker that does not report shows its context fill in grey instead.

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

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": { "savvy-progress": { "options": { "language": "ru" } } } }

Install instructions are in the berkays-mods README.

Source 4 files
hooks/register.tsx 1342 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentRun, BgSession, Flow, Panel, Phase, PlannedTask } from '../types'
5import { aliasLabel, bgLaunches, bgState, branchOf, isRelated, parseAgents, parseWorktrees, tierFor } from './bg'
6import { SPRITE_W, boundsOf, downsample, rasterize, toRuns } from './sprite'
7import type { Fill, Grid, Run } from './sprite'
8
9const flow = atom({ plugin: 'savvy-progress', key: 'flow' } as const, null)
10const agents = atom({ plugin: 'savvy-progress', key: 'agents' } as const, [])
11const panel = atom({ plugin: 'savvy-progress', key: 'panel' } as const, {
12  isCompact: false,
13  isDoneCollapsed: false,
14  autoOpenedFor: '',
15})
16const now = atom({ plugin: 'savvy-progress', key: 'now' } as const, 0)
17const bg = atom({ plugin: 'savvy-progress', key: 'bg' } as const, [])
18const launches = atom({ plugin: 'savvy-progress', key: 'launches' } as const, {})
19
20const TOOL = 'mcp__savvy-progress__progress'
21const STEP_TOOL = 'mcp__savvy-progress__step'
22const PANE = 'savvy-agents'
23const BG_EVERY_MS = 5000
24const PHASES: readonly Phase[] = ['plan', 'design', 'delegate', 'review', 'close']
25const ACCENT = '#8f8cf4'
26const DONE = '#5fbf8f'
27
28type ProgressInput = {
29  title?: string
30  total?: number
31  done?: number
32  phase?: Phase
33  finished?: boolean
34  tasks?: { title?: string; tier?: string; after?: number[] }[]
35}
36
37// ---------------------------------------------------------------------------
38// Language: the `language` option, else Claude Code's `language` setting, else the
39// process locale; English when nothing says Russian.
40
41type Lang = 'en' | 'ru'
42
43const STRINGS = {
44  en: {
45    pane: 'Agents',
46    cost: 'Cost',
47    tokens: 'Tokens',
48    time: 'Time',
49    collapse: 'Collapse',
50    expand: 'Expand',
51    running: 'Running',
52    finished: 'Finished',
53    planned: 'Planned',
54    empty: 'No subagents yet.',
55    round: 'round',
56    failed: 'error',
57    after: 'after',
58    tokensWord: 'tokens',
59    agentsCount: 'agents',
60    isRunning: 'running',
61    isFinished: 'finished',
62    isPlanned: 'planned',
63    opened: 'Agents panel opened.',
64    closed: 'Agents panel closed.',
65    done: 'Done',
66    plan: 'Plan',
67    design: 'Design',
68    tasks: 'Tasks',
69    review: 'Review',
70    busy: 'running',
71    background: 'Background',
72    bgBusy: 'busy',
73    bgIdle: 'idle',
74    bgWaiting: 'waiting for you',
75    session: 'session',
76  },
77  ru: {
78    pane: 'Агенты',
79    cost: 'Стоимость',
80    tokens: 'Токены',
81    time: 'Время',
82    collapse: 'Свернуть',
83    expand: 'Развернуть',
84    running: 'Работают',
85    finished: 'Завершены',
86    planned: 'Запланированы',
87    empty: 'Субагентов пока нет.',
88    round: 'раунд',
89    failed: 'ошибка',
90    after: 'после',
91    tokensWord: 'токенов',
92    agentsCount: 'агентов',
93    isRunning: 'работает',
94    isFinished: 'завершён',
95    isPlanned: 'запланирована',
96    opened: 'Панель агентов открыта.',
97    closed: 'Панель агентов закрыта.',
98    done: 'Готово',
99    plan: 'План',
100    design: 'Дизайн',
101    tasks: 'Задачи',
102    review: 'Ревью',
103    busy: 'в работе',
104    background: 'Фоновые',
105    bgBusy: 'работает',
106    bgIdle: 'простаивает',
107    bgWaiting: 'ждёт вас',
108    session: 'сессия',
109  },
110} as const
111
112// Module scope is fine here: session.start sets it again on every (re)load.
113let lang: Lang = 'en'
114const tr = () => STRINGS[lang]
115
116const isRussian = (v: unknown): boolean => typeof v === 'string' && /^(ru|russian|рус)/i.test(v.trim())
117
118async function detectLang($: EngineInterface, option: unknown): Promise<Lang> {
119  if (option === 'en' || option === 'ru') return option
120  try {
121    const settings = (await $.settings.read()) as Record<string, unknown>
122    if (typeof settings.language === 'string' && settings.language.trim()) return isRussian(settings.language) ? 'ru' : 'en'
123  } catch {
124    // No settings: fall through to the locale.
125  }
126  const locale = (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG'))
127  return isRussian(locale) ? 'ru' : 'en'
128}
129
130const blank = (): Flow => ({
131  title: 'savvy-flow',
132  total: 0,
133  done: 0,
134  running: 0,
135  phase: 'plan',
136  isFinished: false,
137  tasks: [],
138})
139
140const isNewFlow = (prev: Flow | null, input: ProgressInput): boolean =>
141  !prev || prev.isFinished || (input.title !== undefined && input.title.trim() !== prev.title)
142
143const cleanTasks = (tasks: ProgressInput['tasks']): PlannedTask[] | undefined =>
144  tasks
145    ?.filter(t => t.title?.trim())
146    .map(t => ({
147      title: (t.title ?? '').trim(),
148      tier: (t.tier ?? '').replace(/^savvy-/, '').trim().toLowerCase(),
149      after: (t.after ?? []).filter(n => Number.isInteger(n) && n > 0),
150    }))
151
152const merge = (prev: Flow | null, input: ProgressInput): Flow => {
153  // A new title means a new flow: never carry counters over from an earlier one.
154  const base = isNewFlow(prev, input) || !prev ? blank() : { ...blank(), ...prev }
155  const tasks = cleanTasks(input.tasks) ?? base.tasks
156  const total = Math.max(0, Math.round(input.total ?? (input.tasks ? tasks.length : base.total)))
157  const done = Math.min(total || Infinity, Math.max(0, Math.round(input.done ?? base.done)))
158  const phase = input.phase && PHASES.includes(input.phase) ? input.phase : base.phase
159  return {
160    ...base,
161    title: input.title?.trim() || base.title,
162    total,
163    done,
164    phase: input.finished ? 'close' : phase,
165    isFinished: input.finished === true,
166    tasks,
167  }
168}
169
170const label = (f: Flow): string => {
171  const s = tr()
172  if (f.isFinished) return s.done
173  if (f.phase === 'plan') return s.plan
174  if (f.phase === 'design') return s.design
175  const count = f.total ? `${f.done}/${f.total}` : `${f.running} ${s.busy}`
176  return `${f.phase === 'review' ? s.review : s.tasks} ${count}`
177}
178
179const ratio = (f: Flow): number => (f.isFinished ? 1 : f.total ? f.done / f.total : 0)
180
181// Deterministic noise so the dither does not shimmer between redraws.
182const noise = (x: number, y: number): number => {
183  const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
184  return s - Math.floor(s)
185}
186
187// The whole row is one SVG: the desktop wraps sibling elements onto new lines,
188// so title, bar, percent and the crab live in one drawing; only the count and
189// the dismiss are Buttons beside it.
190const H = 22
191const BAR_H = 16
192const CRAB_W = 26
193const CELL = 3
194const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
195
196const xml = (s: string): string =>
197  s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c)
198
199const clip = (s: string, max: number): string => (s.length > max ? s.slice(0, Math.max(1, max - 1)) + '…' : s)
200
201// Rough advance of system UI text, in em; good enough to size the title's slot.
202const charEm = (ch: string): number =>
203  /[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-ZА-ЯЁmwшщжюМШЩЖЮ@%]/.test(ch) ? 0.72 : 0.56
204
205const textWidth = (s: string, size: number): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
206
207// Cuts `s` to fit `maxW` pixels, with an ellipsis when it had to cut.
208const fitText = (s: string, size: number, maxW: number): string => {
209  if (textWidth(s, size) <= maxW) return s
210  let out = ''
211  for (const ch of s) {
212    if (textWidth(out + ch + '…', size) > maxW) break
213    out += ch
214  }
215  return out + '…'
216}
217
218const rowSvg = (f: Flow, W: number, isWorking: boolean): string => {
219  // The title takes what it needs, up to 40% of the row; the bar takes the rest.
220  const title = fitText(f.title, 13, Math.max(60, W * 0.4))
221  const BAR_X = Math.round(16 + textWidth(title, 13) + 12)
222  const BAR_W = Math.max(60, W - BAR_X - 46 - CRAB_W)
223  const color = f.isFinished ? DONE : ACCENT
224  const y0 = (H - BAR_H) / 2
225  const fillW = Math.round(BAR_W * ratio(f))
226  const runW = f.total ? Math.round((BAR_W * Math.min(f.total, f.done + f.running)) / f.total) : 0
227  const dots: string[] = []
228
229  // Dithered fill: sparse at the start, dense toward the head.
230  const cols = Math.floor(fillW / CELL)
231  const rows = Math.floor(BAR_H / CELL)
232  for (let c = 0; c < cols; c++) {
233    const density = 0.35 + 0.6 * Math.pow(c / Math.max(1, cols), 1.2)
234    for (let r = 0; r < rows; r++) {
235      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"/>`)
236    }
237  }
238  // Handed to workers, not yet accepted: a faint second layer.
239  const faint: string[] = []
240  for (let c = cols; c < Math.floor(runW / CELL); c++) {
241    for (let r = 0; r < rows; r++) {
242      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"/>`)
243    }
244  }
245
246  const ticks: string[] = []
247  for (let i = 1; i < f.total; i++) {
248    const x = Math.round((BAR_W * i) / f.total)
249    if (x > fillW + 4) ticks.push(`<rect x="${x}" y="${BAR_H / 2 - 4}" width="1.5" height="8" rx="0.75"/>`)
250  }
251
252  const text = label(f)
253  const pillW = Math.round(18 + text.length * 6.6)
254  const pillX = Math.max(0, Math.min(BAR_W - pillW, fillW - pillW))
255  const percent = `${Math.round(ratio(f) * 100)}%`
256
257  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
258<style>
259.t{fill:#1f1f1f}.m{fill:#8a8a8a}.k{fill:#e4e4e2}.tk{fill:#b4b4b0}
260@media (prefers-color-scheme: dark){.t{fill:#ececec}.m{fill:#9a9a9a}.k{fill:#2c2c2c}.tk{fill:#5a5a5a}}
261/* Pixels twinkle in four out-of-phase groups; a finished bar settles to a slow glow. */
262.t0,.t1,.t2,.t3{animation:tw ${f.isFinished ? 3.2 : 2.2}s ease-in-out infinite}
263.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}
264@keyframes tw{0%,100%{opacity:1}50%{opacity:${f.isFinished ? 0.8 : 0.3}}}
265@media (prefers-reduced-motion: reduce){.t0,.t1,.t2,.t3{animation:none}}
266</style>
267<defs><clipPath id="c"><rect x="0" y="0" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/></clipPath></defs>
268<circle cx="5" cy="${H / 2}" r="4" fill="${color}"/>
269<text class="t" x="16" y="${H / 2 + 4.5}" font-family="${FONT}" font-size="13" font-weight="500">${xml(title)}</text>
270<g transform="translate(${BAR_X},${y0})">
271<rect class="k" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/>
272<g clip-path="url(#c)">
273<g fill="${color}">${dots.join('')}</g>
274<g fill="${color}" opacity="0.45">${faint.join('')}</g>
275<g class="tk">${ticks.join('')}</g>
276</g>
277<rect x="${pillX}" width="${pillW}" height="${BAR_H}" rx="${BAR_H / 2}" fill="${color}"/>
278<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>
279</g>
280<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>
281${CRAB_CSS}${crab(W - CRAB_W + 1, 0, 'orchestrator', false, isWorking, 0.8)}
282</svg>`
283}
284
285const barText = (f: Flow, width: number): string => {
286  const filled = Math.round(width * ratio(f))
287  return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
288}
289
290// ---------------------------------------------------------------------------
291// Agents panel: every subagent of the session, plus the tasks the flow planned.
292
293const TIER_COLOR: Record<string, string> = {
294  fable: '#7F77DD',
295  heavy: '#D85A30',
296  careful: '#BA7517',
297  medium: '#378ADD',
298  light: '#1D9E75',
299  // berkays-mods: the claude-config agents.
300  scout: '#5E8C3A',
301  builder: '#E07B39',
302  reviewer: '#8F6BD8',
303  // The main session, drawn on the progress bar.
304  orchestrator: '#C8A13A',
305  other: '#888780',
306}
307
308// What each savvy tier runs on, for planned tasks that have no run yet.
309const colorOf = (tier: string): string => TIER_COLOR[tier] ?? '#888780'
310
311const TIER_MODEL: Record<string, string> = {
312  fable: 'Fable · high',
313  heavy: 'Opus · xhigh',
314  careful: 'Opus · high',
315  medium: 'Opus · medium',
316  light: 'Opus · low',
317  scout: 'Haiku · medium',
318  builder: 'Sonnet · high',
319  reviewer: 'Opus · medium',
320}
321
322// USD per million tokens: input, output, cache read, cache write (5-minute TTL).
323// The engine reports tokens, not money, so the panel's cost is an estimate.
324const PRICES: [RegExp, [number, number, number, number]][] = [
325  [/fable|mythos/, [10, 50, 0.25, 12.5]],
326  [/opus-5-5/, [4, 20, 0.2, 5]],
327  [/opus/, [5, 25, 0.5, 6.25]],
328  [/sonnet/, [2, 10, 0.2, 2.5]],
329  [/haiku/, [1, 5, 0.1, 1.25]],
330]
331
332type Usage = {
333  input_tokens: number
334  output_tokens: number
335  cache_read_input_tokens: number
336  cache_creation_input_tokens: number
337}
338
339const priceOf = (model: string): [number, number, number, number] =>
340  PRICES.find(([re]) => re.test(model.toLowerCase()))?.[1] ?? [4, 20, 0.2, 5]
341
342const costOf = (model: string, u: Usage): number => {
343  const [i, o, r, w] = priceOf(model)
344  return (
345    ((u.input_tokens || 0) * i +
346      (u.output_tokens || 0) * o +
347      (u.cache_read_input_tokens || 0) * r +
348      (u.cache_creation_input_tokens || 0) * w) /
349    1e6
350  )
351}
352
353const windowOf = (model: string): number => (/haiku/i.test(model) ? 200_000 : 1_000_000)
354
355// The claude-config agents are their own tiers, without the savvy- prefix.
356const CREW = ['scout', 'builder', 'reviewer']
357
358// `savvy-careful`, or `savvy-flow:savvy-careful` when the agents ship in a plugin; `scout`, `builder`, `reviewer`.
359export const tierOf = (type: string): string => {
360  const bare = type.replace(/^[^:]*:/, '')
361  const t = bare.replace(/^savvy-/, '').toLowerCase()
362  return t in TIER_COLOR && (bare.startsWith('savvy-') || CREW.includes(bare.toLowerCase())) ? t : 'other'
363}
364
365// A worker the bar counts and the panel opens for.
366const isCrew = (type: string): boolean => type.startsWith('savvy-') || CREW.includes(tierOf(type))
367
368const modelName = (id: string): string => {
369  const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
370  const [, family = '', major = '', minor] = m ?? []
371  if (!family) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '') || '—'
372  return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()} ${major}${minor ? '.' + minor : ''}`
373}
374
375const norm = (s: string): string => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
376
377const fmtTokens = (n: number): string =>
378  n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${Math.round(n)}`
379
380const fmtCost = (usd: number): string => `$${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`
381
382const fmtTime = (ms: number): string => {
383  const s = Math.max(0, Math.round(ms / 1000))
384  const h = Math.floor(s / 3600)
385  const m = Math.floor((s % 3600) / 60)
386  const ss = String(s % 60).padStart(2, '0')
387  return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
388}
389
390const elapsed = (a: AgentRun, at: number): number => (a.endedAt ?? Math.max(at, a.startedAt)) - a.startedAt
391
392type Planned = PlannedTask & { n: number }
393
394const plannedOf = (f: Flow | null, list: AgentRun[]): Planned[] => {
395  if (!f || f.isFinished) return []
396  const started = new Set(list.map(a => norm(a.description)))
397  return (f.tasks ?? []).map((t, i) => ({ ...t, n: i + 1 })).filter(t => !started.has(norm(t.title)))
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 (type Fill, in sprite.ts).
424
425const stamp = (f: Fill, x: number, y: number, rows: string[], map: Record<string, string>, cls?: string): void =>
426  rows.forEach((row, dy) => [...row].forEach((ch, dx) => map[ch] && f(x + dx, y + dy, 1, 1, map[ch] ?? '', cls)))
427
428// `armCls` lets a raised claw travel with the prop it holds.
429const crabBody = (f: Fill, armFront = 0, armCls?: string): void => {
430  f(7, 10, 16, 12, CLAY)
431  f(3, 14, 4, 4, CLAY)
432  f(23, 14 + armFront, 4, 4, CLAY, armCls)
433  f(9, 12, 2, 2, INK)
434  f(19, 12, 2, 2, INK)
435  f(7, 22, 2, 4, CLAY, 'la')
436  f(17, 22, 2, 4, CLAY, 'la')
437  f(11, 22, 2, 4, CLAY, 'lb')
438  f(21, 22, 2, 4, CLAY, 'lb')
439}
440
441// Pure CSS, run by the compositor: no redraws. Periods divide one second, so the
442// once-a-second redraw of a running row restarts them in phase. Every crab walks;
443// each costume adds its prop's own motion on top.
444const CRAB_CSS = `<style>
445.run .la{animation:st .5s steps(1) infinite}.run .lb{animation:st .5s steps(1) infinite -.25s}
446.run .bd{animation:bob .5s steps(1) infinite -.125s}
447.run g{transform-box:fill-box}
448@keyframes st{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}
449.c-fable.run{animation:float 1s ease-in-out infinite}
450.c-fable.run .la,.c-fable.run .lb,.c-fable.run .bd{animation:none}
451.c-fable.run .ant{animation:blink 1s steps(1) infinite}
452.c-fable.run .star{animation:blink .5s steps(1) infinite -.25s}
453@keyframes float{50%{transform:translateY(-2px)}}@keyframes blink{50%{opacity:.15}}
454.c-heavy.run .it{animation:scan 1s steps(1) infinite}
455.c-heavy.run .gl{animation:blink 1s steps(1) infinite -.5s}
456@keyframes scan{25%{transform:translate(-1px,1px)}50%{transform:translate(-2px,2px)}75%{transform:translate(-1px,1px)}}
457.c-careful.run .it{transform-origin:100% 100%;animation:twist .5s ease-in-out infinite}
458@keyframes twist{50%{transform:rotate(-35deg)}}
459.c-medium.run .pan{transform-origin:0 50%;animation:tilt 1s ease-in-out infinite}
460.c-medium.run .egg{animation:flip 1s ease-in-out infinite}
461@keyframes tilt{20%,40%{transform:rotate(-12deg)}}@keyframes flip{30%{transform:translateY(-5px) scaleY(-1)}60%{transform:translateY(0)}}
462.c-light.run .la{animation-duration:.25s}.c-light.run .lb{animation-duration:.25s;animation-delay:-.125s}
463.c-light.run .flag{transform-origin:0 50%;animation:wave .25s steps(1) infinite}
464@keyframes wave{50%{transform:skewY(-12deg) scaleX(.85)}}
465.c-explore.run .it{transform-origin:50% 100%;animation:fence .5s ease-in-out infinite}
466@keyframes fence{50%{transform:rotate(25deg)}}
467.c-scout.run .it{animation:look 1s steps(1) infinite}
468@keyframes look{25%{transform:translateX(-1px)}75%{transform:translateX(1px)}}
469.c-builder.run .it{transform-origin:50% 100%;animation:hammer .5s steps(1) infinite}
470@keyframes hammer{50%{transform:rotate(35deg)}}
471.c-reviewer.run .ck{animation:blink 1s steps(1) infinite}
472.c-reviewer.run .tas{transform-origin:50% 0;animation:sway 1s ease-in-out infinite}
473@keyframes sway{50%{transform:rotate(20deg)}}
474.c-orchestrator.run .it{transform-origin:0 100%;animation:conduct 1s ease-in-out infinite}
475@keyframes conduct{25%{transform:rotate(-20deg)}75%{transform:rotate(15deg)}}
476@media (prefers-reduced-motion: reduce){.run,.run g{animation:none!important}}
477</style>`
478
479const COSTUMES: Record<string, (f: Fill, t: string) => void> = {
480  // Fable: astronaut in a glass dome; floats instead of walking, the antenna and the star blink.
481  fable: (f, t) => {
482    crabBody(f)
483    f(6, 7, 18, 1, '#E6E8EE'); f(5, 8, 1, 14, '#E6E8EE'); f(24, 8, 1, 14, '#E6E8EE'); f(6, 22, 18, 1, '#C9CCD2')
484    f(6, 8, 18, 14, 'rgba(169,214,245,.32)'); f(8, 9, 2, 1, '#fff'); f(8, 10, 1, 2, '#fff')
485    f(14, 4, 2, 3, '#C9CCD2'); f(14, 2, 2, 2, t, 'ant'); f(13, 18, 4, 2, t)
486    f(27, 3, 1, 3, '#F5C542', 'star'); f(26, 4, 3, 1, '#F5C542', 'star')
487  },
488  // Heavy: detective with a deerstalker; the magnifier sweeps and glints.
489  heavy: (f, t) => {
490    crabBody(f, -4, 'it')
491    stamp(f, 6, 3, ['......bbbbbb......', '....bbcbbcbbbb....', '...bbbbbbbbbbbb...', '..bcbbcbbcbbcbbb..', '.bbbbbbbbbbbbbbbb.', 'dddddddddddddddddd'], { b: '#7A4A26', c: '#A0703F', d: '#5A3519' })
492    f(6, 9, 18, 1, t)
493    stamp(f, 23, 1, ['.kkk.', 'k...k', 'k...k', 'k...k', '.kkk.'], { k: '#3A3A3C' }, 'it')
494    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')
495  },
496  // Careful: engineer in a hard hat; the wrench turns a bolt.
497  careful: (f, t) => {
498    crabBody(f)
499    stamp(f, 6, 4, ['.....yyyyyyyy.....', '...yyyyyhhyyyyy...', '..yyyyyyhhyyyyyy..', '..yyyyyyhhyyyyyy..', '.yyyyyyyhhyyyyyyy.', 'dddddddddddddddddd'], { y: '#F5C542', h: '#FBE08A', d: '#C99A1E' })
500    f(13, 5, 4, 2, t)
501    stamp(f, 0, 10, ['.s.s', 'sss.', '.s..', '.s..'], { s: '#8E929A' }, 'it')
502  },
503  // Medium: chef, the toque traced from DockCrab's Sprites.chefHat; tosses the omelette.
504  medium: (f, t) => {
505    crabBody(f, -4, 'pan')
506    stamp(f, 6, 0, ['........lll.......', '.......lllll......', '.wwwwgwwwwwwgwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwgwwwwwggwwwww', '.wwwwgwwwwwggwwwww', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.'], { w: '#F4F3EE', l: '#F7F6F2', g: '#D2D1C8', b: t, d: '#B45F43' })
507    f(22, 8, 7, 2, '#4A4A48', 'pan'); f(26, 10, 1, 1, '#4A4A48', 'pan'); f(24, 7, 3, 1, '#F5B731', 'egg')
508  },
509  // Light: racer in a helmet; runs at double pace, the checkered flag flutters.
510  light: (f, t) => {
511    crabBody(f, -4)
512    stamp(f, 6, 5, ['....rrrrrrrrrr....', '..rrrrrrwwrrrrrr..', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.kkkkkkkkkkkkkkkkr'], { r: t, w: '#F8F6F1', k: INK })
513    f(25, 1, 1, 9, '#8E929A')
514    stamp(f, 26, 1, ['wkwk', 'kwkw', 'wkwk'], { w: '#F8F6F1', k: INK }, 'flag')
515  },
516  // Explore: pirate scouting the code; the cutlass fences.
517  explore: f => {
518    crabBody(f)
519    stamp(f, 5, 3, ['.kk..............kk.', '.kkk....kkkk....kkk.', '..kkkkkkkwwkkkkkkk..', '..kkkkkkkkkkkkkkkk..', '.gggggggggggggggggg.'], { k: '#55514C', w: '#F8F6F1', g: '#F5C542' })
520    f(7, 11, 11, 1, INK); f(18, 11, 4, 3, INK)
521    f(27, 6, 1, 9, '#C9CCD2', 'it'); f(26, 15, 3, 1, '#7A4A26', 'it')
522  },
523  // Scout (Haiku): ranger in a campaign hat; sweeps the binoculars left and right.
524  scout: (f, t) => {
525    crabBody(f, -4, 'it')
526    stamp(f, 6, 4, ['.......kkkk.......', '......kkkkkk......', '.....kkkkkkkk.....', '.....bbbbbbbb.....', 'dddddddddddddddddd'], { k: '#B89A5E', b: t, d: '#8C7140' })
527    stamp(f, 22, 7, ['kk.kk', 'kkkkk', 'll.ll'], { k: '#3A3A3C', l: 'rgba(169,214,245,.8)' }, 'it')
528  },
529  // Builder (Sonnet): hard hat in the tier color and a tool belt; the hammer swings.
530  builder: (f, t) => {
531    crabBody(f, -4, 'it')
532    stamp(f, 6, 5, ['.....hhhhhhhh.....', '...hhhhhwwhhhhh...', '..hhhhhhwwhhhhhh..', '.hhhhhhhwwhhhhhhh.', 'dddddddddddddddddd'], { h: t, w: 'rgba(255,255,255,.45)', d: '#5A3519' })
533    f(7, 19, 16, 1, '#7A4A26'); f(14, 19, 2, 1, '#F5C542')
534    f(25, 3, 1, 8, '#A0703F', 'it'); f(23, 1, 5, 2, '#8E929A', 'it')
535  },
536  // Reviewer (Opus): mortarboard and round glasses; the clipboard's check mark ticks, the tassel sways.
537  reviewer: (f, t) => {
538    crabBody(f, -4)
539    stamp(f, 6, 4, ['.......bbbb.......', '....bbbbbbbbbb....', '.bbbbbbbbbbbbbbbb.', '....bbbbbbbbbb....', '.....cccccccc.....', '.....cccccccc.....'], { b: '#2C2C2E', c: '#3A3A3C' })
540    f(21, 6, 1, 4, t, 'tas'); f(20, 10, 3, 1, t, 'tas')
541    const k = '#3A3A3C'
542    f(8, 11, 4, 1, k); f(8, 14, 4, 1, k); f(8, 11, 1, 4, k); f(11, 11, 1, 4, k)
543    f(18, 11, 4, 1, k); f(18, 14, 4, 1, k); f(18, 11, 1, 4, k); f(21, 11, 1, 4, k); f(12, 12, 6, 1, k)
544    f(24, 3, 5, 8, '#A0703F'); f(25, 4, 3, 6, '#F8F6F1'); f(25, 2, 3, 2, '#8E929A')
545    f(25, 6, 1, 1, t, 'ck'); f(26, 7, 1, 1, t, 'ck'); f(27, 5, 1, 2, t, 'ck')
546  },
547  // Orchestrator (the main session, on the progress bar): conductor in a top hat and bow tie; the baton keeps time.
548  orchestrator: (f, t) => {
549    crabBody(f, -4, 'it')
550    stamp(f, 8, 3, ['...hhhhhhhh...', '...hhhhhhhh...', '...hhhhhhhh...', '...hhhhhhhh...', '...bbbbbbbb...', '...hhhhhhhh...', 'hhhhhhhhhhhhhh'], { h: '#2C2C2E', b: t })
551    stamp(f, 12, 16, ['tt..tt', 'tttttt', 'tt..tt'], { t })
552    stamp(f, 25, 3, ['....w', '....k', '...k.', '...k.', '..k..', '..k..', '.k...'], { k: '#3A3A3C', w: t }, 'it')
553  },
554  other: f => crabBody(f),
555}
556
557const costumeOf = (type: string): string => (type === 'Explore' ? 'explore' : tierOf(type))
558
559// --- terminal crabs: the same costumes, rasterized and shrunk (sprite.ts).
560
561// Crop to the union of all costumes, so every tier's crab is drawn at one scale.
562const sprites = new Map<string, Grid>()
563export const spriteOf = (costume: string): Grid => {
564  if (sprites.size === 0) {
565    const raw = Object.entries(COSTUMES).map(([k, draw]) => [k, rasterize(f => draw(f, colorOf(k)))] as const)
566    const box = boundsOf(raw.map(([, g]) => g))
567    for (const [k, g] of raw) sprites.set(k, downsample(g, box))
568  }
569  return sprites.get(costume) ?? sprites.get('other') ?? []
570}
571
572// The band is one line of text and short on room: the orchestrator alone, cropped to itself, 10 by 6 pixels.
573let mini: Grid | undefined
574const miniOrchestrator = (): Grid => {
575  if (!mini) {
576    const g = rasterize(f => COSTUMES.orchestrator?.(f, colorOf('orchestrator')))
577    mini = downsample(g, boundsOf([g]), 10, 6)
578  }
579  return mini
580}
581
582const CRAB_SCALE = 1.1
583
584// Body and props nest inside `bd` so a prop rides the bob and adds its own motion;
585// legs stay outside it and step on their own.
586export const crab = (x: number, y: number, costume: string, dim = false, isWalking = false, scale = CRAB_SCALE): string => {
587  const groups = new Map<string, string[]>([['bd', []]])
588  const f: Fill = (cx, cy, w, h, c, cls = 'bd') => {
589    if (!groups.has(cls)) groups.set(cls, [])
590    groups.get(cls)?.push(`<rect x="${cx}" y="${cy}" width="${w}" height="${h}" fill="${c}"/>`)
591  }
592  const draw = COSTUMES[costume] ?? ((g: Fill) => crabBody(g))
593  draw(f, colorOf(costume))
594  const group = (cls: string) => `<g class="${cls}">${(groups.get(cls) ?? []).join('')}</g>`
595  const props = [...groups.keys()].filter(k => k !== 'bd' && k !== 'la' && k !== 'lb')
596  const body = `<g class="bd">${(groups.get('bd') ?? []).join('')}${props.map(group).join('')}</g>`
597  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>`
598}
599
600const statusMark = (x: number, y: number, status: string, color: string): string => {
601  if (status === 'running') return `<circle class="live" cx="${x}" cy="${y}" r="3.5" fill="${color}"/>`
602  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"/>`
603  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"/>`
604  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"/>`
605}
606
607const svg = (W: number, H: number, body: string): string =>
608  `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${PANE_CSS}${CRAB_CSS}${body}</svg>`
609
610// The pane's own title already says "Agents": the header names the flow, if any.
611const headerSvg = (W: number, title: string, t: ReturnType<typeof totals>): string => {
612  const s = tr()
613  const gap = 6
614  const tw = (W - gap * 2) / 3
615  const top = title ? 28 : 0
616  const tile = (i: number, k: string, v: string) =>
617    `<rect class="tile" x="${i * (tw + gap)}" y="${top}" width="${tw}" height="40" rx="8"/>
618<text class="s" x="${i * (tw + gap) + 9}" y="${top + 16}" font-family="${FONT}" font-size="11">${k}</text>
619<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>`
620  return svg(
621    W,
622    headerHeight(title),
623    `${title ? `<text class="t" x="0" y="15" font-family="${FONT}" font-size="14" font-weight="600">${xml(fitText(title, 14, W))}</text>` : ''}
624${tile(0, s.cost, '≈' + fmtCost(t.cost))}${tile(1, s.tokens, fmtTokens(t.tokens))}${tile(2, s.time, fmtTime(t.time))}`,
625  )
626}
627
628const headerHeight = (title: string): number => (title ? 72 : 44)
629
630// The task's own progress when the worker reports steps; a finished run is full.
631const progressOf = (a: AgentRun): number | null => {
632  if (a.status === 'done') return 1
633  if (a.stepTotal) return Math.min(1, (a.stepDone ?? 0) / a.stepTotal)
634  return null
635}
636
637const ctxOf = (a: AgentRun): number => (a.contextMax ? Math.min(100, Math.round((a.contextTokens / a.contextMax) * 100)) : 0)
638
639const agentSvg = (W: number, a: AgentRun, at: number): string => {
640  const s = tr()
641  const tier = tierOf(a.type)
642  const color = colorOf(tier)
643  const ctx = ctxOf(a)
644  const textW = W - 42 - 22
645  const meta = [a.effort ? `${modelName(a.model)} · ${a.effort}` : modelName(a.model)]
646  if (a.round > 1) meta.push(`${s.round} ${a.round}`)
647  if (a.status === 'failed') meta.push(s.failed)
648  const barW = textW
649  const progress = progressOf(a)
650  const stats = `ctx ${ctx}% · ${fmtTokens(a.contextTokens)}  ≈${fmtCost(a.costUsd)}  ${fmtTime(elapsed(a, at))}`
651  const steps = a.stepTotal ? `${a.stepDone ?? 0}/${a.stepTotal}${a.stepNote ? ' · ' + a.stepNote : ''}` : ''
652  const stepsW = Math.max(0, barW - textWidth(stats, 11) - 12)
653  // Without reported steps the bar falls back to the context, drawn grey.
654  const fillW = Math.round(barW * (progress ?? ctx / 100))
655  return svg(
656    W,
657    66,
658    `${crab(0, 14, costumeOf(a.type), false, a.status === 'running')}
659<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>
660<text x="42" y="34" font-family="${FONT}" font-size="11"><tspan fill="${color}">${xml(tier === 'other' ? a.type : tier)}</tspan><tspan class="s">  ${xml(meta.join('  ·  '))}</tspan></text>
661${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>` : ''}
662<text class="s" x="${42 + barW}" y="49" text-anchor="end" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${stats}</text>
663<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}"`}/>
664${statusMark(W - 8, 16, a.status, color)}
665<line class="ln" x1="0" y1="65.5" x2="${W}" y2="65.5"/>`,
666  )
667}
668
669const plannedSvg = (W: number, p: Planned): string => {
670  const tier = p.tier in TIER_COLOR ? p.tier : 'other'
671  const color = colorOf(tier)
672  const textW = W - 42 - 22
673  const meta = [TIER_MODEL[tier] ?? '']
674  if (p.after.length) meta.push(`${tr().after} ${p.after.join(', ')}`)
675  return svg(
676    W,
677    46,
678    `${crab(0, 6, tier, true)}
679<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>
680<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>
681${statusMark(W - 8, 16, 'planned', color)}
682<line class="ln" x1="0" y1="45.5" x2="${W}" y2="45.5"/>`,
683  )
684}
685
686const WAITING = '#E0A030'
687
688const bgModel = (b: BgSession): string => [b.model ? (aliasLabel(b.model) ?? modelName(b.model)) : '—', b.effort].filter(Boolean).join(' · ')
689
690const bgStateText = (b: BgSession): string => (b.state === 'waiting' ? tr().bgWaiting : b.state === 'busy' ? tr().bgBusy : tr().bgIdle)
691
692// A session of its own, not a subagent: no cost, tokens or context (they are not visible from here).
693const bgSvg = (W: number, b: BgSession): string => {
694  const color = colorOf(b.tier)
695  const state = bgStateText(b)
696  const textW = W - 42 - 22
697  const branch = b.branch ? fitText(b.branch, 11, textW / 2) : ''
698  const tier = b.tier === 'other' ? tr().session : b.tier
699  const mark =
700    b.state === 'busy'
701      ? statusMark(W - 8, 16, 'running', color)
702      : b.state === 'waiting'
703        ? `<circle class="live" cx="${W - 8}" cy="16" r="3.5" fill="${WAITING}"/>`
704        : `<circle cx="${W - 8}" cy="16" r="3.5" fill="none" stroke="#9a9a96" stroke-width="1.4"/>`
705  return svg(
706    W,
707    46,
708    `${crab(0, 6, b.tier, b.state === 'idle', b.state === 'busy')}
709<text class="t" x="42" y="18" font-family="${FONT}" font-size="13" font-weight="600">${xml(fitText(b.name, 13, textW - textWidth(state, 11) - 8))}</text>
710<text class="s" x="${W - 22}" y="18" text-anchor="end" font-family="${FONT}" font-size="11"${b.state === 'waiting' ? ` fill="${WAITING}"` : ''}>${xml(state)}</text>
711<text x="42" y="34" font-family="${FONT}" font-size="11"><tspan fill="${color}">${xml(tier)}</tspan><tspan class="s">  ·  ${xml(fitText(bgModel(b), 11, Math.max(30, textW - textWidth(tier, 11) - textWidth(branch, 11) - 24)))}</tspan></text>
712${branch ? `<text class="m" x="${W - 22}" y="34" text-anchor="end" font-family="${FONT}" font-size="11">${xml(branch)}</text>` : ''}
713${mark}
714<line class="ln" x1="0" y1="45.5" x2="${W}" y2="45.5"/>`,
715  )
716}
717
718const compactSvg = (W: number, list: AgentRun[], planned: Planned[], sessions: BgSession[], t: ReturnType<typeof totals>): string => {
719  const icons = [
720    ...list.filter(a => a.status === 'running').map(a => ({ k: costumeOf(a.type), c: colorOf(tierOf(a.type)), s: 'running', dim: false })),
721    ...list.filter(a => a.status !== 'running').map(a => ({ k: costumeOf(a.type), c: colorOf(tierOf(a.type)), s: a.status, dim: false })),
722    ...planned.map(p => ({ k: p.tier in TIER_COLOR ? p.tier : 'other', c: colorOf(p.tier), s: 'planned', dim: true })),
723    ...sessions.map(b => ({ k: b.tier, c: colorOf(b.tier), s: b.state === 'busy' ? 'running' : 'idle', dim: b.state === 'idle' })),
724  ]
725  const fit = Math.max(1, Math.floor((W - 150) / 36))
726  const shown = icons.slice(0, fit)
727  const more = icons.length - shown.length
728  const body = shown
729    .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}"/>` : ''))
730    .join('')
731  const x = shown.length * 36 + (more ? 4 : 0)
732  return svg(
733    W,
734    32,
735    `${body}${more ? `<text class="s" x="${x}" y="21" font-family="${FONT}" font-size="12">+${more}</text>` : ''}
736<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>`,
737  )
738}
739
740// --- terminal drawing: the same rows in text.
741
742type TextTag = ReturnType<EngineInterface['ui']['resolve']>['Text']
743
744// A sprite as lines of text: each run of cells is one Text, the top pixel its color and the bottom its background.
745const crabLines = (Text: TextTag, grid: Grid, key: string) =>
746  toRuns(grid).map((line: Run[], y) => (
747    <Text key={`${key}-${y}`}>
748      {line.map((r, x) => (
749        <Text key={`${x}`} color={r.fg} backgroundColor={r.bg}>
750          {r.text}
751        </Text>
752      ))}
753    </Text>
754  ))
755
756const BG_GLYPH = { busy: '●', waiting: '▲', idle: '○' } as const
757
758const ctxBar = (pct: number, width: number): string => {
759  const filled = Math.round((width * pct) / 100)
760  return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
761}
762
763const STATUS_GLYPH: Record<string, string> = { running: '●', done: '✓', failed: '✗', planned: '◷' }
764
765// Background sessions: `claude agents --json` plus one `git worktree list` per poll, so the branch costs no
766// extra call per session. Runs only while the pane is open or a flow's bar is up (the clock in session.start).
767let isPolling = false
768
769async function pollBg($: EngineInterface): Promise<void> {
770  if (isPolling) return
771  isPolling = true
772  try {
773    const self = await $.session.id()
774    const repo = await $.session.repo()
775    const ran = await $.process.run(['claude', 'agents', '--json'], { timeoutMs: 10_000 })
776    if (ran.exitCode !== 0) return
777    const tree = repo ? await $.process.run(['git', '-C', repo.root, 'worktree', 'list', '--porcelain'], { timeoutMs: 5000 }).catch(() => null) : null
778    const trees = tree?.exitCode === 0 ? parseWorktrees(tree.stdout) : []
779    const launched = await read($, launches)
780    const list: BgSession[] = parseAgents(ran.stdout)
781      .filter(a => a.sessionId !== self && a.kind !== 'interactive' && isRelated(a, trees, launched))
782      .slice(0, 24)
783      .map(a => {
784        const l = launched[a.name.toLowerCase()]
785        return { id: a.sessionId, name: a.name, state: bgState(a), tier: tierFor(l), model: l?.model, effort: l?.effort, branch: branchOf(a, trees) }
786      })
787    const prev = await read($, bg)
788    if (JSON.stringify(prev) !== JSON.stringify(list)) await update($, bg, () => list)
789  } catch {
790    // The list stays as it was; the next poll tries again.
791  } finally {
792    isPolling = false
793  }
794}
795
796// Opens the agents pane, or closes it when it is up; true when it ends up open.
797async function togglePane($: EngineInterface): Promise<boolean> {
798  const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
799  if (isOpen) {
800    await $.ui.close({ id: PANE })
801    return false
802  }
803  const at = await $.clock.now()
804  await update($, now, () => at)
805  await $.ui.open({ id: PANE, title: tr().pane })
806  await pollBg($)
807  return true
808}
809
810async function autoOpen($: EngineInterface, key: string): Promise<void> {
811  const p = await read($, panel)
812  if (p.autoOpenedFor === key) return
813  await update($, panel, prev => ({ ...prev, autoOpenedFor: key }))
814  void $.ui.open({ id: PANE, title: tr().pane })
815  void pollBg($)
816}
817
818export const register: Register = (on, options) => {
819  on('session.start', async ($, e, next) => {
820    const started = await next(e)
821    lang = await detectLang($, options.language)
822    await $.tool.register({
823      name: 'progress',
824      description:
825        'Report /savvy-flow progress to the progress bar above the prompt and the agents panel. ' +
826        'Call it after presenting the plan (title, total, tasks, phase "delegate"), each time a task is accepted (done), ' +
827        'when switching phase or re-planning (tasks), and once at the end with finished: true. Fields left out keep their previous value.',
828      inputSchema: {
829        type: 'object',
830        properties: {
831          title: { type: 'string', description: 'Short name of the overall task, a few words.' },
832          total: { type: 'integer', minimum: 0, description: 'Number of planned worker tasks.' },
833          done: { type: 'integer', minimum: 0, description: 'Number of tasks accepted after review.' },
834          phase: { type: 'string', enum: [...PHASES] },
835          finished: { type: 'boolean', description: 'True once the flow is closed.' },
836          tasks: {
837            type: 'array',
838            description:
839              '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.',
840            items: {
841              type: 'object',
842              properties: {
843                title: { type: 'string', description: 'A few words; reused verbatim as the Agent description.' },
844                tier: { type: 'string', enum: ['fable', 'heavy', 'careful', 'medium', 'light', ...CREW] },
845                after: { type: 'array', items: { type: 'integer' }, description: 'Numbers of the tasks this one waits for.' },
846              },
847              required: ['title', 'tier'],
848            },
849          },
850        },
851      },
852    })
853    await $.tool.register({
854      name: 'step',
855      description:
856        'For savvy-flow workers: report progress on your own task to the agents panel. ' +
857        'Right after reading the brief, call it with `total` (your plan in 3-8 steps) and `done: 0`; ' +
858        'call it again as each step finishes. Cheap and silent: it only draws a bar.',
859      inputSchema: {
860        type: 'object',
861        properties: {
862          done: { type: 'integer', minimum: 0, description: 'Steps finished so far.' },
863          total: { type: 'integer', minimum: 1, description: 'Steps planned; may change if the plan changes.' },
864          note: { type: 'string', description: 'The step in progress, a few words.' },
865        },
866        required: ['done'],
867      },
868    })
869    await $.command.register({
870      name: 'agents-info',
871      description: 'Show or hide the panel of subagents: running, finished and planned, with model, context, cost and time',
872    })
873
874    // Polls the background sessions, but only with the pane open or a flow's bar up: otherwise nothing runs.
875    $.clock.every(BG_EVERY_MS, () => {
876      void (async () => {
877        const f = await read($, flow)
878        if ((await $.ui.panes()).some(p => p.id === PANE) || (f && !f.isFinished)) await pollBg($)
879      })()
880    })
881
882    // Ticks the running agents' clocks; quiet when nothing runs.
883    $.clock.every(1000, () => {
884      void (async () => {
885        const list = await read($, agents)
886        if (!list.some(a => a.status === 'running')) return
887        const at = await $.clock.now()
888        await update($, now, () => at)
889      })()
890    })
891    return started
892  })
893
894  on('command.run', { command: 'agents-info' }, async $ => {
895    const isOpen = await togglePane($)
896    return { text: isOpen ? tr().opened : tr().closed }
897  })
898
899  on('tool.call', { tool: TOOL }, async ($, e) => {
900    const input = e as unknown as ProgressInput
901    const prev = await read($, flow)
902    if (isNewFlow(prev, input) && input.title !== undefined) {
903      // A new flow starts with a clean list; agents still running stay.
904      await update($, agents, list => list.filter(a => a.status === 'running'))
905    }
906    const next = await update($, flow, p => merge(p, input))
907    if (next && input.tasks?.length) await autoOpen($, next.title)
908    return { result: `ok: ${label(next ?? blank())}` }
909  })
910
911  // A worker's own progress: the call runs in the worker's loop, so agentId names it.
912  on('tool.call', { tool: STEP_TOOL }, async ($, e) => {
913    const input = e as unknown as { done?: number; total?: number; note?: string }
914    const agentId = e.agentId
915    if (!agentId) return { result: 'ignored: only subagents report steps' }
916    await update($, agents, list =>
917      list.map(a => {
918        if (a.agentId !== agentId) return a
919        const total = Math.max(0, Math.round(input.total ?? a.stepTotal ?? 0))
920        const done = Math.max(0, Math.round(input.done ?? a.stepDone ?? 0))
921        return { ...a, stepTotal: total, stepDone: total ? Math.min(total, done) : done, stepNote: input.note?.trim() || undefined }
922      }),
923    )
924    return { result: 'ok' }
925  })
926
927  // This session's own background launches: the only place their model, effort and agent can be learned.
928  on('tool.call', { tool: ['Bash', 'PowerShell'] }, async ($, e, next) => {
929    try {
930      const found = bgLaunches(String(e.command ?? '')).filter(l => l.name)
931      if (found.length) {
932        await update($, launches, all => ({
933          ...all,
934          ...Object.fromEntries(found.map(l => [(l.name ?? '').toLowerCase(), { model: l.model, effort: l.effort, agent: l.agent }])),
935        }))
936      }
937    } catch {
938      // Only a label for the panel: never worth failing the call.
939    }
940    return next(e)
941  })
942
943  // Safety net: worker launches move the faint layer even if the orchestrator forgets to report.
944  on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
945    const type = String(e.subagent_type ?? '')
946    if (!isCrew(type)) return next(e)
947
948    await update($, flow, prev => {
949      const base = prev && !prev.isFinished ? { ...blank(), ...prev } : blank()
950      return { ...base, running: base.running + 1, phase: base.phase === 'plan' ? 'delegate' : base.phase }
951    })
952    try {
953      return await next(e)
954    } finally {
955      await update($, flow, prev => (prev ? { ...prev, running: Math.max(0, prev.running - 1) } : prev))
956    }
957  })
958
959  on('agent.spawn', async ($, e, next) => {
960    const started = await next(e)
961    if (started.deny !== undefined) return started
962
963    const at = await $.clock.now()
964    await update($, agents, list => {
965      const round = 1 + list.filter(a => norm(a.description) === norm(e.description) && e.description).length
966      const run: AgentRun = {
967        id: started.agentId ?? e.tool_use_id,
968        agentId: started.agentId,
969        type: e.subagentType,
970        description: e.description,
971        model: started.model,
972        status: 'running',
973        startedAt: at,
974        contextTokens: 0,
975        contextMax: windowOf(started.model),
976        tokens: 0,
977        costUsd: 0,
978        steps: 0,
979        round,
980      }
981      return [...list.filter(a => a.id !== run.id), run].slice(-200)
982    })
983    await update($, now, () => at)
984    if (isCrew(e.subagentType)) {
985      const f = await read($, flow)
986      await autoOpen($, f && !f.isFinished ? f.title : 'savvy-flow')
987    }
988    return started
989  })
990
991  // Each model request of a subagent: live context, tokens and cost.
992  on('turn.step', async function* ($, e, next) {
993    const result = yield* next(e)
994    const agentId = e.agentId
995    const usage = result.usage
996    if (!agentId || !usage) return result
997
998    const model = usage.model || e.model
999    await update($, agents, list =>
1000      list.map(a =>
1001        a.agentId !== agentId
1002          ? a
1003          : {
1004              ...a,
1005              model,
1006              effort: typeof e.effort === 'string' ? e.effort : a.effort,
1007              status: 'running',
1008              endedAt: undefined,
1009              contextTokens:
1010                (usage.input_tokens || 0) +
1011                (usage.cache_read_input_tokens || 0) +
1012                (usage.cache_creation_input_tokens || 0) +
1013                (usage.output_tokens || 0),
1014              contextMax: windowOf(model),
1015              tokens:
1016                a.tokens +
1017                (usage.input_tokens || 0) +
1018                (usage.output_tokens || 0) +
1019                (usage.cache_read_input_tokens || 0) +
1020                (usage.cache_creation_input_tokens || 0),
1021              costUsd: a.costUsd + costOf(model, usage),
1022              steps: a.steps + 1,
1023            },
1024      ),
1025    )
1026    return result
1027  })
1028
1029  on('turn.complete', async ($, e, next) => {
1030    const agentId = e.agentId
1031    if (agentId) {
1032      const at = await $.clock.now()
1033      await update($, agents, list =>
1034        list.map(a => {
1035          if (a.agentId !== agentId) return a
1036          // A run whose steps went unseen still gets the turn's own sum.
1037          const fallback = a.steps === 0 && e.usage
1038          return {
1039            ...a,
1040            status: e.reason === 'answer' ? 'done' : 'failed',
1041            endedAt: at,
1042            ...(fallback && e.usage
1043              ? {
1044                  model: e.usage.model || a.model,
1045                  tokens:
1046                    e.usage.input_tokens +
1047                    e.usage.output_tokens +
1048                    e.usage.cache_read_input_tokens +
1049                    e.usage.cache_creation_input_tokens,
1050                  costUsd: costOf(e.usage.model || a.model, e.usage),
1051                }
1052              : {}),
1053          }
1054        }),
1055      )
1056      await update($, now, () => at)
1057    }
1058    return next(e)
1059  })
1060
1061  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1062    const s = tr()
1063    const ui = $.ui.resolve(e)
1064    const { Box, Text, Button } = ui
1065    const list = await read($, agents)
1066    const sessions = await read($, bg)
1067    const f = await read($, flow)
1068    const p: Panel = await read($, panel)
1069    const at = Math.max(await read($, now), ...list.map(a => a.startedAt), 0)
1070
1071    const running = list.filter(a => a.status === 'running').reverse()
1072    const finished = list.filter(a => a.status !== 'running').reverse()
1073    const planned = plannedOf(f, list)
1074    const t = totals(list, at)
1075    // The pane's title says "Agents"; inside, only the flow's own name.
1076    const title = f && !f.isFinished ? f.title : ''
1077
1078    const toggleCompact = (
1079      <Button
1080        key="compact"
1081        label={p.isCompact ? s.expand : s.collapse}
1082        plain
1083        onPress={() => update($, panel, prev => ({ ...prev, isCompact: !prev.isCompact }))}
1084      />
1085    )
1086    const toggleDone = (
1087      <Button
1088        key="done"
1089        label={`${p.isDoneCollapsed ? '▸' : '▾'} ${s.finished} · ${finished.length}`}
1090        plain
1091        onPress={() => update($, panel, prev => ({ ...prev, isDoneCollapsed: !prev.isDoneCollapsed }))}
1092      />
1093    )
1094    const isEmpty = list.length === 0 && planned.length === 0 && sessions.length === 0
1095    const summary = `≈${fmtCost(t.cost)}, ${fmtTokens(t.tokens)} ${s.tokensWord}, ${fmtTime(t.time)}`
1096
1097    if (e.surface === 'desktop' && 'Svg' in ui) {
1098      const { Svg } = ui
1099      const W = Math.max(240, Math.min(900, (e.props.bodyColumns || 40) * 8 - 8))
1100      const section = (key: string, text: string) => (
1101        <Text key={key} dimColor>
1102          {text}
1103        </Text>
1104      )
1105
1106      if (p.isCompact) {
1107        return (
1108          <Box flexDirection="column" gap={1}>
1109            <Svg source={compactSvg(W, list, planned, sessions, t)} alt={`${list.length + sessions.length} ${s.agentsCount}, ${summary}`} width={W} height={32} />
1110            {toggleCompact}
1111          </Box>
1112        )
1113      }
1114      return (
1115        <Box flexDirection="column">
1116          <Svg source={headerSvg(W, title, t)} alt={title ? `${title}: ${summary}` : summary} width={W} height={headerHeight(title)} />
1117          {toggleCompact}
1118          {isEmpty && <Text dimColor>{s.empty}</Text>}
1119          {running.length > 0 && section('h-run', `${s.running} · ${running.length}`)}
1120          {running.map(a => (
1121            <Svg key={a.id} source={agentSvg(W, a, at)} alt={`${a.description}: ${modelName(a.model)}, ${s.isRunning}`} width={W} height={66} />
1122          ))}
1123          {finished.length > 0 && toggleDone}
1124          {!p.isDoneCollapsed &&
1125            finished.map(a => (
1126              <Svg key={a.id} source={agentSvg(W, a, at)} alt={`${a.description}: ${modelName(a.model)}, ${s.isFinished}`} width={W} height={66} />
1127            ))}
1128          {sessions.length > 0 && section('h-bg', `${s.background} · ${sessions.length}`)}
1129          {sessions.map(b => (
1130            <Svg key={`bg-${b.id}`} source={bgSvg(W, b)} alt={`${b.name}: ${bgStateText(b)}`} width={W} height={46} />
1131          ))}
1132          {planned.length > 0 && section('h-plan', `${s.planned} · ${planned.length}`)}
1133          {planned.map(pl => (
1134            <Svg key={`plan-${pl.n}`} source={plannedSvg(W, pl)} alt={`${pl.n}. ${pl.title}: ${s.isPlanned}`} width={W} height={46} />
1135          ))}
1136        </Box>
1137      )
1138    }
1139
1140    // Terminal: the same content in text rows.
1141    const cols = Math.max(24, e.props.bodyColumns || 40)
1142    // The crab takes SPRITE_W columns and a gap; on a narrow pane the old glyph stays.
1143    const hasCrab = cols >= 40
1144    const room = cols - (hasCrab ? SPRITE_W + 1 : 0)
1145    const barW = Math.max(6, Math.min(20, room - 34))
1146    const indent = hasCrab ? '' : '  '
1147    const withCrab = (key: string, costume: string, body: JSX.Element) =>
1148      hasCrab ? (
1149        <Box key={key} flexDirection="row" gap={1} marginBottom={1}>
1150          <Box flexDirection="column" flexShrink={0} width={SPRITE_W}>
1151            {crabLines(Text, spriteOf(costume), key)}
1152          </Box>
1153          <Box flexDirection="column" justifyContent="center" width={room}>
1154            {body}
1155          </Box>
1156        </Box>
1157      ) : (
1158        <Box key={key} flexDirection="column" marginBottom={1}>
1159          {body}
1160        </Box>
1161      )
1162    const bgRow = (b: BgSession) => {
1163      const color = colorOf(b.tier)
1164      return withCrab(
1165        `bg-${b.id}`,
1166        b.tier,
1167        <>
1168          <Box flexDirection="row" gap={1}>
1169            {!hasCrab && <Text color={color}>▣</Text>}
1170            <Text bold wrap="truncate-end">
1171              {b.name}
1172            </Text>
1173            <Text color={b.state === 'waiting' ? 'warning' : b.state === 'busy' ? color : undefined} dimColor={b.state === 'idle'}>
1174              {BG_GLYPH[b.state]}
1175            </Text>
1176          </Box>
1177          <Text dimColor wrap="truncate-end">
1178            {indent}
1179            {b.tier === 'other' ? s.session : b.tier} · {bgModel(b)}
1180          </Text>
1181          <Text dimColor wrap="truncate-end">
1182            {indent}
1183            {bgStateText(b)}
1184            {b.branch ? ` · ${b.branch}` : ''}
1185          </Text>
1186        </>,
1187      )
1188    }
1189    const row = (a: AgentRun) => {
1190      const tier = tierOf(a.type)
1191      const color = colorOf(tier)
1192      const ctx = ctxOf(a)
1193      const progress = progressOf(a)
1194      const model = a.effort ? `${modelName(a.model)} · ${a.effort}` : modelName(a.model)
1195      const steps = a.stepTotal ? `${a.stepDone ?? 0}/${a.stepTotal}${a.stepNote ? ' ' + a.stepNote : ''} · ` : ''
1196      return withCrab(
1197        a.id,
1198        costumeOf(a.type),
1199        <>
1200          <Box flexDirection="row" gap={1}>
hooks/bg.ts 184 lines
1// Background sessions (`claude --bg`) for the agents panel. Plugins cannot import each other, so these
2// helpers are copies: parseAgents from plugins/workers/hooks/lib.ts, split and bgLaunches from
3// plugins/effort-gate/hooks/register.ts (route-ledger carries the same parser).
4
5import type { BgState } from '../types'
6
7/** One entry of `claude agents --json`. `status` is busy/idle/waiting, `state` working/done/blocked; either may be absent. */
8export type AgentEntry = {
9  pid?: number
10  cwd: string
11  kind?: string
12  sessionId: string
13  name: string
14  status?: string
15  state?: string
16}
17
18export const parseAgents = (stdout: string): AgentEntry[] => {
19  try {
20    const list: unknown = JSON.parse(stdout)
21    if (!Array.isArray(list)) return []
22    return list.filter(
23      (a): a is AgentEntry =>
24        typeof a === 'object' && a !== null && typeof a.sessionId === 'string' && typeof a.name === 'string',
25    )
26  } catch {
27    return []
28  }
29}
30
31/** Waiting for you beats busy; a session that reports a status is read by it, one that does not by its state. */
32export const bgState = (a: AgentEntry): BgState =>
33  a.status === 'waiting' || a.state === 'blocked'
34    ? 'waiting'
35    : a.status === 'busy' || (a.status === undefined && a.state === 'working')
36      ? 'busy'
37      : 'idle'
38
39// ---- reading `claude ... --bg` out of a shell command ----
40
41type Tok = { text: string; quoted: boolean } // quoted: the word began inside quotes (a prompt, never a flag)
42
43// Splits a command into segments (at unquoted ; | & newline) of words (at unquoted whitespace).
44// ponytail: no heredocs, backticks, $(...) or backslash escapes outside quotes; an odd apostrophe swallows the rest, which only hides flags.
45function split(cmd: string): Tok[][] {
46  const segs: Tok[][] = [[]]
47  let cur = ''
48  let has = false
49  let startQ = false
50  let q: string | null = null
51  const word = () => {
52    if (has) segs[segs.length - 1]!.push({ text: cur, quoted: startQ })
53    cur = ''
54    has = false
55    startQ = false
56  }
57  for (let i = 0; i < cmd.length; i++) {
58    const c = cmd[i]!
59    if (q) {
60      if (c === q) q = null
61      else if (c === '\\' && q === '"' && cmd[i + 1] === '"') cur += cmd[++i]
62      else cur += c
63    } else if (c === '"' || c === "'") {
64      if (!has) startQ = true
65      has = true
66      q = c
67    } else if (c === '\n' || c === ';' || c === '|' || c === '&') {
68      word()
69      if (segs[segs.length - 1]!.length) segs.push([])
70    } else if (/\s/.test(c)) word()
71    else {
72      cur += c
73      has = true
74    }
75  }
76  word()
77  return segs.filter(s => s.length)
78}
79
80/** What a `claude --bg` launch asked for. */
81export type Launch = { model?: string; effort?: string; agent?: string; name?: string }
82
83const VALUE_FLAGS: Record<string, keyof Launch> = {
84  '--model': 'model',
85  '--effort': 'effort',
86  '--agent': 'agent',
87  '--name': 'name',
88  '-n': 'name',
89}
90
91const SHELL = /^(?:(?:ba|z|da|k|c)?sh|pwsh|powershell|cmd)(?:\.exe)?$/i
92
93// Every `claude ... --bg/--background` segment. `claude` must be the segment's command word (after FOO=bar
94// assignments; a path ending in claude or claude.exe is fine); flags are read only from unquoted words, and a
95// quoted word counts only as the value right after a flag that takes one. `sh -c "..."` is read inside.
96export function bgLaunches(cmd: string): Launch[] {
97  const out: Launch[] = []
98  for (const seg of split(cmd)) {
99    let i = 0
100    while (i < seg.length && !seg[i]!.quoted && /^[A-Za-z_]\w*=/.test(seg[i]!.text)) i++
101    const word = seg[i]?.text.split(/[\\/]/).pop() ?? ''
102    if (SHELL.test(word)) {
103      const k = seg.findIndex((t, n) => n > i && !t.quoted && /^(?:-[a-z]*c|-command|\/c)$/i.test(t.text))
104      if (k >= 0) {
105        const rest = seg.slice(k + 1)
106        out.push(...bgLaunches(rest.length === 1 ? rest[0]!.text : rest.map(t => (t.quoted ? JSON.stringify(t.text) : t.text)).join(' ')))
107      }
108      continue
109    }
110    if (!/^claude(?:\.(?:exe|cmd|ps1))?$/i.test(word)) continue
111    const found: Launch = {}
112    let bg = false
113    for (let j = i + 1; j < seg.length; j++) {
114      const t = seg[j]!
115      if (t.quoted) continue
116      if (t.text === '--') break
117      const m = /^(--[a-z-]+|-n)(?:=(.*))?$/i.exec(t.text)
118      if (!m) continue
119      if (m[1] === '--bg' || m[1] === '--background') {
120        bg = true
121        continue
122      }
123      const key = VALUE_FLAGS[m[1]!.toLowerCase()]
124      if (!key) continue
125      let v = m[2]
126      const next = seg[j + 1]
127      if (v === undefined && next && (next.quoted || !next.text.startsWith('-'))) {
128        v = next.text
129        j++
130      }
131      if (v !== undefined) found[key] = v
132    }
133    if (bg) out.push(found)
134  }
135  return out
136}
137
138// ---- which sessions belong to this repository ----
139
140export type Worktree = { path: string; branch: string | null }
141
142/** `git worktree list --porcelain`: blocks of `worktree <path>` / `branch refs/heads/<name>`. */
143export const parseWorktrees = (porcelain: string): Worktree[] =>
144  porcelain
145    .split(/\r?\n\r?\n/)
146    .map(block => ({
147      path: /^worktree (.+)$/m.exec(block)?.[1]?.trim() ?? '',
148      branch: /^branch refs\/heads\/(.+)$/m.exec(block)?.[1]?.trim() ?? null,
149    }))
150    .filter(w => w.path)
151
152// Windows and git disagree on slashes, and on case; compare in one spelling.
153const norm = (p: string): string => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
154
155const worktreeOf = (cwd: string, trees: Worktree[]): Worktree | undefined => {
156  const c = norm(cwd)
157  return trees
158    .filter(w => c === norm(w.path) || c.startsWith(norm(w.path) + '/'))
159    .sort((a, b) => b.path.length - a.path.length)[0]
160}
161
162/** Related: launched from this session (by name), or running in a worktree of this repository. */
163export const isRelated = (a: AgentEntry, trees: Worktree[], launched: Record<string, Launch>): boolean =>
164  a.name.toLowerCase() in launched || worktreeOf(a.cwd, trees) !== undefined
165
166/** The branch checked out where the session runs, from the one worktree list per poll. */
167export const branchOf = (a: AgentEntry, trees: Worktree[]): string | undefined => worktreeOf(a.cwd, trees)?.branch ?? undefined
168
169// ---- crab and label ----
170
171const CREW = ['scout', 'builder', 'reviewer']
172
173/** `--agent scout|builder|reviewer` picks that crab; else the model's family: haiku scout, sonnet builder, opus reviewer. */
174export const tierFor = (l?: Launch): string => {
175  const agent = l?.agent?.replace(/^[^:]*:/, '').toLowerCase() ?? ''
176  if (CREW.includes(agent)) return agent
177  const m = l?.model?.toLowerCase() ?? ''
178  return /haiku/.test(m) ? 'scout' : /sonnet/.test(m) ? 'builder' : /opus/.test(m) ? 'reviewer' : 'other'
179}
180
181/** `sonnet` -> `Sonnet`; a full id is left to the caller's modelName. */
182export const aliasLabel = (model: string): string | undefined =>
183  /^[a-z]+$/i.test(model) ? model.charAt(0).toUpperCase() + model.slice(1).toLowerCase() : undefined
184
hooks/sprite.ts 117 lines
1// Terminal crabs: the costume's own pixel data (COSTUMES in register.tsx), drawn into a grid,
2// shrunk, and printed as half-blocks (top pixel = foreground, bottom pixel = background).
3
4export type Fill = (x: number, y: number, w: number, h: number, c: string, cls?: string) => void
5
6/** A pixel: a `#rrggbb` color, or null for transparent. */
7export type Px = string | null
8export type Grid = Px[][]
9
10/** One run of cells that share colors: what a `<Text color backgroundColor>` draws. */
11export type Run = { text: string; fg?: string; bg?: string }
12
13export const GRID_W = 30
14export const GRID_H = 28
15/** The terminal sprite: 12 columns by 5 lines of two pixels each. */
16export const SPRITE_W = 12
17export const SPRITE_H = 10
18
19// '#rgb', '#rrggbb' or 'rgba(r,g,b,a)'. A mostly see-through color (the astronaut's glass) is transparent.
20const solid = (c: string): Px => {
21  const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(c)?.[1]
22  if (hex) return '#' + (hex.length === 3 ? [...hex].map(h => h + h).join('') : hex).toLowerCase()
23  const m = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)$/i.exec(c)
24  if (!m || Number(m[4] ?? 1) < 0.5) return null
25  return '#' + [m[1], m[2], m[3]].map(n => Number(n).toString(16).padStart(2, '0')).join('')
26}
27
28/** Runs a costume against a grid instead of the SVG; later fills paint over earlier ones. */
29export const rasterize = (draw: (f: Fill) => void): Grid => {
30  const grid: Grid = Array.from({ length: GRID_H }, () => Array<Px>(GRID_W).fill(null))
31  draw((x, y, w, h, c) => {
32    const color = solid(c)
33    if (color === null) return
34    for (let j = Math.max(0, y); j < Math.min(GRID_H, y + h); j++)
35      for (let i = Math.max(0, x); i < Math.min(GRID_W, x + w); i++) grid[j]![i] = color
36  })
37  return grid
38}
39
40/** The smallest rectangle holding every drawn pixel of every grid: crabs of all tiers crop alike, so they stay one size. */
41export const boundsOf = (grids: Grid[]): { x0: number; y0: number; x1: number; y1: number } => {
42  const b = { x0: GRID_W, y0: GRID_H, x1: 0, y1: 0 }
43  for (const g of grids)
44    g.forEach((row, y) =>
45      row.forEach((c, x) => {
46        if (c === null) return
47        b.x0 = Math.min(b.x0, x)
48        b.y0 = Math.min(b.y0, y)
49        b.x1 = Math.max(b.x1, x + 1)
50        b.y1 = Math.max(b.y1, y + 1)
51      }),
52    )
53  return b
54}
55
56const BODY = '#d97757'
57const EYE = '#1f1e1d'
58
59/**
60 * Shrinks the cropped grid to `cols` by `rows` (the sprite's size by default). A block is drawn when at least a third of it is;
61 * its color is the commonest one, except that anything but the body counts 1.5 times, the eyes' ink 4 times, so that they,
62 * hat bands and props survive the shrink.
63 */
64export const downsample = (grid: Grid, box: ReturnType<typeof boundsOf>, cols = SPRITE_W, rows = SPRITE_H): Grid => {
65  const w = box.x1 - box.x0
66  const h = box.y1 - box.y0
67  return Array.from({ length: rows }, (_, j) =>
68    Array.from({ length: cols }, (_, i): Px => {
69      const xs = box.x0 + Math.floor((i * w) / cols)
70      const xe = Math.max(xs + 1, box.x0 + Math.floor(((i + 1) * w) / cols))
71      const ys = box.y0 + Math.floor((j * h) / rows)
72      const ye = Math.max(ys + 1, box.y0 + Math.floor(((j + 1) * h) / rows))
73      const votes = new Map<string, number>()
74      let drawn = 0
75      for (let y = ys; y < ye; y++)
76        for (let x = xs; x < xe; x++) {
77          const c = grid[y]?.[x]
78          if (!c) continue
79          drawn++
80          votes.set(c, (votes.get(c) ?? 0) + (c === BODY ? 1 : c === EYE ? 4 : 1.5))
81        }
82      if (drawn * 3 < (xe - xs) * (ye - ys)) return null
83      return [...votes].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null
84    }),
85  )
86}
87
88/** One terminal line per two pixel rows: `▀` top only, `▄` bottom only, `█` both alike, or top over bottom. */
89export const toRuns = (grid: Grid): Run[][] =>
90  Array.from({ length: Math.ceil(grid.length / 2) }, (_, line) => {
91    const runs: Run[] = []
92    for (let i = 0; i < (grid[0]?.length ?? 0); i++) {
93      const top = grid[line * 2]?.[i] ?? null
94      const bottom = grid[line * 2 + 1]?.[i] ?? null
95      const cell: Run =
96        top === null && bottom === null
97          ? { text: ' ' }
98          : bottom === null
99            ? { text: '▀', fg: top! }
100            : top === null
101              ? { text: '▄', fg: bottom }
102              : top === bottom
103                ? { text: '█', fg: top }
104                : { text: '▀', fg: top, bg: bottom }
105      const last = runs[runs.length - 1]
106      if (last && last.fg === cell.fg && last.bg === cell.bg) last.text += cell.text
107      else runs.push(cell)
108    }
109    return runs
110  })
111
112/** The grid as plain text, for tests and a quick look: color-blind, one glyph per cell. */
113export const toText = (grid: Grid): string =>
114  toRuns(grid)
115    .map(line => line.map(r => r.text).join(''))
116    .join('\n')
117
types/index.d.ts 77 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 BgState = 'busy' | 'idle' | 'waiting'
44
45/** A background session related to this one, as the last poll of the agents list saw it. */
46export type BgSession = {
47  id: string
48  name: string
49  state: BgState
50  /** The crab: scout, builder, reviewer or other. */
51  tier: string
52  /** Known only for sessions this one launched. */
53  model?: string
54  effort?: string
55  branch?: string
56}
57
58export type Panel = {
59  isCompact: boolean
60  isDoneCollapsed: boolean
61  autoOpenedFor: string
62}
63
64declare module 'claude-code' {
65  interface PluginState {
66    'savvy-progress': {
67      flow: Flow | null
68      agents: AgentRun[]
69      panel: Panel
70      now: number
71      bg: BgSession[]
72      /** What this session's own background launches asked for, by lowercase `--name`. */
73      launches: Record<string, { model?: string; effort?: string; agent?: string }>
74    }
75  }
76}
77