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

berkays-mods copy of johnnyvizz/claude-kit 1.2.0 (MIT, johnnyvizz). Added: the claude-config agents
scout(Haiku),builder(Sonnet) andreviewer(Opus) are tiers of their own, with their own crabs, and count for the bar and the panel's auto-open the waysavvy-*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 withclaude 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.
savvy-* worker starts./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.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.▀ in the top pixel's color over the bottom pixel's background). No animation.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.
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.
hooks/register.tsx 1342 lines1import { 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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 lines1// 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
184hooks/sprite.ts 117 lines1// 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')
117types/index.d.ts 77 lines1export 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