SLOPSHOPPER

control-tower

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

newpanebandguardcommandstatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · control-tower
│ ┃ Control Tower ✕ › fix the failing auth test and add an audit log call │ ┃ CONTROL TOWER · — IDLE │ ┃ ■ main ■ gate ⏺ Read(src/auth.ts) │ ┃ ╭─────────────────────────────────────────── ⎿ Read 6 lines │ ┃ │ — · main $0. ⏺ Update(src/auth.ts) │ ┃ │ effort ▯▯▯▯▯ — ⎿ Added 2 lines, removed 1 line │ ┃ │ ctx ▰▰▰▰▰▱▱▱▱▱ 49% 97k/200k ⏺ Bash(bun test) │ ┃ │ usage 5h ▰▰▱▱▱ 31% ⎿ 3 pass, 1 fail │ ┃ ╰─────────────────────────────────────────── │ ┃ ▣ client module ./rail.tsx ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ✓ last turn 42s · 0 agents · 3 edits · 1 e ✻ Worked for 42s · done 4:20 PM │ ┃ ╰─────────────────────────────────────────── │ ┃ ╭─────────────────────────────────────────── › /control-tower │ ┃ │ session log ⎿ control-tower: Control Tower opened. Focus it with ctrl+x tab; f │ ┃ │ 08:53:20 main Edit → src/auth.ts │ ┃ │ 08:53:20 main Write → src/audit.ts │ ┃ │ 08:53:20 main Write → src/cache.ts │ ┃ │ 08:53:20 main Bash → bun test ✗ │ ┃ ╰─────────────────────────────────────────── │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ control-tower: ctx 49%

Draws

Pane · Control Tower
CONTROL TOWER · — IDLE ■ main ■ gate ╭──────────────────────────────────────────────────────╮ │ — · main $0.42 ○ idle │ │ effort ▯▯▯▯▯ — │ │ ctx ▰▰▰▰▰▱▱▱▱▱ 49% 97k/200k │ │ usage 5h ▰▰▱▱▱ 31% │ ╰──────────────────────────────────────────────────────╯ ▣ client module ./rail.tsx ╭──────────────────────────────────────────────────────╮ │ ✓ last turn 42s · 0 agents · 3 edits · 1 error │ ╰──────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │ session log │ │ 08:53:20 main Edit → src/auth.ts │ │ 08:53:20 main Write → src/audit.ts │ │ 08:53:20 main Write → src/cache.ts │ │ 08:53:20 main Bash → bun test ✗ │ ╰──────────────────────────────────────────────────────╯
README

Control Tower

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:

  • Flightdeck by Stephen Casella: main model, context, cost and rate limits, the advisor ("architect") timeline, the permission gate, the turn receipt and the session log.
  • savvy-progress by johnnyvizz: the subagent list (model, context, tokens, cost, time), the progress band above the prompt, and the progress / step tools.

Control Tower demo

The 18 crab costumes

Install

/plugin install control-tower --marketplace nicandcranny/claude-code-mods

Use

CommandDoes
/control-toweropen the pane
/control-tower closeclose it
/control-tower resetclear 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.

What you see

  • Docked (fullscreen terminal, desktop app): the dashboard panels on top. The main panel reads in two columns (effort, ctx, usage), with the session cost and working/idle status in its header. Below sits the subagent list in an orange box.
  • The subagent list: each agent is a pixel crab. Costumes are handed out in order (astronaut, detective, builder, chef, racer, pirate, wizard, viking, cowboy, propeller, gentleman, ninja, royal, diver, artist, dj, graduate, party), so none repeats until all 18 are out. A running crab walks and shows its latest tool call (▸ 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.
  • Inline (terminal main screen): the dashboard's 8-row summary, with up to 3 agents.
  • Above the prompt: while agents run, their crabs walk in a strip with a count and elapsed time; while a flow reports through the mcp__control-tower__progress tool, a progress bar instead.
  • Session log: prompts, edits, errors, denials and consults, and agent <title> finished when an agent hands its report back.

Agent costs are estimates from token counts and a built-in price table, marked ≈.

Configure

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).

Develop

claude plugin validate .
claude plugin test .

License

MIT. See LICENSE; the original notices are kept in LICENSE.flightdeck.txt and LICENSE.savvy-progress.txt.

Source 5 files
hooks/register.tsx 2434 lines
1import { 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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)}`
1200
hooks/core.ts 493 lines
1// 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
493
hooks/rail.tsx 75 lines
1// 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
75
hooks/elapsed.tsx 38 lines
1// 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
38
types/index.d.ts 173 lines
1export 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