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…

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.
savvy-* worker starts./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.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.
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" } } } }
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.
hooks/register.tsx 2291 lines1import { 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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 metatypes/index.d.ts 146 lines1export 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