SLOPSHOPPER

savvy-progress

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 /…

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

Draws

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

savvy-progress (fork)

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-progress 1.2.0 (MIT, see LICENSE). 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 的工具名照样对得上。

功能(上游)

  • 进度条:flow 标题、阶段、已验收 / 计划任务数,以及打开面板的按钮。有东西上报进度(savvy-flow 会)或 savvy-* worker 启动后出现。
  • Agents 面板(/agents-info 开关):运行中、已结束、计划中的子 agent,含模型、effort、任务进度、上下文、估算花费与耗时。prefers-reduced-motion 时停动画。
  • 新增工具:
  • mcp__savvy-progress__progress:编排者上报计划、阶段、已验收任务;
  • mcp__savvy-progress__step:worker 上报自己的步骤(done / total / note)。

花费是按 token 数和内置价格表(hooks/register.tsx 的 PRICES)的粗估,不是账单。

omd-lite runs(本 fork)

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 调用之后立即扫一次)。

  • Band(提示框下方):run、轮次 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-harness
Source 2 files
hooks/register.tsx 1451 lines
1import { 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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 lines
1export type Phase = 'plan' | 'design' | 'delegate' | 'review' | 'close'
2
3export type PlannedTask = {
4  title: string
5  tier: string
6  after: number[]
7}
8
9export type Flow = {
10  title: string
11  total: number
12  done: number
13  running: number
14  phase: Phase
15  isFinished: boolean
16  tasks: PlannedTask[]
17}
18
19export type AgentStatus = 'running' | 'done' | 'failed'
20
21export type AgentRun = {
22  id: string
23  agentId?: string
24  type: string
25  description: string
26  model: string
27  effort?: string
28  status: AgentStatus
29  startedAt: number
30  endedAt?: number
31  contextTokens: number
32  contextMax: number
33  tokens: number
34  costUsd: number
35  steps: number
36  round: number
37  /** Self-reported by the worker through the `step` tool. */
38  stepDone?: number
39  stepTotal?: number
40  stepNote?: string
41}
42
43export type Panel = {
44  isCompact: boolean
45  isDoneCollapsed: boolean
46  autoOpenedFor: string
47}
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