SLOPSHOPPER

savvy-progress

Session row above the prompt in every session (model, cost, tokens, context, 5-hour and 7-day limits, time) and a session panel (Details or /agents-info): cost…

panebandguardcommandprompttool
v1.2.0-sgs.11-lh.1MITupdated 2026-10-24lhughey/claude-plugins/plugins/savvy-progress
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · savvy-progress
│ ┃ Session ✕ › fix the failing auth test and add an audit log call │ ┃ $0.42 /cost · Elapsed 30 min · Active 0:42 │ ┃ Main session ≈$0.00 · Subagents ≈$0.00 · 0 … ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ 5h ██████░░░░░░░░░░░░░░ 31% ⏺ Update(src/auth.ts) │ ┃ Context ██████████░░░░░░░░░░ 49% 97k / 200k ⎿ Added 2 lines, removed 1 line │ ┃ ≈ API-equivalent estimate from token counts… ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Subagents │ ┃ No subagents yet. ● 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. │ │ ● Cost $0.42 · Context 49% · 5h 31% · Tokens 0 · Elapsed 30 min Details ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
● Cost $0.42 · Context 49% · 5h 31% · Tokens 0 · Elapsed 30 min Details
Pane · Session
$0.42 /cost · Elapsed 30 min · Active 0:42 Main session ≈$0.00 · Subagents ≈$0.00 · 0 tokens 5h ██████░░░░░░░░░░░░░░ 31% Context ██████████░░░░░░░░░░ 49% 97k / 200k ≈ API-equivalent estimate from token counts; not a bill Subagents No subagents yet.
README

savvy-progress

sgs-kit fork (1.2.0-sgs.8) of savvy-progress from johnnyvizz/claude-kit. It adds an always-on session row above the prompt (model, cost, tokens, context fill, the 5-hour and 7-day limits, session time) whose Details button opens the session panel: cost with the main session / subagent split, usage limits with reset times, the context window by category, tokens by kind with the cache hit rate and cost by model, above the subagents. Cost is Claude Code's own ledger (/cost) when the host keeps one, else an API-equivalent estimate from token counts; never a bill. Planned tasks may name their model/effort, and the panel speaks Turkish (language: tr).

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

<img src="../../docs/savvy-progress/band.png" alt="The session row above a savvy-flow progress bar at 3 of 6 tasks, with 7 agents" width="1010">

<img src="../../docs/savvy-progress/panel.png" alt="The session panel: cost split, usage limits, context window, tokens, cost by model and the subagents card" width="542">

Drawn by the mod's own rendering code with sample data; more in the repository README.

  • Session row (fork): always on; figures drop by priority as the band narrows, gauges turn amber at 70 % and red at 90 %, and the figure is always printed.
  • Progress bar: the flow's title, phase, accepted tasks out of planned, and the flow's crew size (its runs plus the tasks still planned) beside the crab. The band follows the window: on a narrow one the row drops the word "agents", then the percent, then shortens the title. A finished flow shows Done until your next prompt, then leaves; there is no dismiss, so nothing can hide a running flow. Subagents belong to the session: a new flow keeps the runs of earlier ones, so their cost stays in the totals. It appears once something reports progress (savvy-flow does) or a savvy-* worker starts.
  • Session panel (/agents-info or the row's Details button toggles it): the session cards above, then a subagents card of running, finished and planned subagents with model, effort, task progress, context, estimated cost and time, with Collapse and Hide finished under it. It opens itself when a flow reports its plan, and once a turn when any subagent starts, the model's own Explore or general-purpose included; close it and only a new flow opens it again until you open it yourself. Details on a panel hidden behind another tab brings it forward rather than closing it. The panel and the row redraw at most every two seconds while tokens stream in, so their buttons stay pressable. A subagent another subagent started sits indented under it, with its SendMessage name when it has one, and a workflow's agents are grouped under one header in their script's order. Open one agent's transcript from the terminal's tasks list, or pick a run in the panel's In view picker on the desktop, and that agent leads the panel under In view while the row above the prompt shows its task, model, cost, context, tokens and time instead of the session's; Whole session puts the session back. Collapsed, the subagents card sums the subagents alone. Working crabs walk, and each savvy tier animates its prop: the astronaut floats, the detective sweeps the magnifier, the engineer turns the wrench, the chef tosses the omelette, the racer runs with a fluttering flag, the scholar swings its tassel, the sprout sways. prefers-reduced-motion stops them.

Tools it adds

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

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

Settings

language: auto (default), en, ru or tr. auto follows Claude Code's language setting, then the system locale, and falls back to English. Set it in /config, or, for a mod loaded by hand, in ~/.claude/settings.json:

{ "pluginConfigs": { "savvy-progress": { "options": { "language": "ru" } } } }

Subagent model routing (off by default)

An Agent call that names no model runs on the main session's model: under Opus, every quick Explore search runs on Opus too. With routing on, savvy-progress gives such a call a model of your choice, by agent type:

  • exploreModel: Explore subagents.
  • generalModel: general-purpose subagents, which is also what an Agent call without a type starts.

Each takes inherit (default: routing off), haiku, sonnet, opus or fable.

  • inherit: the subagent gets the main session's model, as Claude Code does on its own.
  • opus: the subagent runs on Opus whatever model the main session uses. Pick it if a call with no model should go to the top model even when the session runs on Sonnet or Haiku.

Routing never overrides a choice that was made: a model named on the Agent call wins, and any other agent type (your own agents, a plugin's agents such as the savvy tiers, Plan, forks, workflow agents, teammates) is left alone, since its definition may name a model of its own. A routed run shows auto next to its model in the panel.

Set it with /agent-routing, which also works in the desktop app, where there is no /config:

/agent-routing                 show the current settings
/agent-routing on              Explore on haiku, general-purpose on sonnet
/agent-routing explore haiku   one type at a time
/agent-routing general opus
/agent-routing off             both back to inherit

The change is saved to the plugin's own pluginConfigs entry in ~/.claude/settings.json and applies to subagents started after it. Claude Code's own CLAUDE_CODE_SUBAGENT_MODEL sets one default for every agent type without a model; routing, being a model on the call, takes precedence over it for Explore and general-purpose, and CLAUDE_CODE_SUBAGENT_MODEL_FORCE takes precedence over routing.

On Claude Code 2.1.275 or later, one step:

/plugin install savvy-progress --marketplace Phalegethon/sgs-kit

More install options are in the repository README.

Source 2 files
hooks/register.tsx 2291 lines
1import { atom, read, update } from 'claude-code'
2import type { AgentSpawnInput, EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import type { AgentRun, AgentStatus, Flow, Frame, MainUsage, Meter, Panel, Phase, PlannedTask, RateWindow, TokenSplit } from '../types'
5
6const MAIN0: MainUsage = { model: '', tokens: 0, costUsd: 0, steps: 0 }
7const METER0: Meter = { startedAt: 0, contextWindow: 0, rateLimits: [] }
8
9// The live values, written as events arrive; the drawings read `frame` alone.
10const flow = atom({ plugin: 'savvy-progress', key: 'flow' } as const, null)
11const agents = atom({ plugin: 'savvy-progress', key: 'agents' } as const, [])
12const panel = atom({ plugin: 'savvy-progress', key: 'panel' } as const, {
13  isCompact: false,
14  isDoneCollapsed: false,
15  autoOpenedFor: '',
16})
17const main = atom({ plugin: 'savvy-progress', key: 'main' } as const, MAIN0)
18const meter = atom({ plugin: 'savvy-progress', key: 'meter' } as const, METER0)
19const frame = atom({ plugin: 'savvy-progress', key: 'frame' } as const, {
20  flow: null,
21  agents: [],
22  main: MAIN0,
23  meter: METER0,
24  at: 0,
25} as Frame)
26
27const TOOL = 'mcp__savvy-progress__progress'
28const STEP_TOOL = 'mcp__savvy-progress__step'
29const PANE = 'savvy-agents'
30const PHASES: readonly Phase[] = ['plan', 'design', 'delegate', 'review', 'close']
31const ACCENT = '#8f8cf4'
32const DONE = '#5fbf8f'
33const WARN = '#D9A23A'
34const ALERT = '#D0453F'
35
36type ProgressInput = {
37  title?: string
38  total?: number
39  done?: number
40  phase?: Phase
41  finished?: boolean
42  tasks?: { title?: string; tier?: string; after?: number[]; model?: string; effort?: string }[]
43}
44
45// ---------------------------------------------------------------------------
46// Language: the `language` option, else Claude Code's `language` setting, else the
47// process locale; English when nothing says Russian or Turkish.
48
49type Lang = 'en' | 'ru' | 'tr'
50
51const STRINGS = {
52  en: {
53    pane: 'Session',
54    details: 'Details',
55    context: 'Context',
56    sessionCost: 'Session cost',
57    estimated: 'estimated',
58    elapsed: 'Elapsed',
59    active: 'Active',
60    limits: 'Usage limits',
61    fiveHour: '5-hour window',
62    fiveShort: '5h',
63    sevenDay: '7-day window',
64    sevenShort: '7d',
65    spend: 'Spend limit',
66    spendShort: 'limit',
67    ctxWindow: 'Context window',
68    noReading: 'No reading yet',
69    tokenSplit: 'Tokens',
70    input: 'Input',
71    output: 'Output',
72    cacheRead: 'Cache read',
73    cacheWrite: 'Cache write',
74    cacheHit: 'Cache hit',
75    requests: 'model requests',
76    byModel: 'By model',
77    emptyHint: 'A subagent shows up here with its model, context and cost once it starts.',
78    d: 'd',
79    h: 'h',
80    min: 'min',
81    lessMin: '<1 min',
82    cost: 'Cost',
83    tokens: 'Tokens',
84    time: 'Time',
85    collapse: 'Collapse',
86    expand: 'Expand',
87    running: 'Running',
88    finished: 'Finished',
89    planned: 'Planned',
90    empty: 'No subagents yet.',
91    round: 'round',
92    failed: 'error',
93    after: 'after',
94    tokensWord: 'tokens',
95    agentOne: 'agent',
96    agentFew: 'agents',
97    agentsCount: 'agents',
98    isRunning: 'running',
99    isFinished: 'finished',
100    isPlanned: 'planned',
101    opened: 'Agents panel opened.',
102    closed: 'Agents panel closed.',
103    done: 'Done',
104    plan: 'Plan',
105    design: 'Design',
106    tasks: 'Tasks',
107    review: 'Review',
108    busy: 'running',
109    mainSession: 'Main session',
110    subagents: 'Subagents',
111    estimate: '≈ API-equivalent estimate from token counts; not a bill',
112    hideDone: 'Hide finished',
113    showDone: 'Show finished',
114    auto: 'auto',
115    routedAlt: 'model set by routing',
116    routingHead: 'Subagent model routing, for an Agent call that names no model:',
117    routingUsage: 'Usage: /agent-routing on | off | explore <model> | general <model>, with model one of inherit, haiku, sonnet, opus, fable. inherit: the main session\'s model; opus: Opus whatever the main session runs on.',
118    routingSet: 'Saved; new subagents use it:',
119    routingDenied: 'Not saved',
120    inView: 'In view',
121    wholeSession: 'Whole session',
122    workflow: 'Workflow',
123  },
124  ru: {
125    pane: 'Сессия',
126    details: 'Подробнее',
127    context: 'Контекст',
128    sessionCost: 'Стоимость сессии',
129    estimated: 'оценка',
130    elapsed: 'Прошло',
131    active: 'Активно',
132    limits: 'Лимиты',
133    fiveHour: '5-часовое окно',
134    fiveShort: '5ч',
135    sevenDay: '7-дневное окно',
136    sevenShort: '7д',
137    spend: 'Лимит расходов',
138    spendShort: 'лимит',
139    ctxWindow: 'Контекстное окно',
140    noReading: 'Пока нет данных',
141    tokenSplit: 'Токены',
142    input: 'Ввод',
143    output: 'Вывод',
144    cacheRead: 'Чтение кэша',
145    cacheWrite: 'Запись кэша',
146    cacheHit: 'Попадание в кэш',
147    requests: 'запросов к модели',
148    byModel: 'По моделям',
149    emptyHint: 'Субагент появится здесь с моделью, контекстом и стоимостью, как только начнёт работу.',
150    d: 'д',
151    h: 'ч',
152    min: 'мин',
153    lessMin: '<1 мин',
154    cost: 'Стоимость',
155    tokens: 'Токены',
156    time: 'Время',
157    collapse: 'Свернуть',
158    expand: 'Развернуть',
159    running: 'Работают',
160    finished: 'Завершены',
161    planned: 'Запланированы',
162    empty: 'Субагентов пока нет.',
163    round: 'раунд',
164    failed: 'ошибка',
165    after: 'после',
166    tokensWord: 'токенов',
167    agentOne: 'агент',
168    agentFew: 'агента',
169    agentsCount: 'агентов',
170    isRunning: 'работает',
171    isFinished: 'завершён',
172    isPlanned: 'запланирована',
173    opened: 'Панель агентов открыта.',
174    closed: 'Панель агентов закрыта.',
175    done: 'Готово',
176    plan: 'План',
177    design: 'Дизайн',
178    tasks: 'Задачи',
179    review: 'Ревью',
180    busy: 'в работе',
181    mainSession: 'Основная сессия',
182    subagents: 'Субагенты',
183    estimate: '≈ оценка по ценам API из числа токенов; не счёт',
184    hideDone: 'Скрыть завершённые',
185    showDone: 'Показать завершённые',
186    auto: 'авто',
187    routedAlt: 'модель выбрана маршрутизацией',
188    routingHead: 'Модель субагентов, когда вызов Agent не называет модель:',
189    routingUsage: 'Использование: /agent-routing on | off | explore <модель> | general <модель>, модель: inherit, haiku, sonnet, opus, fable. inherit: модель основной сессии; opus: Opus при любой модели основной сессии.',
190    routingSet: 'Сохранено; новые субагенты используют это:',
191    routingDenied: 'Не сохранено',
192    inView: 'В фокусе',
193    wholeSession: 'Вся сессия',
194    workflow: 'Воркфлоу',
195  },
196  tr: {
197    pane: 'Oturum',
198    details: 'Ayrıntılar',
199    context: 'Bağlam',
200    sessionCost: 'Oturum maliyeti',
201    estimated: 'tahmini',
202    elapsed: 'Süre',
203    active: 'Aktif',
204    limits: 'Kullanım limitleri',
205    fiveHour: '5 saatlik pencere',
206    fiveShort: '5 sa',
207    sevenDay: '7 günlük pencere',
208    sevenShort: '7 gün',
209    spend: 'Harcama limiti',
210    spendShort: 'limit',
211    ctxWindow: 'Bağlam penceresi',
212    noReading: 'Henüz ölçüm yok',
213    tokenSplit: 'Token dağılımı',
214    input: 'Girdi',
215    output: 'Çıktı',
216    cacheRead: 'Önbellek okuma',
217    cacheWrite: 'Önbellek yazma',
218    cacheHit: 'Önbellek isabeti',
219    requests: 'model isteği',
220    byModel: 'Modellere göre',
221    emptyHint: 'Bir alt ajan başladığında modeli, bağlamı ve maliyetiyle burada görünür.',
222    d: 'gün',
223    h: 'sa',
224    min: 'dk',
225    lessMin: '<1 dk',
226    cost: 'Maliyet',
227    tokens: 'Token',
228    time: 'Süre',
229    collapse: 'Daralt',
230    expand: 'Genişlet',
231    running: 'Çalışıyor',
232    finished: 'Bitti',
233    planned: 'Planlandı',
234    empty: 'Henüz alt ajan yok.',
235    round: 'tur',
236    failed: 'hata',
237    after: 'sonra',
238    tokensWord: 'token',
239    agentOne: 'ajan',
240    agentFew: 'ajan',
241    agentsCount: 'ajan',
242    isRunning: 'çalışıyor',
243    isFinished: 'bitti',
244    isPlanned: 'planlandı',
245    opened: 'Ajan paneli açıldı.',
246    closed: 'Ajan paneli kapandı.',
247    done: 'Bitti',
248    plan: 'Plan',
249    design: 'Tasarım',
250    tasks: 'Görevler',
251    review: 'İnceleme',
252    busy: 'çalışıyor',
253    mainSession: 'Ana oturum',
254    subagents: 'Alt ajanlar',
255    estimate: '≈ token sayısından API fiyatıyla tahmin; fatura değil',
256    hideDone: 'Bitenleri gizle',
257    showDone: 'Bitenleri göster',
258    auto: 'otomatik',
259    routedAlt: 'model yönlendirmeyle seçildi',
260    routingHead: 'Alt ajan model yönlendirmesi, model belirtmeyen Agent çağrıları için:',
261    routingUsage: 'Kullanım: /agent-routing on | off | explore <model> | general <model>; model: inherit, haiku, sonnet, opus, fable. inherit: ana oturumun modeli; opus: ana oturum hangi modelde olursa olsun Opus.',
262    routingSet: 'Kaydedildi; yeni alt ajanlar bunu kullanır:',
263    routingDenied: 'Kaydedilmedi',
264    inView: 'Görüntülenen',
265    wholeSession: 'Tüm oturum',
266    workflow: 'İş akışı',
267  },
268} as const
269
270// Module scope is fine here: session.start sets it again on every (re)load.
271let lang: Lang = 'en'
272const tr = () => STRINGS[lang]
273
274// "1 agent", "2 agents"; Russian has three forms (1 агент, 3 агента, 5 агентов).
275const agentWord = (n: number): string => {
276  const s = tr()
277  const one = n % 10 === 1 && n % 100 !== 11
278  const few = n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)
279  if (lang === 'ru') return one ? s.agentOne : few ? s.agentFew : s.agentsCount
280  return n === 1 ? s.agentOne : s.agentsCount
281}
282
283const langOf = (v: unknown): Lang =>
284  typeof v !== 'string' ? 'en' : /^(ru|russian|рус)/i.test(v.trim()) ? 'ru' : /^(tr|turkish|türk)/i.test(v.trim()) ? 'tr' : 'en'
285
286async function detectLang($: EngineInterface, option: unknown): Promise<Lang> {
287  if (option === 'en' || option === 'ru' || option === 'tr') return option
288  try {
289    const settings = (await $.settings.read()) as Record<string, unknown>
290    if (typeof settings.language === 'string' && settings.language.trim()) return langOf(settings.language)
291  } catch {
292    // No settings: fall through to the locale.
293  }
294  const locale = (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG'))
295  return langOf(locale)
296}
297
298const blank = (): Flow => ({
299  title: 'savvy-flow',
300  total: 0,
301  done: 0,
302  running: 0,
303  phase: 'plan',
304  isFinished: false,
305  tasks: [],
306})
307
308const isNewFlow = (prev: Flow | null, input: ProgressInput): boolean =>
309  !prev || prev.isFinished || (input.title !== undefined && input.title.trim() !== prev.title)
310
311const cleanTasks = (tasks: ProgressInput['tasks']): PlannedTask[] | undefined =>
312  tasks
313    ?.filter(t => t.title?.trim())
314    .map(t => ({
315      title: (t.title ?? '').trim(),
316      tier: (t.tier ?? '').replace(/^savvy-/, '').trim().toLowerCase(),
317      after: (t.after ?? []).filter(n => Number.isInteger(n) && n > 0),
318      ...(t.model?.trim() ? { model: t.model.trim().toLowerCase() } : {}),
319      ...(t.effort?.trim() ? { effort: t.effort.trim().toLowerCase() } : {}),
320    }))
321
322const merge = (prev: Flow | null, input: ProgressInput, at: number): Flow => {
323  // A new title means a new flow: never carry counters over from an earlier one.
324  const base = isNewFlow(prev, input) || !prev ? { ...blank(), startedAt: at } : { ...blank(), ...prev }
325  const tasks = cleanTasks(input.tasks) ?? base.tasks
326  const total = Math.max(0, Math.round(input.total ?? (input.tasks ? tasks.length : base.total)))
327  const done = Math.min(total || Infinity, Math.max(0, Math.round(input.done ?? base.done)))
328  const phase = input.phase && PHASES.includes(input.phase) ? input.phase : base.phase
329  return {
330    ...base,
331    title: input.title?.trim() || base.title,
332    total,
333    done,
334    phase: input.finished ? 'close' : phase,
335    isFinished: input.finished === true,
336    tasks,
337  }
338}
339
340const label = (f: Flow): string => {
341  const s = tr()
342  if (f.isFinished) return s.done
343  if (f.phase === 'plan') return s.plan
344  if (f.phase === 'design') return s.design
345  const count = f.total ? `${f.done}/${f.total}` : `${f.running} ${s.busy}`
346  return `${f.phase === 'review' ? s.review : s.tasks} ${count}`
347}
348
349const ratio = (f: Flow): number => (f.isFinished ? 1 : f.total ? f.done / f.total : 0)
350
351// Deterministic noise so the dither does not shimmer between redraws.
352const noise = (x: number, y: number): number => {
353  const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
354  return s - Math.floor(s)
355}
356
357// The whole row is one SVG: the desktop wraps sibling elements onto new lines,
358// so title, bar, percent, the crab and the crew count live in one drawing.
359const H = 22
360const BAR_H = 16
361const CELL = 3
362const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
363
364const xml = (s: string): string =>
365  s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c)
366
367const clip = (s: string, max: number): string => (s.length > max ? s.slice(0, Math.max(1, max - 1)) + '…' : s)
368
369// Rough advance of system UI text, in em; good enough to size the title's slot.
370const charEm = (ch: string): number =>
371  /[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-ZА-ЯЁmwшщжюМШЩЖЮ@%]/.test(ch) ? 0.72 : 0.56
372
373const textWidth = (s: string, size: number): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
374
375// Cuts `s` to fit `maxW` pixels, with an ellipsis when it had to cut.
376const fitText = (s: string, size: number, maxW: number): string => {
377  if (textWidth(s, size) <= maxW) return s
378  let out = ''
379  for (const ch of s) {
380    if (textWidth(out + ch + '…', size) > maxW) break
381    out += ch
382  }
383  return out + '…'
384}
385
386// The right end reads "29%  🦀 2 agents": the crew is a figure, not a control.
387// On a narrow band the row gives way in order: the word "agents", then the percent
388// (the pill already says 2/7), then the title, so the bar always holds its pill.
389const rowSvg = (f: Flow, W: number, isWorking: boolean, crew: number): string => {
390  const percent = fmtPct(ratio(f) * 100)
391  const crewWord = ` ${agentWord(crew)}`
392  const text = label(f)
393  const pillW = Math.round(18 + text.length * 6.6)
394  const minBar = pillW + 24
395  // At 0.8 the crab's body spans x 2.4 to 21.6 of its box and y 8 to 21: lifted 3 px
396  // so it centres on the row's middle line, as the text and the bar do.
397  const place = (hasWord: boolean, hasPercent: boolean) => {
398    const crewW = crew ? Math.ceil(textWidth(String(crew), 12.5) + (hasWord ? textWidth(crewWord, 11.5) : 0)) : 0
399    const crabX = Math.round(W - (crew ? crewW + 6 : 0) - 22)
400    const barEnd = hasPercent ? crabX - 4 - textWidth(percent, 12.5) - 12 : crabX - 6
401    return { hasWord, hasPercent, crabX, barEnd }
402  }
403  // The title takes what it needs, up to 40% of the row; the bar takes the rest.
404  const titleW = (end: number) => Math.min(W * 0.4, end - minBar - 28)
405  const fits = (l: ReturnType<typeof place>) => titleW(l.barEnd) >= Math.min(textWidth(f.title, 13), 60)
406  const layout = [place(true, true), place(false, true), place(false, false)].find(fits) ?? place(false, false)
407  const title = fitText(f.title, 13, Math.max(24, titleW(layout.barEnd)))
408  const { crabX, barEnd } = layout
409  const percentX = crabX - 4
410  const BAR_X = Math.round(16 + textWidth(title, 13) + 12)
411  const BAR_W = Math.max(minBar, Math.round(barEnd - BAR_X))
412  const color = f.isFinished ? DONE : ACCENT
413  const y0 = (H - BAR_H) / 2
414  const fillW = Math.round(BAR_W * ratio(f))
415  const runW = f.total ? Math.round((BAR_W * Math.min(f.total, f.done + f.running)) / f.total) : 0
416  const dots: string[] = []
417
418  // Dithered fill: sparse at the start, dense toward the head.
419  const cols = Math.floor(fillW / CELL)
420  const rows = Math.floor(BAR_H / CELL)
421  for (let c = 0; c < cols; c++) {
422    const density = 0.35 + 0.6 * Math.pow(c / Math.max(1, cols), 1.2)
423    for (let r = 0; r < rows; r++) {
424      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"/>`)
425    }
426  }
427  // Handed to workers, not yet accepted: a faint second layer.
428  const faint: string[] = []
429  for (let c = cols; c < Math.floor(runW / CELL); c++) {
430    for (let r = 0; r < rows; r++) {
431      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"/>`)
432    }
433  }
434
435  const ticks: string[] = []
436  for (let i = 1; i < f.total; i++) {
437    const x = Math.round((BAR_W * i) / f.total)
438    if (x > fillW + 4) ticks.push(`<rect x="${x}" y="${BAR_H / 2 - 4}" width="1.5" height="8" rx="0.75"/>`)
439  }
440
441  const pillX = Math.max(0, Math.min(BAR_W - pillW, fillW - pillW))
442
443  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
444<style>
445.t{fill:#1f1f1f}.m{fill:#8a8a8a}.k{fill:#e4e4e2}.tk{fill:#b4b4b0}
446@media (prefers-color-scheme: dark){.t{fill:#ececec}.m{fill:#9a9a9a}.k{fill:#2c2c2c}.tk{fill:#5a5a5a}}
447/* Pixels twinkle in four out-of-phase groups; a finished bar settles to a slow glow. */
448.t0,.t1,.t2,.t3{animation:tw ${f.isFinished ? 3.2 : 2.2}s ease-in-out infinite}
449.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}
450@keyframes tw{0%,100%{opacity:1}50%{opacity:${f.isFinished ? 0.8 : 0.3}}}
451@media (prefers-reduced-motion: reduce){.t0,.t1,.t2,.t3{animation:none}}
452</style>
453<defs><clipPath id="c"><rect x="0" y="0" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/></clipPath></defs>
454<circle cx="5" cy="${H / 2}" r="4" fill="${color}"/>
455<text class="t" x="16" y="${H / 2 + 4.5}" font-family="${FONT}" font-size="13" font-weight="500">${xml(title)}</text>
456<g transform="translate(${BAR_X},${y0})">
457<rect class="k" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/>
458<g clip-path="url(#c)">
459<g fill="${color}">${dots.join('')}</g>
460<g fill="${color}" opacity="0.45">${faint.join('')}</g>
461<g class="tk">${ticks.join('')}</g>
462</g>
463<rect x="${pillX}" width="${pillW}" height="${BAR_H}" rx="${BAR_H / 2}" fill="${color}"/>
464<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>
465</g>
466${layout.hasPercent ? `<text class="m" x="${percentX}" y="${H / 2 + 4.5}" text-anchor="end" font-family="${FONT}" font-size="12.5" font-variant-numeric="tabular-nums">${percent}</text>` : ''}
467${CRAB_CSS}${crab(crabX, -3, 'other', false, isWorking, 0.8)}
468${crew ? `<text x="${W}" y="${H / 2 + 4.5}" text-anchor="end" font-family="${FONT}" font-variant-numeric="tabular-nums"><tspan class="t" font-size="12.5" font-weight="600">${crew}</tspan>${layout.hasWord ? `<tspan class="m" font-size="11.5">${xml(crewWord)}</tspan>` : ''}</text>` : ''}
469</svg>`
470}
471
472const barText = (f: Flow, width: number): string => {
473  const filled = Math.round(width * ratio(f))
474  return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
475}
476
477// ---------------------------------------------------------------------------
478// Agents panel: every subagent of the session, plus the tasks the flow planned.
479
480const TIER_COLOR: Record<string, string> = {
481  fable: '#7F77DD',
482  heavy: '#D85A30',
483  careful: '#BA7517',
484  medium: '#378ADD',
485  light: '#1D9E75',
486  sonnet: '#C2477A',
487  haiku: '#6F9E3B',
488  other: '#888780',
489}
490
491const colorOf = (tier: string): string => TIER_COLOR[tier] ?? '#888780'
492
493// What each savvy tier runs on, for planned tasks that have no run yet and name no model.
494
495const TIER_MODEL: Record<string, string> = {
496  fable: 'Fable · high',
497  heavy: 'Opus · xhigh',
498  careful: 'Opus · high',
499  medium: 'Opus · medium',
500  light: 'Opus · low',
501  sonnet: 'Sonnet · medium',
502  haiku: 'Haiku · low',
503}
504
505// USD per million tokens: input, output, cache read, cache write. The first row that
506// matches a model id wins, so a release sits above its family.
507// The engine reports tokens, not money, so the panel's cost is an estimate. It reports
508// one cache-write count with no split by lifetime; writes are priced at the 1-hour
509// rate (2x input), which is what Claude Code writes on a subscription.
510type Price = [input: number, output: number, cacheRead: number, cacheWrite: number]
511
512const PRICES: [RegExp, Price][] = [
513  [/(fable|mythos)-5-1/, [10, 50, 0.25, 20]],
514  [/fable|mythos/, [10, 50, 1, 20]],
515  [/opus-5-5/, [4, 20, 0.2, 8]],
516  [/opus/, [5, 25, 0.5, 10]],
517  [/sonnet-5-5/, [2, 10, 0.1, 4]],
518  [/sonnet-5/, [2, 10, 0.2, 4]],
519  [/sonnet/, [3, 15, 0.3, 6]],
520  [/haiku-5-5/, [0.1, 0.5, 0.01, 0.2]],
521  [/haiku/, [1, 5, 0.1, 2]],
522]
523
524// Haiku 5.5 is priced by prompt length: a request whose prompt (input, cache reads and
525// cache writes together) is over 100,000 tokens pays these prices instead.
526const HAIKU_LONG_PROMPT = 100_000
527const HAIKU_LONG: Price = [0.5, 2.5, 0.05, 1]
528
529type Usage = {
530  input_tokens: number
531  output_tokens: number
532  cache_read_input_tokens: number
533  cache_creation_input_tokens: number
534}
535
536const priceOf = (model: string, prompt: number): Price => {
537  const id = model.toLowerCase()
538  if (prompt > HAIKU_LONG_PROMPT && /haiku-5-5/.test(id)) return HAIKU_LONG
539  return PRICES.find(([re]) => re.test(id))?.[1] ?? [4, 20, 0.2, 8]
540}
541
542// `isRequest` says the usage is one model request, which is what a prompt-length price
543// is set by; a turn's sum is priced at the model's ordinary prices.
544const costOf = (model: string, u: Usage, isRequest: boolean): number => {
545  const prompt = isRequest ? (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0) : 0
546  const [i, o, r, w] = priceOf(model, prompt)
547  return (
548    ((u.input_tokens || 0) * i +
549      (u.output_tokens || 0) * o +
550      (u.cache_read_input_tokens || 0) * r +
551      (u.cache_creation_input_tokens || 0) * w) /
552    1e6
553  )
554}
555
556// A planned task's model: the one the orchestrator named, else its tier's default.
557const plannedModel = (p: PlannedTask, tier: string): string =>
558  p.model ? `${modelName(p.model)}${p.effort ? ' · ' + p.effort : ''}` : (TIER_MODEL[tier] ?? '')
559
560// Subagent model routing. Only built-in types with no model of their own are routed:
561// any other type may name one in its definition, which a hook cannot read and a
562// model on the call would override. A model the call names always wins.
563const ROUTE_MODELS: readonly string[] = ['inherit', 'haiku', 'sonnet', 'opus', 'fable']
564const ROUTE_OPTION: Record<string, 'exploreModel' | 'generalModel'> = { Explore: 'exploreModel', 'general-purpose': 'generalModel' }
565
566const routeOption = (options: PluginOptions, key: 'exploreModel' | 'generalModel'): string => {
567  const v = options[key]
568  return typeof v === 'string' && ROUTE_MODELS.includes(v) ? v : 'inherit'
569}
570
571// Forks always inherit, a workflow agent ignores a rewrite and a teammate keeps its own.
572const routeModel = (options: PluginOptions, e: AgentSpawnInput): string | undefined => {
573  const key = ROUTE_OPTION[e.subagentType]
574  if (!key || e.model || e.fork || e.workflow || e.isTeammate) return undefined
575  const model = routeOption(options, key)
576  return model === 'inherit' ? undefined : model
577}
578
579const windowOf = (model: string): number => (/haiku-4|claude-3/i.test(model) ? 200_000 : 1_000_000)
580
581// `savvy-careful`, or `savvy-flow:savvy-careful` when the agents ship in a plugin.
582const tierOf = (type: string): string => {
583  const bare = type.replace(/^[^:]*:/, '')
584  const t = bare.replace(/^savvy-/, '').toLowerCase()
585  return t in TIER_COLOR && bare.startsWith('savvy-') ? t : 'other'
586}
587
588const modelName = (id: string): string => {
589  const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
590  const [, family = '', major = '', minor] = m ?? []
591  if (!family) {
592    const bare = /^(fable|mythos|opus|sonnet|haiku)$/i.exec(id.trim())?.[1]
593    if (bare) return bare.charAt(0).toUpperCase() + bare.slice(1).toLowerCase()
594    return id.replace(/^claude-/, '').replace(/\[.*\]$/, '') || '—'
595  }
596  return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()} ${major}${minor ? '.' + minor : ''}`
597}
598
599const norm = (s: string): string => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
600
601const fmtTokens = (n: number): string =>
602  n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${Math.round(n)}`
603
604const fmtCost = (usd: number): string => `$${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`
605
606const fmtTime = (ms: number): string => {
607  const s = Math.max(0, Math.round(ms / 1000))
608  const h = Math.floor(s / 3600)
609  const m = Math.floor((s % 3600) / 60)
610  const ss = String(s % 60).padStart(2, '0')
611  return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
612}
613
614const fmtPct = (n: number): string => (lang === 'tr' ? `%${Math.round(n)}` : `${Math.round(n)}%`)
615
616// A share that is neither none nor all never rounds to 0 or 100.
617const fmtShare = (n: number): string =>
618  n > 0 && n < 1 ? (lang === 'tr' ? '<%1' : '<1%') : n < 100 && n > 99 ? (lang === 'tr' ? '>%99' : '>99%') : fmtPct(n)
619
620// A coarse span for the band and reset times: "2 d 21 h", "1 h 12 min", "12 min", "<1 min".
621const fmtSpan = (ms: number): string => {
622  const s = tr()
623  const min = Math.floor(Math.max(0, ms) / 60000)
624  if (min < 1) return s.lessMin
625  const h = Math.floor(min / 60)
626  if (h >= 24) return `${Math.floor(h / 24)} ${s.d} ${h % 24} ${s.h}`
627  return h ? `${h} ${s.h} ${min % 60} ${s.min}` : `${min} ${s.min}`
628}
629
630const resetsIn = (span: string): string =>
631  lang === 'tr' ? `${span} sonra sıfırlanır` : lang === 'ru' ? `сброс через ${span}` : `resets in ${span}`
632
633// Gauges turn amber, then red, as a window fills; the figure is always printed beside them.
634const levelColor = (pct: number): string => (pct >= 90 ? ALERT : pct >= 70 ? WARN : ACCENT)
635
636const rateLabel = (kind: string, isShort: boolean): string => {
637  const s = tr()
638  if (kind === 'five_hour') return isShort ? s.fiveShort : s.fiveHour
639  if (kind === 'seven_day') return isShort ? s.sevenShort : s.sevenDay
640  if (kind === 'spend_limit') return isShort ? s.spendShort : s.spend
641  return kind.replace(/_/g, ' ')
642}
643
644// /context's category names, in the panel's language where it is not English.
645const CATEGORY_TR: Record<string, string> = {
646  'system prompt': 'Sistem istemi',
647  'system tools': 'Sistem araçları',
648  'mcp tools': 'MCP araçları',
649  'custom agents': 'Özel ajanlar',
650  'memory files': 'Bellek dosyaları',
651  skills: 'Beceriler',
652  'slash commands': 'Komutlar',
653  messages: 'Mesajlar',
654  'free space': 'Boş alan',
655  'autocompact buffer': 'Otomatik sıkıştırma payı',
656}
657
658const categoryName = (name: string): string => (lang === 'tr' ? (CATEGORY_TR[name.trim().toLowerCase()] ?? name) : name)
659
660const ZERO: TokenSplit = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
661
662const splitOf = (u: Usage): TokenSplit => ({
663  input: u.input_tokens || 0,
664  output: u.output_tokens || 0,
665  cacheRead: u.cache_read_input_tokens || 0,
666  cacheWrite: u.cache_creation_input_tokens || 0,
667})
668
669const addSplit = (a: TokenSplit | undefined, b: TokenSplit | undefined): TokenSplit => ({
670  input: (a?.input ?? 0) + (b?.input ?? 0),
671  output: (a?.output ?? 0) + (b?.output ?? 0),
672  cacheRead: (a?.cacheRead ?? 0) + (b?.cacheRead ?? 0),
673  cacheWrite: (a?.cacheWrite ?? 0) + (b?.cacheWrite ?? 0),
674})
675
676const sumSplit = (t: TokenSplit): number => t.input + t.output + t.cacheRead + t.cacheWrite
677
678const rateWindows = (list: readonly { kind: string; percentUsed: number; resetsAt?: string }[]): RateWindow[] =>
679  list.map(r => ({ kind: r.kind, percentUsed: r.percentUsed, ...(r.resetsAt ? { resetsAt: r.resetsAt } : {}) }))
680
681const elapsed = (a: AgentRun, at: number): number => (a.endedAt ?? Math.max(at, a.startedAt)) - a.startedAt
682
683type Planned = PlannedTask & { n: number }
684
685// The list holds every subagent of the session; a flow's own are those started
686// since it began, plus any still running from before.
687const flowRuns = (f: Flow | null, list: AgentRun[]): AgentRun[] =>
688  f ? list.filter(a => a.status === 'running' || a.startedAt >= (f.startedAt ?? 0)) : []
689
690const plannedOf = (f: Flow | null, list: AgentRun[]): Planned[] => {
691  if (!f || f.isFinished) return []
692  const started = new Set(flowRuns(f, list).map(a => norm(a.description)))
693  return (f.tasks ?? []).map((t, i) => ({ ...t, n: i + 1 })).filter(t => !started.has(norm(t.title)))
694}
695
696// Subagents and the main loop, both priced from their token counts; the headline
697// cost is the engine's own ledger (/cost) when the host keeps one.
698const totals = (list: AgentRun[], at: number, m?: MainUsage, mt?: Meter) => {
699  const agentsCost = list.reduce((s, a) => s + a.costUsd, 0)
700  const mainCost = m?.costUsd ?? 0
701  const tokens = list.reduce((s, a) => s + a.tokens, 0) + (m?.tokens ?? 0)
702  const start = Math.min(...list.map(a => a.startedAt))
703  const end = Math.max(...list.map(a => a.endedAt ?? Math.max(at, a.startedAt)))
704  const split = list.reduce((s, a) => addSplit(s, a.split), addSplit(ZERO, m?.split))
705  const isLedger = mt?.costUsd !== undefined && mt.costUsd > 0
706  const estimate = agentsCost + mainCost
707  // Splits are priced from token counts; with a ledger they are scaled to it, so
708  // the parts always add up to the headline.
709  const k = isLedger && estimate > 0 ? (mt?.costUsd ?? 0) / estimate : 1
710  return {
711    cost: isLedger ? (mt?.costUsd ?? 0) : estimate,
712    isLedger,
713    k,
714    agentsCost: agentsCost * k,
715    mainCost: mainCost * k,
716    tokens,
717    split,
718    steps: (m?.steps ?? 0) + list.reduce((s, a) => s + a.steps, 0),
719    time: list.length ? end - start : 0,
720    sessionMs: mt?.startedAt ? Math.max(0, at - mt.startedAt) : 0,
721    activeMs: (m?.activeMs ?? 0) + (m?.turnStartedAt ? Math.max(0, at - m.turnStartedAt) : 0),
722  }
723}
724
725type Totals = ReturnType<typeof totals>
726
727// --- desktop drawings: each row is one SVG, as the band above the prompt is.
728
729const PANE_CSS = `<style>
730.t{fill:#1f1f1f}.s{fill:#6b6b68}.m{fill:#9a9a96}.k{fill:#ecebe8}.ln{stroke:#e4e4e1}.tile{fill:#f4f3f0}
731@media (prefers-color-scheme: dark){.t{fill:#ececec}.s{fill:#a8a8a4}.m{fill:#7d7d79}.k{fill:#2c2c2b}.ln{stroke:#333331}.tile{fill:#262625}}
732.live{animation:p 1.6s ease-in-out infinite}@keyframes p{50%{opacity:.3}}
733@media (prefers-reduced-motion: reduce){.live{animation:none}}
734</style>`
735
736// Pixel Clawd from DockCrab (Clawdy): a 24×18 crab on a 30×28 grid, one costume per tier.
737// The body keeps the brand clay; the tier's color lives in the costume's accent.
738const CLAY = '#D97757'
739const INK = '#1F1E1D'
740
741// `cls` puts a pixel in a named group: `bd` (the default) is the body and its
742// costume, `la`/`lb` the leg pairs, anything else a prop with its own motion.
743type Fill = (x: number, y: number, w: number, h: number, c: string, cls?: string) => void
744
745const stamp = (f: Fill, x: number, y: number, rows: string[], map: Record<string, string>, cls?: string): void =>
746  rows.forEach((row, dy) => [...row].forEach((ch, dx) => map[ch] && f(x + dx, y + dy, 1, 1, map[ch] ?? '', cls)))
747
748// `armCls` lets a raised claw travel with the prop it holds.
749const crabBody = (f: Fill, armFront = 0, armCls?: string): void => {
750  f(7, 10, 16, 12, CLAY)
751  f(3, 14, 4, 4, CLAY)
752  f(23, 14 + armFront, 4, 4, CLAY, armCls)
753  f(9, 12, 2, 2, INK)
754  f(19, 12, 2, 2, INK)
755  f(7, 22, 2, 4, CLAY, 'la')
756  f(17, 22, 2, 4, CLAY, 'la')
757  f(11, 22, 2, 4, CLAY, 'lb')
758  f(21, 22, 2, 4, CLAY, 'lb')
759}
760
761// Pure CSS, run by the compositor: no redraws. Periods divide one second, so the
762// once-a-second redraw of a running row restarts them in phase. Every crab walks;
763// each costume adds its prop's own motion on top.
764const CRAB_CSS = `<style>
765.run .la{animation:st .5s steps(1) infinite}.run .lb{animation:st .5s steps(1) infinite -.25s}
766.run .bd{animation:bob .5s steps(1) infinite -.125s}
767.run g{transform-box:fill-box}
768@keyframes st{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}
769.c-fable.run{animation:float 1s ease-in-out infinite}
770.c-fable.run .la,.c-fable.run .lb,.c-fable.run .bd{animation:none}
771.c-fable.run .ant{animation:blink 1s steps(1) infinite}
772.c-fable.run .star{animation:blink .5s steps(1) infinite -.25s}
773@keyframes float{50%{transform:translateY(-2px)}}@keyframes blink{50%{opacity:.15}}
774.c-heavy.run .it{animation:scan 1s steps(1) infinite}
775.c-heavy.run .gl{animation:blink 1s steps(1) infinite -.5s}
776@keyframes scan{25%{transform:translate(-1px,1px)}50%{transform:translate(-2px,2px)}75%{transform:translate(-1px,1px)}}
777.c-careful.run .it{transform-origin:100% 100%;animation:twist .5s ease-in-out infinite}
778@keyframes twist{50%{transform:rotate(-35deg)}}
779.c-medium.run .pan{transform-origin:0 50%;animation:tilt 1s ease-in-out infinite}
780.c-medium.run .egg{animation:flip 1s ease-in-out infinite}
781@keyframes tilt{20%,40%{transform:rotate(-12deg)}}@keyframes flip{30%{transform:translateY(-5px) scaleY(-1)}60%{transform:translateY(0)}}
782.c-light.run .la{animation-duration:.25s}.c-light.run .lb{animation-duration:.25s;animation-delay:-.125s}
783.c-light.run .flag{transform-origin:0 50%;animation:wave .25s steps(1) infinite}
784@keyframes wave{50%{transform:skewY(-12deg) scaleX(.85)}}
785.c-explore.run .it{transform-origin:50% 100%;animation:fence .5s ease-in-out infinite}
786@keyframes fence{50%{transform:rotate(25deg)}}
787.c-sonnet.run .it{transform-origin:50% 0;animation:swing 1s ease-in-out infinite}
788@keyframes swing{50%{transform:rotate(20deg)}}
789.c-haiku.run .it{transform-origin:50% 100%;animation:sway 1s ease-in-out infinite}
790@keyframes sway{50%{transform:rotate(-15deg)}}
791@media (prefers-reduced-motion: reduce){.run,.run g{animation:none!important}}
792</style>`
793
794const COSTUMES: Record<string, (f: Fill, t: string) => void> = {
795  // Fable: astronaut in a glass dome; floats instead of walking, the antenna and the star blink.
796  fable: (f, t) => {
797    crabBody(f)
798    f(6, 7, 18, 1, '#E6E8EE'); f(5, 8, 1, 14, '#E6E8EE'); f(24, 8, 1, 14, '#E6E8EE'); f(6, 22, 18, 1, '#C9CCD2')
799    f(6, 8, 18, 14, 'rgba(169,214,245,.32)'); f(8, 9, 2, 1, '#fff'); f(8, 10, 1, 2, '#fff')
800    f(14, 4, 2, 3, '#C9CCD2'); f(14, 2, 2, 2, t, 'ant'); f(13, 18, 4, 2, t)
801    f(27, 3, 1, 3, '#F5C542', 'star'); f(26, 4, 3, 1, '#F5C542', 'star')
802  },
803  // Heavy: detective with a deerstalker; the magnifier sweeps and glints.
804  heavy: (f, t) => {
805    crabBody(f, -4, 'it')
806    stamp(f, 6, 3, ['......bbbbbb......', '....bbcbbcbbbb....', '...bbbbbbbbbbbb...', '..bcbbcbbcbbcbbb..', '.bbbbbbbbbbbbbbbb.', 'dddddddddddddddddd'], { b: '#7A4A26', c: '#A0703F', d: '#5A3519' })
807    f(6, 9, 18, 1, t)
808    stamp(f, 23, 1, ['.kkk.', 'k...k', 'k...k', 'k...k', '.kkk.'], { k: '#3A3A3C' }, 'it')
809    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')
810  },
811  // Careful: engineer in a hard hat; the wrench turns a bolt.
812  careful: (f, t) => {
813    crabBody(f)
814    stamp(f, 6, 4, ['.....yyyyyyyy.....', '...yyyyyhhyyyyy...', '..yyyyyyhhyyyyyy..', '..yyyyyyhhyyyyyy..', '.yyyyyyyhhyyyyyyy.', 'dddddddddddddddddd'], { y: '#F5C542', h: '#FBE08A', d: '#C99A1E' })
815    f(13, 5, 4, 2, t)
816    stamp(f, 0, 10, ['.s.s', 'sss.', '.s..', '.s..'], { s: '#8E929A' }, 'it')
817  },
818  // Medium: chef, the toque traced from DockCrab's Sprites.chefHat; tosses the omelette.
819  medium: (f, t) => {
820    crabBody(f, -4, 'pan')
821    stamp(f, 6, 0, ['........lll.......', '.......lllll......', '.wwwwgwwwwwwgwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwgwwwwwggwwwww', '.wwwwgwwwwwggwwwww', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.'], { w: '#F4F3EE', l: '#F7F6F2', g: '#D2D1C8', b: t, d: '#B45F43' })
822    f(22, 8, 7, 2, '#4A4A48', 'pan'); f(26, 10, 1, 1, '#4A4A48', 'pan'); f(24, 7, 3, 1, '#F5B731', 'egg')
823  },
824  // Light: racer in a helmet; runs at double pace, the checkered flag flutters.
825  light: (f, t) => {
826    crabBody(f, -4)
827    stamp(f, 6, 5, ['....rrrrrrrrrr....', '..rrrrrrwwrrrrrr..', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.kkkkkkkkkkkkkkkkr'], { r: t, w: '#F8F6F1', k: INK })
828    f(25, 1, 1, 9, '#8E929A')
829    stamp(f, 26, 1, ['wkwk', 'kwkw', 'wkwk'], { w: '#F8F6F1', k: INK }, 'flag')
830  },
831  // Explore: pirate scouting the code; the cutlass fences.
832  explore: f => {
833    crabBody(f)
834    stamp(f, 5, 3, ['.kk..............kk.', '.kkk....kkkk....kkk.', '..kkkkkkkwwkkkkkkk..', '..kkkkkkkkkkkkkkkk..', '.gggggggggggggggggg.'], { k: '#55514C', w: '#F8F6F1', g: '#F5C542' })
835    f(7, 11, 11, 1, INK); f(18, 11, 4, 3, INK)
836    f(27, 6, 1, 9, '#C9CCD2', 'it'); f(26, 15, 3, 1, '#7A4A26', 'it')
837  },
838  // Sonnet: scholar in a mortarboard; the tassel swings.
839  sonnet: (f, t) => {
840    crabBody(f)
841    stamp(f, 4, 6, ['bbbbbbbbbbbbbbbbbbbbbb', '...ddddddddddddddd....', '....ddddddddddddd.....', '....ddddddddddddd.....'], { b: INK, d: '#3A3A3C' })
842    f(14, 5, 2, 1, t); f(24, 7, 1, 4, t, 'it'); f(23, 11, 3, 2, t, 'it')
843  },
844  // Haiku: a sprout on its head; the leaves sway.
845  haiku: (f, t) => {
846    crabBody(f)
847    f(15, 4, 1, 6, '#4C8B3A')
848    stamp(f, 12, 2, ['....tt', '...ttt', 'tt.tt.', 'ttt...', '.tt...'], { t }, 'it')
849  },
850  other: f => crabBody(f),
851}
852
853const costumeOf = (type: string): string => (type === 'Explore' ? 'explore' : tierOf(type))
854
855const CRAB_SCALE = 1.1
856
857// Body and props nest inside `bd` so a prop rides the bob and adds its own motion;
858// legs stay outside it and step on their own.
859const crab = (x: number, y: number, costume: string, dim = false, isWalking = false, scale = CRAB_SCALE): string => {
860  const groups = new Map<string, string[]>([['bd', []]])
861  const f: Fill = (cx, cy, w, h, c, cls = 'bd') => {
862    if (!groups.has(cls)) groups.set(cls, [])
863    groups.get(cls)?.push(`<rect x="${cx}" y="${cy}" width="${w}" height="${h}" fill="${c}"/>`)
864  }
865  const draw = COSTUMES[costume] ?? ((g: Fill) => crabBody(g))
866  draw(f, colorOf(costume))
867  const group = (cls: string) => `<g class="${cls}">${(groups.get(cls) ?? []).join('')}</g>`
868  const props = [...groups.keys()].filter(k => k !== 'bd' && k !== 'la' && k !== 'lb')
869  const body = `<g class="bd">${(groups.get('bd') ?? []).join('')}${props.map(group).join('')}</g>`
870  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>`
871}
872
873const statusMark = (x: number, y: number, status: string, color: string): string => {
874  if (status === 'running') return `<circle class="live" cx="${x}" cy="${y}" r="3.5" fill="${color}"/>`
875  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"/>`
876  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"/>`
877  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"/>`
878}
879
880const svg = (W: number, H: number, body: string): string =>
881  `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${PANE_CSS}${CRAB_CSS}${body}</svg>`
882
883type TextOpts = {
884  size?: number
885  weight?: number
886  cls?: string
887  anchor?: 'middle' | 'end'
888  fill?: string
889  num?: boolean
890}
891
892const txt = (x: number, y: number, s: string, o: TextOpts = {}): string =>
893  `<text${o.fill ? ` fill="${o.fill}"` : ` class="${o.cls ?? 't'}"`} x="${x}" y="${y}"${o.anchor ? ` text-anchor="${o.anchor}"` : ''} font-family="${FONT}" font-size="${o.size ?? 12}"${o.weight ? ` font-weight="${o.weight}"` : ''}${o.num ? ' font-variant-numeric="tabular-nums"' : ''}>${xml(s)}</text>`
894
895// A rounded track with a fill from the left; `pct` is 0 to 100.
896const meterBar = (x: number, y: number, w: number, h: number, pct: number, color: string): string => {
897  const fill = Math.round((w * Math.max(0, Math.min(100, pct))) / 100)
898  return `<rect class="k" x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/>${fill > 0 ? `<rect x="${x}" y="${y}" width="${Math.max(h, fill)}" height="${h}" rx="${h / 2}" fill="${color}"/>` : ''}`
899}
900
901// Segments laid end to end inside one rounded track, a hairline between them.
902const stackBar = (x: number, y: number, w: number, h: number, parts: { w: number; color: string }[]): string => {
903  let cx = x
904  const segs = parts
905    .filter(p => p.w >= 1)
906    .map(p => {
907      const r = `<rect x="${cx}" y="${y}" width="${Math.max(1, p.w - 1)}" height="${h}" fill="${p.color}"/>`
908      cx += p.w
909      return r
910    })
911  return `<defs><clipPath id="sb"><rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/></clipPath></defs>
912<rect class="k" x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/><g clip-path="url(#sb)">${segs.join('')}</g>`
913}
914
915const PAD = 14
916
917const card = (W: number, H: number, title: string, right: string, body: string): string =>
918  svg(
919    W,
920    H,
921    `<rect class="tile" width="${W}" height="${H}" rx="10"/>
922${txt(PAD, 22, fitText(title, 11.5, right ? W * 0.55 : W - PAD * 2), { size: 11.5, weight: 600, cls: 's' })}
923${right ? txt(W - PAD, 22, fitText(right, 11.5, W * 0.4), { size: 11.5, cls: 's', anchor: 'end', num: true }) : ''}
924${body}`,
925  )
926
927const swatch = (x: number, y: number, color: string): string =>
928  `<rect x="${x}" y="${y - 8}" width="8" height="8" rx="2" fill="${color}"/>`
929
930// Two-column legend rows: swatch, name, and a value right-aligned in its column.
931const legend = (W: number, top: number, rows: { name: string; value: string; color: string }[], rowH = 20): string => {
932  const colW = (W - PAD * 2 - 16) / 2
933  return rows
934    .map((r, i) => {
935      const x = PAD + (i % 2) * (colW + 16)
936      const y = top + Math.floor(i / 2) * rowH
937      const vw = textWidth(r.value, 11.5)
938      return `${swatch(x, y, r.color)}${txt(x + 13, y, fitText(r.name, 11.5, colW - vw - 22), { size: 11.5, cls: 's' })}${txt(x + colW, y, r.value, { size: 11.5, anchor: 'end', num: true })}`
939    })
940    .join('')
941}
942
943// Cost first: the engine's ledger when there is one, else the estimate; the split
944// bar is always the estimate, labelled so.
945const HERO_H = 118
946
947const heroSvg = (W: number, t: Totals): string => {
948  const s = tr()
949  const inner = W - PAD * 2
950  const sum = t.mainCost + t.agentsCost
951  const mainW = sum ? Math.round((inner * t.mainCost) / sum) : 0
952  const cost = (t.isLedger ? '' : '≈') + fmtCost(t.cost)
953  const stat = (x: number, label: string, value: string) =>
954    `${txt(x, 40, label, { size: 11, cls: 'm', anchor: 'end' })}${txt(x, 59, value, { size: 15, weight: 600, anchor: 'end', num: true })}`
955  const hasActive = W >= 340
956  const mainLegend = `${s.mainSession} ≈${fmtCost(t.mainCost)}`
957  const agentsX = PAD + 13 + textWidth(mainLegend, 11.5) + 18
958  return card(
959    W,
960    HERO_H,
961    s.sessionCost,
962    t.isLedger ? '/cost' : s.estimated,
963    `${txt(PAD, 60, cost, { size: 28, weight: 650, num: true })}
964${stat(W - PAD, hasActive ? s.active : s.elapsed, hasActive ? fmtTime(t.activeMs) : fmtSpan(t.sessionMs))}
965${hasActive ? stat(W - PAD - 86, s.elapsed, fmtSpan(t.sessionMs)) : ''}
966${sum ? stackBar(PAD, 74, inner, 8, [{ w: mainW, color: ACCENT }, { w: inner - mainW, color: CLAY }]) : meterBar(PAD, 74, inner, 8, 0, ACCENT)}
967${swatch(PAD, 104, ACCENT)}${txt(PAD + 13, 104, mainLegend, { size: 11.5, cls: 's', num: true })}
968${swatch(agentsX, 104, CLAY)}${txt(agentsX + 13, 104, `${s.subagents} ≈${fmtCost(t.agentsCost)}`, { size: 11.5, cls: 's', num: true })}`,
969  )
970}
971
972const limitsHeight = (n: number): number => 34 + n * 46
973
974const limitsSvg = (W: number, rl: RateWindow[], at: number): string =>
975  card(
976    W,
977    limitsHeight(rl.length),
978    tr().limits,
979    '',
980    rl
981      .map((r, i) => {
982        const y0 = 34 + i * 46
983        const pct = r.percentUsed
984        const color = levelColor(pct)
985        const left = r.resetsAt ? Date.parse(r.resetsAt) - at : NaN
986        return `${txt(PAD, y0 + 10, rateLabel(r.kind, false), { size: 12.5, weight: 500 })}
987${txt(W - PAD, y0 + 10, fmtPct(pct), { size: 12.5, weight: 600, anchor: 'end', num: true, ...(pct >= 70 ? { fill: color } : {}) })}
988${meterBar(PAD, y0 + 17, W - PAD * 2, 6, pct, color)}
989${Number.isFinite(left) && left > 0 ? txt(PAD, y0 + 37, resetsIn(fmtSpan(left)), { size: 11, cls: 'm' }) : ''}`
990      })
991      .join(''),
992  )
993
994const PALETTE = ['#8f8cf4', '#378ADD', '#1D9E75', '#BA7517', '#D97757', '#7F77DD', '#D85A30', '#888780']
995
996const contextSlices = (mt: Meter) => (mt.categories ?? []).filter(c => c.kind === 'used' && c.tokens > 0).slice(0, 8)
997
998const contextHeight = (mt: Meter): number => {
999  if (mt.contextTokens === undefined) return 70
1000  const rows = Math.ceil(contextSlices(mt).length / 2)
1001  return rows ? 58 + rows * 20 : 54
1002}
1003
1004const contextSvg = (W: number, mt: Meter): string => {
1005  const s = tr()
1006  const inner = W - PAD * 2
1007  if (mt.contextTokens === undefined) {
1008    return card(W, contextHeight(mt), s.ctxWindow, '', `${meterBar(PAD, 32, inner, 10, 0, ACCENT)}${txt(PAD, 60, s.noReading, { size: 11.5, cls: 'm' })}`)
1009  }
1010  const pct = mt.contextPercent ?? (mt.contextWindow ? (mt.contextTokens / mt.contextWindow) * 100 : 0)
1011  const right = `${fmtPct(pct)} · ${fmtTokens(mt.contextTokens)} / ${fmtTokens(mt.contextWindow)}`
1012  const slices = contextSlices(mt)
1013  const used = slices.reduce((a, c) => a + c.tokens, 0)
1014  // The categories are estimates against the compaction window: scale them to the measured fill.
1015  const bar = slices.length
1016    ? stackBar(
1017        PAD,
1018        32,
1019        inner,
1020        10,
1021        slices.map((c, i) => ({ w: Math.round((inner * (pct / 100) * c.tokens) / used), color: PALETTE[i % PALETTE.length] ?? ACCENT })),
1022      )
1023    : meterBar(PAD, 32, inner, 10, pct, levelColor(pct))
1024  const rows = slices.map((c, i) => ({ name: categoryName(c.name), value: fmtTokens(c.tokens), color: PALETTE[i % PALETTE.length] ?? ACCENT }))
1025  return card(W, contextHeight(mt), s.ctxWindow, right, `${bar}${legend(W, 66, rows)}`)
1026}
1027
1028const TOKEN_COLORS = { input: '#378ADD', output: ACCENT, cacheRead: '#1D9E75', cacheWrite: '#BA7517' }
1029
1030const tokensHeight = (t: Totals): number => (sumSplit(t.split) ? 120 : 70)
1031
1032const tokensSvg = (W: number, t: Totals): string => {
1033  const s = tr()
1034  const inner = W - PAD * 2
1035  const total = sumSplit(t.split)
1036  if (!total) return card(W, tokensHeight(t), s.tokenSplit, '', `${meterBar(PAD, 32, inner, 10, 0, ACCENT)}${txt(PAD, 60, s.noReading, { size: 11.5, cls: 'm' })}`)
1037  const kinds = [
1038    { name: s.input, v: t.split.input, color: TOKEN_COLORS.input },
1039    { name: s.output, v: t.split.output, color: TOKEN_COLORS.output },
1040    { name: s.cacheRead, v: t.split.cacheRead, color: TOKEN_COLORS.cacheRead },
1041    { name: s.cacheWrite, v: t.split.cacheWrite, color: TOKEN_COLORS.cacheWrite },
1042  ]
1043  const prompt = t.split.input + t.split.cacheRead + t.split.cacheWrite
1044  const hit = prompt ? (t.split.cacheRead / prompt) * 100 : 0
1045  return card(
1046    W,
1047    tokensHeight(t),
1048    s.tokenSplit,
1049    fmtTokens(total),
1050    `${stackBar(PAD, 32, inner, 10, kinds.map(k => ({ w: Math.round((inner * k.v) / total), color: k.color })))}
1051${legend(W, 66, kinds.map(k => ({ name: k.name, value: `${fmtTokens(k.v)} · ${fmtShare((k.v / total) * 100)}`, color: k.color })))}
1052${txt(PAD, 108, fitText(`${s.cacheHit} ${fmtShare(hit)} · ${t.steps} ${s.requests}`, 11, inner), { size: 11, cls: 'm', num: true })}`,
1053  )
1054}
1055
1056type ModelRow = { name: string; costUsd: number; tokens: number }
1057
1058const modelRows = (m: MainUsage, list: AgentRun[], k: number): ModelRow[] => {
1059  const rows = new Map<string, ModelRow>()
1060  const add = (name: string, costUsd: number, tokens: number) => {
1061    if (!name || (!costUsd && !tokens)) return
1062    const r = rows.get(name) ?? { name, costUsd: 0, tokens: 0 }
1063    rows.set(name, { name, costUsd: r.costUsd + costUsd * k, tokens: r.tokens + tokens })
1064  }
1065  for (const [name, v] of Object.entries(m.byModel ?? {})) add(name, v.costUsd, v.tokens)
1066  for (const a of list) add(modelName(a.model), a.costUsd, a.tokens)
1067  return [...rows.values()].sort((a, b) => b.costUsd - a.costUsd).slice(0, 6)
1068}
1069
1070const modelsHeight = (n: number): number => 36 + n * 24
1071
1072const modelsSvg = (W: number, rows: ModelRow[]): string => {
1073  const top = Math.max(...rows.map(r => r.costUsd), 0.000001)
1074  const nameW = 96
1075  const valueW = 108
1076  const barW = Math.max(30, W - PAD * 2 - nameW - valueW)
1077  return card(
1078    W,
1079    modelsHeight(rows.length),
1080    tr().byModel,
1081    '',
1082    rows
1083      .map((r, i) => {
1084        const y = 44 + i * 24
1085        return `${txt(PAD, y, fitText(r.name, 12, nameW - 8), { size: 12, weight: 500 })}
1086${meterBar(PAD + nameW, y - 7, barW, 6, (r.costUsd / top) * 100, PALETTE[i % PALETTE.length] ?? ACCENT)}
1087${txt(W - PAD, y, `≈${fmtCost(r.costUsd)} · ${fmtTokens(r.tokens)}`, { size: 11.5, cls: 's', anchor: 'end', num: true })}`
1088      })
1089      .join(''),
1090  )
1091}
1092
1093// --- the band's session row: one SVG of labelled figures, dropped by priority as it narrows.
1094
1095type Seg = { prio: number; w: number; draw: (x: number) => string }
1096
1097const BAND_Y = 15.5
1098
1099const figure = (label: string, value: string, prio: number, color?: string): Seg => {
1100  const lw = label ? textWidth(label, 11.5) + 5 : 0
1101  return {
1102    prio,
1103    w: lw + textWidth(value, 12.5),
1104    draw: x =>
1105      (label ? txt(x, BAND_Y, label, { size: 11.5, cls: 'm' }) : '') +
1106      txt(x + lw, BAND_Y, value, { size: 12.5, weight: 600, num: true, ...(color ? { fill: color } : {}) }),
1107  }
1108}
1109
1110const gauge = (label: string, pct: number, prio: number): Seg => {
1111  const lw = textWidth(label, 11.5) + 6
1112  const bw = 30
1113  const v = fmtPct(pct)
1114  const color = levelColor(pct)
1115  return {
1116    prio,
1117    w: lw + bw + 6 + textWidth(v, 12.5),
1118    draw: x =>
1119      txt(x, BAND_Y, label, { size: 11.5, cls: 'm' }) +
1120      meterBar(x + lw, 8, bw, 6, pct, color) +
1121      txt(x + lw + bw + 6, BAND_Y, v, { size: 12.5, weight: 600, num: true, ...(pct >= 70 ? { fill: color } : {}) }),
1122  }
1123}
1124
1125const RATE_PRIO: Record<string, number> = { five_hour: 3, spend_limit: 3, seven_day: 6 }
1126
1127const bandSegs = (t: Totals, m: MainUsage, mt: Meter): Seg[] => {
1128  const s = tr()
1129  const segs: Seg[] = []
1130  if (m.model) segs.push(figure('', modelName(m.model), 7))
1131  segs.push(figure(s.cost, (t.isLedger ? '' : '≈') + fmtCost(t.cost), 1))
1132  segs.push(figure(s.tokens, fmtTokens(t.tokens), 4))
1133  if (mt.contextTokens !== undefined && mt.contextPercent !== undefined) segs.push(gauge(s.context, mt.contextPercent, 2))
1134  for (const r of mt.rateLimits) segs.push(gauge(rateLabel(r.kind, true), r.percentUsed, RATE_PRIO[r.kind] ?? 8))
1135  if (t.sessionMs) segs.push(figure(s.elapsed, fmtSpan(t.sessionMs), 5))
1136  return segs
1137}
1138
1139const bandText = (t: Totals, m: MainUsage, mt: Meter): string => {
1140  const s = tr()
1141  const parts: string[] = []
1142  if (m.model) parts.push(modelName(m.model))
1143  parts.push(`${s.cost} ${t.isLedger ? '' : '≈'}${fmtCost(t.cost)}`)
1144  if (mt.contextPercent !== undefined) parts.push(`${s.context} ${fmtPct(mt.contextPercent)}`)
1145  for (const r of mt.rateLimits) parts.push(`${rateLabel(r.kind, true)} ${fmtPct(r.percentUsed)}`)
1146  parts.push(`${s.tokens} ${fmtTokens(t.tokens)}`)
1147  if (t.sessionMs) parts.push(`${s.elapsed} ${fmtSpan(t.sessionMs)}`)
1148  return parts.join(' · ')
1149}
1150
1151const BAND_GAP = 19
1152
1153// Returns the drawing and its width: the figures that fit, and no more. `textWidth`
1154// runs a few px short on figures, so the end keeps room for the last glyph.
1155const statsSvg = (W: number, segs: Seg[], isWorking: boolean): { source: string; width: number } => {
1156  // Keep the most important figures that fit, then draw them in their own order.
1157  let used = 16
1158  const kept = new Set<Seg>()
1159  for (const seg of [...segs].sort((a, b) => a.prio - b.prio)) {
1160    const w = seg.w + (kept.size ? BAND_GAP : 0)
1161    if (used + w > W) continue
1162    kept.add(seg)
1163    used += w
1164  }
1165  let x = 16
1166  const body = segs
1167    .filter(seg => kept.has(seg))
1168    .map((seg, i) => {
1169      const sep = i ? `<rect class="k" x="${x + Math.floor(BAND_GAP / 2) - 0.5}" y="5" width="1" height="12"/>` : ''
1170      if (i) x += BAND_GAP
1171      const out = sep + seg.draw(x)
1172      x += seg.w
1173      return out
1174    })
1175    .join('')
1176  const width = Math.ceil(x + 10)
1177  return { source: svg(width, H, `<circle${isWorking ? ' class="live"' : ''} cx="5" cy="${H / 2}" r="4" fill="${isWorking ? ACCENT : DONE}"/>${body}`), width }
1178}
1179
1180// The task's own progress when the worker reports steps; a finished run is full.
1181const progressOf = (a: AgentRun): number | null => {
1182  if (a.status === 'done') return 1
1183  if (a.stepTotal) return Math.min(1, (a.stepDone ?? 0) / a.stepTotal)
1184  return null
1185}
1186
1187// The run's model, its effort, and "auto" when routing picked the model.
1188const modelLabel = (a: AgentRun): string =>
1189  `${modelName(a.model)}${a.effort ? ` · ${a.effort}` : ''}${a.routedModel ? ` · ${tr().auto}` : ''}`
1190
1191const ctxOf = (a: AgentRun): number => (a.contextMax ? Math.min(100, Math.round((a.contextTokens / a.contextMax) * 100)) : 0)
1192
1193// The run's meta line: its model, round, SendMessage address and failure.
1194const runMeta = (a: AgentRun): string[] => {
1195  const s = tr()
1196  const meta = [modelLabel(a)]
1197  if (a.round > 1) meta.push(`${s.round} ${a.round}`)
1198  if (a.name) meta.push(`@${a.name}`)
1199  if (a.status === 'failed') meta.push(s.failed)
1200  return meta
types/index.d.ts 146 lines
1export type Phase = 'plan' | 'design' | 'delegate' | 'review' | 'close'
2
3export type PlannedTask = {
4  title: string
5  tier: string
6  after: number[]
7  /** The model the task will run on, when the orchestrator names it; the tier's default otherwise. */
8  model?: string
9  effort?: string
10}
11
12/** Token counts by kind, summed over model requests. */
13export type TokenSplit = {
14  input: number
15  output: number
16  cacheRead: number
17  cacheWrite: number
18}
19
20/** The main loop's own model requests: an API-equivalent estimate, never a bill. */
21export type MainUsage = {
22  model: string
23  tokens: number
24  costUsd: number
25  steps: number
26  split?: TokenSplit
27  /** Estimated cost and tokens of the main loop per model display name. */
28  byModel?: Record<string, { costUsd: number; tokens: number }>
29  /** Wall-clock time of finished main-loop turns. */
30  activeMs?: number
31  turns?: number
32  /** Set while a main-loop turn runs. */
33  turnStartedAt?: number
34  /** The main loop's current or last turn. */
35  turnId?: string
36}
37
38export type RateWindow = {
39  kind: string
40  percentUsed: number
41  resetsAt?: string
42}
43
44export type ContextSlice = {
45  name: string
46  tokens: number
47  kind: string
48}
49
50/** The engine's own session figures (`$.session.usage()` and `session.measure`). */
51export type Meter = {
52  startedAt: number
53  contextTokens?: number
54  contextWindow: number
55  contextPercent?: number
56  rateLimits: RateWindow[]
57  /** The engine's ledger, as /cost totals it; absent where the host keeps none. */
58  costUsd?: number
59  categories?: ContextSlice[]
60  categoriesMax?: number
61  breakdownAt?: number
62}
63
64export type Flow = {
65  title: string
66  total: number
67  done: number
68  running: number
69  phase: Phase
70  isFinished: boolean
71  tasks: PlannedTask[]
72  /** When the flow began: runs that started earlier belong to an earlier flow. */
73  startedAt?: number
74}
75
76export type AgentStatus = 'running' | 'done' | 'failed'
77
78export type AgentRun = {
79  id: string
80  agentId?: string
81  type: string
82  description: string
83  model: string
84  effort?: string
85  status: AgentStatus
86  startedAt: number
87  endedAt?: number
88  contextTokens: number
89  contextMax: number
90  tokens: number
91  costUsd: number
92  steps: number
93  round: number
94  split?: TokenSplit
95  /** The model routing gave the run, when the Agent call named none (`exploreModel`, `generalModel`). */
96  routedModel?: string
97  /** The run whose loop started this one; absent when the main loop did. */
98  parentAgentId?: string
99  /** The workflow run (`wf_...`) and the agent's place in it, for a workflow's agent. */
100  workflowRunId?: string
101  workflowIndex?: number
102  /** What SendMessage addresses it by, when the call named it. */
103  name?: string
104  /** Self-reported by the worker through the `step` tool. */
105  stepDone?: number
106  stepTotal?: number
107  stepNote?: string
108}
109
110export type Panel = {
111  isCompact: boolean
112  isDoneCollapsed: boolean
113  /** The flow title or main turn the panel last opened itself for. */
114  autoOpenedFor: string
115  /** The person closed the panel: subagents outside a flow leave it closed until they open it. */
116  isAutoOff?: boolean
117  /** The run the person picked to keep in view on a surface with no agent view of its own. */
118  focusAgentId?: string
119}
120
121/**
122 * What the drawings read: a copy of the live values, published at most every two
123 * seconds while tokens stream in, so a press does not land on a retired handle.
124 */
125export type Frame = {
126  flow: Flow | null
127  agents: AgentRun[]
128  main: MainUsage
129  meter: Meter
130  /** The time the frame was taken. */
131  at: number
132}
133
134declare module 'claude-code' {
135  interface PluginState {
136    'savvy-progress': {
137      flow: Flow | null
138      agents: AgentRun[]
139      panel: Panel
140      frame: Frame
141      main: MainUsage
142      meter: Meter
143    }
144  }
145}
146