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…

newpanebandguardcommandprompt
★ 1v1.2.0-sgs.7MITupdated 2026-10-09Phalegethon/sgs-kit/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:00 · 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:00 · 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.7) 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. Working crabs walk, and each savvy tier animates its prop: the astronaut floats, the detective sweeps the magnifier, the engineer turns the wrench, the chef tosses the omelette, the racer runs with a fluttering flag. prefers-reduced-motion stops them.

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" } } } }

Install instructions are in the repository README.

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