Fork of johnnyvizz/claude-kit savvy-progress 1.2.0: the /savvy-flow bar and /agents-info panel, plus omd-lite runs this session launched (mcode / claude -p /…

Progress bar above the prompt plus a live agents panel for Claude Code subagents, extended to also track omd-lite executor runs.
Forked from johnnyvizz/claude-kit
plugins/savvy-progress1.2.0 (MIT, seeLICENSE). Changes in this fork:
- 跟踪本会话启动的 omd-lite run(
bin/omd-scan.mjs+ 提示框下方的 band + 面板里的omd-lite一节);- MiniMax 执行器的像素形象(桌面端 SVG,终端半块字符);
- 中文界面(
language: zh);- runs 目录可用环境变量
OMD_LITE_RUNS_DIR覆盖。保留原名,所以 savvy-flow 的工具名照样对得上。
savvy-* worker 启动后出现。/agents-info 开关):运行中、已结束、计划中的子 agent,含模型、effort、任务进度、上下文、估算花费与耗时。prefers-reduced-motion 时停动画。mcp__savvy-progress__progress:编排者上报计划、阶段、已验收任务;mcp__savvy-progress__step:worker 上报自己的步骤(done / total / note)。花费是按 token 数和内置价格表(hooks/register.tsx 的 PRICES)的粗估,不是账单。
savvy-progress 本来只看得到 Claude Code 自己的子 agent。本 fork 还跟踪 omd-lite 在 Claude Code 之外启动的执行器(mcode、claude -p、pi),只看本会话启动的 run:omd-lite 把 claude_session(取自 CLAUDE_CODE_SESSION_ID)和 driver_pid 写进 <runs>/<label>/meta.json,bin/omd-scan.mjs <session-id> 只读这些(纯读;有 run 在跑时每 2 秒,否则每 10 秒,以及提到 omd-lite 的 Bash 调用之后立即扫一次)。
n/max、执行中或在跑闸、工具调用数、当前工具、token、耗时。最后一个 run 结束后保留十分钟;✕ 隐藏;×N 打开面板。omd-lite 一节,每个 run 一行,带结果(stuck-gate、失败的闸、interrupted)。没装 omd-lite 时这部分什么都不显示。
language:auto(默认)、en、ru、zh。auto 依次跟随 Claude Code 的 language 设置、系统 locale,最后回落英文。在 /config 设置,或手动加载时写进 ~/.claude/settings.json: { "pluginConfigs": { "savvy-progress": { "options": { "language": "zh" } } } }
OMD_LITE_RUNS_DIR:omd-lite runs 目录,默认 $HOME/.omd-lite/runs。claude plugin test .(band 和面板)与 node --test bin/omd-scan.spec.mjs(扫描器)。
/plugin marketplace add AbyssCN/claude-lead-harness
/plugin install savvy-progress@claude-lead-harnesshooks/register.tsx 1451 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register } from 'claude-code'
3
4import type { AgentRun, Flow, OmdRun, Panel, Phase, PlannedTask } from '../types'
5
6const flow = atom({ plugin: 'savvy-progress', key: 'flow' } as const, null)
7const agents = atom({ plugin: 'savvy-progress', key: 'agents' } as const, [])
8const panel = atom({ plugin: 'savvy-progress', key: 'panel' } as const, {
9 isCompact: false,
10 isDoneCollapsed: false,
11 autoOpenedFor: '',
12})
13const now = atom({ plugin: 'savvy-progress', key: 'now' } as const, 0)
14// omd-lite runs launched from this session, as bin/omd-scan.mjs reads them; ids the band was dismissed for.
15const omd = atom({ plugin: 'savvy-progress', key: 'omd' } as const, [])
16const omdDismissed = atom({ plugin: 'savvy-progress', key: 'omdDismissed' } as const, [])
17
18const TOOL = 'mcp__savvy-progress__progress'
19const STEP_TOOL = 'mcp__savvy-progress__step'
20const PANE = 'savvy-agents'
21const PHASES: readonly Phase[] = ['plan', 'design', 'delegate', 'review', 'close']
22const ACCENT = '#8f8cf4'
23const DONE = '#5fbf8f'
24
25type ProgressInput = {
26 title?: string
27 total?: number
28 done?: number
29 phase?: Phase
30 finished?: boolean
31 tasks?: { title?: string; tier?: string; after?: number[] }[]
32}
33
34// ---------------------------------------------------------------------------
35// Language: the `language` option, else Claude Code's `language` setting, else the
36// process locale; English when nothing says Russian or Chinese.
37
38type Lang = 'en' | 'ru' | 'zh'
39
40const STRINGS = {
41 en: {
42 pane: 'Agents',
43 cost: 'Cost',
44 tokens: 'Tokens',
45 time: 'Time',
46 collapse: 'Collapse',
47 expand: 'Expand',
48 running: 'Running',
49 finished: 'Finished',
50 planned: 'Planned',
51 empty: 'No subagents yet.',
52 round: 'round',
53 failed: 'error',
54 after: 'after',
55 tokensWord: 'tokens',
56 agentsCount: 'agents',
57 isRunning: 'running',
58 isFinished: 'finished',
59 isPlanned: 'planned',
60 opened: 'Agents panel opened.',
61 closed: 'Agents panel closed.',
62 done: 'Done',
63 plan: 'Plan',
64 design: 'Design',
65 tasks: 'Tasks',
66 review: 'Review',
67 busy: 'running',
68 omd: 'omd-lite',
69 exec: 'executing',
70 gate: 'gates',
71 interrupted: 'interrupted',
72 toolsWord: 'tools',
73 of: 'of',
74 },
75 ru: {
76 pane: 'Агенты',
77 cost: 'Стоимость',
78 tokens: 'Токены',
79 time: 'Время',
80 collapse: 'Свернуть',
81 expand: 'Развернуть',
82 running: 'Работают',
83 finished: 'Завершены',
84 planned: 'Запланированы',
85 empty: 'Субагентов пока нет.',
86 round: 'раунд',
87 failed: 'ошибка',
88 after: 'после',
89 tokensWord: 'токенов',
90 agentsCount: 'агентов',
91 isRunning: 'работает',
92 isFinished: 'завершён',
93 isPlanned: 'запланирована',
94 opened: 'Панель агентов открыта.',
95 closed: 'Панель агентов закрыта.',
96 done: 'Готово',
97 plan: 'План',
98 design: 'Дизайн',
99 tasks: 'Задачи',
100 review: 'Ревью',
101 busy: 'в работе',
102 omd: 'omd-lite',
103 exec: 'выполняет',
104 gate: 'проверки',
105 interrupted: 'прервано',
106 toolsWord: 'инструм.',
107 of: 'из',
108 },
109 zh: {
110 pane: '代理',
111 cost: '花费',
112 tokens: 'Token',
113 time: '用时',
114 collapse: '收起',
115 expand: '展开',
116 running: '运行中',
117 finished: '已结束',
118 planned: '计划中',
119 empty: '还没有子代理。',
120 round: '轮次',
121 failed: '出错',
122 after: '等待',
123 tokensWord: 'token',
124 agentsCount: '个代理',
125 isRunning: '运行中',
126 isFinished: '已结束',
127 isPlanned: '计划中',
128 opened: '代理面板已打开。',
129 closed: '代理面板已关闭。',
130 done: '完成',
131 plan: '规划',
132 design: '设计',
133 tasks: '任务',
134 review: '审查',
135 busy: '运行中',
136 omd: 'omd-lite',
137 exec: '执行中',
138 gate: '闸检查',
139 interrupted: '中断',
140 toolsWord: '工具',
141 of: '/',
142 },
143} as const
144
145// Module scope is fine here: session.start sets it again on every (re)load.
146let lang: Lang = 'en'
147const tr = () => STRINGS[lang]
148
149const langOf = (v: unknown): Lang =>
150 typeof v !== 'string' ? 'en' : /^(ru|russian|рус)/i.test(v.trim()) ? 'ru' : /^(zh|chinese|中文|简体|繁體)/i.test(v.trim()) ? 'zh' : 'en'
151
152async function detectLang($: EngineInterface, option: unknown): Promise<Lang> {
153 if (option === 'en' || option === 'ru' || option === 'zh') return option
154 try {
155 const settings = (await $.settings.read()) as Record<string, unknown>
156 if (typeof settings.language === 'string' && settings.language.trim()) return langOf(settings.language)
157 } catch {
158 // No settings: fall through to the locale.
159 }
160 const locale = (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG'))
161 return langOf(locale)
162}
163
164const blank = (): Flow => ({
165 title: 'savvy-flow',
166 total: 0,
167 done: 0,
168 running: 0,
169 phase: 'plan',
170 isFinished: false,
171 tasks: [],
172})
173
174const isNewFlow = (prev: Flow | null, input: ProgressInput): boolean =>
175 !prev || prev.isFinished || (input.title !== undefined && input.title.trim() !== prev.title)
176
177const cleanTasks = (tasks: ProgressInput['tasks']): PlannedTask[] | undefined =>
178 tasks
179 ?.filter(t => t.title?.trim())
180 .map(t => ({
181 title: (t.title ?? '').trim(),
182 tier: (t.tier ?? '').replace(/^savvy-/, '').trim().toLowerCase(),
183 after: (t.after ?? []).filter(n => Number.isInteger(n) && n > 0),
184 }))
185
186const merge = (prev: Flow | null, input: ProgressInput): Flow => {
187 // A new title means a new flow: never carry counters over from an earlier one.
188 const base = isNewFlow(prev, input) || !prev ? blank() : { ...blank(), ...prev }
189 const tasks = cleanTasks(input.tasks) ?? base.tasks
190 const total = Math.max(0, Math.round(input.total ?? (input.tasks ? tasks.length : base.total)))
191 const done = Math.min(total || Infinity, Math.max(0, Math.round(input.done ?? base.done)))
192 const phase = input.phase && PHASES.includes(input.phase) ? input.phase : base.phase
193 return {
194 ...base,
195 title: input.title?.trim() || base.title,
196 total,
197 done,
198 phase: input.finished ? 'close' : phase,
199 isFinished: input.finished === true,
200 tasks,
201 }
202}
203
204const label = (f: Flow): string => {
205 const s = tr()
206 if (f.isFinished) return s.done
207 if (f.phase === 'plan') return s.plan
208 if (f.phase === 'design') return s.design
209 const count = f.total ? `${f.done}/${f.total}` : `${f.running} ${s.busy}`
210 return `${f.phase === 'review' ? s.review : s.tasks} ${count}`
211}
212
213const ratio = (f: Flow): number => (f.isFinished ? 1 : f.total ? f.done / f.total : 0)
214
215// Deterministic noise so the dither does not shimmer between redraws.
216const noise = (x: number, y: number): number => {
217 const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
218 return s - Math.floor(s)
219}
220
221// The whole row is one SVG: the desktop wraps sibling elements onto new lines,
222// so title, bar, percent and the crab live in one drawing; only the count and
223// the dismiss are Buttons beside it.
224const H = 22
225const BAR_H = 16
226const CRAB_W = 26
227const CELL = 3
228const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
229
230const xml = (s: string): string =>
231 s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
232
233const clip = (s: string, max: number): string => (s.length > max ? s.slice(0, Math.max(1, max - 1)) + '…' : s)
234
235// Rough advance of system UI text, in em; good enough to size the title's slot.
236const charEm = (ch: string): number =>
237 /[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-ZА-ЯЁmwшщжюМШЩЖЮ@%]/.test(ch) ? 0.72 : 0.56
238
239const textWidth = (s: string, size: number): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
240
241// Cuts `s` to fit `maxW` pixels, with an ellipsis when it had to cut.
242const fitText = (s: string, size: number, maxW: number): string => {
243 if (textWidth(s, size) <= maxW) return s
244 let out = ''
245 for (const ch of s) {
246 if (textWidth(out + ch + '…', size) > maxW) break
247 out += ch
248 }
249 return out + '…'
250}
251
252const rowSvg = (f: Flow, W: number, isWorking: boolean): string => {
253 // The title takes what it needs, up to 40% of the row; the bar takes the rest.
254 const title = fitText(f.title, 13, Math.max(60, W * 0.4))
255 const BAR_X = Math.round(16 + textWidth(title, 13) + 12)
256 const BAR_W = Math.max(60, W - BAR_X - 46 - CRAB_W)
257 const color = f.isFinished ? DONE : ACCENT
258 const y0 = (H - BAR_H) / 2
259 const fillW = Math.round(BAR_W * ratio(f))
260 const runW = f.total ? Math.round((BAR_W * Math.min(f.total, f.done + f.running)) / f.total) : 0
261 const dots: string[] = []
262
263 // Dithered fill: sparse at the start, dense toward the head.
264 const cols = Math.floor(fillW / CELL)
265 const rows = Math.floor(BAR_H / CELL)
266 for (let c = 0; c < cols; c++) {
267 const density = 0.35 + 0.6 * Math.pow(c / Math.max(1, cols), 1.2)
268 for (let r = 0; r < rows; r++) {
269 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"/>`)
270 }
271 }
272 // Handed to workers, not yet accepted: a faint second layer.
273 const faint: string[] = []
274 for (let c = cols; c < Math.floor(runW / CELL); c++) {
275 for (let r = 0; r < rows; r++) {
276 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"/>`)
277 }
278 }
279
280 const ticks: string[] = []
281 for (let i = 1; i < f.total; i++) {
282 const x = Math.round((BAR_W * i) / f.total)
283 if (x > fillW + 4) ticks.push(`<rect x="${x}" y="${BAR_H / 2 - 4}" width="1.5" height="8" rx="0.75"/>`)
284 }
285
286 const text = label(f)
287 const pillW = Math.round(18 + text.length * 6.6)
288 const pillX = Math.max(0, Math.min(BAR_W - pillW, fillW - pillW))
289 const percent = `${Math.round(ratio(f) * 100)}%`
290
291 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
292<style>
293.t{fill:#1f1f1f}.m{fill:#8a8a8a}.k{fill:#e4e4e2}.tk{fill:#b4b4b0}
294@media (prefers-color-scheme: dark){.t{fill:#ececec}.m{fill:#9a9a9a}.k{fill:#2c2c2c}.tk{fill:#5a5a5a}}
295/* Pixels twinkle in four out-of-phase groups; a finished bar settles to a slow glow. */
296.t0,.t1,.t2,.t3{animation:tw ${f.isFinished ? 3.2 : 2.2}s ease-in-out infinite}
297.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}
298@keyframes tw{0%,100%{opacity:1}50%{opacity:${f.isFinished ? 0.8 : 0.3}}}
299@media (prefers-reduced-motion: reduce){.t0,.t1,.t2,.t3{animation:none}}
300</style>
301<defs><clipPath id="c"><rect x="0" y="0" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/></clipPath></defs>
302<circle cx="5" cy="${H / 2}" r="4" fill="${color}"/>
303<text class="t" x="16" y="${H / 2 + 4.5}" font-family="${FONT}" font-size="13" font-weight="500">${xml(title)}</text>
304<g transform="translate(${BAR_X},${y0})">
305<rect class="k" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/>
306<g clip-path="url(#c)">
307<g fill="${color}">${dots.join('')}</g>
308<g fill="${color}" opacity="0.45">${faint.join('')}</g>
309<g class="tk">${ticks.join('')}</g>
310</g>
311<rect x="${pillX}" width="${pillW}" height="${BAR_H}" rx="${BAR_H / 2}" fill="${color}"/>
312<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>
313</g>
314<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>
315${CRAB_CSS}${crab(W - CRAB_W + 1, 0, 'other', false, isWorking, 0.8)}
316</svg>`
317}
318
319const barText = (f: Flow, width: number): string => {
320 const filled = Math.round(width * ratio(f))
321 return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
322}
323
324// ---------------------------------------------------------------------------
325// Agents panel: every subagent of the session, plus the tasks the flow planned.
326
327const TIER_COLOR: Record<string, string> = {
328 fable: '#7F77DD',
329 heavy: '#D85A30',
330 careful: '#BA7517',
331 medium: '#378ADD',
332 light: '#1D9E75',
333 other: '#888780',
334}
335
336// What each savvy tier runs on, for planned tasks that have no run yet.
337const colorOf = (tier: string): string => TIER_COLOR[tier] ?? '#888780'
338
339const TIER_MODEL: Record<string, string> = {
340 fable: 'Fable · high',
341 heavy: 'Opus · xhigh',
342 careful: 'Opus · high',
343 medium: 'Opus · medium',
344 light: 'Opus · low',
345}
346
347// USD per million tokens: input, output, cache read, cache write (5-minute TTL).
348// The engine reports tokens, not money, so the panel's cost is an estimate.
349const PRICES: [RegExp, [number, number, number, number]][] = [
350 [/fable|mythos/, [10, 50, 0.25, 12.5]],
351 [/opus-5-5/, [4, 20, 0.2, 5]],
352 [/opus/, [5, 25, 0.5, 6.25]],
353 [/sonnet/, [2, 10, 0.2, 2.5]],
354 [/haiku/, [1, 5, 0.1, 1.25]],
355]
356
357type Usage = {
358 input_tokens: number
359 output_tokens: number
360 cache_read_input_tokens: number
361 cache_creation_input_tokens: number
362}
363
364const priceOf = (model: string): [number, number, number, number] =>
365 PRICES.find(([re]) => re.test(model.toLowerCase()))?.[1] ?? [4, 20, 0.2, 5]
366
367const costOf = (model: string, u: Usage): number => {
368 const [i, o, r, w] = priceOf(model)
369 return (
370 ((u.input_tokens || 0) * i +
371 (u.output_tokens || 0) * o +
372 (u.cache_read_input_tokens || 0) * r +
373 (u.cache_creation_input_tokens || 0) * w) /
374 1e6
375 )
376}
377
378const windowOf = (model: string): number => (/haiku/i.test(model) ? 200_000 : 1_000_000)
379
380// `savvy-careful`, or `savvy-flow:savvy-careful` when the agents ship in a plugin.
381const tierOf = (type: string): string => {
382 const bare = type.replace(/^[^:]*:/, '')
383 const t = bare.replace(/^savvy-/, '').toLowerCase()
384 return t in TIER_COLOR && bare.startsWith('savvy-') ? t : 'other'
385}
386
387const modelName = (id: string): string => {
388 const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
389 const [, family = '', major = '', minor] = m ?? []
390 if (!family) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '') || '—'
391 return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()} ${major}${minor ? '.' + minor : ''}`
392}
393
394const norm = (s: string): string => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
395
396const fmtTokens = (n: number): string =>
397 n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${Math.round(n)}`
398
399const fmtCost = (usd: number): string => `$${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`
400
401const fmtTime = (ms: number): string => {
402 const s = Math.max(0, Math.round(ms / 1000))
403 const h = Math.floor(s / 3600)
404 const m = Math.floor((s % 3600) / 60)
405 const ss = String(s % 60).padStart(2, '0')
406 return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
407}
408
409const elapsed = (a: AgentRun, at: number): number => (a.endedAt ?? Math.max(at, a.startedAt)) - a.startedAt
410
411type Planned = PlannedTask & { n: number }
412
413const plannedOf = (f: Flow | null, list: AgentRun[]): Planned[] => {
414 if (!f || f.isFinished) return []
415 const started = new Set(list.map(a => norm(a.description)))
416 return (f.tasks ?? []).map((t, i) => ({ ...t, n: i + 1 })).filter(t => !started.has(norm(t.title)))
417}
418
419const totals = (list: AgentRun[], at: number) => {
420 const cost = list.reduce((s, a) => s + a.costUsd, 0)
421 const tokens = list.reduce((s, a) => s + a.tokens, 0)
422 const start = Math.min(...list.map(a => a.startedAt))
423 const end = Math.max(...list.map(a => a.endedAt ?? Math.max(at, a.startedAt)))
424 return { cost, tokens, time: list.length ? end - start : 0 }
425}
426
427// --- desktop drawings: each row is one SVG, as the band above the prompt is.
428
429const PANE_CSS = `<style>
430.t{fill:#1f1f1f}.s{fill:#6b6b68}.m{fill:#9a9a96}.k{fill:#ecebe8}.ln{stroke:#e4e4e1}.tile{fill:#f4f3f0}
431@media (prefers-color-scheme: dark){.t{fill:#ececec}.s{fill:#a8a8a4}.m{fill:#7d7d79}.k{fill:#2c2c2b}.ln{stroke:#333331}.tile{fill:#262625}}
432.live{animation:p 1.6s ease-in-out infinite}@keyframes p{50%{opacity:.3}}
433@media (prefers-reduced-motion: reduce){.live{animation:none}}
434</style>`
435
436// Pixel Clawd from DockCrab (Clawdy): a 24×18 crab on a 30×28 grid, one costume per tier.
437// The body keeps the brand clay; the tier's color lives in the costume's accent.
438const CLAY = '#D97757'
439const INK = '#1F1E1D'
440
441// `cls` puts a pixel in a named group: `bd` (the default) is the body and its
442// costume, `la`/`lb` the leg pairs, anything else a prop with its own motion.
443type Fill = (x: number, y: number, w: number, h: number, c: string, cls?: string) => void
444
445const stamp = (f: Fill, x: number, y: number, rows: string[], map: Record<string, string>, cls?: string): void =>
446 rows.forEach((row, dy) => [...row].forEach((ch, dx) => map[ch] && f(x + dx, y + dy, 1, 1, map[ch] ?? '', cls)))
447
448// `armCls` lets a raised claw travel with the prop it holds.
449const crabBody = (f: Fill, armFront = 0, armCls?: string): void => {
450 f(7, 10, 16, 12, CLAY)
451 f(3, 14, 4, 4, CLAY)
452 f(23, 14 + armFront, 4, 4, CLAY, armCls)
453 f(9, 12, 2, 2, INK)
454 f(19, 12, 2, 2, INK)
455 f(7, 22, 2, 4, CLAY, 'la')
456 f(17, 22, 2, 4, CLAY, 'la')
457 f(11, 22, 2, 4, CLAY, 'lb')
458 f(21, 22, 2, 4, CLAY, 'lb')
459}
460
461// Pure CSS, run by the compositor: no redraws. Periods divide one second, so the
462// once-a-second redraw of a running row restarts them in phase. Every crab walks;
463// each costume adds its prop's own motion on top.
464const CRAB_CSS = `<style>
465.run .la{animation:st .5s steps(1) infinite}.run .lb{animation:st .5s steps(1) infinite -.25s}
466.run .bd{animation:bob .5s steps(1) infinite -.125s}
467.run g{transform-box:fill-box}
468@keyframes st{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}
469.c-fable.run{animation:float 1s ease-in-out infinite}
470.c-fable.run .la,.c-fable.run .lb,.c-fable.run .bd{animation:none}
471.c-fable.run .ant{animation:blink 1s steps(1) infinite}
472.c-fable.run .star{animation:blink .5s steps(1) infinite -.25s}
473@keyframes float{50%{transform:translateY(-2px)}}@keyframes blink{50%{opacity:.15}}
474.c-heavy.run .it{animation:scan 1s steps(1) infinite}
475.c-heavy.run .gl{animation:blink 1s steps(1) infinite -.5s}
476@keyframes scan{25%{transform:translate(-1px,1px)}50%{transform:translate(-2px,2px)}75%{transform:translate(-1px,1px)}}
477.c-careful.run .it{transform-origin:100% 100%;animation:twist .5s ease-in-out infinite}
478@keyframes twist{50%{transform:rotate(-35deg)}}
479.c-medium.run .pan{transform-origin:0 50%;animation:tilt 1s ease-in-out infinite}
480.c-medium.run .egg{animation:flip 1s ease-in-out infinite}
481@keyframes tilt{20%,40%{transform:rotate(-12deg)}}@keyframes flip{30%{transform:translateY(-5px) scaleY(-1)}60%{transform:translateY(0)}}
482.c-light.run .la{animation-duration:.25s}.c-light.run .lb{animation-duration:.25s;animation-delay:-.125s}
483.c-light.run .flag{transform-origin:0 50%;animation:wave .25s steps(1) infinite}
484@keyframes wave{50%{transform:skewY(-12deg) scaleX(.85)}}
485.c-explore.run .it{transform-origin:50% 100%;animation:fence .5s ease-in-out infinite}
486@keyframes fence{50%{transform:rotate(25deg)}}
487.c-minimax.run .e0,.c-minimax.run .e1,.c-minimax.run .e2,.c-minimax.run .e3,.c-minimax.run .e4{transform-origin:50% 100%;animation:eq .5s ease-in-out infinite}
488.c-minimax.run .e1{animation-delay:-.1s}.c-minimax.run .e2{animation-delay:-.2s}.c-minimax.run .e3{animation-delay:-.3s}.c-minimax.run .e4{animation-delay:-.4s}
489.c-minimax.run .arm{transform-origin:0 100%;animation:tilt 1s ease-in-out infinite}
490@keyframes eq{50%{transform:scaleY(.35)}}
491@media (prefers-reduced-motion: reduce){.run,.run g{animation:none!important}}
492</style>`
493
494const COSTUMES: Record<string, (f: Fill, t: string) => void> = {
495 // Fable: astronaut in a glass dome; floats instead of walking, the antenna and the star blink.
496 fable: (f, t) => {
497 crabBody(f)
498 f(6, 7, 18, 1, '#E6E8EE'); f(5, 8, 1, 14, '#E6E8EE'); f(24, 8, 1, 14, '#E6E8EE'); f(6, 22, 18, 1, '#C9CCD2')
499 f(6, 8, 18, 14, 'rgba(169,214,245,.32)'); f(8, 9, 2, 1, '#fff'); f(8, 10, 1, 2, '#fff')
500 f(14, 4, 2, 3, '#C9CCD2'); f(14, 2, 2, 2, t, 'ant'); f(13, 18, 4, 2, t)
501 f(27, 3, 1, 3, '#F5C542', 'star'); f(26, 4, 3, 1, '#F5C542', 'star')
502 },
503 // Heavy: detective with a deerstalker; the magnifier sweeps and glints.
504 heavy: (f, t) => {
505 crabBody(f, -4, 'it')
506 stamp(f, 6, 3, ['......bbbbbb......', '....bbcbbcbbbb....', '...bbbbbbbbbbbb...', '..bcbbcbbcbbcbbb..', '.bbbbbbbbbbbbbbbb.', 'dddddddddddddddddd'], { b: '#7A4A26', c: '#A0703F', d: '#5A3519' })
507 f(6, 9, 18, 1, t)
508 stamp(f, 23, 1, ['.kkk.', 'k...k', 'k...k', 'k...k', '.kkk.'], { k: '#3A3A3C' }, 'it')
509 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')
510 },
511 // Careful: engineer in a hard hat; the wrench turns a bolt.
512 careful: (f, t) => {
513 crabBody(f)
514 stamp(f, 6, 4, ['.....yyyyyyyy.....', '...yyyyyhhyyyyy...', '..yyyyyyhhyyyyyy..', '..yyyyyyhhyyyyyy..', '.yyyyyyyhhyyyyyyy.', 'dddddddddddddddddd'], { y: '#F5C542', h: '#FBE08A', d: '#C99A1E' })
515 f(13, 5, 4, 2, t)
516 stamp(f, 0, 10, ['.s.s', 'sss.', '.s..', '.s..'], { s: '#8E929A' }, 'it')
517 },
518 // Medium: chef, the toque traced from DockCrab's Sprites.chefHat; tosses the omelette.
519 medium: (f, t) => {
520 crabBody(f, -4, 'pan')
521 stamp(f, 6, 0, ['........lll.......', '.......lllll......', '.wwwwgwwwwwwgwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwgwwwwwggwwwww', '.wwwwgwwwwwggwwwww', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.'], { w: '#F4F3EE', l: '#F7F6F2', g: '#D2D1C8', b: t, d: '#B45F43' })
522 f(22, 8, 7, 2, '#4A4A48', 'pan'); f(26, 10, 1, 1, '#4A4A48', 'pan'); f(24, 7, 3, 1, '#F5B731', 'egg')
523 },
524 // Light: racer in a helmet; runs at double pace, the checkered flag flutters.
525 light: (f, t) => {
526 crabBody(f, -4)
527 stamp(f, 6, 5, ['....rrrrrrrrrr....', '..rrrrrrwwrrrrrr..', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.kkkkkkkkkkkkkkkkr'], { r: t, w: '#F8F6F1', k: INK })
528 f(25, 1, 1, 9, '#8E929A')
529 stamp(f, 26, 1, ['wkwk', 'kwkw', 'wkwk'], { w: '#F8F6F1', k: INK }, 'flag')
530 },
531 // Explore: pirate scouting the code; the cutlass fences.
532 explore: f => {
533 crabBody(f)
534 stamp(f, 5, 3, ['.kk..............kk.', '.kkk....kkkk....kkk.', '..kkkkkkkwwkkkkkkk..', '..kkkkkkkkkkkkkkkk..', '.gggggggggggggggggg.'], { k: '#55514C', w: '#F8F6F1', g: '#F5C542' })
535 f(7, 11, 11, 1, INK); f(18, 11, 4, 3, INK)
536 f(27, 6, 1, 9, '#C9CCD2', 'it'); f(26, 15, 3, 1, '#7A4A26', 'it')
537 },
538 other: f => crabBody(f),
539 // MiniMax (mcode): a peach blob in the brand's pink-to-orange, the logo's waveform as its crest;
540 // the bars pump like an equalizer while it works and the right arm types.
541 minimax: f => {
542 MM_WAVE.forEach(([x, h, c], i) => f(x, 10 - h, 2, h, c, `e${i}`))
543 f(8, 10, 14, 1, MM.pink); f(7, 11, 16, 1, MM.pink); f(6, 12, 18, 6, MM.pink)
544 f(6, 18, 18, 3, MM.coral); f(7, 21, 16, 1, MM.orange)
545 f(9, 13, 4, 4, '#FFFFFF'); f(17, 13, 4, 4, '#FFFFFF'); f(11, 14, 2, 2, INK); f(19, 14, 2, 2, INK)
546 f(8, 17, 2, 1, MM.cheek); f(20, 17, 2, 1, MM.cheek); f(14, 18, 2, 1, MM.deep)
547 f(3, 15, 3, 3, MM.pink); f(24, 14, 3, 3, MM.pink, 'arm')
548 f(9, 22, 3, 3, MM.deep, 'la'); f(18, 22, 3, 3, MM.deep, 'lb')
549 },
550}
551
552const costumeOf = (type: string): string => (type === 'Explore' ? 'explore' : tierOf(type))
553
554// --- MiniMax: the same character for the desktop (SVG) and the terminal (half blocks).
555
556const MM = { pink: '#F23F5D', coral: '#FF6B4A', orange: '#FF9A3C', deep: '#B81E4B', cheek: '#FFC2CC' }
557// Crest bars: x, height, colour, from pink to orange like the logo's gradient.
558const MM_WAVE: [number, number, string][] = [
559 [8, 3, '#F23F5D'],
560 [11, 6, '#F5505A'],
561 [14, 8, '#F96650'],
562 [17, 6, '#FC7D46'],
563 [20, 3, '#FF9A3C'],
564]
565
566// 7×6 pixels, two per terminal cell row: crest, face, belly, feet. The crest has two frames.
567const MM_PIXELS = (frame: number): string[] => [
568 frame % 2 ? 'a.b.c.d' : '.a.b.c.',
569 '.ppppp.',
570 'pwkpwkp',
571 'pppdppp',
572 'ooooooo',
573 frame % 2 ? '..d.d..' : '.d...d.',
574]
575const MM_COLORS: Record<string, string> = {
576 a: '#F23F5D', b: '#F96650', c: '#FF9A3C', d: MM.deep, p: MM.pink, w: '#FFFFFF', k: INK, o: MM.coral,
577}
578
579// Clawd for the terminal: 8×6 pixels, a hat in the tier's colour (none for plain subagents), legs that step.
580const CRAB_PIXELS = (frame: number, hasHat: boolean): string[] => [
581 hasHat ? '..tttt..' : '........',
582 '.cccccc.',
583 '.ckccck.',
584 'cccccccc',
585 '.cccccc.',
586 frame % 2 ? 'c.c..c.c' : '.c.cc.c.',
587]
588const CRAB_COLORS: Record<string, string> = { c: CLAY, k: INK }
589
590// Waveform glyph for one-line places (the band), animated by the clock.
591const WAVE_FRAMES = ['▂▄▆▄▂', '▃▆▄▂▄', '▄▄▂▄▆', '▆▂▄▆▄', '▄▄▆▄▂']
592const WAVE_COLORS = ['#F23F5D', '#F5505A', '#F96650', '#FC7D46', '#FF9A3C']
593
594const isMinimax = (r: OmdRun): boolean => r.executor === 'mcode' || /minimax/i.test(r.model)
595
596const CRAB_SCALE = 1.1
597
598// Body and props nest inside `bd` so a prop rides the bob and adds its own motion;
599// legs stay outside it and step on their own.
600const crab = (x: number, y: number, costume: string, dim = false, isWalking = false, scale = CRAB_SCALE): string => {
601 const groups = new Map<string, string[]>([['bd', []]])
602 const f: Fill = (cx, cy, w, h, c, cls = 'bd') => {
603 if (!groups.has(cls)) groups.set(cls, [])
604 groups.get(cls)?.push(`<rect x="${cx}" y="${cy}" width="${w}" height="${h}" fill="${c}"/>`)
605 }
606 const draw = COSTUMES[costume] ?? ((g: Fill) => crabBody(g))
607 draw(f, colorOf(costume))
608 const group = (cls: string) => `<g class="${cls}">${(groups.get(cls) ?? []).join('')}</g>`
609 const props = [...groups.keys()].filter(k => k !== 'bd' && k !== 'la' && k !== 'lb')
610 const body = `<g class="bd">${(groups.get('bd') ?? []).join('')}${props.map(group).join('')}</g>`
611 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>`
612}
613
614const statusMark = (x: number, y: number, status: string, color: string): string => {
615 if (status === 'running') return `<circle class="live" cx="${x}" cy="${y}" r="3.5" fill="${color}"/>`
616 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"/>`
617 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"/>`
618 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"/>`
619}
620
621const svg = (W: number, H: number, body: string): string =>
622 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${PANE_CSS}${CRAB_CSS}${body}</svg>`
623
624// The pane's own title already says "Agents": the header names the flow, if any.
625const headerSvg = (W: number, title: string, t: ReturnType<typeof totals>): string => {
626 const s = tr()
627 const gap = 6
628 const tw = (W - gap * 2) / 3
629 const top = title ? 28 : 0
630 const tile = (i: number, k: string, v: string) =>
631 `<rect class="tile" x="${i * (tw + gap)}" y="${top}" width="${tw}" height="40" rx="8"/>
632<text class="s" x="${i * (tw + gap) + 9}" y="${top + 16}" font-family="${FONT}" font-size="11">${k}</text>
633<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>`
634 return svg(
635 W,
636 headerHeight(title),
637 `${title ? `<text class="t" x="0" y="15" font-family="${FONT}" font-size="14" font-weight="600">${xml(fitText(title, 14, W))}</text>` : ''}
638${tile(0, s.cost, '≈' + fmtCost(t.cost))}${tile(1, s.tokens, fmtTokens(t.tokens))}${tile(2, s.time, fmtTime(t.time))}`,
639 )
640}
641
642const headerHeight = (title: string): number => (title ? 72 : 44)
643
644// The task's own progress when the worker reports steps; a finished run is full.
645const progressOf = (a: AgentRun): number | null => {
646 if (a.status === 'done') return 1
647 if (a.stepTotal) return Math.min(1, (a.stepDone ?? 0) / a.stepTotal)
648 return null
649}
650
651const ctxOf = (a: AgentRun): number => (a.contextMax ? Math.min(100, Math.round((a.contextTokens / a.contextMax) * 100)) : 0)
652
653const agentSvg = (W: number, a: AgentRun, at: number): string => {
654 const s = tr()
655 const tier = tierOf(a.type)
656 const color = colorOf(tier)
657 const ctx = ctxOf(a)
658 const textW = W - 42 - 22
659 const meta = [a.effort ? `${modelName(a.model)} · ${a.effort}` : modelName(a.model)]
660 if (a.round > 1) meta.push(`${s.round} ${a.round}`)
661 if (a.status === 'failed') meta.push(s.failed)
662 const barW = textW
663 const progress = progressOf(a)
664 const stats = `ctx ${ctx}% · ${fmtTokens(a.contextTokens)} ≈${fmtCost(a.costUsd)} ${fmtTime(elapsed(a, at))}`
665 const steps = a.stepTotal ? `${a.stepDone ?? 0}/${a.stepTotal}${a.stepNote ? ' · ' + a.stepNote : ''}` : ''
666 const stepsW = Math.max(0, barW - textWidth(stats, 11) - 12)
667 // Without reported steps the bar falls back to the context, drawn grey.
668 const fillW = Math.round(barW * (progress ?? ctx / 100))
669 return svg(
670 W,
671 66,
672 `${crab(0, 14, costumeOf(a.type), false, a.status === 'running')}
673<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>
674<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>
675${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>` : ''}
676<text class="s" x="${42 + barW}" y="49" text-anchor="end" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${stats}</text>
677<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}"`}/>
678${statusMark(W - 8, 16, a.status, color)}
679<line class="ln" x1="0" y1="65.5" x2="${W}" y2="65.5"/>`,
680 )
681}
682
683const plannedSvg = (W: number, p: Planned): string => {
684 const tier = p.tier in TIER_COLOR ? p.tier : 'other'
685 const color = colorOf(tier)
686 const textW = W - 42 - 22
687 const meta = [TIER_MODEL[tier] ?? '']
688 if (p.after.length) meta.push(`${tr().after} ${p.after.join(', ')}`)
689 return svg(
690 W,
691 46,
692 `${crab(0, 6, tier, true)}
693<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>
694<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>
695${statusMark(W - 8, 16, 'planned', color)}
696<line class="ln" x1="0" y1="45.5" x2="${W}" y2="45.5"/>`,
697 )
698}
699
700const compactSvg = (W: number, list: AgentRun[], planned: Planned[], t: ReturnType<typeof totals>): string => {
701 const icons = [
702 ...list.filter(a => a.status === 'running').map(a => ({ k: costumeOf(a.type), c: colorOf(tierOf(a.type)), s: 'running', dim: false })),
703 ...list.filter(a => a.status !== 'running').map(a => ({ k: costumeOf(a.type), c: colorOf(tierOf(a.type)), s: a.status, dim: false })),
704 ...planned.map(p => ({ k: p.tier in TIER_COLOR ? p.tier : 'other', c: colorOf(p.tier), s: 'planned', dim: true })),
705 ]
706 const fit = Math.max(1, Math.floor((W - 150) / 36))
707 const shown = icons.slice(0, fit)
708 const more = icons.length - shown.length
709 const body = shown
710 .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}"/>` : ''))
711 .join('')
712 const x = shown.length * 36 + (more ? 4 : 0)
713 return svg(
714 W,
715 32,
716 `${body}${more ? `<text class="s" x="${x}" y="21" font-family="${FONT}" font-size="12">+${more}</text>` : ''}
717<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>`,
718 )
719}
720
721// --- omd-lite runs: an executor outside Claude Code (mcode, claude -p, pi) in a run this session launched.
722
723const omdCostume = (r: OmdRun): string => (isMinimax(r) ? 'minimax' : 'other')
724const omdColor = (r: OmdRun): string => (isMinimax(r) ? MM.pink : TIER_COLOR.other ?? '#888780')
725const omdElapsed = (r: OmdRun, at: number): number => (r.endedAt ?? Math.max(at, r.startedAt)) - r.startedAt
726
727// `mcode · MiniMax-M3.1 · round 2/4 · executing`
728const omdMeta = (r: OmdRun): string => {
729 const s = tr()
730 const parts = [r.executor, r.model.replace(/-Preview$/, '')]
731 parts.push(lang === 'zh' ? `${s.round} ${r.round}${s.of}${r.maxRounds}` : `${s.round} ${r.round} ${s.of} ${r.maxRounds}`)
732 if (r.status === 'running') parts.push(r.phase === 'gate' ? s.gate : s.exec)
733 else if (r.status === 'failed') parts.push(r.note === 'interrupted' ? s.interrupted : r.note || s.failed)
734 return parts.filter(Boolean).join(' · ')
735}
736
737// `74 tools · bash: npx tsc · 5.6M tokens · 10:31`
738const omdStats = (r: OmdRun, at: number, withTool = true): string =>
739 [
740 `${r.toolCalls} ${tr().toolsWord}`,
741 withTool && r.status === 'running' ? r.lastTool : '',
742 r.tokens ? `${fmtTokens(r.tokens)} ${tr().tokensWord}` : '',
743 fmtTime(omdElapsed(r, at)),
744 ]
745 .filter(Boolean)
746 .join(' · ')
747
748// Rounds used out of the budget; a finished run is full.
749const omdRatio = (r: OmdRun): number => (r.status === 'done' ? 1 : Math.min(1, r.round / Math.max(1, r.maxRounds)))
750
751const omdSvg = (W: number, r: OmdRun, at: number): string => {
752 const color = r.status === 'done' ? DONE : r.status === 'failed' ? '#D0453F' : omdColor(r)
753 const textW = W - 42 - 22
754 const stats = omdStats(r, at, false)
755 const tool = r.status === 'running' ? r.lastTool : r.message
756 const toolW = Math.max(0, textW - textWidth(stats, 11) - 12)
757 return svg(
758 W,
759 66,
760 `${crab(0, 14, omdCostume(r), false, r.status === 'running')}
761<text class="t" x="42" y="18" font-family="${FONT}" font-size="13" font-weight="600">${xml(fitText(r.label, 13, textW))}</text>
762<text x="42" y="34" font-family="${FONT}" font-size="11"><tspan fill="${omdColor(r)}">omd</tspan><tspan class="s"> ${xml(fitText(omdMeta(r), 11, textW - 30))}</tspan></text>
763${tool && toolW > 30 ? `<text class="s" x="42" y="49" font-family="${FONT}" font-size="11">${xml(fitText(tool, 11, toolW))}</text>` : ''}
764<text class="s" x="${42 + textW}" y="49" text-anchor="end" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${xml(stats)}</text>
765<rect class="k" x="42" y="55" width="${textW}" height="4" rx="2"/><rect x="42" y="55" width="${Math.round(textW * omdRatio(r))}" height="4" rx="2" fill="${color}"/>
766${statusMark(W - 8, 16, r.status, omdColor(r))}
767<line class="ln" x1="0" y1="65.5" x2="${W}" y2="65.5"/>`,
768 )
769}
770
771// The band's row on the desktop: the character, the run, what it does now.
772const omdBandSvg = (W: number, r: OmdRun, more: number, at: number): string => {
773 const head = `${r.label} · ${omdMeta(r).split(' · ').slice(2).join(' · ')}`
774 const tail = omdStats(r, at) + (more ? ` +${more}` : '')
775 const tailW = Math.min(W * 0.55, textWidth(tail, 12))
776 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${PANE_CSS}${CRAB_CSS}
777${crab(0, -3, omdCostume(r), false, r.status === 'running', 0.8)}
778<text class="t" x="28" y="${H / 2 + 4.5}" font-family="${FONT}" font-size="13" font-weight="500">${xml(fitText(head, 13, W - 40 - tailW))}</text>
779<text class="s" x="${W}" y="${H / 2 + 4.5}" text-anchor="end" font-family="${FONT}" font-size="12" font-variant-numeric="tabular-nums">${xml(fitText(tail, 12, tailW))}</text>
780</svg>`
781}
782
783// Which runs the band speaks for: running ones, else ones that ended in the last ten minutes, not dismissed.
784const BAND_LINGER_MS = 10 * 60 * 1000
785const bandRuns = (list: OmdRun[], dismissed: string[], at: number): OmdRun[] => {
786 const live = list.filter(r => !dismissed.includes(r.id))
787 const running = live.filter(r => r.status === 'running')
788 return running.length ? running : live.filter(r => r.endedAt !== undefined && at - r.endedAt < BAND_LINGER_MS).reverse()
789}
790
791type TermUi = Pick<Elements['terminal'], 'Box' | 'Text'>
792
793// Pixel rows drawn two per line with half blocks: the top pixel is the glyph, the bottom its background.
794function halfBlocks(ui: TermUi, pixels: string[], colors: Record<string, string>, key: string) {
795 const { Box, Text } = ui
796 const lines = []
797 for (let y = 0; y < pixels.length; y += 2) {
798 const top = pixels[y] ?? ''
799 const bottom = pixels[y + 1] ?? ''
800 const cells = [...top].map((t, x) => {
801 const a = colors[t]
802 const b = colors[bottom[x] ?? '.']
803 if (a && b) return <Text key={`c${x}`} color={a} backgroundColor={b}>▀</Text>
804 if (a) return <Text key={`c${x}`} color={a}>▀</Text>
805 if (b) return <Text key={`c${x}`} color={b}>▄</Text>
806 return <Text key={`c${x}`}> </Text>
807 })
808 lines.push(<Text key={`${key}-${y}`}>{cells}</Text>)
809 }
810 return (
811 <Box key={key} flexDirection="column" flexShrink={0}>
812 {lines}
813 </Box>
814 )
815}
816
817const waveText = (ui: TermUi, at: number, isRunning: boolean) => {
818 const { Text } = ui
819 const frame = isRunning ? WAVE_FRAMES[Math.floor(at / 1000) % WAVE_FRAMES.length] ?? '' : WAVE_FRAMES[0] ?? ''
820 return [...frame].map((ch, i) => (
821 <Text key={`w${i}`} color={WAVE_COLORS[i]}>
822 {ch}
823 </Text>
824 ))
825}
826
827// --- terminal drawing: the same rows in text.
828
829const ctxBar = (pct: number, width: number): string => {
830 const filled = Math.round((width * pct) / 100)
831 return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
832}
833
834const STATUS_GLYPH: Record<string, string> = { running: '●', done: '✓', failed: '✗', planned: '◷' }
835
836// Opens the agents pane, or closes it when it is up; true when it ends up open.
837async function togglePane($: EngineInterface): Promise<boolean> {
838 const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
839 if (isOpen) {
840 await $.ui.close({ id: PANE })
841 return false
842 }
843 const at = await $.clock.now()
844 await update($, now, () => at)
845 await $.ui.open({ id: PANE, title: tr().pane })
846 return true
847}
848
849async function autoOpen($: EngineInterface, key: string): Promise<void> {
850 const p = await read($, panel)
851 if (p.autoOpenedFor === key) return
852 await update($, panel, prev => ({ ...prev, autoOpenedFor: key }))
853 void $.ui.open({ id: PANE, title: tr().pane })
854}
855
856let sessionId = ''
857let isScanning = false
858
859// One run of bin/omd-scan.mjs; the atom changes only when what it reports does.
860async function scanOmd($: EngineInterface): Promise<void> {
861 if (isScanning || !sessionId) return
862 isScanning = true
863 try {
864 const out = await $.process.run(['node', `${$.plugin.root}/bin/omd-scan.mjs`, sessionId], { timeoutMs: 15000 })
865 if (out.exitCode !== 0) return
866 const runs = JSON.parse(out.stdout || '[]') as OmdRun[]
867 const prev = await read($, omd)
868 if (JSON.stringify(prev) === JSON.stringify(runs)) return
869 await update($, omd, () => runs)
870 const at = await $.clock.now()
871 await update($, now, () => at)
872 // A run seen for the first time opens the panel, once.
873 const fresh = runs.find(r => r.status === 'running' && !prev.some(p => p.id === r.id))
874 if (fresh) await autoOpen($, fresh.id)
875 } catch {
876 // A run dir mid-write or node missing: the next poll tries again.
877 } finally {
878 isScanning = false
879 }
880}
881
882export const register: Register = (on, options) => {
883 on('session.start', async ($, e, next) => {
884 const started = await next(e)
885 lang = await detectLang($, options.language)
886 await $.tool.register({
887 name: 'progress',
888 description:
889 'Report /savvy-flow progress to the progress bar above the prompt and the agents panel. ' +
890 'Call it after presenting the plan (title, total, tasks, phase "delegate"), each time a task is accepted (done), ' +
891 'when switching phase or re-planning (tasks), and once at the end with finished: true. Fields left out keep their previous value.',
892 inputSchema: {
893 type: 'object',
894 properties: {
895 title: { type: 'string', description: 'Short name of the overall task, a few words.' },
896 total: { type: 'integer', minimum: 0, description: 'Number of planned worker tasks.' },
897 done: { type: 'integer', minimum: 0, description: 'Number of tasks accepted after review.' },
898 phase: { type: 'string', enum: [...PHASES] },
899 finished: { type: 'boolean', description: 'True once the flow is closed.' },
900 tasks: {
901 type: 'array',
902 description:
903 '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.',
904 items: {
905 type: 'object',
906 properties: {
907 title: { type: 'string', description: 'A few words; reused verbatim as the Agent description.' },
908 tier: { type: 'string', enum: ['fable', 'heavy', 'careful', 'medium', 'light'] },
909 after: { type: 'array', items: { type: 'integer' }, description: 'Numbers of the tasks this one waits for.' },
910 },
911 required: ['title', 'tier'],
912 },
913 },
914 },
915 },
916 })
917 await $.tool.register({
918 name: 'step',
919 description:
920 'For savvy-flow workers: report progress on your own task to the agents panel. ' +
921 'Right after reading the brief, call it with `total` (your plan in 3-8 steps) and `done: 0`; ' +
922 'call it again as each step finishes. Cheap and silent: it only draws a bar.',
923 inputSchema: {
924 type: 'object',
925 properties: {
926 done: { type: 'integer', minimum: 0, description: 'Steps finished so far.' },
927 total: { type: 'integer', minimum: 1, description: 'Steps planned; may change if the plan changes.' },
928 note: { type: 'string', description: 'The step in progress, a few words.' },
929 },
930 required: ['done'],
931 },
932 })
933 await $.command.register({
934 name: 'agents-info',
935 description: 'Show or hide the panel of subagents: running, finished and planned, with model, context, cost and time',
936 })
937
938 // Ticks the running agents' clocks; quiet when nothing runs.
939 $.clock.every(1000, () => {
940 void (async () => {
941 const list = await read($, agents)
942 const runs = await read($, omd)
943 if (!list.some(a => a.status === 'running') && !runs.some(r => r.status === 'running')) return
944 const at = await $.clock.now()
945 await update($, now, () => at)
946 })()
947 })
948
949 // omd-lite runs this session launched: every 2 s while one runs, every 10 s otherwise.
950 sessionId = await $.session.id()
951 let ticks = 0
952 $.clock.every(2000, () => {
953 ticks += 1
954 void (async () => {
955 const runs = await read($, omd)
956 if (runs.some(r => r.status === 'running') || ticks % 5 === 0) await scanOmd($)
957 })()
958 })
959 void scanOmd($)
960 return started
961 })
962
963 // A Bash call that starts omd-lite: look for the new run at once, and again once it has written its meta.
964 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
965 if (!/omd-lite/.test(String(e.command ?? ''))) return next(e)
966 $.clock.after(3000, () => void scanOmd($))
967 const result = await next(e)
968 void scanOmd($)
969 return result
970 })
971
972 on('command.run', { command: 'agents-info' }, async $ => {
973 const isOpen = await togglePane($)
974 return { text: isOpen ? tr().opened : tr().closed }
975 })
976
977 on('tool.call', { tool: TOOL }, async ($, e) => {
978 const input = e as unknown as ProgressInput
979 const prev = await read($, flow)
980 if (isNewFlow(prev, input) && input.title !== undefined) {
981 // A new flow starts with a clean list; agents still running stay.
982 await update($, agents, list => list.filter(a => a.status === 'running'))
983 }
984 const next = await update($, flow, p => merge(p, input))
985 if (next && input.tasks?.length) await autoOpen($, next.title)
986 return { result: `ok: ${label(next ?? blank())}` }
987 })
988
989 // A worker's own progress: the call runs in the worker's loop, so agentId names it.
990 on('tool.call', { tool: STEP_TOOL }, async ($, e) => {
991 const input = e as unknown as { done?: number; total?: number; note?: string }
992 const agentId = e.agentId
993 if (!agentId) return { result: 'ignored: only subagents report steps' }
994 await update($, agents, list =>
995 list.map(a => {
996 if (a.agentId !== agentId) return a
997 const total = Math.max(0, Math.round(input.total ?? a.stepTotal ?? 0))
998 const done = Math.max(0, Math.round(input.done ?? a.stepDone ?? 0))
999 return { ...a, stepTotal: total, stepDone: total ? Math.min(total, done) : done, stepNote: input.note?.trim() || undefined }
1000 }),
1001 )
1002 return { result: 'ok' }
1003 })
1004
1005 // Safety net: worker launches move the faint layer even if the orchestrator forgets to report.
1006 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
1007 const type = String(e.subagent_type ?? '')
1008 if (!type.startsWith('savvy-')) return next(e)
1009
1010 await update($, flow, prev => {
1011 const base = prev && !prev.isFinished ? { ...blank(), ...prev } : blank()
1012 return { ...base, running: base.running + 1, phase: base.phase === 'plan' ? 'delegate' : base.phase }
1013 })
1014 try {
1015 return await next(e)
1016 } finally {
1017 await update($, flow, prev => (prev ? { ...prev, running: Math.max(0, prev.running - 1) } : prev))
1018 }
1019 })
1020
1021 on('agent.spawn', async ($, e, next) => {
1022 const started = await next(e)
1023 if (started.deny !== undefined) return started
1024
1025 const at = await $.clock.now()
1026 await update($, agents, list => {
1027 const round = 1 + list.filter(a => norm(a.description) === norm(e.description) && e.description).length
1028 const run: AgentRun = {
1029 id: started.agentId ?? e.tool_use_id,
1030 agentId: started.agentId,
1031 type: e.subagentType,
1032 description: e.description,
1033 model: started.model,
1034 status: 'running',
1035 startedAt: at,
1036 contextTokens: 0,
1037 contextMax: windowOf(started.model),
1038 tokens: 0,
1039 costUsd: 0,
1040 steps: 0,
1041 round,
1042 }
1043 return [...list.filter(a => a.id !== run.id), run].slice(-200)
1044 })
1045 await update($, now, () => at)
1046 if (e.subagentType.startsWith('savvy-')) {
1047 const f = await read($, flow)
1048 await autoOpen($, f && !f.isFinished ? f.title : 'savvy-flow')
1049 }
1050 return started
1051 })
1052
1053 // Each model request of a subagent: live context, tokens and cost.
1054 on('turn.step', async function* ($, e, next) {
1055 const result = yield* next(e)
1056 const agentId = e.agentId
1057 const usage = result.usage
1058 if (!agentId || !usage) return result
1059
1060 const model = usage.model || e.model
1061 await update($, agents, list =>
1062 list.map(a =>
1063 a.agentId !== agentId
1064 ? a
1065 : {
1066 ...a,
1067 model,
1068 effort: typeof e.effort === 'string' ? e.effort : a.effort,
1069 status: 'running',
1070 endedAt: undefined,
1071 contextTokens:
1072 (usage.input_tokens || 0) +
1073 (usage.cache_read_input_tokens || 0) +
1074 (usage.cache_creation_input_tokens || 0) +
1075 (usage.output_tokens || 0),
1076 contextMax: windowOf(model),
1077 tokens:
1078 a.tokens +
1079 (usage.input_tokens || 0) +
1080 (usage.output_tokens || 0) +
1081 (usage.cache_read_input_tokens || 0) +
1082 (usage.cache_creation_input_tokens || 0),
1083 costUsd: a.costUsd + costOf(model, usage),
1084 steps: a.steps + 1,
1085 },
1086 ),
1087 )
1088 return result
1089 })
1090
1091 on('turn.complete', async ($, e, next) => {
1092 const agentId = e.agentId
1093 if (agentId) {
1094 const at = await $.clock.now()
1095 await update($, agents, list =>
1096 list.map(a => {
1097 if (a.agentId !== agentId) return a
1098 // A run whose steps went unseen still gets the turn's own sum.
1099 const fallback = a.steps === 0 && e.usage
1100 return {
1101 ...a,
1102 status: e.reason === 'answer' ? 'done' : 'failed',
1103 endedAt: at,
1104 ...(fallback && e.usage
1105 ? {
1106 model: e.usage.model || a.model,
1107 tokens:
1108 e.usage.input_tokens +
1109 e.usage.output_tokens +
1110 e.usage.cache_read_input_tokens +
1111 e.usage.cache_creation_input_tokens,
1112 costUsd: costOf(e.usage.model || a.model, e.usage),
1113 }
1114 : {}),
1115 }
1116 }),
1117 )
1118 await update($, now, () => at)
1119 }
1120 return next(e)
1121 })
1122
1123 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1124 const s = tr()
1125 const ui = $.ui.resolve(e)
1126 const { Box, Text, Button } = ui
1127 const list = await read($, agents)
1128 const f = await read($, flow)
1129 const p: Panel = await read($, panel)
1130 const runs = await read($, omd)
1131 const at = Math.max(await read($, now), ...list.map(a => a.startedAt), ...runs.map(r => r.startedAt), 0)
1132 const omdRunning = runs.filter(r => r.status === 'running').reverse()
1133 const omdEnded = runs.filter(r => r.status !== 'running').reverse()
1134
1135 const running = list.filter(a => a.status === 'running').reverse()
1136 const finished = list.filter(a => a.status !== 'running').reverse()
1137 const planned = plannedOf(f, list)
1138 const t = totals(list, at)
1139 // The pane's title says "Agents"; inside, only the flow's own name.
1140 const title = f && !f.isFinished ? f.title : ''
1141
1142 const toggleCompact = (
1143 <Button
1144 key="compact"
1145 label={p.isCompact ? s.expand : s.collapse}
1146 plain
1147 onPress={() => update($, panel, prev => ({ ...prev, isCompact: !prev.isCompact }))}
1148 />
1149 )
1150 const toggleDone = (
1151 <Button
1152 key="done"
1153 label={`${p.isDoneCollapsed ? '▸' : '▾'} ${s.finished} · ${finished.length}`}
1154 plain
1155 onPress={() => update($, panel, prev => ({ ...prev, isDoneCollapsed: !prev.isDoneCollapsed }))}
1156 />
1157 )
1158 const isEmpty = list.length === 0 && planned.length === 0 && runs.length === 0
1159 const omdTitle = `${s.omd} · ${omdRunning.length ? `${omdRunning.length} ${s.isRunning}` : runs.length}`
1160 const summary = `≈${fmtCost(t.cost)}, ${fmtTokens(t.tokens)} ${s.tokensWord}, ${fmtTime(t.time)}`
1161
1162 if (e.surface === 'desktop' && 'Svg' in ui) {
1163 const { Svg } = ui
1164 const W = Math.max(240, Math.min(900, (e.props.bodyColumns || 40) * 8 - 8))
1165 const section = (key: string, text: string) => (
1166 <Text key={key} dimColor>
1167 {text}
1168 </Text>
1169 )
1170
1171 if (p.isCompact) {
1172 return (
1173 <Box flexDirection="column" gap={1}>
1174 <Svg source={compactSvg(W, list, planned, t)} alt={`${list.length} ${s.agentsCount}, ${summary}`} width={W} height={32} />
1175 {runs.length > 0 && <Text dimColor>{omdTitle}</Text>}
1176 {toggleCompact}
1177 </Box>
1178 )
1179 }
1180 return (
1181 <Box flexDirection="column">
1182 <Svg source={headerSvg(W, title, t)} alt={title ? `${title}: ${summary}` : summary} width={W} height={headerHeight(title)} />
1183 {toggleCompact}
1184 {isEmpty && <Text dimColor>{s.empty}</Text>}
1185 {running.length > 0 && section('h-run', `${s.running} · ${running.length}`)}
1186 {running.map(a => (
1187 <Svg key={a.id} source={agentSvg(W, a, at)} alt={`${a.description}: ${modelName(a.model)}, ${s.isRunning}`} width={W} height={66} />
1188 ))}
1189 {finished.length > 0 && toggleDone}
1190 {!p.isDoneCollapsed &&
1191 finished.map(a => (
1192 <Svg key={a.id} source={agentSvg(W, a, at)} alt={`${a.description}: ${modelName(a.model)}, ${s.isFinished}`} width={W} height={66} />
1193 ))}
1194 {planned.length > 0 && section('h-plan', `${s.planned} · ${planned.length}`)}
1195 {planned.map(pl => (
1196 <Svg key={`plan-${pl.n}`} source={plannedSvg(W, pl)} alt={`${pl.n}. ${pl.title}: ${s.isPlanned}`} width={W} height={46} />
1197 ))}
1198 {runs.length > 0 && section('h-omd', omdTitle)}
1199 {[...omdRunning, ...(p.isDoneCollapsed ? [] : omdEnded)].map(r => (
1200 <Svg key={r.id} source={omdSvg(W, r, at)} alt={`${r.label}: ${omdMeta(r)}`} width={W} height={66} />types/index.d.ts 84 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 Panel = {
44 isCompact: boolean
45 isDoneCollapsed: boolean
46 autoOpenedFor: string
47}
48
49/** An omd-lite run launched from this session (its meta.json names the session). Written by bin/omd-scan.mjs. */
50export type OmdRun = {
51 id: string
52 label: string
53 executor: string
54 spec: string
55 model: string
56 status: AgentStatus
57 /** exec: the executor works; gate: the gates run; end: result.json written or the driver gone. */
58 phase: 'exec' | 'gate' | 'end'
59 /** Why a run failed: the stuck reason, the failed gate, or `interrupted`. */
60 note: string
61 round: number
62 maxRounds: number
63 toolCalls: number
64 lastTool: string
65 message: string
66 tokens: number
67 contextTokens: number
68 startedAt: number
69 endedAt?: number
70}
71
72declare module 'claude-code' {
73 interface PluginState {
74 'savvy-progress': {
75 flow: Flow | null
76 agents: AgentRun[]
77 panel: Panel
78 now: number
79 omd: OmdRun[]
80 omdDismissed: string[]
81 }
82 }
83}
84