Control Tower: Flightdeck's live agent dashboard on top, savvy-progress's savvy-flow agents panel below, plus the progress band above the prompt

A Claude Code mod: one pane with a live agent dashboard on top and a list of your subagents below, each drawn as a pixel crab in its own costume.
It merges two MIT-licensed mods:
progress / step tools.

/plugin install control-tower --marketplace nicandcranny/claude-code-mods
| Command | Does | |||
|---|---|---|---|---|
/control-tower | open the pane | |||
/control-tower close | close it | |||
/control-tower reset | clear agents, checks, consults, log and the turn | |||
| `/control-tower layout auto\ | compact\ | wide\ | mini` | override the layout for this session |
Focus the pane with ctrl+x tab; f s o open the gate's file / shell / other drill-down.
effort, ctx, usage), with the session cost and working/idle status in its header. Below sits the subagent list in an orange box.▸ Read → src/app.ts); a finished one dozes and shows the first line of its answer (» …); a failed one lies tipped over. The latest three finished agents show; +N earlier expands the rest, clear removes them.mcp__control-tower__progress tool, a progress bar instead.agent <title> finished when an agent hands its report back.Agent costs are estimates from token counts and a built-in price table, marked ≈.
In /config, or under pluginConfigs["control-tower"].options in settings.json: architectPattern, architectLabel, gateLabel, panels, motion, moments, matchDescriptions, maxCards, layout, palette (theme or pastel), openOnStart, statusLine, language (auto, en, ru).
claude plugin validate .
claude plugin test .
MIT. See LICENSE; the original notices are kept in LICENSE.flightdeck.txt and LICENSE.savvy-progress.txt.
hooks/register.tsx 2434 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Hook, On, PluginOptions, Register, RenderElement, RenderInput } from 'claude-code'
3
4import type { AgentCard, Architect, Bucket, Check, Gate, Layout, LogLine, Loop, Main, Roster, Turn, Usage, View } from '../types'
5import type { AgentRun, Flow, Phase, PlannedTask, SavvyPanel } from '../types'
6import {
7 DEFAULT_ARCHITECT,
8 DEFAULT_GATE,
9 DEFAULT_MAIN,
10 DEFAULT_ROSTER,
11 DEFAULT_TURN,
12 DEFAULT_USAGE,
13 DEFAULT_VIEW,
14 EDIT_TOOLS,
15 SCHEMA_VERSION,
16 afterCall,
17 applyStep,
18 bucketOf,
19 cardTitle,
20 titleLines,
21 consultTimeline,
22 describeInput,
23 endConsult,
24 fitLegend,
25 fmtClock,
26 fmtDuration,
27 fmtTimer,
28 fmtUsd,
29 plural,
30 gateSummary,
31 gauge,
32 isAdvising,
33 isLoopActive,
34 kTokens,
35 lanes,
36 limitLabel,
37 listOf,
38 logRows,
39 momentOf,
40 normalize,
41 normalizeCard,
42 normalizeGate,
43 normalizeLog,
44 noteTool,
45 PALETTES,
46 parseConfig,
47 prettyModel,
48 promptLine,
49 handbackOf,
50 adviceLine,
51 receiptOf,
52 recordCheck,
53 settleCheck,
54 shorten,
55 startConsult,
56 stepLoop,
57} from './core'
58import type { Config, Panel } from './core'
59
60const PANE = 'control-tower'
61const TITLE = 'Control Tower'
62const PANE_COLUMNS = 66
63
64
65// ---------------------------------------------------------------- state
66
67const meta = atom({ plugin: 'control-tower', key: 'meta' } as const, { schemaVersion: SCHEMA_VERSION })
68const main = atom({ plugin: 'control-tower', key: 'main' } as const, DEFAULT_MAIN)
69const usage = atom({ plugin: 'control-tower', key: 'usage' } as const, DEFAULT_USAGE)
70const architect = atom({ plugin: 'control-tower', key: 'architect' } as const, DEFAULT_ARCHITECT)
71const gate = atom({ plugin: 'control-tower', key: 'gate' } as const, DEFAULT_GATE)
72const agents = atom({ plugin: 'control-tower', key: 'agents' } as const, [])
73const loops = atom({ plugin: 'control-tower', key: 'loops' } as const, [])
74const log = atom({ plugin: 'control-tower', key: 'log' } as const, [])
75const turn = atom({ plugin: 'control-tower', key: 'turn' } as const, DEFAULT_TURN)
76const receipt = atom({ plugin: 'control-tower', key: 'receipt' } as const, null)
77const view = atom({ plugin: 'control-tower', key: 'view' } as const, DEFAULT_VIEW)
78const roster = atom({ plugin: 'control-tower', key: 'roster' } as const, DEFAULT_ROSTER)
79
80type ServerBlock = { type: string; id?: string; name?: string; tool_use_id?: string }
81
82// Every read goes through these, so a value saved under an older shape still reads.
83async function getMain($: EngineInterface): Promise<Main> {
84 return normalize(DEFAULT_MAIN, await read($, main))
85}
86async function getUsage($: EngineInterface): Promise<Usage> {
87 return normalize(DEFAULT_USAGE, await read($, usage))
88}
89async function getArchitect($: EngineInterface): Promise<Architect> {
90 const a = normalize(DEFAULT_ARCHITECT, await read($, architect))
91 return { ...a, consults: listOf(a.consults), ids: listOf(a.ids), seen: listOf(a.seen) }
92}
93async function getGate($: EngineInterface): Promise<Gate> {
94 return normalizeGate(await read($, gate))
95}
96async function getCards($: EngineInterface): Promise<AgentCard[]> {
97 return listOf<unknown>(await read($, agents)).map(normalizeCard)
98}
99async function getLoops($: EngineInterface): Promise<Loop[]> {
100 return listOf<Loop>(await read($, loops))
101}
102async function getLog($: EngineInterface): Promise<LogLine[]> {
103 return normalizeLog(await read($, log))
104}
105async function getTurn($: EngineInterface): Promise<Turn> {
106 return normalize(DEFAULT_TURN, await read($, turn))
107}
108async function getView($: EngineInterface): Promise<View> {
109 return normalize(DEFAULT_VIEW, await read($, view))
110}
111async function getRoster($: EngineInterface): Promise<Roster> {
112 const r = normalize(DEFAULT_ROSTER, await read($, roster))
113 return { architectTypes: listOf(r.architectTypes) }
114}
115
116/** A stored shape older than this build's: drop what cannot be read, keep the rest. */
117async function migrate($: EngineInterface) {
118 const m = await read($, meta)
119 if ((m?.schemaVersion ?? 0) >= SCHEMA_VERSION) return
120 await update($, log, list => normalizeLog(list))
121 await update($, agents, list => listOf<unknown>(list).map(normalizeCard))
122 await update($, gate, g => normalizeGate(g))
123 await update($, meta, () => ({ schemaVersion: SCHEMA_VERSION }))
124}
125
126async function say($: EngineInterface, who: string, text: string, kind: LogLine['kind'] = 'info', agentId: string | null = null) {
127 const line: LogLine = { at: await $.clock.now(), who, text, kind, agentId }
128 await update($, log, list => [...normalizeLog(list), line].slice(-60))
129}
130
131async function refreshStatus($: EngineInterface, cfg: Config) {
132 if (!cfg.statusLine) return $.ui.status(undefined)
133 const [u, a, g, cards] = await Promise.all([getUsage($), getArchitect($), getGate($), getCards($)])
134 const running = cards.filter(c => c.status === 'running').length
135 const s = gateSummary(g)
136 const parts = [
137 u.pct !== null ? `ctx ${Math.round(u.pct)}%` : null,
138 cards.length > 0 ? `agents ${running}/${cards.length}` : null,
139 a.consults.length > 0 || a.ids.length > 0 ? `${cfg.architectLabel.toLowerCase()} ${isAdvising(a) ? 'advising' : a.consults.length}` : null,
140 s.deny > 0 ? `denied ${s.deny}` : null,
141 ]
142 // Only fields with something to say; with none, no status entry at all.
143 const shown = parts.filter(Boolean)
144 $.ui.status(shown.length > 0 ? shown.join(' · ') : undefined)
145}
146
147async function whoIs($: EngineInterface, agentId: string | undefined) {
148 if (!agentId) return 'main'
149 const card = (await getCards($)).find(c => c.id === agentId)
150 return card ? shorten(cardTitle(card), 14) : 'agent'
151}
152
153async function consultStarted($: EngineInterface, cfg: Config, id: string, via: string) {
154 const t = await getTurn($)
155 const moment = momentOf(t)
156 const at = await $.clock.now()
157 await update($, architect, a => startConsult(normalize(DEFAULT_ARCHITECT, a), { id, at, moment, via }))
158 if (moment === 'before done') await update($, turn, x => ({ ...normalize(DEFAULT_TURN, x), isReviewing: true }))
159 await say($, cfg.architectLabel.toLowerCase(), cfg.moments ? `${moment} · ${via}` : `consulted · ${via}`, 'consult')
160 await refreshStatus($, cfg)
161}
162
163async function consultEnded($: EngineInterface, cfg: Config, advice: string | null, id?: string) {
164 const at = await $.clock.now()
165 const first = advice?.split('\n').find(l => l.trim()) ?? null
166 const text = first ? shorten(first.replace(/^[#>*\s-]+/, ''), 160) : null
167 await update($, architect, a => endConsult(normalize(DEFAULT_ARCHITECT, a), at, text, id))
168 await update($, turn, t => ({ ...normalize(DEFAULT_TURN, t), isReviewing: false }))
169 await say($, cfg.architectLabel.toLowerCase(), text ? `advice: ${shorten(text, 60)}` : 'advice returned', 'consult')
170 await refreshStatus($, cfg)
171}
172
173async function noteAdvice($: EngineInterface, cfg: Config, advice: string) {
174 await update($, architect, x => ({ ...normalize(DEFAULT_ARCHITECT, x), lastAdvice: advice }))
175 await say($, cfg.architectLabel.toLowerCase(), `advice: ${shorten(advice, 60)}`, 'consult')
176}
177
178async function isArchitectType($: EngineInterface, cfg: Config, type: string) {
179 return cfg.architect.test(type) || (await getRoster($)).architectTypes.includes(type)
180}
181
182async function openPane($: EngineInterface) {
183 // columns apply when docked beside the transcript, rows when seated inline above the prompt.
184 return $.ui.open({ id: PANE, title: TITLE, columns: PANE_COLUMNS, rows: 8 })
185}
186
187async function resetAll($: EngineInterface) {
188 await update($, main, m => ({ ...DEFAULT_MAIN, model: normalize(DEFAULT_MAIN, m).model, mode: normalize(DEFAULT_MAIN, m).mode }))
189 await update($, architect, () => DEFAULT_ARCHITECT)
190 await update($, gate, () => DEFAULT_GATE)
191 await update($, agents, () => [])
192 await update($, loops, () => [])
193 await update($, log, () => [])
194 await update($, turn, () => DEFAULT_TURN)
195 await update($, receipt, () => null)
196 await update($, view, () => DEFAULT_VIEW)
197 await update($, runs, () => [])
198 await update($, flow, () => null)
199 // The context gauge waits for the next measurement rather than showing the pre-clear fill.
200 await update($, usage, x => ({ ...normalize(DEFAULT_USAGE, x), pct: null, tokens: null }))
201}
202
203/** The session's cost read fresh, not from the last measurement: the receipt subtracts two of these. */
204async function costNow($: EngineInterface): Promise<number | null> {
205 const u = await $.session.usage().catch(() => null)
206 return u?.cost?.usd ?? null
207}
208
209async function noteMode($: EngineInterface, mode: string | undefined) {
210 if (mode) await update($, main, m => (normalize(DEFAULT_MAIN, m).mode === mode ? normalize(DEFAULT_MAIN, m) : { ...normalize(DEFAULT_MAIN, m), mode }))
211}
212
213// ---------------------------------------------------------------- savvy-progress
214
215const flow = atom({ plugin: 'control-tower', key: 'flow' } as const, null)
216const runs = atom({ plugin: 'control-tower', key: 'runs' } as const, [])
217const panel = atom({ plugin: 'control-tower', key: 'savvyPanel' } as const, {
218 isCompact: false,
219 isDoneCollapsed: false,
220 showAllDone: false,
221 autoOpenedFor: '',
222})
223const savvyNow = atom({ plugin: 'control-tower', key: 'now' } as const, 0)
224
225const TOOL = 'mcp__control-tower__progress'
226const STEP_TOOL = 'mcp__control-tower__step'
227const PHASES: readonly Phase[] = ['plan', 'design', 'delegate', 'review', 'close']
228const ACCENT = '#8f8cf4'
229const DONE = '#5fbf8f'
230
231type ProgressInput = {
232 title?: string
233 total?: number
234 done?: number
235 phase?: Phase
236 finished?: boolean
237 tasks?: { title?: string; tier?: string; after?: number[] }[]
238}
239
240// ---------------------------------------------------------------------------
241// Language: the `language` option, else Claude Code's `language` setting, else the
242// process locale; English when nothing says Russian.
243
244type Lang = 'en' | 'ru'
245
246const STRINGS = {
247 en: {
248 pane: 'Agents',
249 cost: 'Cost',
250 tokens: 'Tokens',
251 time: 'Time',
252 collapse: 'Collapse',
253 clearDone: 'clear',
254 earlier: 'earlier',
255 showFewer: 'show fewer',
256 agentOne: 'agent',
257 runningWord: 'running',
258 openPane: 'open',
259 expand: 'Expand',
260 running: 'Running',
261 finished: 'Finished',
262 planned: 'Planned',
263 empty: 'No subagents yet.',
264 round: 'round',
265 failed: 'error',
266 after: 'after',
267 tokensWord: 'tokens',
268 agentsCount: 'agents',
269 isRunning: 'running',
270 isFinished: 'finished',
271 isPlanned: 'planned',
272 opened: 'Agents panel opened.',
273 closed: 'Agents panel closed.',
274 done: 'Done',
275 plan: 'Plan',
276 design: 'Design',
277 tasks: 'Tasks',
278 review: 'Review',
279 busy: 'running',
280 },
281 ru: {
282 pane: 'Агенты',
283 cost: 'Стоимость',
284 tokens: 'Токены',
285 time: 'Время',
286 collapse: 'Свернуть',
287 clearDone: 'очистить',
288 earlier: 'ранее',
289 showFewer: 'показать меньше',
290 agentOne: 'агент',
291 runningWord: 'работают',
292 openPane: 'открыть',
293 expand: 'Развернуть',
294 running: 'Работают',
295 finished: 'Завершены',
296 planned: 'Запланированы',
297 empty: 'Субагентов пока нет.',
298 round: 'раунд',
299 failed: 'ошибка',
300 after: 'после',
301 tokensWord: 'токенов',
302 agentsCount: 'агентов',
303 isRunning: 'работает',
304 isFinished: 'завершён',
305 isPlanned: 'запланирована',
306 opened: 'Панель агентов открыта.',
307 closed: 'Панель агентов закрыта.',
308 done: 'Готово',
309 plan: 'План',
310 design: 'Дизайн',
311 tasks: 'Задачи',
312 review: 'Ревью',
313 busy: 'в работе',
314 },
315} as const
316
317// Module scope is fine here: session.start sets it again on every (re)load.
318let lang: Lang = 'en'
319const tr = () => STRINGS[lang]
320
321const isRussian = (v: unknown): boolean => typeof v === 'string' && /^(ru|russian|рус)/i.test(v.trim())
322
323async function detectLang($: EngineInterface, option: unknown): Promise<Lang> {
324 if (option === 'en' || option === 'ru') return option
325 try {
326 const settings = (await $.settings.read()) as Record<string, unknown>
327 if (typeof settings.language === 'string' && settings.language.trim()) return isRussian(settings.language) ? 'ru' : 'en'
328 } catch {
329 // No settings: fall through to the locale.
330 }
331 const locale = (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG'))
332 return isRussian(locale) ? 'ru' : 'en'
333}
334
335const blank = (): Flow => ({
336 title: 'savvy-flow',
337 total: 0,
338 done: 0,
339 running: 0,
340 phase: 'plan',
341 isFinished: false,
342 tasks: [],
343})
344
345const isNewFlow = (prev: Flow | null, input: ProgressInput): boolean =>
346 !prev || prev.isFinished || (input.title !== undefined && input.title.trim() !== prev.title)
347
348const cleanTasks = (tasks: ProgressInput['tasks']): PlannedTask[] | undefined =>
349 tasks
350 ?.filter(t => t.title?.trim())
351 .map(t => ({
352 title: (t.title ?? '').trim(),
353 tier: (t.tier ?? '').replace(/^savvy-/, '').trim().toLowerCase(),
354 after: (t.after ?? []).filter(n => Number.isInteger(n) && n > 0),
355 }))
356
357const merge = (prev: Flow | null, input: ProgressInput): Flow => {
358 // A new title means a new flow: never carry counters over from an earlier one.
359 const base = isNewFlow(prev, input) || !prev ? blank() : { ...blank(), ...prev }
360 const tasks = cleanTasks(input.tasks) ?? base.tasks
361 const total = Math.max(0, Math.round(input.total ?? (input.tasks ? tasks.length : base.total)))
362 const done = Math.min(total || Infinity, Math.max(0, Math.round(input.done ?? base.done)))
363 const phase = input.phase && PHASES.includes(input.phase) ? input.phase : base.phase
364 return {
365 ...base,
366 title: input.title?.trim() || base.title,
367 total,
368 done,
369 phase: input.finished ? 'close' : phase,
370 isFinished: input.finished === true,
371 tasks,
372 }
373}
374
375const label = (f: Flow): string => {
376 const s = tr()
377 if (f.isFinished) return s.done
378 if (f.phase === 'plan') return s.plan
379 if (f.phase === 'design') return s.design
380 const count = f.total ? `${f.done}/${f.total}` : `${f.running} ${s.busy}`
381 return `${f.phase === 'review' ? s.review : s.tasks} ${count}`
382}
383
384const ratio = (f: Flow): number => (f.isFinished ? 1 : f.total ? f.done / f.total : 0)
385
386// Deterministic noise so the dither does not shimmer between redraws.
387const noise = (x: number, y: number): number => {
388 const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
389 return s - Math.floor(s)
390}
391
392// The whole row is one SVG: the desktop wraps sibling elements onto new lines,
393// so title, bar, percent and the crab live in one drawing; only the count and
394// the dismiss are Buttons beside it.
395const H = 22
396const BAR_H = 16
397const CRAB_W = 26
398const CELL = 3
399const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
400
401const xml = (s: string): string =>
402 s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
403
404const clip = (s: string, max: number): string => (s.length > max ? s.slice(0, Math.max(1, max - 1)) + '…' : s)
405
406// Rough advance of system UI text, in em; good enough to size the title's slot.
407const charEm = (ch: string): number =>
408 /[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-ZА-ЯЁmwшщжюМШЩЖЮ@%]/.test(ch) ? 0.72 : 0.56
409
410const textWidth = (s: string, size: number): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
411
412// Cuts `s` to fit `maxW` pixels, with an ellipsis when it had to cut.
413const fitText = (s: string, size: number, maxW: number): string => {
414 if (textWidth(s, size) <= maxW) return s
415 let out = ''
416 for (const ch of s) {
417 if (textWidth(out + ch + '…', size) > maxW) break
418 out += ch
419 }
420 return out + '…'
421}
422
423const rowSvg = (f: Flow, W: number, isWorking: boolean): string => {
424 // The title takes what it needs, up to 40% of the row; the bar takes the rest.
425 const title = fitText(f.title, 13, Math.max(60, W * 0.4))
426 const BAR_X = Math.round(16 + textWidth(title, 13) + 12)
427 const BAR_W = Math.max(60, W - BAR_X - 46 - CRAB_W)
428 const color = f.isFinished ? DONE : ACCENT
429 const y0 = (H - BAR_H) / 2
430 const fillW = Math.round(BAR_W * ratio(f))
431 const runW = f.total ? Math.round((BAR_W * Math.min(f.total, f.done + f.running)) / f.total) : 0
432 const dots: string[] = []
433
434 // Dithered fill: sparse at the start, dense toward the head.
435 const cols = Math.floor(fillW / CELL)
436 const rows = Math.floor(BAR_H / CELL)
437 for (let c = 0; c < cols; c++) {
438 const density = 0.35 + 0.6 * Math.pow(c / Math.max(1, cols), 1.2)
439 for (let r = 0; r < rows; r++) {
440 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"/>`)
441 }
442 }
443 // Handed to workers, not yet accepted: a faint second layer.
444 const faint: string[] = []
445 for (let c = cols; c < Math.floor(runW / CELL); c++) {
446 for (let r = 0; r < rows; r++) {
447 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"/>`)
448 }
449 }
450
451 const ticks: string[] = []
452 for (let i = 1; i < f.total; i++) {
453 const x = Math.round((BAR_W * i) / f.total)
454 if (x > fillW + 4) ticks.push(`<rect x="${x}" y="${BAR_H / 2 - 4}" width="1.5" height="8" rx="0.75"/>`)
455 }
456
457 const text = label(f)
458 const pillW = Math.round(18 + text.length * 6.6)
459 const pillX = Math.max(0, Math.min(BAR_W - pillW, fillW - pillW))
460 const percent = `${Math.round(ratio(f) * 100)}%`
461
462 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
463<style>
464.t{fill:#1f1f1f}.m{fill:#8a8a8a}.k{fill:#e4e4e2}.tk{fill:#b4b4b0}
465@media (prefers-color-scheme: dark){.t{fill:#ececec}.m{fill:#9a9a9a}.k{fill:#2c2c2c}.tk{fill:#5a5a5a}}
466/* Pixels twinkle in four out-of-phase groups; a finished bar settles to a slow glow. */
467.t0,.t1,.t2,.t3{animation:tw ${f.isFinished ? 3.2 : 2.2}s ease-in-out infinite}
468.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}
469@keyframes tw{0%,100%{opacity:1}50%{opacity:${f.isFinished ? 0.8 : 0.3}}}
470@media (prefers-reduced-motion: reduce){.t0,.t1,.t2,.t3{animation:none}}
471</style>
472<defs><clipPath id="c"><rect x="0" y="0" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/></clipPath></defs>
473<circle cx="5" cy="${H / 2}" r="4" fill="${color}"/>
474<text class="t" x="16" y="${H / 2 + 4.5}" font-family="${FONT}" font-size="13" font-weight="500">${xml(title)}</text>
475<g transform="translate(${BAR_X},${y0})">
476<rect class="k" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/>
477<g clip-path="url(#c)">
478<g fill="${color}">${dots.join('')}</g>
479<g fill="${color}" opacity="0.45">${faint.join('')}</g>
480<g class="tk">${ticks.join('')}</g>
481</g>
482<rect x="${pillX}" width="${pillW}" height="${BAR_H}" rx="${BAR_H / 2}" fill="${color}"/>
483<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>
484</g>
485<text class="m" x="${W - CRAB_W - 6}" y="${H / 2 + 4.5}" text-anchor="end" font-family="${FONT}" font-size="12.5" font-variant-numeric="tabular-nums">${percent}</text>
486${CRAB_CSS}${crab(W - CRAB_W + 1, 0, 'other', false, isWorking, 0.8)}
487</svg>`
488}
489
490const barText = (f: Flow, width: number): string => {
491 const filled = Math.round(width * ratio(f))
492 return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
493}
494
495// ---------------------------------------------------------------------------
496// Agents panel: every subagent of the session, plus the tasks the flow planned.
497
498const TIER_COLOR: Record<string, string> = {
499 fable: '#7F77DD',
500 heavy: '#D85A30',
501 careful: '#BA7517',
502 medium: '#378ADD',
503 light: '#1D9E75',
504 other: '#888780',
505}
506
507// What each savvy tier runs on, for planned tasks that have no run yet.
508const colorOf = (tier: string): string => TIER_COLOR[tier] ?? '#888780'
509
510const TIER_MODEL: Record<string, string> = {
511 fable: 'Fable · high',
512 heavy: 'Opus · xhigh',
513 careful: 'Opus · high',
514 medium: 'Opus · medium',
515 light: 'Opus · low',
516}
517
518// USD per million tokens: input, output, cache read, cache write (5-minute TTL).
519// The engine reports tokens, not money, so the panel's cost is an estimate.
520const PRICES: [RegExp, [number, number, number, number]][] = [
521 [/fable|mythos/, [10, 50, 0.25, 12.5]],
522 [/opus-5-5/, [4, 20, 0.2, 5]],
523 [/opus/, [5, 25, 0.5, 6.25]],
524 [/sonnet/, [2, 10, 0.2, 2.5]],
525 [/haiku/, [1, 5, 0.1, 1.25]],
526]
527
528type ApiUsage = {
529 input_tokens: number
530 output_tokens: number
531 cache_read_input_tokens: number
532 cache_creation_input_tokens: number
533}
534
535const priceOf = (model: string): [number, number, number, number] =>
536 PRICES.find(([re]) => re.test(model.toLowerCase()))?.[1] ?? [4, 20, 0.2, 5]
537
538const costOf = (model: string, u: ApiUsage): number => {
539 const [i, o, r, w] = priceOf(model)
540 return (
541 ((u.input_tokens || 0) * i +
542 (u.output_tokens || 0) * o +
543 (u.cache_read_input_tokens || 0) * r +
544 (u.cache_creation_input_tokens || 0) * w) /
545 1e6
546 )
547}
548
549const windowOf = (model: string): number => (/haiku/i.test(model) ? 200_000 : 1_000_000)
550
551// `savvy-careful`, or `savvy-flow:savvy-careful` when the agents ship in a plugin.
552const tierOf = (type: string): string => {
553 const bare = type.replace(/^[^:]*:/, '')
554 const t = bare.replace(/^savvy-/, '').toLowerCase()
555 return t in TIER_COLOR && bare.startsWith('savvy-') ? t : 'other'
556}
557
558const modelName = (id: string): string => {
559 const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
560 const [, family = '', major = '', minor] = m ?? []
561 if (!family) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '') || '—'
562 return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()} ${major}${minor ? '.' + minor : ''}`
563}
564
565const norm = (s: string): string => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
566
567const fmtTokens = (n: number): string =>
568 n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${Math.round(n)}`
569
570const fmtCost = (usd: number): string => `$${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`
571
572const fmtTime = (ms: number): string => {
573 const s = Math.max(0, Math.round(ms / 1000))
574 const h = Math.floor(s / 3600)
575 const m = Math.floor((s % 3600) / 60)
576 const ss = String(s % 60).padStart(2, '0')
577 return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
578}
579
580const elapsed = (a: AgentRun, at: number): number => (a.endedAt ?? Math.max(at, a.startedAt)) - a.startedAt
581
582type Planned = PlannedTask & { n: number }
583
584const plannedOf = (f: Flow | null, list: AgentRun[]): Planned[] => {
585 if (!f || f.isFinished) return []
586 const started = new Set(list.map(a => norm(a.description)))
587 return (f.tasks ?? []).map((t, i) => ({ ...t, n: i + 1 })).filter(t => !started.has(norm(t.title)))
588}
589
590const totals = (list: AgentRun[], at: number) => {
591 const cost = list.reduce((s, a) => s + a.costUsd, 0)
592 const tokens = list.reduce((s, a) => s + a.tokens, 0)
593 const start = Math.min(...list.map(a => a.startedAt))
594 const end = Math.max(...list.map(a => a.endedAt ?? Math.max(at, a.startedAt)))
595 return { cost, tokens, time: list.length ? end - start : 0 }
596}
597
598// --- desktop drawings: each row is one SVG, as the band above the prompt is.
599
600const PANE_CSS = `<style>
601.t{fill:#1f1f1f}.s{fill:#6b6b68}.m{fill:#9a9a96}.k{fill:#ecebe8}.ln{stroke:#e4e4e1}.tile{fill:#f4f3f0}
602@media (prefers-color-scheme: dark){.t{fill:#ececec}.s{fill:#a8a8a4}.m{fill:#7d7d79}.k{fill:#2c2c2b}.ln{stroke:#333331}.tile{fill:#262625}}
603.live{animation:p 1.6s ease-in-out infinite}@keyframes p{50%{opacity:.3}}
604@media (prefers-reduced-motion: reduce){.live{animation:none}}
605</style>`
606
607// Pixel Clawd from DockCrab (Clawdy): a 24×18 crab on a 30×28 grid, one costume per tier.
608// The body keeps the brand clay; the tier's color lives in the costume's accent.
609const CLAY = '#D97757'
610const INK = '#1F1E1D'
611
612// `cls` puts a pixel in a named group: `bd` (the default) is the body and its
613// costume, `la`/`lb` the leg pairs, anything else a prop with its own motion.
614type Fill = (x: number, y: number, w: number, h: number, c: string, cls?: string) => void
615
616const stamp = (f: Fill, x: number, y: number, rows: string[], map: Record<string, string>, cls?: string): void =>
617 rows.forEach((row, dy) => [...row].forEach((ch, dx) => map[ch] && f(x + dx, y + dy, 1, 1, map[ch] ?? '', cls)))
618
619// `armCls` lets a raised claw travel with the prop it holds.
620const crabBody = (f: Fill, armFront = 0, armCls?: string): void => {
621 f(7, 10, 16, 12, CLAY)
622 f(3, 14, 4, 4, CLAY)
623 f(23, 14 + armFront, 4, 4, CLAY, armCls)
624 f(9, 12, 2, 2, INK)
625 f(19, 12, 2, 2, INK)
626 f(7, 22, 2, 4, CLAY, 'la')
627 f(17, 22, 2, 4, CLAY, 'la')
628 f(11, 22, 2, 4, CLAY, 'lb')
629 f(21, 22, 2, 4, CLAY, 'lb')
630}
631
632// Pure CSS, run by the compositor: no redraws. Periods divide one second, so the
633// once-a-second redraw of a running row restarts them in phase. Every crab walks;
634// each costume adds its prop's own motion on top.
635const CRAB_CSS = `<style>
636.run .la{animation:st .5s steps(1) infinite}.run .lb{animation:st .5s steps(1) infinite -.25s}
637.run .bd{animation:bob .5s steps(1) infinite -.125s}
638.run g{transform-box:fill-box}
639@keyframes st{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}
640.c-astronaut.run{animation:float 1s ease-in-out infinite}
641.c-astronaut.run .la,.c-astronaut.run .lb,.c-astronaut.run .bd{animation:none}
642.c-astronaut.run .ant{animation:blink 1s steps(1) infinite}
643.c-astronaut.run .star{animation:blink .5s steps(1) infinite -.25s}
644@keyframes float{50%{transform:translateY(-2px)}}@keyframes blink{50%{opacity:.15}}
645.c-detective.run .it{animation:scan 1s steps(1) infinite}
646.c-detective.run .gl{animation:blink 1s steps(1) infinite -.5s}
647@keyframes scan{25%{transform:translate(-1px,1px)}50%{transform:translate(-2px,2px)}75%{transform:translate(-1px,1px)}}
648.c-builder.run .it{transform-origin:100% 100%;animation:twist .5s ease-in-out infinite}
649@keyframes twist{50%{transform:rotate(-35deg)}}
650.c-chef.run .pan{transform-origin:0 50%;animation:tilt 1s ease-in-out infinite}
651.c-chef.run .egg{animation:flip 1s ease-in-out infinite}
652@keyframes tilt{20%,40%{transform:rotate(-12deg)}}@keyframes flip{30%{transform:translateY(-5px) scaleY(-1)}60%{transform:translateY(0)}}
653.c-racer.run .la{animation-duration:.25s}.c-racer.run .lb{animation-duration:.25s;animation-delay:-.125s}
654.c-racer.run .flag{transform-origin:0 50%;animation:wave .25s steps(1) infinite}
655@keyframes wave{50%{transform:skewY(-12deg) scaleX(.85)}}
656.c-pirate.run .it{transform-origin:50% 100%;animation:fence .5s ease-in-out infinite}
657@keyframes fence{50%{transform:rotate(25deg)}}
658.c-wizard.run .it{transform-origin:50% 100%;animation:wiggle .5s ease-in-out infinite}
659.c-wizard.run .sp{animation:blink .5s steps(1) infinite}
660@keyframes wiggle{50%{transform:rotate(-15deg)}}
661.c-viking.run .it{transform-origin:50% 100%;animation:chop .5s ease-in infinite}
662@keyframes chop{50%{transform:rotate(-40deg)}}
663.c-cowboy.run .lo{animation:loop .5s linear infinite}
664@keyframes loop{25%{transform:scaleX(.5)}50%{transform:scaleX(-1)}75%{transform:scaleX(-.5)}}
665.c-propeller.run .pr{animation:prop .25s steps(1) infinite}
666@keyframes prop{25%{transform:scaleX(.3)}50%{transform:scaleX(-1)}75%{transform:scaleX(-.3)}}
667.c-gentleman.run .it{animation:tap .5s steps(1) infinite}
668.c-gentleman.run .gl{animation:blink 1s steps(1) infinite}
669@keyframes tap{50%{transform:translateY(-2px)}}
670.c-ninja.run .sh{animation:whirl .5s linear infinite}
671.c-ninja.run .tl{transform-origin:100% 0;animation:wave .25s steps(1) infinite}
672@keyframes whirl{to{transform:rotate(360deg)}}
673.c-royal.run .jw{animation:blink .5s steps(1) infinite}
674.c-royal.run .it{transform-origin:50% 100%;animation:wiggle 1s ease-in-out infinite}
675.c-diver.run .bu{animation:rise 1s ease-out infinite}
676@keyframes rise{0%{opacity:0;transform:translateY(3px)}40%{opacity:1}100%{opacity:0;transform:translateY(-3px)}}
677.c-artist.run .it{transform-origin:50% 100%;animation:dab .5s ease-in-out infinite}
678@keyframes dab{50%{transform:rotate(25deg) translateY(1px)}}
679.c-dj.run .n1{animation:rise 1s ease-out infinite}.c-dj.run .n2{animation:rise 1s ease-out infinite -.5s}
680.c-graduate.run .ta{transform-origin:50% 0;animation:swing 1s ease-in-out infinite}
681@keyframes swing{25%{transform:rotate(20deg)}75%{transform:rotate(-20deg)}}
682.c-party.run .nz{transform-origin:0 50%;animation:blow .5s ease-in-out infinite}
683.c-party.run .pm{animation:blink .5s steps(1) infinite}
684@keyframes blow{0%,100%{transform:scaleX(.25)}50%{transform:scaleX(1)}}
685@media (prefers-reduced-motion: reduce){.run,.run g{animation:none!important}}
686</style>`
687
688const COSTUMES: Record<string, (f: Fill, t: string) => void> = {
689 // Astronaut in a glass dome; floats instead of walking, the antenna and the star blink.
690 astronaut: f => {
691 crabBody(f)
692 f(6, 7, 18, 1, '#E6E8EE'); f(5, 8, 1, 14, '#E6E8EE'); f(24, 8, 1, 14, '#E6E8EE'); f(6, 22, 18, 1, '#C9CCD2')
693 f(6, 8, 18, 14, 'rgba(169,214,245,.32)'); f(8, 9, 2, 1, '#fff'); f(8, 10, 1, 2, '#fff')
694 f(14, 4, 2, 3, '#C9CCD2'); f(14, 2, 2, 2, '#7F77DD', 'ant'); f(13, 18, 4, 2, '#7F77DD')
695 f(27, 3, 1, 3, '#F5C542', 'star'); f(26, 4, 3, 1, '#F5C542', 'star')
696 },
697 // Detective with a deerstalker; the magnifier sweeps and glints.
698 detective: f => {
699 crabBody(f, -4, 'it')
700 stamp(f, 6, 3, ['......bbbbbb......', '....bbcbbcbbbb....', '...bbbbbbbbbbbb...', '..bcbbcbbcbbcbbb..', '.bbbbbbbbbbbbbbbb.', 'dddddddddddddddddd'], { b: '#7A4A26', c: '#A0703F', d: '#5A3519' })
701 f(6, 9, 18, 1, '#D85A30')
702 stamp(f, 23, 1, ['.kkk.', 'k...k', 'k...k', 'k...k', '.kkk.'], { k: '#3A3A3C' }, 'it')
703 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')
704 },
705 // Builder in a hard hat; the wrench turns a bolt.
706 builder: f => {
707 crabBody(f)
708 stamp(f, 6, 4, ['.....yyyyyyyy.....', '...yyyyyhhyyyyy...', '..yyyyyyhhyyyyyy..', '..yyyyyyhhyyyyyy..', '.yyyyyyyhhyyyyyyy.', 'dddddddddddddddddd'], { y: '#F5C542', h: '#FBE08A', d: '#C99A1E' })
709 f(13, 5, 4, 2, '#BA7517')
710 stamp(f, 0, 10, ['.s.s', 'sss.', '.s..', '.s..'], { s: '#8E929A' }, 'it')
711 },
712 // Chef, the toque traced from DockCrab's Sprites.chefHat; tosses the omelette.
713 chef: f => {
714 crabBody(f, -4, 'pan')
715 stamp(f, 6, 0, ['........lll.......', '.......lllll......', '.wwwwgwwwwwwgwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwgwwwwwggwwwww', '.wwwwgwwwwwggwwwww', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.'], { w: '#F4F3EE', l: '#F7F6F2', g: '#D2D1C8', b: '#378ADD', d: '#B45F43' })
716 f(22, 8, 7, 2, '#4A4A48', 'pan'); f(26, 10, 1, 1, '#4A4A48', 'pan'); f(24, 7, 3, 1, '#F5B731', 'egg')
717 },
718 // Racer in a helmet; runs at double pace, the checkered flag flutters.
719 racer: f => {
720 crabBody(f, -4)
721 stamp(f, 6, 5, ['....rrrrrrrrrr....', '..rrrrrrwwrrrrrr..', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.kkkkkkkkkkkkkkkkr'], { r: '#1D9E75', w: '#F8F6F1', k: INK })
722 f(25, 1, 1, 9, '#8E929A')
723 stamp(f, 26, 1, ['wkwk', 'kwkw', 'wkwk'], { w: '#F8F6F1', k: INK }, 'flag')
724 },
725 // Pirate scouting the code; the cutlass fences.
726 pirate: f => {
727 crabBody(f)
728 stamp(f, 5, 3, ['.kk..............kk.', '.kkk....kkkk....kkk.', '..kkkkkkkwwkkkkkkk..', '..kkkkkkkkkkkkkkkk..', '.gggggggggggggggggg.'], { k: '#55514C', w: '#F8F6F1', g: '#F5C542' })
729 f(7, 11, 11, 1, INK); f(18, 11, 4, 3, INK)
730 f(27, 6, 1, 9, '#C9CCD2', 'it'); f(26, 15, 3, 1, '#7A4A26', 'it')
731 },
732 // Wizard: pointed hat with stars; the wand wiggles and sparkles.
733 wizard: f => {
734 crabBody(f, -4, 'it')
735 stamp(f, 6, 0, ['........pp........', '.......ppp........', '.......pppp.......', '......ppsppp......', '......pppppp......', '.....pppppppp.....', '....pppppppsppp...', '...pppsppppppppp..', '..bbbbbbbbbbbbbb..', 'dddddddddddddddddd'], { p: '#6B4FBF', s: '#F5C542', b: '#F5C542', d: '#4B3590' })
736 f(25, 3, 1, 8, '#4A3B2A', 'it'); f(25, 2, 1, 1, '#fff', 'it')
737 f(27, 0, 1, 3, '#F5C542', 'sp'); f(26, 1, 3, 1, '#F5C542', 'sp')
738 },
739 // Viking: horned helmet; the axe chops.
740 viking: f => {
741 crabBody(f, -4, 'it')
742 stamp(f, 5, 2, ['h..................h', 'hh................hh', '.hh....mmmmmm....hh.', '..hhmmmmmmmmmmmmhh..', '...mmmmmmlmmmmmmm...', '..mmmmmmmlmmmmmmmm..', '..mmmmmmmlmmmmmmmm..', '.rrrrrrrrrrrrrrrrrr.'], { h: '#EFE6D2', m: '#8E929A', l: '#C9CCD2', r: '#6A6E75' })
743 f(25, 4, 1, 10, '#7A4A26', 'it')
744 stamp(f, 26, 3, ['bb.', 'bbb', 'bbb', 'bb.'], { b: '#C9CCD2' }, 'it')
745 },
746 // Cowboy: wide-brim hat; the lasso loops overhead.
747 cowboy: f => {
748 crabBody(f, -4)
749 stamp(f, 4, 4, ['.......cccccccc.......', '......cccccccccc......', '......cccccccccc......', '......bbbbbbbbbb......', 'kk..cccccccccccccc..kk', '.kkkkkkkkkkkkkkkkkkkk.'], { c: '#A0703F', b: '#5A3519', k: '#7A4A26' })
750 f(25, 4, 1, 6, '#D9B27C')
751 stamp(f, 22, 0, ['.rrrrr.', 'r.....r', 'r.....r', '.rrrrr.'], { r: '#D9B27C' }, 'lo')
752 },
753 // Propeller: striped beanie; the propeller spins.
754 propeller: f => {
755 crabBody(f)
756 stamp(f, 6, 3, ['........kk........', '.....rrryyyybbb...', '...rrrrryyyybbbbb.', '..rrrrrryyyybbbbbb', '..rrrrrryyyybbbbbb', 'gggggggggggggggggg'], { k: '#3A3A3C', r: '#E5534B', y: '#F5C542', b: '#4F7FD9', g: '#5FBF8F' })
757 f(14, 2, 2, 1, '#3A3A3C')
758 f(10, 2, 4, 1, '#E5534B', 'pr'); f(16, 2, 4, 1, '#4F7FD9', 'pr')
759 },
760 // Gentleman: top hat and monocle; the cane taps.
761 gentleman: f => {
762 crabBody(f)
763 stamp(f, 8, 2, ['..khkkkkkkkk..', '..khkkkkkkkk..', '..khkkkkkkkk..', '..khkkkkkkkk..', '..khkkkkkkkk..', '..rrrrrrrrrr..', '..kkkkkkkkkk..', 'kkkkkkkkkkkkkk'], { k: '#2A2A2C', h: '#4A4A4E', r: '#B23A3A' })
764 stamp(f, 18, 11, ['.gg.', 'g..g', 'g..g', '.gg.'], { g: '#F5C542' })
765 f(21, 15, 1, 3, '#F5C542'); f(19, 12, 1, 1, '#fff', 'gl')
766 f(26, 9, 1, 15, '#3A3A3C', 'it'); f(24, 9, 3, 1, '#3A3A3C', 'it')
767 },
768 // Ninja: hood and headband; the shuriken whirls, the band's tails flutter.
769 ninja: f => {
770 crabBody(f, -4)
771 stamp(f, 7, 6, ['..kkkkkkkkkkkk..', '.kkkkkkkkkkkkkk.', 'kkkkkkkkkkkkkkkk', 'rrrrrrrrrrrrrrrr'], { k: '#2A2A2C', r: '#C8322C' })
772 f(4, 9, 3, 1, '#C8322C', 'tl'); f(2, 10, 3, 1, '#C8322C', 'tl'); f(1, 11, 2, 1, '#C8322C', 'tl')
773 stamp(f, 23, 3, ['..s..', '..s..', 'ssgss', '..s..', '..s..'], { s: '#C9CCD2', g: '#3A3A3C' }, 'sh')
774 },
775 // Royal: jewelled crown; the scepter's gem blinks.
776 royal: f => {
777 crabBody(f, -4, 'it')
778 stamp(f, 8, 3, ['g.....gg.....g', 'gg...gggg...gg', 'ggg.gggggg.ggg', 'gggggggggggggg', 'ggrgggbbgggrgg', 'gggggggggggggg', 'dddddddddddddd'], { g: '#F5C542', r: '#D64545', b: '#4F7FD9', d: '#C99A1E' })
779 f(25, 3, 1, 9, '#C99A1E', 'it'); f(24, 1, 3, 2, '#F5C542', 'it'); f(25, 0, 1, 1, '#D64545', 'jw')
780 },
781 // Diver: mask and snorkel; bubbles rise.
782 diver: f => {
783 crabBody(f)
784 f(8, 11, 14, 4, '#3A6EA5'); f(7, 12, 1, 2, '#2A2A2C'); f(22, 12, 1, 2, '#2A2A2C')
785 f(9, 12, 4, 2, '#A9D6F5'); f(17, 12, 4, 2, '#A9D6F5')
786 f(9, 12, 2, 2, INK); f(19, 12, 2, 2, INK)
787 f(5, 4, 2, 8, '#F5C542'); f(5, 12, 3, 1, '#F5C542'); f(5, 3, 2, 1, '#E5534B')
788 f(4, 0, 2, 2, '#A9D6F5', 'bu'); f(7, 1, 1, 1, '#A9D6F5', 'bu')
789 },
790 // Artist: beret and palette; the brush dabs.
791 artist: f => {
792 crabBody(f, -4, 'it')
793 stamp(f, 5, 5, ['..........k........', '....bbbbbbbbbbb....', '..bbbbbbbbbbbbbbbb.', '.bbbbbbbbbbbbbbbbbb', '..dddddddddddddddd.'], { k: '#8E2420', b: '#C8322C', d: '#8E2420' })
794 stamp(f, 0, 15, ['wwww', 'rwyw', 'wbww'], { w: '#E8D8B8', r: '#D64545', y: '#F5C542', b: '#4F7FD9' })
795 f(25, 3, 1, 8, '#A0703F', 'it'); f(25, 2, 1, 1, '#C9CCD2', 'it'); f(25, 0, 1, 2, '#4F7FD9', 'it')
796 },
797 // DJ: headphones; music notes float up.
798 dj: f => {
799 crabBody(f)
800 stamp(f, 5, 4, ['.....kkkkkkkkkk.....', '...kk..........kk...', '..k..............k..', '.k................k.', '.k................k.', 'ccc..............ccc', 'cec..............cec', 'cec..............cec', 'ccc..............ccc'], { k: '#3A3A3C', c: '#3A3A3C', e: '#E5534B' })
801 f(27, 1, 1, 5, '#8f8cf4', 'n1'); f(25, 4, 2, 2, '#8f8cf4', 'n1'); f(28, 1, 1, 2, '#8f8cf4', 'n1')
802 f(24, 0, 1, 3, '#8f8cf4', 'n2'); f(22, 2, 2, 2, '#8f8cf4', 'n2')
803 },
804 // Graduate: mortarboard; the tassel swings, the diploma is in hand.
805 graduate: f => {
806 crabBody(f)
807 stamp(f, 5, 3, ['.........kk.........', '.....kkkkkkkkkk.....', 'kkkkkkkkkkkkkkkkkkkk', '.....kkkkkkkkkk.....', '......ssssssss......', '......ssssssss......', '......ssssssss......'], { k: '#2A2A2C', s: '#3A3A3C' })
808 f(14, 4, 2, 1, '#F5C542')
809 f(24, 6, 1, 3, '#F5C542', 'ta'); f(23, 9, 3, 2, '#F5C542', 'ta')
810 f(24, 15, 6, 2, '#E8D8B8'); f(24, 15, 1, 2, '#B89A6A'); f(29, 15, 1, 2, '#B89A6A'); f(26, 15, 1, 2, '#D64545')
811 },
812 // Party: striped cone hat; the noisemaker unrolls.
813 party: f => {
814 crabBody(f, 2)
815 stamp(f, 9, 0, ['.....ww.....', '.....ww.....', '.....pp.....', '....ppyy....', '....yypp....', '...ppyypp...', '...yyppyy...', '..ppyyppyy..', '..yyppyypp..', '.pppppppppp.'], { p: '#E66BA8', y: '#55C1E0' })
816 f(14, 0, 2, 2, '#F8F6F1', 'pm')
817 f(22, 15, 2, 1, '#F5C542'); f(24, 15, 5, 1, '#E5534B', 'nz'); f(28, 13, 1, 2, '#E5534B', 'nz')
818 },
819}
820
821const COSTUME_KEYS = Object.keys(COSTUMES)
822
823/** A planned task's crab wears its savvy tier's costume. */
824const TIER_COSTUME: Record<string, string> = { fable: 'astronaut', heavy: 'detective', careful: 'builder', medium: 'chef', light: 'racer' }
825
826/** A run's costume: the one it was given at spawn; older runs pick by their id. */
827const costumeOf = (a: AgentRun): string => {
828 if (a.costume && a.costume in COSTUMES) return a.costume
829 const hash = [...a.id].reduce((h, ch) => (h * 31 + ch.charCodeAt(0)) >>> 0, 7)
830 return COSTUME_KEYS[hash % COSTUME_KEYS.length] ?? 'other'
831}
832
833/** The next costume in order: the least worn in `list`, so none repeats until all are out. */
834const nextCostume = (list: AgentRun[]): string => {
835 const worn = new Map<string, number>()
836 for (const a of list) worn.set(costumeOf(a), (worn.get(costumeOf(a)) ?? 0) + 1)
837 return COSTUME_KEYS.reduce((best, k) => ((worn.get(k) ?? 0) < (worn.get(best) ?? 0) ? k : best), COSTUME_KEYS[0] ?? 'other')
838}
839
840const CRAB_SCALE = 1.1
841
842// Body and props nest inside `bd` so a prop rides the bob and adds its own motion;
843// legs stay outside it and step on their own.
844const crab = (x: number, y: number, costume: string, dim = false, isWalking = false, scale = CRAB_SCALE): string => {
845 const groups = new Map<string, string[]>([['bd', []]])
846 const f: Fill = (cx, cy, w, h, c, cls = 'bd') => {
847 if (!groups.has(cls)) groups.set(cls, [])
848 groups.get(cls)?.push(`<rect x="${cx}" y="${cy}" width="${w}" height="${h}" fill="${c}"/>`)
849 }
850 const draw = COSTUMES[costume] ?? ((g: Fill) => crabBody(g))
851 draw(f, colorOf(costume))
852 const group = (cls: string) => `<g class="${cls}">${(groups.get(cls) ?? []).join('')}</g>`
853 const props = [...groups.keys()].filter(k => k !== 'bd' && k !== 'la' && k !== 'lb')
854 const body = `<g class="bd">${(groups.get('bd') ?? []).join('')}${props.map(group).join('')}</g>`
855 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>`
856}
857
858const statusMark = (x: number, y: number, status: string, color: string): string => {
859 if (status === 'running') return `<circle class="live" cx="${x}" cy="${y}" r="3.5" fill="${color}"/>`
860 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"/>`
861 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"/>`
862 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"/>`
863}
864
865const svg = (W: number, H: number, body: string): string =>
866 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${PANE_CSS}${CRAB_CSS}${body}</svg>`
867
868// The task's own progress when the worker reports steps; a finished run is full.
869const progressOf = (a: AgentRun): number | null => {
870 if (a.status === 'done') return 1
871 if (a.stepTotal) return Math.min(1, (a.stepDone ?? 0) / a.stepTotal)
872 return null
873}
874
875const ctxOf = (a: AgentRun): number => (a.contextMax ? Math.min(100, Math.round((a.contextTokens / a.contextMax) * 100)) : 0)
876
877/** The card's note: what a running agent is doing, or the first line of what a finished one found. */
878const noteOf = (a: AgentRun): string => (a.status === 'running' ? (a.doing ? `▸ ${a.doing}` : '') : a.result ? `» ${a.result}` : '')
879
880/** A card is taller by one line when it carries a note. */
881const cardHeight = (a: AgentRun): number => (noteOf(a) ? 80 : 66)
882
883// The crab says the status: it walks while running, dozes when done, lies tipped over when it failed.
884const moodCrab = (a: AgentRun): string => {
885 const body = crab(0, 14, costumeOf(a), false, a.status === 'running')
886 if (a.status === 'done')
887 return `${body}<text class="s" x="31" y="16" font-family="${FONT}" font-size="10" font-weight="600">z</text><text class="s" x="35" y="9" font-family="${FONT}" font-size="8" font-weight="600">z</text>`
888 if (a.status === 'failed')
889 return `<g transform="rotate(90 17 30)">${body}</g><path d="M30 6l6 6M36 6l-6 6" stroke="#D0453F" stroke-width="1.8" stroke-linecap="round"/>`
890 return body
891}
892
893const agentSvg = (W: number, a: AgentRun, at: number): string => {
894 const s = tr()
895 const tier = tierOf(a.type)
896 const color = colorOf(tier)
897 const ctx = ctxOf(a)
898 const textW = W - 42 - 22
899 const meta = [a.effort ? `${modelName(a.model)} · ${a.effort}` : modelName(a.model)]
900 if (a.round > 1) meta.push(`${s.round} ${a.round}`)
901 if (a.status === 'failed') meta.push(s.failed)
902 const barW = textW
903 const progress = progressOf(a)
904 const stats = `ctx ${ctx}% · ${fmtTokens(a.contextTokens)} ≈${fmtCost(a.costUsd)} ${fmtTime(elapsed(a, at))}`
905 const steps = a.stepTotal ? `${a.stepDone ?? 0}/${a.stepTotal}${a.stepNote ? ' · ' + a.stepNote : ''}` : ''
906 const stepsW = Math.max(0, barW - textWidth(stats, 11) - 12)
907 // Without reported steps the bar falls back to the context, drawn grey.
908 const fillW = Math.round(barW * (progress ?? ctx / 100))
909 const note = noteOf(a)
910 // A note takes the line under the meta; the stats and the bar move down by it.
911 const dy = note ? 14 : 0
912 return svg(
913 W,
914 cardHeight(a),
915 `${moodCrab(a)}
916<text class="t" x="42" y="18" font-family="${FONT}" font-size="13" font-weight="600">${xml(fitText(a.description || a.type, 13, textW))}</text>
917<text x="42" y="34" font-family="${FONT}" font-size="11"><tspan fill="${color}">${xml(tier === 'other' ? a.type : tier)}</tspan><tspan class="s"> ${xml(meta.join(' · '))}</tspan></text>
918${note ? `<text class="t" x="42" y="49" font-family="${FONT}" font-size="11">${xml(fitText(note, 11, textW + 22))}</text>` : ''}
919${steps && stepsW > 30 ? `<text class="t" x="42" y="${49 + dy}" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${xml(fitText(steps, 11, stepsW))}</text>` : ''}
920<text class="s" x="${42 + barW}" y="${49 + dy}" text-anchor="end" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${stats}</text>
921<rect class="k" x="42" y="${55 + dy}" width="${barW}" height="4" rx="2"/><rect${progress === null ? ' class="m"' : ''} x="42" y="${55 + dy}" width="${fillW}" height="4" rx="2"${progress === null ? '' : ` fill="${color}"`}/>
922<line class="ln" x1="0" y1="${65.5 + dy}" x2="${W}" y2="${65.5 + dy}"/>`,
923 )
924}
925
926const plannedSvg = (W: number, p: Planned): string => {
927 const tier = p.tier in TIER_COLOR ? p.tier : 'other'
928 const color = colorOf(tier)
929 const textW = W - 42 - 22
930 const meta = [TIER_MODEL[tier] ?? '']
931 if (p.after.length) meta.push(`${tr().after} ${p.after.join(', ')}`)
932 return svg(
933 W,
934 46,
935 `${crab(0, 6, TIER_COSTUME[tier] ?? tier, true)}
936<text class="s" x="42" y="18" font-family="${FONT}" font-size="13" font-weight="600">${xml(fitText(`${p.n}. ${p.title}`, 13, textW))}</text>
937<text x="42" y="34" font-family="${FONT}" font-size="11"><tspan fill="${color}">${xml(tier)}</tspan><tspan class="m"> ${xml(meta.filter(Boolean).join(' · '))}</tspan></text>
938${statusMark(W - 8, 16, 'planned', color)}
939<line class="ln" x1="0" y1="45.5" x2="${W}" y2="45.5"/>`,
940 )
941}
942
943const compactSvg = (W: number, list: AgentRun[], planned: Planned[], t: ReturnType<typeof totals>): string => {
944 const icons = [
945 ...list.filter(a => a.status === 'running').map(a => ({ k: costumeOf(a), c: colorOf(tierOf(a.type)), s: 'running', dim: false })),
946 ...list.filter(a => a.status !== 'running').map(a => ({ k: costumeOf(a), c: colorOf(tierOf(a.type)), s: a.status, dim: false })),
947 ...planned.map(p => ({ k: TIER_COSTUME[p.tier] ?? 'other', c: colorOf(p.tier), s: 'planned', dim: true })),
948 ]
949 const fit = Math.max(1, Math.floor((W - 150) / 36))
950 const shown = icons.slice(0, fit)
951 const more = icons.length - shown.length
952 const body = shown
953 .map((ic, i) => crab(i * 36, 0, ic.k, ic.dim, ic.s === 'running') + (ic.s === 'running' ? `<circle class="live" cx="${i * 36 + 32}" cy="4" r="3" fill="${ic.c}"/>` : ''))
954 .join('')
955 const x = shown.length * 36 + (more ? 4 : 0)
956 return svg(
957 W,
958 32,
959 `${body}${more ? `<text class="s" x="${x}" y="21" font-family="${FONT}" font-size="12">+${more}</text>` : ''}
960<text class="s" x="${W}" y="21" text-anchor="end" font-family="${FONT}" font-size="12" font-variant-numeric="tabular-nums">≈${fmtCost(t.cost)} · ${fmtTokens(t.tokens)} · ${fmtTime(t.time)}</text>`,
961 )
962}
963
964// --- terminal drawing: the same rows in text.
965
966const ctxBar = (pct: number, width: number): string => {
967 const filled = Math.round((width * pct) / 100)
968 return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
969}
970
971const STATUS_GLYPH: Record<string, string> = { running: '●', done: '✓', failed: '✗', planned: '◷' }
972
973// Opens the combined pane, or closes it when it is up; true when it ends up open.
974async function togglePane($: EngineInterface): Promise<boolean> {
975 const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
976 if (isOpen) {
977 await $.ui.close({ id: PANE })
978 return false
979 }
980 const at = await $.clock.now()
981 await update($, savvyNow, () => at)
982 await openPane($)
983 return true
984}
985
986async function autoOpen($: EngineInterface, key: string): Promise<void> {
987 const p = await read($, panel)
988 if (p.autoOpenedFor === key) return
989 await update($, panel, prev => ({ ...prev, autoOpenedFor: key }))
990 void openPane($).catch(() => undefined)
991}
992
993/** True when the savvy half has a flow or agent runs to show. */
994async function hasSavvy($: EngineInterface): Promise<boolean> {
995 return (await read($, flow)) !== null || (await read($, runs)).length > 0
996}
997
998/** Registers the hooks only savvy watches. */
999function registerSavvy(on: On, options: PluginOptions) {
1000 on('tool.call', { tool: TOOL }, async ($, e) => {
1001 const input = e as unknown as ProgressInput
1002 const prev = await read($, flow)
1003 if (isNewFlow(prev, input) && input.title !== undefined) {
1004 // A new flow starts with a clean list; agents still running stay.
1005 await update($, runs, list => list.filter(a => a.status === 'running'))
1006 }
1007 const next = await update($, flow, p => merge(p, input))
1008 if (next && input.tasks?.length) await autoOpen($, next.title)
1009 return { result: `ok: ${label(next ?? blank())}` }
1010 })
1011
1012 // A worker's own progress: the call runs in the worker's loop, so agentId names it.
1013 on('tool.call', { tool: STEP_TOOL }, async ($, e) => {
1014 const input = e as unknown as { done?: number; total?: number; note?: string }
1015 const agentId = e.agentId
1016 if (!agentId) return { result: 'ignored: only subagents report steps' }
1017 await update($, runs, list =>
1018 list.map(a => {
1019 if (a.agentId !== agentId) return a
1020 const total = Math.max(0, Math.round(input.total ?? a.stepTotal ?? 0))
1021 const done = Math.max(0, Math.round(input.done ?? a.stepDone ?? 0))
1022 return { ...a, stepTotal: total, stepDone: total ? Math.min(total, done) : done, stepNote: input.note?.trim() || undefined }
1023 }),
1024 )
1025 return { result: 'ok' }
1026 })
1027
1028 // Safety net: worker launches move the faint layer even if the orchestrator forgets to report.
1029 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
1030 const type = String(e.subagent_type ?? '')
1031 if (tierOf(type) === 'other') return next(e)
1032
1033 await update($, flow, prev => {
1034 const base = prev && !prev.isFinished ? { ...blank(), ...prev } : blank()
1035 return { ...base, running: base.running + 1, phase: base.phase === 'plan' ? 'delegate' : base.phase }
1036 })
1037 try {
1038 return await next(e)
1039 } finally {
1040 await update($, flow, prev => (prev ? { ...prev, running: Math.max(0, prev.running - 1) } : prev))
1041 }
1042 })
1043
1044 // Each model request of a subagent: live context, tokens and cost.
1045 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1046 const f = await read($, flow)
1047 if (e.props.hasSurvey) return next(e)
1048 if (f === null) return crewBand($, e, next)
1049
1050 const ui = $.ui.resolve(e)
1051 const { Box, Text, Button } = ui
1052 const list = await read($, runs)
1053 const crew = list.length + plannedOf(f, list).length
1054 const isWorking = list.some(a => a.status === 'running')
1055 const crewButton = (
1056 <Button key="savvy-agents" label={`×${crew}`} plain onPress={() => void togglePane($)} />
1057 )
1058 const percent = `${Math.round(ratio(f) * 100)}%`
1059 const dismiss = (
1060 <Button
1061 key="savvy-dismiss"
1062 label="✕"
1063 plain
1064 role="dismiss"
1065 onPress={() => update($, flow, () => null)}
1066 />
1067 )
1068
1069 if ('Svg' in ui) {
1070 const { Svg } = ui
1071 // About 8 CSS px per reported column; the rest is the count, the dismiss
1072 // and their gaps. No floor above the slot: a row wider than it would wrap.
1073 const width = Math.max(180, Math.min(1600, (e.props.bodyColumns || 100) * 8 - 96))
1074 return (
1075 <Box flexDirection="row" alignItems="center" gap={1}>
1076 <Svg source={rowSvg(f, width, isWorking)} alt={`${f.title}: ${label(f)}, ${percent}`} width={width} height={H} />
1077 {crewButton}
1078 {dismiss}
1079 </Box>
1080 )
1081 }
1082
1083 const cols = e.props.bodyColumns
1084 const titleW = Math.max(8, Math.min(30, f.title.length + 2, Math.floor(cols / 3)))
1085 const width = Math.max(6, Math.min(40, cols - titleW - 32))
1086 return (
1087 <Box flexDirection="row" gap={2}>
1088 <Box width={titleW} flexShrink={0}>
1089 <Text color={f.isFinished ? DONE : ACCENT}>● </Text>
1090 <Text wrap="truncate-end">{f.title}</Text>
1091 </Box>
1092 <Text color={f.isFinished ? DONE : ACCENT}>{barText(f, width)}</Text>
1093 <Text bold>{label(f)}</Text>
1094 <Text dimColor>{percent}</Text>
1095 <Text color={CLAY}>▣</Text>
1096 {crewButton}
1097 {dismiss}
1098 </Box>
1099 )
1100 })
1101}
1102
1103/** Above the prompt while agents run and no flow reports: their crabs walking, how many, how long. */
1104async function crewBand($: EngineInterface, e: RenderInput<'AbovePrompt'>, next: (e: RenderInput<'AbovePrompt'>) => Promise<RenderElement>) {
1105 const list = await read($, runs)
1106 const running = list.filter(a => a.status === 'running')
1107 if (running.length === 0) return next(e)
1108 const ui = $.ui.resolve(e)
1109 const { Box, Text, Button } = ui
1110 const at = Math.max(await read($, savvyNow), ...running.map(a => a.startedAt))
1111 const longest = Math.max(...running.map(a => elapsed(a, at)))
1112 const summary = `${running.length} ${running.length === 1 ? tr().agentOne : tr().agentsCount} ${tr().runningWord} · ${fmtTime(longest)}`
1113 const open = <Button key="crew-open" label={tr().openPane} plain dimColor onPress={() => void togglePane($)} />
1114 if (e.surface === 'desktop' && 'Svg' in ui) {
1115 const { Svg } = ui
1116 const shown = running.slice(0, Math.max(1, Math.floor(((e.props.bodyColumns || 100) * 8 - 320) / 36)))
1117 const W = shown.length * 36 + 8 + Math.ceil(textWidth(summary, 12))
1118 const body = shown.map((a, i) => crab(i * 36, 0, costumeOf(a), false, true)).join('')
1119 const text = `<text class="s" x="${shown.length * 36 + 8}" y="21" font-family="${FONT}" font-size="12" font-variant-numeric="tabular-nums">${xml(summary)}</text>`
1120 return (
1121 <Box flexDirection="row" alignItems="center" gap={1}>
1122 <Svg source={svg(W, 32, body + text)} alt={summary} width={W} height={32} />
1123 {open}
1124 </Box>
1125 )
1126 }
1127 const names = running.map(a => a.description || a.type).join(', ')
1128 return (
1129 <Box flexDirection="row" gap={1}>
1130 <Text color={CLAY}>▣</Text>
1131 <Text>{summary}</Text>
1132 <Text dimColor wrap="truncate-end">
1133 {names}
1134 </Text>
1135 {open}
1136 </Box>
1137 )
1138}
1139
1140/** The savvy-progress half of the combined pane. */
1141async function renderSavvy($: EngineInterface, e: RenderInput<'Pane'>) {
1142 const s = tr()
1143 const ui = $.ui.resolve(e)
1144 const { Box, Text, Button } = ui
1145 const list = await read($, runs)
1146 const f = await read($, flow)
1147 const p: SavvyPanel = await read($, panel)
1148 const at = Math.max(await read($, savvyNow), ...list.map(a => a.startedAt), 0)
1149
1150 const running = list.filter(a => a.status === 'running').reverse()
1151 const finished = list.filter(a => a.status !== 'running').reverse()
1152 const planned = plannedOf(f, list)
1153 const t = totals(list, at)
1154 // The pane's title says "Agents"; inside, only the flow's own name.
1155 const title = f && !f.isFinished ? f.title : ''
1156
1157 const toggleCompact = (
1158 <Button
1159 key="compact"
1160 label={p.isCompact ? `${s.expand} ▾` : `${s.collapse} ▴`}
1161 plain
1162 dimColor
1163 onPress={() => update($, panel, prev => ({ ...prev, isCompact: !prev.isCompact }))}
1164 />
1165 )
1166 const toggleDone = (
1167 <Button
1168 key="done"
1169 label={`${p.isDoneCollapsed ? '▸' : '▾'} ${s.finished} · ${finished.length}`}
1170 plain
1171 dimColor
1172 onPress={() => update($, panel, prev => ({ ...prev, isDoneCollapsed: !prev.isDoneCollapsed }))}
1173 />
1174 )
1175 // The latest few finished runs; the rest wait behind "+N earlier".
1176 const DONE_SHOWN = 3
1177 const shownDone = p.showAllDone ? finished : finished.slice(0, DONE_SHOWN)
1178 const earlier = finished.length - shownDone.length
1179 const doneHeader =
1180 finished.length > 0 ? (
1181 <Box key="done-head" flexDirection="row" justifyContent="space-between" alignItems="center">
1182 {toggleDone}
1183 <Button key="clear-done" label={s.clearDone} plain dimColor onPress={() => update($, runs, l => l.filter(a => a.status === 'running'))} />
1184 </Box>
1185 ) : null
1186 const earlierButton = (
1187 <Button
1188 key="earlier"
1189 label={p.showAllDone ? s.showFewer : `+${earlier} ${s.earlier}`}
1190 plain
1191 dimColor
1192 onPress={() => update($, panel, prev => ({ ...prev, showAllDone: !prev.showAllDone }))}
1193 />
1194 )
1195 const hasMoreDone = finished.length > DONE_SHOWN
1196 const isEmpty = list.length === 0 && planned.length === 0
1197 // Drawn inside a bordered box: its border and padding take 4 columns.
1198 const bodyCols = (e.props.bodyColumns || 40) - 4
1199 const summary = `≈${fmtCost(t.cost)}, ${fmtTokens(t.tokens)} ${s.tokensWord}, ${fmtTime(t.time)}`
1200hooks/core.ts 493 lines1// Pure data: defaults, reducers, formatting and layout math. Nothing here touches `$`, so every
2// behaviour is testable directly (the test kit cannot raise a subagent's tool call or a
3// permission check inside a call; these functions are what the hooks apply).
4import type {
5 AgentCard,
6 Architect,
7 Bucket,
8 Check,
9 Gate,
10 Layout,
11 LogLine,
12 Loop,
13 Main,
14 Moment,
15 Receipt,
16 Roster,
17 Tally,
18 ToolNote,
19 Turn,
20 Usage,
21 View,
22} from '../types'
23
24export const SCHEMA_VERSION = 2
25
26// ---------------------------------------------------------------- defaults
27
28export const DEFAULT_MAIN: Main = { model: '', effort: '', mode: '', steps: 0, isRunning: false }
29export const DEFAULT_USAGE: Usage = {
30 pct: null,
31 tokens: null,
32 window: 0,
33 costUsd: null,
34 limits: [],
35 compactions: 0,
36 lastCompactAt: null,
37}
38export const DEFAULT_ARCHITECT: Architect = { consults: [], ids: [], seen: [], lastAdvice: '' }
39const ZERO: Tally = { rule: 0, ask: 0, cleared: 0, deny: 0 }
40export const DEFAULT_GATE: Gate = { recent: [], totals: { file: ZERO, shell: ZERO, other: ZERO } }
41export const DEFAULT_TURN: Turn = { edits: 0, errorStreak: 0, errors: 0, isReviewing: false, startedAt: 0, costAtStart: null }
42export const DEFAULT_VIEW: View = { expanded: null, gateOpen: null, layout: null }
43export const DEFAULT_ROSTER: Roster = { architectTypes: [] }
44
45const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
46
47/** A stored object merged over its defaults, so a value saved under an older shape still reads. */
48export const normalize = <T extends object>(def: T, stored: unknown): T =>
49 isObject(stored) ? ({ ...def, ...stored } as T) : def
50
51/** A stored list, or empty when what is stored is not a list. */
52export const listOf = <T>(stored: unknown): T[] => (Array.isArray(stored) ? (stored as T[]) : [])
53
54export const normalizeGate = (stored: unknown): Gate => {
55 const g = normalize(DEFAULT_GATE, stored)
56 const totals = normalize(DEFAULT_GATE.totals, g.totals)
57 return {
58 recent: listOf<Check>(g.recent),
59 totals: { file: normalize(ZERO, totals.file), shell: normalize(ZERO, totals.shell), other: normalize(ZERO, totals.other) },
60 }
61}
62
63export const normalizeCard = (stored: unknown): AgentCard =>
64 normalize<AgentCard>(
65 {
66 id: '',
67 type: 'agent',
68 model: '',
69 description: '',
70 status: 'running',
71 spawnedAt: 0,
72 endedAt: null,
73 ctx: 0,
74 out: 0,
75 steps: 0,
76 lastStop: null,
77 tools: [],
78 answer: '',
79 },
80 stored,
81 )
82
83export const normalizeLog = (stored: unknown): LogLine[] =>
84 listOf<Record<string, unknown>>(stored).map(l => ({
85 at: typeof l.at === 'number' ? l.at : 0,
86 who: String(l.who ?? ''),
87 text: String(l.text ?? ''),
88 agentId: typeof l.agentId === 'string' ? l.agentId : null,
89 kind: l.kind === 'error' || l.kind === 'consult' || l.kind === 'done' ? l.kind : 'info',
90 }))
91
92// ---------------------------------------------------------------- config
93
94export type Panel = 'main' | 'architect' | 'gate' | 'agents' | 'loops' | 'receipt' | 'log'
95const PANELS: readonly Panel[] = ['main', 'architect', 'gate', 'agents', 'loops', 'receipt', 'log']
96
97export type Config = {
98 architect: RegExp
99 architectLabel: string
100 gateLabel: string
101 panels: Panel[]
102 motion: boolean
103 moments: boolean
104 matchDescriptions: boolean
105 maxCards: number
106 layout: Layout
107 palette: Palette
108 openOnStart: boolean
109 statusLine: boolean
110}
111
112const safeRegExp = (source: string, fallback: string) => {
113 try {
114 return new RegExp(source || fallback, 'i')
115 } catch {
116 return new RegExp(fallback, 'i')
117 }
118}
119
120/** The plugin's `/config` values, read leniently: anything malformed falls back to the default. */
121export const parseConfig = (o: Readonly<Record<string, unknown>>): Config => {
122 const str = (k: string, d: string) => (typeof o[k] === 'string' && o[k] !== '' ? (o[k] as string) : d)
123 const bool = (k: string, d: boolean) => (typeof o[k] === 'boolean' ? (o[k] as boolean) : d)
124 const panels = str('panels', PANELS.join(','))
125 .split(',')
126 .map(s => s.trim())
127 .filter((p): p is Panel => (PANELS as readonly string[]).includes(p))
128 const layout = str('layout', 'auto')
129 const max = typeof o.maxCards === 'number' ? Math.round(o.maxCards) : 3
130 return {
131 architect: safeRegExp(str('architectPattern', ''), 'advisor|architect'),
132 architectLabel: str('architectLabel', 'ARCHITECT'),
133 gateLabel: str('gateLabel', 'GATE'),
134 panels: panels.length > 0 ? [...new Set(panels)] : [...PANELS],
135 motion: str('motion', 'while-active') !== 'off',
136 moments: bool('moments', true),
137 matchDescriptions: bool('matchDescriptions', false),
138 maxCards: Math.min(6, Math.max(1, max)),
139 layout: layout === 'compact' || layout === 'wide' || layout === 'mini' ? layout : 'auto',
140 palette: str('palette', 'theme') === 'pastel' ? 'pastel' : 'theme',
141 openOnStart: bool('openOnStart', true),
142 statusLine: bool('statusLine', true),
143 }
144}
145
146// ---------------------------------------------------------------- palette
147
148export type Palette = 'theme' | 'pastel'
149export type Colors = Record<'main' | 'agent' | 'gate' | 'cleared' | 'arch' | 'amber' | 'warn' | 'dim' | 'faint' | 'text', string>
150
151/**
152 * `theme` names the person's own theme colours (they follow light, dark and colour-blind themes);
153 * `pastel` is fixed hex tuned for dark terminals.
154 */
155export const PALETTES: Record<Palette, Colors> = {
156 theme: {
157 main: 'claude',
158 agent: 'suggestion',
159 gate: 'success',
160 cleared: 'permission',
161 arch: 'merged',
162 amber: 'warning',
163 warn: 'error',
164 dim: 'inactive',
165 faint: 'subtle',
166 text: 'text',
167 },
168 pastel: {
169 main: '#7dd3fc',
170 agent: '#93c5fd',
171 gate: '#86efac',
172 cleared: '#5eead4',
173 arch: '#c4b5fd',
174 amber: '#fcd34d',
175 warn: '#fca5a5',
176 dim: '#6b7280',
177 faint: '#3f4654',
178 text: '#e5e7eb',
179 },
180}
181
182/** SVG cannot name theme keys: mid-tone colours that read on light and dark backgrounds. */
183export const SVG_COLORS = { running: '#3b82f6', done: '#16a34a', failed: '#dc2626', other: '#8b5cf6', label: '#6b7280' }
184
185// ---------------------------------------------------------------- formatting
186
187/** `claude-opus-5-5` → `Opus 5.5`; anything else is shown as given. */
188/**
189 * A model id as people say it, from any provider's spelling: `claude-opus-5-5[1m]` → `Opus 5.5 1M`,
190 * `us.anthropic.claude-sonnet-4-5-20250929-v1:0` → `Sonnet 4.5`, `claude-3-5-haiku-20241022` →
191 * `Haiku 3.5`. Anything else is shown as given, cut to 22 characters.
192 */
193export const prettyModel = (id: string) => {
194 if (!id) return '—'
195 const cap = (f: string) => f.charAt(0).toUpperCase() + f.slice(1)
196 const big = /\[1m\]|-1m\b/i.test(id) ? ' 1M' : ''
197 const now = /claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(?![\d])/i.exec(id)
198 if (now?.[1] && !/^\d/.test(now[1])) return `${cap(now[1].toLowerCase())} ${now[2]}${now[3] ? `.${now[3]}` : ''}${big}`
199 const old = /claude-(\d+)(?:-(\d))?-([a-z]+)/i.exec(id)
200 if (old?.[3]) return `${cap(old[3].toLowerCase())} ${old[1]}${old[2] ? `.${old[2]}` : ''}${big}`
201 return shorten(id, 22)
202}
203
204export const shorten = (s: string, n: number) => {
205 const one = s.replace(/\s+/g, ' ').trim()
206 return n <= 0 ? '' : one.length > n ? `${one.slice(0, Math.max(0, n - 1)).trimEnd()}…` : one
207}
208
209export const kTokens = (n: number) =>
210 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
211
212export const fmtDuration = (ms: number) => {
213 const s = Math.max(0, Math.round(ms / 1000))
214 if (s < 60) return `${s}s`
215 const m = Math.floor(s / 60)
216 return m < 60 ? `${m}m${String(s % 60).padStart(2, '0')}s` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
217}
218
219/** A running clock as the live cards draw it: m:ss, or XhYY past an hour. */
220export const fmtTimer = (ms: number) => {
221 const s = Math.max(0, Math.floor(ms / 1000))
222 const m = Math.floor(s / 60)
223 return m < 60 ? `${m}:${String(s % 60).padStart(2, '0')}` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
224}
225
226export const fmtClock = (ms: number) => (ms > 0 ? new Date(ms).toTimeString().slice(0, 8) : '--:--:--')
227
228/** `1 error`, `2 errors`. */
229export const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
230
231export const fmtUsd = (n: number) => (n >= 100 ? `$${Math.round(n)}` : `$${n.toFixed(2)}`)
232
233/** A gauge of `width` cells: ▰ filled, ▱ empty. */
234export const gauge = (pct: number, width: number) => {
235 const full = Math.max(0, Math.min(width, Math.round((pct / 100) * width)))
236 return { on: '▰'.repeat(full), off: '▱'.repeat(width - full) }
237}
238
239/** A rate-limit window's short name: `five_hour` → `5h`, `seven_day_opus` → `7d opus`. */
240export const limitLabel = (kind: string) =>
241 kind
242 .replace(/five[_ -]?hours?/i, '5h')
243 .replace(/seven[_ -]?days?/i, '7d')
244 .replace(/[_-]+/g, ' ')
245 .trim()
246
247// ---------------------------------------------------------------- redaction
248
249const SECRETS: [RegExp, string][] = [
250 [/(authorization\s*[:=]\s*)(bearer\s+|basic\s+)?\S+/gi, '$1$2•••'],
251 [/\b(bearer)\s+[A-Za-z0-9._~+/-]{8,}=*/gi, '$1 •••'],
252 [/\b(sk|pk|rk|ghp|gho|ghs|github_pat|xox[abprs])[-_][A-Za-z0-9_-]{8,}/g, '•••'],
253 [/((?:api[_-]?key|access[_-]?token|token|secret|password|passwd|pwd)\s*[=:]\s*)("[^"]*"|'[^']*'|\S+)/gi, '$1•••'],
254 [/(--(?:token|password|api-key|secret)[= ])\S+/gi, '$1•••'],
255 [/(\b[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)=)\S+/g, '$1•••'],
256 [/(\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:)[^\s@]+@/gi, '$1•••@'],
257]
258
259/** What the gate drill-down may store: credentials masked before anything is written to state. */
260export const redact = (s: string) => SECRETS.reduce((t, [re, to]) => t.replace(re, to), s)
261
262// ---------------------------------------------------------------- tools and gate
263
264const FILE_TOOLS = new Set(['Read', 'Edit', 'Write', 'NotebookEdit', 'Glob', 'Grep'])
265const SHELL_TOOLS = new Set(['Bash', 'PowerShell'])
266export const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
267
268export const bucketOf = (tool: string): Bucket =>
269 FILE_TOOLS.has(tool) ? 'file' : SHELL_TOOLS.has(tool) ? 'shell' : 'other'
270
271/** One line saying what a call was about: its command, path or pattern; redacted. */
272export const describeInput = (tool: string, input: unknown) => {
273 const i = isObject(input) ? input : {}
274 const path = typeof i.file_path === 'string' ? i.file_path.split(/[\\/]/).slice(-2).join('/') : ''
275 const what =
276 typeof i.command === 'string'
277 ? i.command
278 : path || (typeof i.pattern === 'string' ? i.pattern : typeof i.url === 'string' ? i.url : typeof i.description === 'string' ? i.description : '')
279 return redact(what ? `${tool} → ${what}` : tool)
280}
281
282/** Keeps the last `max` checks, but never drops a pending ask: its settle must still find it. */
283export const trimRecent = (list: Check[], max: number): Check[] => {
284 let extra = list.length - max
285 if (extra <= 0) return list
286 const out: Check[] = []
287 for (const c of list) {
288 if (extra > 0 && c.verdict !== 'ask') {
289 extra -= 1
290 continue
291 }
292 out.push(c)
293 }
294 return out.slice(-max * 2)
295}
296
297export const recordCheck = (g: Gate, c: Check): Gate => {
298 const t = g.totals[c.bucket]
299 return {
300 recent: trimRecent([...g.recent, c], 80),
301 totals: { ...g.totals, [c.bucket]: { ...t, [c.verdict]: t[c.verdict] + 1 } },
302 }
303}
304
305/** An `ask` settled by the call that followed it: it ran (cleared) or was refused (deny). */
306export const settleCheck = (g: Gate, id: string, didRun: boolean): Gate => {
307 const c = g.recent.find(r => r.id === id && r.verdict === 'ask')
308 if (!c) return g
309 const verdict = didRun ? 'cleared' : 'deny'
310 const t = g.totals[c.bucket]
311 return {
312 recent: g.recent.map(r => (r === c ? { ...r, verdict } : r)),
313 totals: { ...g.totals, [c.bucket]: { ...t, ask: Math.max(0, t.ask - 1), [verdict]: t[verdict] + 1 } },
314 }
315}
316
317export const gateSummary = (g: Gate) => {
318 const all = (['file', 'shell', 'other'] as const).reduce(
319 (s, k) => ({
320 rule: s.rule + g.totals[k].rule,
321 ask: s.ask + g.totals[k].ask,
322 cleared: s.cleared + g.totals[k].cleared,
323 deny: s.deny + g.totals[k].deny,
324 }),
325 { ...ZERO },
326 )
327 return { ...all, total: all.rule + all.ask + all.cleared + all.deny }
328}
329
330// ---------------------------------------------------------------- turn and architect
331
332/**
333 * The turn after one tool call. Errors count in the main loop only (a subagent's failure is its
334 * own); edits count from every loop, so delegated work still reaches "before done".
335 */
336export const afterCall = (t: Turn, c: { inSubagent: boolean; hasFailed: boolean; isEdit: boolean }): Turn => ({
337 ...t,
338 errorStreak: c.inSubagent ? t.errorStreak : c.hasFailed ? t.errorStreak + 1 : 0,
339 errors: t.errors + (!c.inSubagent && c.hasFailed ? 1 : 0),
340 edits: t.edits + (c.isEdit ? 1 : 0),
341})
342
343/** Which of the architect's three moments a consult falls at: an inference over this turn so far. */
344export const momentOf = (t: Pick<Turn, 'edits' | 'errorStreak'>): Moment =>
345 t.errorStreak >= 2 ? 'error repeats' : t.edits === 0 ? 'before a plan' : 'before done'
346
347export const startConsult = (a: Architect, c: { id: string; at: number; moment: Moment; via: string }): Architect =>
348 a.consults.some(x => x.id === c.id) ? a : { ...a, consults: [...a.consults, { ...c, endAt: null }].slice(-40) }
349
350/** Ends the open consult (the latest without an end), or the one named. */
351export const endConsult = (a: Architect, at: number, advice: string | null, id?: string): Architect => {
352 const open = [...a.consults].reverse().find(c => c.endAt === null && (id === undefined || c.id === id))
353 return {
354 ...a,
355 consults: a.consults.map(c => (c === open ? { ...c, endAt: at } : c)),
356 lastAdvice: advice ?? a.lastAdvice,
357 }
358}
359
360export const isAdvising = (a: Architect) => a.consults.some(c => c.endAt === null)
361
362/** A one-row timeline of consults across `width` cells: ◆ a consult, ━ while it ran. */
363export const consultTimeline = (a: Architect, now: number, width: number) => {
364 if (a.consults.length === 0 || width < 4) return '─'.repeat(Math.max(0, width))
365 const first = a.consults[0]?.at ?? now
366 const span = Math.max(1, now - first)
367 const cells = Array.from({ length: width }, () => '─')
368 for (const c of a.consults) {
369 const from = Math.min(width - 1, Math.floor(((c.at - first) / span) * (width - 1)))
370 const to = Math.min(width - 1, Math.floor((((c.endAt ?? now) - first) / span) * (width - 1)))
371 for (let i = from + 1; i <= to; i += 1) cells[i] = '━'
372 cells[from] = '◆'
373 }
374 return cells.join('')
375}
376
377export const receiptOf = (t: Turn, o: { durationMs: number; agentsSince: number; costNow: number | null; reason: string }): Receipt => ({
378 durationMs: o.durationMs,
379 agents: o.agentsSince,
380 edits: t.edits,
381 errors: t.errors,
382 costDelta: o.costNow !== null && t.costAtStart !== null && o.costNow - t.costAtStart >= 0.005 ? o.costNow - t.costAtStart : null,
383 reason: o.reason,
384})
385
386// ---------------------------------------------------------------- agents and loops
387
388type StepUsage = { input_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number; output_tokens?: number } | null
389
390/** A card after one of its model requests: its context is the latest step's whole input; output adds up. */
391export const applyStep = (c: AgentCard, s: { model: string; usage: StepUsage; stopReason: string | null }): AgentCard => {
392 const u = s.usage ?? {}
393 const ctx = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
394 return {
395 ...c,
396 model: c.model || s.model,
397 steps: c.steps + 1,
398 ctx: ctx > 0 ? ctx : c.ctx,
399 out: c.out + (u.output_tokens ?? 0),
400 lastStop: s.stopReason,
401 }
402}
403
404export const noteTool = (c: AgentCard, n: ToolNote): AgentCard => ({ ...c, tools: [...c.tools, n].slice(-3) })
405
406export const stepLoop = (loops: Loop[], id: string, at: number): Loop[] => {
407 const found = loops.find(l => l.id === id)
408 const next = found
409 ? loops.map(l => (l === found ? { ...l, steps: l.steps + 1, lastAt: at } : l))
410 : [...loops, { id, steps: 1, firstAt: at, lastAt: at, isDone: false }]
411 return next.slice(-60)
412}
413
414export const LOOP_ACTIVE_MS = 15_000
415export const isLoopActive = (l: Loop, now: number) => !l.isDone && now - l.lastAt < LOOP_ACTIVE_MS
416
417/** Swimlane geometry: each agent's bar on one shared axis from the first spawn to now. */
418export const lanes = (cards: AgentCard[], now: number, width: number) => {
419 const start = Math.min(...cards.map(c => c.spawnedAt).filter(n => n > 0), now)
420 const span = Math.max(1, now - start)
421 return cards.map(c => {
422 const from = Math.floor(((Math.max(c.spawnedAt, start) - start) / span) * width)
423 const to = Math.max(from + 1, Math.ceil((((c.endedAt ?? now) - start) / span) * width))
424 return { id: c.id, before: Math.min(from, width), bar: Math.min(to, width) - Math.min(from, width), after: width - Math.min(to, width) }
425 })
426}
427
428// ---------------------------------------------------------------- layout
429
430/** How many log lines fit: what the other panels leave, never fewer than 4 nor more than 8. */
431export const logRows = (bodyRows: number, used: number) => Math.max(4, Math.min(8, bodyRows - used - 3))
432
433/** Legend items that fit on one row of `width` cells, in order; the rest are dropped. */
434export const fitLegend = <T extends { label: string }>(items: T[], width: number) => {
435 const out: T[] = []
436 let used = 0
437 for (const it of items) {
438 const w = it.label.length + 4
439 if (used + w > width) break
440 out.push(it)
441 used += w
442 }
443 return out
444}
445
446/** A card's title row: the task in the agent's own words, the type only when there is none. */
447export const cardTitle = (c: AgentCard) => c.description || c.type
448
449/** A title split over two rows at a word boundary: `first` cells on row one, `rest` on row two. */
450export const titleLines = (title: string, first: number, rest: number): [string, string] => {
451 const t = title.replace(/\s+/g, ' ').trim()
452 if (t.length <= first) return [t, '']
453 const cut = t.lastIndexOf(' ', first)
454 const at = cut > 0 ? cut : first
455 return [t.slice(0, at).trim(), shorten(t.slice(at), rest)]
456}
457
458/**
459 * What a turn's opening text was, for the log: the person's words, or for a turn the engine
460 * opened with a tagged message (a subagent's hand-back, a task notification), that message's kind.
461 */
462export const promptLine = (text: string): { who: string; text: string } => {
463 const tag = /^\s*<([a-z][\w-]*)/i.exec(text)?.[1]
464 if (!tag) return { who: 'you', text: shorten(text, 70) }
465 const from = /\bfrom="([^"]+)"/.exec(text)?.[1]
466 return { who: 'engine', text: shorten(`${tag.replace(/[-_]/g, ' ')}${from ? ` from ${from.slice(0, 8)}` : ''}`, 70) }
467}
468
469/**
470 * A subagent's hand-back message: who sent it and the first line of what it said. The report
471 * follows a framing header in the message; without one, the first line after the opening tag.
472 */
473export const handbackOf = (text: string): { from: string; body: string } | null => {
474 const from = /^\s*<agent-message\s+from="([^"]+)"/.exec(text)?.[1]
475 if (!from) return null
476 const afterHeader = text.split(/The report follows:\s*\n/)[1]
477 const rest = afterHeader ?? text.replace(/^\s*<agent-message[^>]*>/, '')
478 const body =
479 rest
480 .split('\n')
481 .map(l => l.trim())
482 .find(l => l && !l.startsWith('[') && !l.startsWith('<') && !l.startsWith('</')) ?? ''
483 return body ? { from, body } : null
484}
485
486/** The advice line a pane shows for a report: its first real line, markdown markers stripped, cut to 160. */
487export const adviceLine = (report: string) => {
488 const first = report.split('\n').map(l => l.trim()).find(l => l && !l.startsWith('[') && !l.startsWith('<')) ?? ''
489 return shorten(first.replace(/\*\*|__/g, '').replace(/^[#>*\s-]+/, ''), 160)
490}
491
492export const elapsedOf = (c: AgentCard, now: number) => (c.endedAt ?? now) - c.spawnedAt
493hooks/rail.tsx 75 lines1// A connector drawn on the surface's own frame clock: packets travel along it while `active`,
2// and it rests as a plain dim line otherwise. Only this region redraws; the pane does not.
3import type { ClientModule } from 'claude-code'
4
5type Props = {
6 active: boolean
7 width: number
8 color: string
9 dim: string
10 /** Cells where a branch drops (┬); the rest of the line is ─. */
11 marks: number[]
12 /** Draw ┴ instead of ┬ at the marks: a merge into what is below. */
13 isMerge: boolean
14}
15
16type Ref = { phase: number; active: boolean }
17type State = { ref: Ref }
18
19const STEP_MS = 110
20
21const Rail: ClientModule<Props, State> = (props, surface) => {
22 const { Box, Text } = surface.elements
23 const ref = surface.state?.ref ?? { phase: 0, active: props.active }
24 ref.active = props.active
25 if (surface.state === undefined) {
26 surface.setState({ ref })
27 surface.every(STEP_MS, () => {
28 if (ref.active) {
29 ref.phase += 1
30 surface.setState({ ref })
31 }
32 })
33 }
34
35 const width = Math.max(1, surface.columns || props.width)
36 const cells = Array.from({ length: width }, () => '─')
37 for (const m of props.marks) if (m >= 0 && m < width) cells[m] = props.isMerge ? '┴' : '┬'
38
39 if (!props.active) {
40 return (
41 <Box>
42 <Text color={props.dim}>{cells.join('')}</Text>
43 </Box>
44 )
45 }
46
47 // Two packets per 24 cells, a bright head and a trailing dot, moving left to right.
48 const lit = new Map<number, string>()
49 for (let base = 0; base < width + 24; base += 24) {
50 const head = (base + ref.phase) % (width + 24)
51 if (head < width) lit.set(head, '●')
52 if (head - 1 >= 0 && head - 1 < width) lit.set(head - 1, '•')
53 }
54 // Runs, not cells: consecutive cells of one kind share a Text, a handful of nodes per frame.
55 const runs: { text: string; isLit: boolean }[] = []
56 cells.forEach((ch, i) => {
57 const isLit = lit.has(i)
58 const last = runs[runs.length - 1]
59 const glyph = lit.get(i) ?? ch
60 if (last && last.isLit === isLit) last.text += glyph
61 else runs.push({ text: glyph, isLit })
62 })
63 return (
64 <Box>
65 {runs.map(r => (
66 <Text color={r.isLit ? props.color : props.dim} bold={r.isLit}>
67 {r.text}
68 </Text>
69 ))}
70 </Box>
71 )
72}
73
74export default Rail
75hooks/elapsed.tsx 38 lines1// A running agent's clock, ticking on the surface's frame clock so the pane need not redraw.
2// `since` and `now` come from the hooks module's $.clock; between redraws it adds its own ticks.
3import type { ClientModule } from 'claude-code'
4
5type Props = { since: number; now: number; endAt: number | null; color: string }
6type Ref = { base: number; ticks: number; lastNow: number; isRunning: boolean }
7type State = { ref: Ref }
8
9const fmt = (ms: number) => {
10 const s = Math.max(0, Math.floor(ms / 1000))
11 const m = Math.floor(s / 60)
12 return m < 60 ? `${m}:${String(s % 60).padStart(2, '0')}` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
13}
14
15const Elapsed: ClientModule<Props, State> = (props, surface) => {
16 const { Text } = surface.elements
17 const ref = surface.state?.ref ?? { base: 0, ticks: 0, lastNow: -1, isRunning: true }
18 if (props.now !== ref.lastNow) {
19 // A fresh reading from the hooks module: restart the local count from it.
20 ref.lastNow = props.now
21 ref.base = (props.endAt ?? props.now) - props.since
22 ref.ticks = 0
23 }
24 ref.isRunning = props.endAt === null
25 if (surface.state === undefined) {
26 surface.setState({ ref })
27 surface.every(1000, () => {
28 if (ref.isRunning) {
29 ref.ticks += 1
30 surface.setState({ ref })
31 }
32 })
33 }
34 return <Text color={props.color}>{fmt(ref.base + ref.ticks * 1000)}</Text>
35}
36
37export default Elapsed
38types/index.d.ts 173 lines1export type Moment = 'before a plan' | 'error repeats' | 'before done'
2
3export type Bucket = 'file' | 'shell' | 'other'
4
5/** rule: settings allowed it. ask: put to the decider, outcome pending. cleared: asked, then ran. deny: refused. */
6export type Verdict = 'rule' | 'ask' | 'cleared' | 'deny'
7
8export type Main = { model: string; effort: string; mode: string; steps: number; isRunning: boolean }
9
10export type Usage = {
11 pct: number | null
12 tokens: number | null
13 window: number
14 costUsd: number | null
15 limits: { kind: string; pct: number }[]
16 compactions: number
17 lastCompactAt: number | null
18}
19
20export type Consult = { id: string; at: number; endAt: number | null; moment: Moment; via: string }
21
22export type Architect = { consults: Consult[]; ids: string[]; seen: string[]; lastAdvice: string }
23
24export type Check = {
25 id: string
26 tool: string
27 bucket: Bucket
28 verdict: Verdict
29 inSubagent: boolean
30 detail: string
31 at: number
32}
33
34export type Tally = { rule: number; ask: number; cleared: number; deny: number }
35
36export type Gate = { recent: Check[]; totals: Record<Bucket, Tally> }
37
38export type ToolNote = { tool: string; text: string; isError: boolean }
39
40export type AgentCard = {
41 id: string
42 type: string
43 model: string
44 description: string
45 status: string
46 spawnedAt: number
47 endedAt: number | null
48 /** The agent's context now: input + cache read + cache write of its latest step. */
49 ctx: number
50 /** Output tokens summed over its steps. */
51 out: number
52 steps: number
53 lastStop: string | null
54 tools: ToolNote[]
55 answer: string
56}
57
58/** A model loop whose id matches no card: a workflow agent, a compaction or a memory fork. */
59export type Loop = { id: string; steps: number; firstAt: number; lastAt: number; isDone: boolean }
60
61export type LogLine = {
62 at: number
63 who: string
64 text: string
65 agentId: string | null
66 kind: 'info' | 'error' | 'consult' | 'done'
67}
68
69export type Turn = {
70 edits: number
71 errorStreak: number
72 errors: number
73 isReviewing: boolean
74 startedAt: number
75 costAtStart: number | null
76}
77
78export type Receipt = {
79 durationMs: number
80 agents: number
81 edits: number
82 errors: number
83 costDelta: number | null
84 reason: string
85}
86
87export type Layout = 'auto' | 'compact' | 'wide' | 'mini'
88
89export type View = { expanded: string | null; gateOpen: Bucket | null; layout: Layout | null }
90
91export type Roster = { architectTypes: string[] }
92
93// ---- savvy-progress
94
95export type Phase = 'plan' | 'design' | 'delegate' | 'review' | 'close'
96
97export type PlannedTask = {
98 title: string
99 tier: string
100 after: number[]
101}
102
103export type Flow = {
104 title: string
105 total: number
106 done: number
107 running: number
108 phase: Phase
109 isFinished: boolean
110 tasks: PlannedTask[]
111}
112
113export type AgentRunStatus = 'running' | 'done' | 'failed'
114
115export type AgentRun = {
116 id: string
117 agentId?: string
118 type: string
119 description: string
120 model: string
121 effort?: string
122 status: AgentRunStatus
123 startedAt: number
124 endedAt?: number
125 contextTokens: number
126 contextMax: number
127 tokens: number
128 costUsd: number
129 steps: number
130 round: number
131 /** Self-reported by the worker through the `step` tool. */
132 stepDone?: number
133 stepTotal?: number
134 stepNote?: string
135 /** The crab's costume, handed out in order at spawn. */
136 costume?: string
137 /** A running agent's latest tool call, described. */
138 doing?: string
139 /** The first line of a finished agent's answer. */
140 result?: string
141}
142
143export type SavvyPanel = {
144 isCompact: boolean
145 isDoneCollapsed: boolean
146 /** Every finished run, not only the latest few. */
147 showAllDone?: boolean
148 autoOpenedFor: string
149}
150
151declare module 'claude-code' {
152 interface PluginState {
153 'control-tower': {
154 meta: { schemaVersion: number }
155 main: Main
156 usage: Usage
157 architect: Architect
158 gate: Gate
159 agents: AgentCard[]
160 loops: Loop[]
161 log: LogLine[]
162 turn: Turn
163 receipt: Receipt | null
164 view: View
165 roster: Roster
166 flow: Flow | null
167 runs: AgentRun[]
168 savvyPanel: SavvyPanel
169 now: number
170 }
171 }
172}
173