SLOPSHOPPER

agent-deck

Agent dashboard (derived from Flightdeck by Stephen Casella, MIT) plus routing of simple subagent tasks to cheaper models and a vault overview

newpanebandguardcommandtoast
v0.2.1MITupdated 2026-10-09SamRoels1/agent-deck
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-deck
│ ┃ Agent deck ✕ › fix the failing auth test and add an audit log call │ ┃ AGENT DECK · — IDLE │ ┃ ■ main ■ agents ■ permissions ⏺ Read(src/auth.ts) │ ┃ ╭─────────────────────────────────────────── ⎿ Read 6 lines │ ┃ │ ▾ — · main ⏺ Update(src/auth.ts) │ ┃ │ effort ▯▯▯▯ — 0 req ⎿ Added 2 lines, removed 1 line │ ┃ │ ctx ▰▰▰▰▰▱▱▱▱▱ 49% 97k/200k ⏺ Bash(bun test) │ ┃ │ $0.42 5h ▰▰▱▱▱ 31% ⎿ 3 pass, 1 fail │ ┃ ╰─────────────────────────────────────────── │ ┃ ╭─────────────────────────────────────────── ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ▾ git │ ┃ │ ⎇ ? ✓ clean ✻ Worked for 42s · done 4:20 PM │ ┃ ╰─────────────────────────────────────────── │ ┃ ▣ client module ./rail.tsx › /todo │ ┃ ╭─────────────────────────────────────────── ⎿ agent-deck: The todo list is empty. │ ┃ │ ✓ last reply 42s · 0 agents · 3 edits · 1 │ ┃ ╰─────────────────────────────────────────── │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ▾ activity · newest first │ ┃ │ 08:53:20 main Bash → bun tes │ ┃ ╰─────────────────────────────────────────── │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ▾ second brain no │ ┃ │ Set vaultPath in the plugin settings to sc │ ┃ │ Obsidian vault here. │ ┃ ╰─────────────────────────────────────────── [ Agent deck ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ agent-deck: ctx 49%

Draws

Band
[ Agent deck ]
Pane · Agent deck
AGENT DECK · — IDLE ■ main ■ agents ■ permissions ╭──────────────────────────────────────────────────────╮ │ ▾ — · main ○ idle │ │ effort ▯▯▯▯ — 0 req │ │ ctx ▰▰▰▰▰▱▱▱▱▱ 49% 97k/200k │ │ $0.42 5h ▰▰▱▱▱ 31% │ ╰──────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │ ▾ git clean │ │ ⎇ ? ✓ clean │ ╰──────────────────────────────────────────────────────╯ ▣ client module ./rail.tsx ╭──────────────────────────────────────────────────────╮ │ ✓ last reply 42s · 0 agents · 3 edits · 1 error │ ╰──────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │ ▾ activity · newest first │ │ 08:53:20 main Bash → bun test ✗ ↗ │ ╰──────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │ ▾ second brain no vault set │ │ Set vaultPath in the plugin settings to scan your │ │ Obsidian vault here. │ ╰──────────────────────────────────────────────────────╯
README

agent-deck

A dashboard for Claude Code, written as a Claude Code mod. It opens a pane next to your conversation that shows what your agents are doing, and it quietly sends simple subagent tasks to cheaper models.

It is a derivative of Flightdeck by Stephen Casella (MIT), extended with the parts below.

What you get

  • Agents: a card or lane per subagent with its model, difficulty level, a progress bar and a run counter when a task repeats.
  • Model routing: when Claude starts a subagent, a small Haiku call rates the task as simple, moderate or complex. Simple goes to Haiku, moderate to Sonnet, complex stays on your session model. A scoreboard shows the split per model, plus all-time totals.
  • Todo list: shared between you and your agents. Add items in the pane or with /todo add <title>, or let a planner agent split a goal with /todo plan <goal>. Agents claim items, report progress and check them off.
  • Activity: your prompts, agent events and errors, newest first, five at a time with an expand arrow. Pressing a line scrolls the chat to it where the surface allows.
  • Git: branch, ahead/behind and changed-file counts, using read-only git status.
  • Second brain: note counts and recently touched notes of an Obsidian vault.
  • Toasts: a notification when an agent finishes, a permission is denied, or context or a rate limit gets close to full.
  • Every box has an arrow that folds it, and a button above the prompt opens the pane.

Commands: /deck (open, close, reset, vault, layout auto|compact|wide|mini) and /todo (add, done, open, rm, clear, list, plan).

Install

You need Claude Code 2.1.287 or newer (2.1.286 for the desktop app). Run these in a terminal; a mod installed at user scope also loads in the desktop app's Code tab.

claude plugin marketplace add SamRoels1/agent-deck
claude plugin install agent-deck@agent-deck --scope user

Restart Claude Code, or run /reload-plugins in a session. Then type /deck.

Update

claude plugin marketplace update agent-deck
claude plugin update agent-deck@agent-deck

Restart Claude Code to apply. An update is picked up when version in .claude-plugin/plugin.json changes, so bump it for every release.

Settings

The one setting you probably want is the vault folder for the second brain box:

claude plugin configure agent-deck@agent-deck

Set vaultPath to your Obsidian vault, for example C:/Users/you/Documents/Vault. Left empty, the box only shows a hint. Other settings (layout, palette, which panels show, whether the pane opens on start) are listed by the same command.

What it can do on your machine

Mods run with your permissions and are not sandboxed, so read this before installing. claude plugin validate <folder> lists the same facts from the source.

  • Reads the folder you set as vaultPath (file names and modification times only, no contents).
  • Runs git status --porcelain=v2 --branch in your session folder.
  • Makes one small Haiku call per subagent start to rate the task.
  • Registers three tools (todo_add, todo_update, todo_list) and one agent type (agent-deck:planner), and adds the open todos to each subagent's prompt.
  • Keeps the todo list and routing totals in Claude Code's own store. It makes no network requests of its own.

To switch mods off: claude --safe-mode for one session, or disable it in the Installed tab of /plugin.

Develop

Clone the repo and load it straight from the folder instead of installing:

claude --plugin-dir /path/to/agent-deck

Notes from building this (the engine enforces them):

  • $ can only be passed to functions declared in the same file. State atoms must be defined in the file that reads them.
  • Only one unmatched on('event') per event across the whole mod, so extra hooks use always-true matchers such as { turnId: /^/ }.
  • claude plugin validate . is the check; there is no type-check step in this repo yet.

License

MIT. See LICENSE. Flightdeck's copyright notice is kept as the license requires.

Source 10 files
hooks/register.tsx 1295 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { GitInfo, Routing, Todo, VaultInfo, AgentCard, Architect, Bucket, Check, Gate, Layout, LogLine, Loop, Main, Roster, Turn, Usage, View } from '../types'
5import {
6  DEFAULT_ARCHITECT,
7  DEFAULT_GATE,
8  DEFAULT_MAIN,
9  DEFAULT_ROSTER,
10  DEFAULT_TURN,
11  DEFAULT_USAGE,
12  DEFAULT_VIEW,
13  EDIT_TOOLS,
14  SCHEMA_VERSION,
15  afterCall,
16  applyStep,
17  bucketOf,
18  cardTitle,
19  titleLines,
20  consultTimeline,
21  describeInput,
22  endConsult,
23  fitLegend,
24  fmtClock,
25  fmtDuration,
26  fmtTimer,
27  fmtUsd,
28  plural,
29  gateSummary,
30  gauge,
31  isAdvising,
32  isLoopActive,
33  kTokens,
34  lanes,
35  limitLabel,
36  listOf,
37  momentOf,
38  normalize,
39  normalizeCard,
40  normalizeGate,
41  normalizeLog,
42  noteTool,
43  PALETTES,
44  SVG_COLORS,
45  parseConfig,
46  prettyModel,
47  promptLine,
48  handbackOf,
49  adviceLine,
50  receiptOf,
51  recordCheck,
52  settleCheck,
53  shorten,
54  startConsult,
55  stepLoop,
56} from './core'
57import type { Config, Panel } from './core'
58
59import { gitPanel, registerGit } from './git'
60import { registerScoreboard, scoreboardPanel } from './scoreboard'
61import type { Totals } from './scoreboard'
62import { registerToasts } from './toasts'
63import { progressOf, registerTodo, todoPanel } from './todo'
64import { panel as foldedPanel } from './ui'
65
66const PANE = 'agent-deck'
67const TITLE = 'Agent deck'
68const PANE_COLUMNS = 66
69
70// ---------------------------------------------------------------- additions to the original: routing + vault
71
72const DEFAULT_VAULT = ''
73const LEVELS = ['simple', 'moderate', 'complex'] as const
74const MODEL_FOR: Record<string, string | undefined> = { simple: 'haiku', moderate: 'sonnet' }
75
76const vault = atom({ plugin: 'agent-deck', key: 'vault' } as const, null as VaultInfo | null)
77const folded = atom({ plugin: 'agent-deck', key: 'folded' } as const, [] as string[])
78const todosAtom = atom({ plugin: 'agent-deck', key: 'todos' } as const, [] as Todo[])
79const routingAtom = atom({ plugin: 'agent-deck', key: 'routing' } as const, { byModel: {}, byLevel: {} } as Routing)
80const gitAtom = atom({ plugin: 'agent-deck', key: 'git' } as const, null as GitInfo | null)
81
82type List = (path: string) => Promise<{ name: string; kind: string; mtimeMs: number }[]>
83
84async function scanVault(list: List, root: string): Promise<VaultInfo> {
85  const all: { name: string; mtimeMs: number }[] = []
86  const areas: VaultInfo['areas'] = []
87  const walk = async (dir: string, depth: number): Promise<number> => {
88    let n = 0
89    for (const f of await list(dir).catch(() => [])) {
90      if (f.name.startsWith('.')) continue
91      if (f.kind === 'dir' && depth < 5) n += await walk(`${dir}/${f.name}`, depth + 1)
92      else if (f.kind === 'file' && f.name.endsWith('.md')) {
93        n++
94        all.push({ name: f.name.replace(/\.md$/, ''), mtimeMs: f.mtimeMs })
95      }
96    }
97    return n
98  }
99  let notes = 0
100  for (const f of await list(root)) {
101    if (f.name.startsWith('.')) continue
102    if (f.kind === 'dir') {
103      const count = await walk(`${root}/${f.name}`, 1)
104      areas.push({ name: f.name, notes: count })
105      notes += count
106    } else if (f.name.endsWith('.md')) {
107      notes++
108      all.push({ name: f.name.replace(/\.md$/, ''), mtimeMs: f.mtimeMs })
109    }
110  }
111  return { notes, areas, recent: all.sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, 5), scannedMs: Date.now() }
112}
113
114const ago = (ms: number) => {
115  const s = Math.max(0, Math.round((Date.now() - ms) / 1000))
116  return s < 90 ? `${s}s` : s < 5400 ? `${Math.round(s / 60)}m` : s < 129600 ? `${Math.round(s / 3600)}h` : `${Math.round(s / 86400)}d`
117}
118
119
120// ---------------------------------------------------------------- state
121
122const meta = atom({ plugin: 'agent-deck', key: 'meta' } as const, { schemaVersion: SCHEMA_VERSION })
123const main = atom({ plugin: 'agent-deck', key: 'main' } as const, DEFAULT_MAIN)
124const usage = atom({ plugin: 'agent-deck', key: 'usage' } as const, DEFAULT_USAGE)
125const architect = atom({ plugin: 'agent-deck', key: 'architect' } as const, DEFAULT_ARCHITECT)
126const gate = atom({ plugin: 'agent-deck', key: 'gate' } as const, DEFAULT_GATE)
127const agents = atom({ plugin: 'agent-deck', key: 'agents' } as const, [])
128const loops = atom({ plugin: 'agent-deck', key: 'loops' } as const, [])
129const log = atom({ plugin: 'agent-deck', key: 'log' } as const, [])
130const turn = atom({ plugin: 'agent-deck', key: 'turn' } as const, DEFAULT_TURN)
131const receipt = atom({ plugin: 'agent-deck', key: 'receipt' } as const, null)
132const view = atom({ plugin: 'agent-deck', key: 'view' } as const, DEFAULT_VIEW)
133const roster = atom({ plugin: 'agent-deck', key: 'roster' } as const, DEFAULT_ROSTER)
134
135type ServerBlock = { type: string; id?: string; name?: string; tool_use_id?: string }
136
137// Every read goes through these, so a value saved under an older shape still reads.
138async function getMain($: EngineInterface): Promise<Main> {
139  return normalize(DEFAULT_MAIN, await read($, main))
140}
141async function getUsage($: EngineInterface): Promise<Usage> {
142  return normalize(DEFAULT_USAGE, await read($, usage))
143}
144async function getArchitect($: EngineInterface): Promise<Architect> {
145  const a = normalize(DEFAULT_ARCHITECT, await read($, architect))
146  return { ...a, consults: listOf(a.consults), ids: listOf(a.ids), seen: listOf(a.seen) }
147}
148async function getGate($: EngineInterface): Promise<Gate> {
149  return normalizeGate(await read($, gate))
150}
151async function getCards($: EngineInterface): Promise<AgentCard[]> {
152  return listOf<unknown>(await read($, agents)).map(normalizeCard)
153}
154async function getLoops($: EngineInterface): Promise<Loop[]> {
155  return listOf<Loop>(await read($, loops))
156}
157async function getLog($: EngineInterface): Promise<LogLine[]> {
158  return normalizeLog(await read($, log))
159}
160async function getTurn($: EngineInterface): Promise<Turn> {
161  return normalize(DEFAULT_TURN, await read($, turn))
162}
163async function getView($: EngineInterface): Promise<View> {
164  return normalize(DEFAULT_VIEW, await read($, view))
165}
166async function getRoster($: EngineInterface): Promise<Roster> {
167  const r = normalize(DEFAULT_ROSTER, await read($, roster))
168  return { architectTypes: listOf(r.architectTypes) }
169}
170
171/** A stored shape older than this build's: drop what cannot be read, keep the rest. */
172async function migrate($: EngineInterface) {
173  const m = await read($, meta)
174  if ((m?.schemaVersion ?? 0) >= SCHEMA_VERSION) return
175  await update($, log, list => normalizeLog(list))
176  await update($, agents, list => listOf<unknown>(list).map(normalizeCard))
177  await update($, gate, g => normalizeGate(g))
178  await update($, meta, () => ({ schemaVersion: SCHEMA_VERSION }))
179}
180
181async function say($: EngineInterface, who: string, text: string, kind: LogLine['kind'] = 'info', agentId: string | null = null, rowId: string | null = null) {
182  const line: LogLine = { at: await $.clock.now(), who, text, kind, agentId, rowId }
183  await update($, log, list => [...normalizeLog(list), line].slice(-60))
184}
185
186async function refreshStatus($: EngineInterface, cfg: Config) {
187  if (!cfg.statusLine) return $.ui.status(undefined)
188  const [u, a, g, cards] = await Promise.all([getUsage($), getArchitect($), getGate($), getCards($)])
189  const running = cards.filter(c => c.status === 'running').length
190  const s = gateSummary(g)
191  const parts = [
192    u.pct !== null ? `ctx ${Math.round(u.pct)}%` : null,
193    cards.length > 0 ? `agents ${running}/${cards.length}` : null,
194    a.consults.length > 0 || a.ids.length > 0 ? `${cfg.architectLabel.toLowerCase()} ${isAdvising(a) ? 'advising' : a.consults.length}` : null,
195    s.deny > 0 ? `denied ${s.deny}` : null,
196  ]
197  // Only fields with something to say; with none, no status entry at all.
198  const shown = parts.filter(Boolean)
199  $.ui.status(shown.length > 0 ? shown.join(' · ') : undefined)
200}
201
202async function whoIs($: EngineInterface, agentId: string | undefined) {
203  if (!agentId) return 'main'
204  const card = (await getCards($)).find(c => c.id === agentId)
205  return card ? shorten(cardTitle(card), 14) : 'agent'
206}
207
208async function consultStarted($: EngineInterface, cfg: Config, id: string, via: string) {
209  const t = await getTurn($)
210  const moment = momentOf(t)
211  const at = await $.clock.now()
212  await update($, architect, a => startConsult(normalize(DEFAULT_ARCHITECT, a), { id, at, moment, via }))
213  if (moment === 'before done') await update($, turn, x => ({ ...normalize(DEFAULT_TURN, x), isReviewing: true }))
214  await say($, cfg.architectLabel.toLowerCase(), cfg.moments ? `${moment} · ${via}` : `consulted · ${via}`, 'consult')
215  await refreshStatus($, cfg)
216}
217
218async function consultEnded($: EngineInterface, cfg: Config, advice: string | null, id?: string) {
219  const at = await $.clock.now()
220  const first = advice?.split('\n').find(l => l.trim()) ?? null
221  const text = first ? shorten(first.replace(/^[#>*\s-]+/, ''), 160) : null
222  await update($, architect, a => endConsult(normalize(DEFAULT_ARCHITECT, a), at, text, id))
223  await update($, turn, t => ({ ...normalize(DEFAULT_TURN, t), isReviewing: false }))
224  await say($, cfg.architectLabel.toLowerCase(), text ? `advice: ${shorten(text, 60)}` : 'advice returned', 'consult')
225  await refreshStatus($, cfg)
226}
227
228async function noteAdvice($: EngineInterface, cfg: Config, advice: string) {
229  await update($, architect, x => ({ ...normalize(DEFAULT_ARCHITECT, x), lastAdvice: advice }))
230  await say($, cfg.architectLabel.toLowerCase(), `advice: ${shorten(advice, 60)}`, 'consult')
231}
232
233async function isArchitectType($: EngineInterface, cfg: Config, type: string) {
234  return cfg.architect.test(type) || (await getRoster($)).architectTypes.includes(type)
235}
236
237async function openPane($: EngineInterface) {
238  // columns apply when docked beside the transcript, rows when seated inline above the prompt.
239  return $.ui.open({ id: PANE, title: TITLE, columns: PANE_COLUMNS, rows: 8 })
240}
241
242async function resetAll($: EngineInterface) {
243  await update($, main, m => ({ ...DEFAULT_MAIN, model: normalize(DEFAULT_MAIN, m).model, mode: normalize(DEFAULT_MAIN, m).mode }))
244  await update($, architect, () => DEFAULT_ARCHITECT)
245  await update($, gate, () => DEFAULT_GATE)
246  await update($, agents, () => [])
247  await update($, loops, () => [])
248  await update($, log, () => [])
249  await update($, turn, () => DEFAULT_TURN)
250  await update($, receipt, () => null)
251  await update($, view, () => DEFAULT_VIEW)
252  // The context gauge waits for the next measurement rather than showing the pre-clear fill.
253  await update($, usage, x => ({ ...normalize(DEFAULT_USAGE, x), pct: null, tokens: null }))
254}
255
256/** The session's cost read fresh, not from the last measurement: the receipt subtracts two of these. */
257async function costNow($: EngineInterface): Promise<number | null> {
258  const u = await $.session.usage().catch(() => null)
259  return u?.cost?.usd ?? null
260}
261
262async function noteMode($: EngineInterface, mode: string | undefined) {
263  if (mode) await update($, main, m => (normalize(DEFAULT_MAIN, m).mode === mode ? normalize(DEFAULT_MAIN, m) : { ...normalize(DEFAULT_MAIN, m), mode }))
264}
265
266// ---------------------------------------------------------------- hooks
267
268export const register: Register = (on, options) => {
269  const cfg = parseConfig(options)
270  registerGit(on)
271  registerScoreboard(on)
272  registerToasts(on)
273  registerTodo(on)
274  const configured = (options as { vaultPath?: unknown } | undefined)?.vaultPath
275  const vaultRoot = typeof configured === 'string' && configured.trim() ? configured.trim().replace(/\\/g, '/') : DEFAULT_VAULT
276  const C = PALETTES[cfg.palette]
277  // tool.check carries no loop id; the tool.call around it does, keyed by the call's id.
278  const callLoop = new Map<string, string | null>()
279  // The chat row of the latest prompt: the activity box scrolls to it when its line is pressed.
280  let lastPromptRow: string | null = null
281
282  on('session.start', async ($, e, next) => {
283    await $.command.register({
284      name: 'deck',
285      description: 'Agent deck, the live agent dashboard: open, close, reset, or set the layout',
286      argumentHint: '[open|close|reset|vault|layout auto|compact|wide|mini]',
287    })
288    await migrate($)
289    // A host without usage (headless, an SDK host, a session not yet bound) just starts without it.
290    const u = await $.session.usage().catch(() => null)
291    if (u) {
292      await update($, usage, x => ({
293        ...normalize(DEFAULT_USAGE, x),
294        pct: u.context.percent ?? null,
295        tokens: u.context.tokens ?? null,
296        window: u.context.window,
297        costUsd: u.cost?.usd ?? null,
298        limits: u.rateLimits.map(r => ({ kind: r.kind, pct: r.percentUsed })),
299      }))
300    }
301    if (cfg.openOnStart) void openPane($).catch(() => undefined)
302    if (vaultRoot) scanVault(p => $.fs.list(p), vaultRoot).then(v => update($, vault, () => v)).catch(() => undefined)
303    await refreshStatus($, cfg)
304    return next(e)
305  })
306
307  on('session.end', async ($, e, next) => {
308    if (e.reason === 'clear') {
309      await resetAll($)
310      await refreshStatus($, cfg)
311    }
312    return next(e)
313  })
314
315  on('command.run', { command: 'deck' }, async ($, e) => {
316    const [verb = 'open', arg = ''] = e.args.trim().split(/\s+/)
317    if (verb === 'close') {
318      await $.ui.close({ id: PANE })
319      return { text: 'Agent deck closed.' }
320    }
321    if (verb === 'reset') {
322      await resetAll($)
323      await refreshStatus($, cfg)
324      return { text: 'Agent deck reset.' }
325    }
326    if (verb === 'vault') {
327      if (vaultRoot) scanVault(p => $.fs.list(p), vaultRoot).then(v => update($, vault, () => v)).catch(() => undefined)
328      return { text: vaultRoot ? `Rescanning the vault at ${vaultRoot}.` : 'No vaultPath is set. Set it in the plugin settings (claude plugin configure agent-deck@agent-deck).' }
329    }
330    if (verb === 'layout') {
331      const layout: Layout | null = arg === 'compact' || arg === 'wide' || arg === 'auto' || arg === 'mini' ? arg : null
332      if (!layout) return { text: 'Usage: /deck layout auto|compact|wide|mini' }
333      await update($, view, v => ({ ...normalize(DEFAULT_VIEW, v), layout }))
334      const opened = await openPane($)
335      return { text: opened.isPlaced ? `Agent deck layout: ${layout}.` : `Layout set to ${layout}; the pane is not shown yet: ${opened.reason}` }
336    }
337    const opened = await openPane($)
338    if (!opened.isPlaced) return { text: `Agent deck is not shown yet: ${opened.reason}` }
339    return { text: 'Agent deck opened. Focus it with ctrl+x tab; 1-6 expand cards, f/s/o open the gate rows.' }
340  })
341
342  on('classic.UserPromptSubmit', async ($, e, next) => {
343    await noteMode($, e.permission_mode)
344    return next(e)
345  })
346
347  on('agent.offer', async ($, e, next) => {
348    const offered = await next(e)
349    if (cfg.architect.test(e.agent) || (cfg.matchDescriptions && cfg.architect.test(e.description))) {
350      await update($, roster, r => {
351        const x = normalize(DEFAULT_ROSTER, r)
352        return x.architectTypes.includes(e.agent) ? x : { architectTypes: [...listOf<string>(x.architectTypes), e.agent].slice(-20) }
353      })
354    }
355    return offered
356  })
357
358  on('turn.start', async ($, e, next) => {
359    const [now, cost] = await Promise.all([$.clock.now(), costNow($)])
360    await update($, turn, () => ({ ...DEFAULT_TURN, startedAt: now, costAtStart: cost }))
361    await update($, main, m => ({ ...normalize(DEFAULT_MAIN, m), isRunning: true }))
362    // A background architect's report reaches the main loop as the text opening this turn. The
363    // SubagentHandback tool call (in tool.call) normally carries it first; this is the fallback.
364    const back = e.text ? handbackOf(e.text) : null
365    const a = back ? await getArchitect($) : null
366    if (back && a && a.ids.includes(back.from)) {
367      const advice = adviceLine(back.body)
368      if (advice && advice !== a.lastAdvice) await noteAdvice($, cfg, advice)
369    } else if (e.text) {
370      const p = promptLine(e.text)
371      await say($, p.who, p.text, 'info', null, p.who === 'you' ? lastPromptRow : null)
372    }
373    return next(e)
374  })
375
376  on('turn.step', async function* ($, e, next) {
377    // The main loop's model is known when its request starts; a long first request shouldn't read "—".
378    if (!e.agentId) {
379      await update($, main, m => {
380        const x = normalize(DEFAULT_MAIN, m)
381        return { ...x, model: e.model, effort: String(e.effort ?? x.effort), steps: x.steps + 1 }
382      })
383      return yield* next(e)
384    }
385    const result = yield* next(e)
386    const id = e.agentId
387    const [cards, a] = await Promise.all([getCards($), getArchitect($)])
388    if (cards.some(c => c.id === id)) {
389      const step = { model: e.model, usage: result.usage, stopReason: result.stopReason }
390      await update($, agents, list => listOf<unknown>(list).map(normalizeCard).map(c => (c.id === id ? applyStep(c, step) : c)))
391      if (result.stopReason === 'max_tokens') await say($, await whoIs($, id), 'hit max_tokens', 'error', id)
392    } else if (!a.ids.includes(id)) {
393      const now = await $.clock.now()
394      await update($, loops, l => stepLoop(listOf<Loop>(l), id, now))
395    }
396    return result
397  })
398
399  on('session.measure', async ($, e, next) => {
400    await update($, usage, x => ({
401      ...normalize(DEFAULT_USAGE, x),
402      pct: e.context.percent ?? null,
403      tokens: e.context.tokens ?? null,
404      window: e.context.window,
405      costUsd: e.cost?.usd ?? null,
406      limits: e.rateLimits.map(r => ({ kind: r.kind, pct: r.percentUsed })),
407    }))
408    if (e.changed.includes('context')) await refreshStatus($, cfg)
409    return next(e)
410  })
411
412  on('session.compact', async ($, e, next) => {
413    const done = await next(e)
414    if (!e.agentId && e.trigger !== 'precompute') {
415      const now = await $.clock.now()
416      await update($, usage, x => {
417        const u = normalize(DEFAULT_USAGE, x)
418        return { ...u, compactions: u.compactions + 1, lastCompactAt: now }
419      })
420      await say($, 'main', `context compacted (${e.trigger})`)
421    }
422    return done
423  })
424
425  on('tool.check', async ($, e, next) => {
426    const verdict = await next(e)
427    if (e.tool_use_id) {
428      const check: Check = {
429        id: e.tool_use_id,
430        tool: e.tool,
431        bucket: bucketOf(e.tool),
432        verdict: verdict.decision === 'allow' ? 'rule' : verdict.decision,
433        inSubagent: Boolean(callLoop.get(e.tool_use_id)),
434        detail: shorten(describeInput(e.tool, e.input), 90),
435        at: await $.clock.now(),
436      }
437      await update($, gate, g => recordCheck(normalizeGate(g), check))
438      // The status line updates when the call settles; only a refusal ends here.
439      if (verdict.decision === 'deny') {
440        await say($, 'gate', `denied by rule · ${check.detail}`, 'error')
441        await refreshStatus($, cfg)
442      }
443    }
444    return verdict
445  })
446
447  on('tool.call', async ($, e, next) => {
448    callLoop.set(e.tool_use_id, e.agentId ?? null)
449    const ran = await next(e).finally(() => callLoop.delete(e.tool_use_id))
450    const didRun = ran.deny === undefined
451    // Settle this call's pending ask, if it had one; skip the write (and the redraw) otherwise.
452    const g0 = await getGate($)
453    const isSettled = settleCheck(g0, e.tool_use_id, didRun) !== g0
454    if (isSettled) await update($, gate, g => settleCheck(normalizeGate(g), e.tool_use_id, didRun))
455    // A background agent hands its report back through this tool; an architect's report is its advice.
456    if (String(e.tool) === 'SubagentHandback') {
457      const message = (e as unknown as { message?: unknown }).message
458      const a = e.agentId ? await getArchitect($) : null
459      if (a && e.agentId && a.ids.includes(e.agentId) && typeof message === 'string') {
460        const advice = adviceLine(message)
461        if (advice && advice !== a.lastAdvice) await noteAdvice($, cfg, advice)
462      }
463      return ran
464    }
465    if (e.tool === 'Agent') {
466      if (isSettled) await refreshStatus($, cfg)
467      return ran
468    }
469    const hasFailed = didRun && ran.isError === true
470    const isEdit = !hasFailed && didRun && EDIT_TOOLS.has(e.tool)
471    const t0 = await getTurn($)
472    if (isEdit || hasFailed || (!e.agentId && t0.errorStreak > 0)) {
473      await update($, turn, t => afterCall(normalize(DEFAULT_TURN, t), { inSubagent: Boolean(e.agentId), hasFailed, isEdit }))
474    }
475    const text = shorten(describeInput(e.tool, e), 64)
476    if (e.agentId) {
477      const id = e.agentId
478      await update($, agents, list =>
479        listOf<unknown>(list)
480          .map(normalizeCard)
481          .map(c => (c.id === id ? noteTool(c, { tool: e.tool, text, isError: hasFailed || ran.deny !== undefined }) : c)),
482      )
483    }
484    // The log keeps what is worth a glance: refusals, errors and edits; the rest is on the cards.
485    if (ran.deny !== undefined) await say($, await whoIs($, e.agentId), `${text}  denied`, 'error', e.agentId ?? null, e.tool_use_id)
486    else if (hasFailed) await say($, await whoIs($, e.agentId), `${text}  ✗`, 'error', e.agentId ?? null, e.tool_use_id)
487    else if (isEdit) await say($, await whoIs($, e.agentId), text, 'info', e.agentId ?? null, e.tool_use_id)
488    if (isSettled || !didRun) await refreshStatus($, cfg)
489    return ran
490  })
491
492  // A server-side review tool never reaches tool.call: it shows only in the assistant's rows.
493  on('session.append', async ($, e, next) => {
494    if (e.door === 'prompt' && !e.agentId) lastPromptRow = e.uuid
495    if (!e.agentId && e.message.type === 'assistant') {
496      const a = await getArchitect($)
497      // Consults this row opened: their result may be in the same row, after the stale read above.
498      const opened = new Set<string>()
499      for (const block of e.message.content as unknown as readonly ServerBlock[]) {
500        if (block.type === 'server_tool_use' && block.name && block.id && cfg.architect.test(block.name)) {
501          if (a.seen.includes(block.id)) continue
502          const id = block.id
503          await update($, architect, x => {
504            const y = normalize(DEFAULT_ARCHITECT, x)
505            return { ...y, seen: [...listOf<string>(y.seen), id].slice(-60) }
506          })
507          opened.add(id)
508          await consultStarted($, cfg, id, `${block.name} tool`)
509        } else if (block.type.endsWith('_tool_result') && block.tool_use_id) {
510          const id = block.tool_use_id
511          const isOpen = opened.has(id) || (await getArchitect($)).consults.some(c => c.id === id && c.endAt === null)
512          if (isOpen) await consultEnded($, cfg, null, id)
513        }
514      }
515    }
516    return next(e)
517  })
518
519  on('agent.spawn', async ($, e, next) => {
520    // Route an easy task to a cheaper model: one small classification per spawn.
521    let model: string | undefined
522    let level: string | undefined
523    const isFixed = e.model !== undefined || e.fork || e.isTeammate || e.workflow
524    if (!isFixed && !(await isArchitectType($, cfg, e.subagentType))) {
525      try {
526        level =
527          (await $.model.classify(
528            `Rate the difficulty of this task for an AI agent. simple = lookup, listing, summarising, reformatting, a small mechanical edit. moderate = ordinary coding or research with some judgement. complex = design, debugging, multi-file reasoning, or anything ambiguous.\n\nTask: ${e.prompt.slice(0, 1500)}`,
529            LEVELS,
530          )) ?? 'complex'
531        model = MODEL_FOR[level]
532      } catch {
533        level = undefined
534      }
535    }
536    const started = await next(model ? { ...e, model } : e)
537    if (!e.parentAgentId) await noteMode($, e.permissionMode)
538    if (started.deny !== undefined || !started.agentId) return started
539    const id = started.agentId
540    if (await isArchitectType($, cfg, e.subagentType)) {
541      await update($, architect, a => {
542        const x = normalize(DEFAULT_ARCHITECT, a)
543        return { ...x, ids: [...listOf<string>(x.ids), id].slice(-40) }
544      })
545      await update($, loops, l => listOf<Loop>(l).filter(x => x.id !== id))
546      await consultStarted($, cfg, id, e.subagentType.split(':').pop() ?? 'agent')
547      return started
548    }
549    const card: AgentCard = {
550      ...normalizeCard({}),
551      id,
552      type: e.name ?? e.subagentType,
553      model: started.model,
554      description: e.description,
555      level: level ?? '',
556      spawnedAt: await $.clock.now(),
557    }
558    // A repeat of a finished task reuses its box instead of adding another.
559    await update($, agents, list => {
560      const all = listOf<unknown>(list).map(normalizeCard)
561      const i = all.findIndex(c => c.status !== 'running' && c.type === card.type && c.description === card.description)
562      if (i === -1) return [...all, card].slice(-24)
563      all[i] = { ...card, runs: all[i].runs + 1 }
564      return all
565    })
566    await update($, loops, l => listOf<Loop>(l).filter(x => x.id !== id))
567    const via = level ? ` · ${level}${model ? ' (routed)' : ''}` : ''
568    await say($, shorten(cardTitle(card), 18), `started · ${prettyModel(card.model)}${via}`, 'info', id, e.tool_use_id || null)
569    await refreshStatus($, cfg)
570    return started
571  })
572
573  on('turn.complete', async ($, e, next) => {
574    const done = await next(e)
575    const id = e.agentId
576    const now = await $.clock.now()
577    if (!id) {
578      const [t, cards, cost] = await Promise.all([getTurn($), getCards($), costNow($)])
579      const r = receiptOf(t, {
580        durationMs: e.durationMs,
581        agentsSince: cards.filter(c => c.spawnedAt >= t.startedAt).length,
582        costNow: cost,
583        reason: e.reason,
584      })
585      await update($, receipt, () => r)
586      await update($, main, m => ({ ...normalize(DEFAULT_MAIN, m), isRunning: false }))
587      await refreshStatus($, cfg)
588      return done
589    }
590    if ((await getArchitect($)).ids.includes(id)) {
591      await consultEnded($, cfg, e.answer, id)
592      return done
593    }
594    const cards = await getCards($)
595    if (cards.some(c => c.id === id)) {
596      const status = e.reason === 'answer' ? 'done' : e.reason === 'aborted' ? 'stopped' : 'failed'
597      await update($, agents, list =>
598        listOf<unknown>(list)
599          .map(normalizeCard)
600          .map(c => (c.id === id ? { ...c, status, endedAt: now, answer: shorten(e.answer, 400) } : c)),
601      )
602      const card = cards.find(c => c.id === id)
603      const took = card ? fmtDuration(now - card.spawnedAt) : ''
604      await say($, await whoIs($, id), status === 'done' ? `done · ${took}` : status, status === 'done' ? 'done' : 'error', id, (await getLog($)).find(l => l.agentId === id && l.rowId)?.rowId ?? null)
605    } else {
606      await update($, loops, l => listOf<Loop>(l).map(x => (x.id === id ? { ...x, isDone: true, lastAt: now } : x)))
607    }
608    await refreshStatus($, cfg)
609    return done
610  })
611
612  // ---------------------------------------------------------------- drawing
613
614  // A button above the prompt that opens the pane, so nobody has to type /deck.
615  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
616    if (e.props.hasSurvey) return next(e)
617    const { Box, Button, Text } = $.ui.resolve(e)
618    const running = (await getCards($)).filter(c => c.status === 'running').length
619    return (
620      <Box>
621        <Button key="open-deck" label="Agent deck" onPress={() => openPane($)} />
622        <Text dimColor>{running > 0 ? `  ${running} agent${running === 1 ? '' : 's'} running` : ''}</Text>
623      </Box>
624    )
625  })
626
627  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
628    const els = $.ui.resolve(e)
629    const { Box, Text, Button } = els
630    const hasClient = 'Client' in els
631    const [m, u, a, g, cards, lp, lines, t, r, v, now] = await Promise.all([
632      getMain($),
633      getUsage($),
634      getArchitect($),
635      getGate($),
636      getCards($),
637      getLoops($),
638      getLog($),
639      getTurn($),
640      read($, receipt),
641      getView($),
642      $.clock.now(),
643    ])
644    const W = Math.max(40, e.props.bodyColumns)
645
646    // ---- folding: every box has an arrow in its header; a folded box is one summary line
647    const foldedIds = await read($, folded)
648    const isOpen = (id: string) => !foldedIds.includes(id)
649    const toggle = (id: string) => update($, folded, list => (list.includes(id) ? list.filter(x => x !== id) : [...list, id]))
650    const arrow = (id: string) => (
651      <Button key={`fold-${id}`} plain label={isOpen(id) ? '▾' : '▸'} onPress={() => toggle(id)} />
652    )
653    const foldedBox = (id: string, title: string, color: string, summary: string, w: number) =>
654      foldedPanel(els, { id, title, color, summary, isOpen: false, onToggle: () => toggle(id), width: w }, null)
655
656    // ---- data for the added boxes
657    const todoItems = await read($, todosAtom)
658    const route = await read($, routingAtom)
659    const gitInfo = await read($, gitAtom)
660    const totals = ((await $.store.get('agent-deck.totals')) ?? null) as Totals | null
661    // A progress bar for the todo an agent has claimed; none claimed means nothing to measure yet.
662    const progressBar = (agentId: string, width: number) => {
663      const pr = progressOf(todoItems, agentId)
664      if (!pr) return <Text color={C.faint} wrap="truncate">{`${'▱'.repeat(Math.max(4, Math.min(10, width - 16)))} no task linked`}</Text>
665      const g = gauge(pr.progress, Math.max(4, Math.min(10, width - 12)))
666      return (
667        <Text wrap="truncate">
668          <Text color={C.agent}>{g.on}</Text>
669          <Text color={C.faint}>{g.off}</Text>
670          <Text dimColor>{` ${Math.round(pr.progress)}% ${pr.id}`}</Text>
671        </Text>
672      )
673    }
674    const layout = v.layout ?? cfg.layout
675    const isWide = layout === 'wide' || (layout === 'auto' && W >= 110)
676    const colW = isWide ? Math.floor((W - 2) / 2) : W
677    const modelName = prettyModel(m.model)
678    const viewed = e.props.view?.agentId ?? null
679    const advising = isAdvising(a)
680    const running = cards.filter(c => c.status === 'running')
681    const showArchitect = a.consults.length > 0 || a.ids.length > 0
682    const motion = cfg.motion && hasClient
683    // A panel with nothing to show yet takes no room: most sessions never spawn an agent.
684    const isEmpty: Record<Panel, boolean> = {
685      main: false,
686      architect: !showArchitect,
687      gate: g.recent.length === 0 && gateSummary(g).total === 0,
688      agents: cards.length === 0,
689      loops: lp.length === 0,
690      receipt: !m.isRunning && !r,
691      log: false,
692    }
693    const panels = cfg.panels.filter(p => !isEmpty[p])
694    const decider = m.mode === 'auto' ? 'classifier' : 'you'
695
696    // A connector between panels: animated while its flow is live, a dim line otherwise.
697    const rail = (key: string, active: boolean, color: string, width: number, marks: number[] = [], isMerge = false) =>
698      motion ? (
699        <els.Client
700          key={key}
701          module="./rail.tsx"
702          width={width}
703          height={1}
704          props={{ active, width, color, dim: C.faint, marks, isMerge }}
705        />
706      ) : (
707        <Text color={C.faint}>{'─'.repeat(Math.max(1, width))}</Text>
708      )
709
710    // A start time of 0 is unknown (state saved before it was recorded): no clock, not decades.
711    const clock = (key: string, since: number, endAt: number | null, color: string) =>
712      since <= 0 ? (
713        <Text color={color}>—</Text>
714      ) : hasClient ? (
715        <els.Client key={key} module="./elapsed.tsx" props={{ since, now, endAt, color }} />
716      ) : (
717        <Text color={color}>{fmtTimer((endAt ?? now) - since)}</Text>
718      )
719
720    // ---- main
721    const effortN = { low: 1, medium: 2, high: 3, xhigh: 4, max: 4 }[m.effort] ?? 0
722    const ctxGauge = u.pct !== null ? gauge(u.pct, 10) : null
723    const mainPanel = (w: number) => (
724      <Box flexDirection="column" borderStyle="round" borderColor={C.main} paddingX={1} width={w}>
725        <Box justifyContent="space-between">
726          <Box>
727            {arrow('main')}
728            <Text color={C.main} bold>{` ${modelName} · main`}</Text>
729          </Box>
730          <Text color={m.isRunning ? C.main : C.dim}>{m.isRunning ? '● working' : '○ idle'}</Text>
731        </Box>
732        <Text wrap="truncate">
733          <Text dimColor>effort </Text>
734          <Text color={C.main}>{'▮'.repeat(effortN) + '▯'.repeat(4 - effortN)} </Text>
735          <Text color={C.main} bold>
736            {m.effort || '—'}
737          </Text>
738          {m.mode ? <Text dimColor>{`   mode ${m.mode}`}</Text> : null}
739          <Text dimColor>{`   ${m.steps} req`}</Text>
740        </Text>
741        {ctxGauge ? (
742          <Text wrap="truncate">
743            <Text dimColor>ctx </Text>
744            <Text color={u.pct !== null && u.pct >= 80 ? C.warn : C.main}>{ctxGauge.on}</Text>
745            <Text color={C.faint}>{ctxGauge.off}</Text>
746            <Text bold>{` ${Math.round(u.pct ?? 0)}%`}</Text>
747            {u.tokens !== null ? <Text dimColor>{` ${kTokens(u.tokens)}/${kTokens(u.window)}`}</Text> : null}
748            {u.compactions > 0 ? <Text color={C.amber}>{`  ⟲${u.compactions}`}</Text> : null}
749          </Text>
750        ) : null}
751        {u.costUsd !== null || u.limits.length > 0 ? (
752          <Text wrap="truncate">
753            {u.costUsd !== null ? <Text color={C.text}>{`${fmtUsd(u.costUsd)}   `}</Text> : null}
754            {u.limits.slice(0, 2).map(l => {
755              const lg = gauge(l.pct, 5)
756              return (
757                <Text wrap="truncate">
758                  <Text dimColor>{`${limitLabel(l.kind)} `}</Text>
759                  <Text color={l.pct >= 80 ? C.warn : C.main}>{lg.on}</Text>
760                  <Text color={C.faint}>{lg.off}</Text>
761                  <Text dimColor>{` ${Math.round(l.pct)}%  `}</Text>
762                </Text>
763              )
764            })}
765          </Text>
766        ) : null}
767      </Box>
768    )
769
770    // ---- architect
771    const lastConsult = a.consults[a.consults.length - 1]
772    const architectPanel = (w: number) => {
773      const tl = consultTimeline(a, now, Math.max(8, w - 4))
774      return (
775        <Box flexDirection="column" borderStyle="round" borderColor={C.arch} paddingX={1} width={w}>
776          <Box justifyContent="space-between">
777            <Box>
778              {arrow('architect')}
779              <Text color={C.arch} bold>{` ${cfg.architectLabel} · ${advising ? 'advising' : 'on call'}`}</Text>
780            </Box>
781            <Text>
782              <Text dimColor>consults </Text>
783              <Text color={C.arch} bold>
784                {a.consults.length}
785              </Text>
786            </Text>
787          </Box>
788          <Text color={C.arch}>{tl}</Text>
789          {lastConsult ? (
790            <Text dimColor wrap="truncate">
791              {advising
792                ? `consulting since ${fmtClock(lastConsult.at)}`
793                : `last ${fmtDuration(now - (lastConsult.endAt ?? lastConsult.at))} ago · took ${fmtDuration((lastConsult.endAt ?? now) - lastConsult.at)}`}
794            </Text>
795          ) : (
796            <Text dimColor>not consulted yet</Text>
797          )}
798          {cfg.moments ? (
799            <Box flexWrap="wrap" columnGap={2}>
800              {(['before a plan', 'error repeats', 'before done'] as const).map(mo => {
801                const isOn = lastConsult?.moment === mo
802                return (
803                  <Text color={isOn ? C.arch : C.dim} bold={isOn}>
804                    {isOn ? '◆' : '◇'} {mo}
805                  </Text>
806                )
807              })}
808              <Text color={C.faint}>(inferred)</Text>
809            </Box>
810          ) : null}
811          {a.lastAdvice ? (
812            <Text color={C.arch} wrap="truncate">
813              » {a.lastAdvice}
814            </Text>
815          ) : null}
816        </Box>
817      )
818    }
819
820    // ---- gate
821    const s = gateSummary(g)
822    const verdictColor = (c: Check) =>
823      c.verdict === 'rule' ? C.gate : c.verdict === 'cleared' ? C.cleared : c.verdict === 'ask' ? C.amber : C.warn
824    const runsOf = (list: Check[]) => {
825      const out: { text: string; color: string; dim: boolean }[] = []
826      for (const c of list) {
827        const ch = c.verdict === 'deny' ? '✗' : '■'
828        const color = verdictColor(c)
829        const last = out[out.length - 1]
830        if (last && last.color === color && last.dim === c.inSubagent && last.text.endsWith(ch)) last.text += ch
831        else out.push({ text: ch, color, dim: c.inSubagent })
832      }
833      return out
834    }
835    const gatePanel = (w: number) => {
836      const strip = g.recent.slice(-Math.min(36, Math.max(8, w - 4)))
837      const open = v.gateOpen
838      return (
839        <Box flexDirection="column" borderStyle="round" borderColor={C.gate} paddingX={1} width={w}>
840          <Box justifyContent="space-between">
841            <Box>
842              {arrow('gate')}
843              <Text color={C.gate} bold>{' permissions'}</Text>
844            </Box>
845            <Text dimColor>{`${s.total} checks`}</Text>
846          </Box>
847          {strip.length === 0 ? <Text color={C.faint}>no checks yet</Text> : null}
848          {strip.length > 0 ? <Text wrap="truncate">{runsOf(strip).map(r => <Text color={r.color} dimColor={r.dim}>{r.text}</Text>)}</Text> : null}
849          <Text wrap="truncate">
850            <Text color={C.gate}>■</Text>
851            <Text dimColor>{` ${s.rule} allowed  `}</Text>
852            <Text color={C.cleared}>■</Text>
853            <Text dimColor>{` ${s.cleared} ${decider}  `}</Text>
854            {s.ask > 0 ? <Text color={C.amber}>{`■ ${s.ask} pending  `}</Text> : null}
855            <Text color={s.deny > 0 ? C.warn : C.dim}>{`✗ ${s.deny} denied`}</Text>
856            {w >= 80 && g.recent.some(c => c.inSubagent) ? <Text color={C.faint}>{'  dim: in subagents'}</Text> : null}
857          </Text>
858          <Box columnGap={2}>
859            {(['file', 'shell', 'other'] as const).map((b: Bucket) => {
860              const tl = g.totals[b]
861              const n = tl.rule + tl.ask + tl.cleared + tl.deny
862              return (
863                <Button
864                  key={`gate-${b}`}
865                  plain
866                  hotkey={b[0]}
867                  label={`${b} ${n}${open === b ? ' ▾' : ''}`}
868                  dimColor={n === 0}
869                  onPress={() => update($, view, x => ({ ...normalize(DEFAULT_VIEW, x), gateOpen: normalize(DEFAULT_VIEW, x).gateOpen === b ? null : b }))}
870                />
871              )
872            })}
873          </Box>
874          {open
875            ? g.recent
876                .filter(c => c.bucket === open)
877                .slice(-5)
878                .map(c => (
879                  <Text wrap="truncate">
880                    <Text color={verdictColor(c)}>{c.verdict === 'deny' ? '✗ ' : '■ '}</Text>
881                    <Text color={C.dim}>{`${(c.verdict === 'rule' ? 'allowed' : c.verdict === 'cleared' ? decider : c.verdict === 'ask' ? 'pending' : 'denied').padEnd(10)} `}</Text>
882                    <Text dimColor={c.inSubagent}>{shorten(c.detail, Math.max(10, w - 18))}</Text>
883                  </Text>
884                ))
885            : null}
886        </Box>
887      )
888    }
889
890    // ---- agents: cards up to the limit, swimlanes beyond it
891    const statusColor = (c: AgentCard) => (c.status === 'failed' ? C.warn : c.status === 'done' ? C.gate : C.agent)
892    const glyph = (c: AgentCard) => (c.status === 'running' ? '◐' : c.status === 'done' ? '✓' : c.status === 'failed' ? '✗' : '■')
893    const expandOnPress = (id: string) => () =>
894      update($, view, x => ({ ...normalize(DEFAULT_VIEW, x), expanded: normalize(DEFAULT_VIEW, x).expanded === id ? null : id }))
895
896    const agentsPanel = (w: number) => {
897      // Cards need 20 columns each; when the pane can't hold the limit, lanes take over.
898      const fit = Math.max(1, Math.min(cfg.maxCards, Math.floor((w + 1) / 21)))
899      const useLanes = cards.length > fit
900      const header = (
901        <Box justifyContent="space-between" width={w}>
902          <Box>
903            {arrow('agents')}
904            <Text bold>{` agents · ${running.length} running · ${cards.length} total`}</Text>
905          </Box>
906          {cards.length > 0 ? <Text color={C.faint}>1-{Math.min(cards.length, useLanes ? 6 : fit)} expand</Text> : null}
907        </Box>
908      )
909      if (cards.length === 0) {
910        return (
911          <Box flexDirection="column" width={w}>
912            {header}
913            <Text color={C.faint}>no subagents yet</Text>
914          </Box>
915        )
916      }
917      if (useLanes) {
918        const shown = cards.slice(-6)
919        const barW = Math.max(8, w - 34)
920        const geo = lanes(shown, now, barW)
921        return (
922          <Box flexDirection="column" width={w}>
923            {header}
924            {cards.length > shown.length ? <Text color={C.faint}>{`+${cards.length - shown.length} earlier`}</Text> : null}
925            {shown.map((c, i) => {
926              const gm = geo[i]
927              const isViewed = viewed === c.id
928              const detail = `${prettyModel(c.model)}${c.level ? ` · ${c.level}` : ''}${c.runs > 1 ? ` · ×${c.runs}` : ''}`
929              return (
930                <Box flexDirection="column">
931                  <Box>
932                    <Text color={statusColor(c)} bold={isViewed}>{`${isViewed ? '▶' : glyph(c)} `}</Text>
933                    <Box width={21}>
934                      <Button key={`card-${c.id}`} plain hotkey={String(i + 1)} label={shorten(cardTitle(c), 18)} onPress={expandOnPress(c.id)} />
935                    </Box>
936                    <Text color={C.faint}>{' ' + '·'.repeat(gm?.before ?? 0)}</Text>
937                    <Text color={statusColor(c)}>{'━'.repeat(gm?.bar ?? 1)}</Text>
938                    <Text color={C.faint}>{'·'.repeat(gm?.after ?? 0) + ' '}</Text>
939                    {clock(`lane-clock-${c.id}`, c.spawnedAt, c.endedAt, C.dim)}
940                  </Box>
941                  <Box>
942                    <Text color={C.dim} wrap="truncate">{`    ${detail}${c.status === 'running' ? '  ' : ''}`}</Text>
943                    {c.status === 'running' ? progressBar(c.id, w - 20) : null}
944                  </Box>
945                </Box>
946              )
947            })}
948          </Box>
949        )
950      }
951      const shown = cards.slice(-fit)
952      const cardW = Math.max(20, Math.floor((w - (shown.length - 1)) / shown.length))
953      const centers = shown.map((_, i) => i * (cardW + 1) + Math.floor(cardW / 2))
954      return (
955        <Box flexDirection="column" width={w}>
956          {header}
957          {rail('fan-out', running.length > 0, C.agent, w, centers)}
958          <Box columnGap={1}>
959            {shown.map((c, i) => {
960              const isViewed = viewed === c.id
961              const sameModel = !c.model || prettyModel(c.model) === modelName
962              return (
963                <Box
964                  flexDirection="column"
965                  borderStyle={isViewed ? 'double' : 'round'}
966                  borderColor={c.lastStop === 'max_tokens' ? C.warn : C.agent}
967                  borderDimColor={c.status !== 'running' && !isViewed}
968                  width={cardW}
969                  paddingX={1}
970                >
971                  <Button key={`card-${c.id}`} plain hotkey={String(i + 1)} label={titleLines(cardTitle(c), cardW - 7, cardW - 4)[0]} onPress={expandOnPress(c.id)} />
972                  <Text bold wrap="truncate">
973                    {titleLines(cardTitle(c), cardW - 7, cardW - 4)[1]}
974                  </Text>
975                  <Text color={C.dim} wrap="truncate">
976                    {`${sameModel ? c.type : `${c.type} · ${prettyModel(c.model)}`}${c.level ? ` · ${c.level}` : ''}${c.runs > 1 ? ` · ×${c.runs}` : ''}`}
977                  </Text>
978                  <Text dimColor wrap="truncate">
979                    {c.steps > 0 ? `ctx ${kTokens(c.ctx)} · out ${kTokens(c.out)} · ${c.steps} st` : 'starting…'}
980                  </Text>
981                  {c.status === 'running' ? progressBar(c.id, cardW) : null}
982                  <Box>
983                    <Text color={c.lastStop === 'max_tokens' ? C.warn : statusColor(c)}>
984                      {cardW >= 26 ? `${glyph(c)} ${c.lastStop === 'max_tokens' ? 'max_tokens' : c.status} ` : `${glyph(c)} `}
985                    </Text>
986                    <Box flexShrink={0}>{clock(`card-clock-${c.id}`, c.spawnedAt, c.endedAt, C.dim)}</Box>
987                  </Box>
988                </Box>
989              )
990            })}
991          </Box>
992          {rail('merge', running.length > 0, C.agent, w, centers, true)}
993        </Box>
994      )
995    }
996
997    const expandedCard = cards.find(c => c.id === v.expanded)
998    const expandedPanel = (w: number) =>
999      expandedCard ? (
1000        <Box flexDirection="column" borderStyle="single" borderColor={C.agent} paddingX={1} width={w}>
1001          <Text bold wrap="wrap">
1002            {expandedCard.description || expandedCard.type}
1003          </Text>
1004          <Text dimColor wrap="truncate">{`${expandedCard.type} · ${prettyModel(expandedCard.model)}${expandedCard.level ? ` (${expandedCard.level})` : ''} · ${expandedCard.status} · ${expandedCard.steps} steps`}</Text>
1005          {expandedCard.tools.length === 0 ? <Text color={C.faint}>no tool calls yet</Text> : null}
1006          {expandedCard.tools.map(n => (
1007            <Text color={n.isError ? C.warn : C.text} wrap="truncate">
1008              {`${n.isError ? '✗' : '·'} ${n.text}`}
1009            </Text>
1010          ))}
1011          {expandedCard.answer ? (
1012            <Text dimColor wrap="wrap">
1013              {`» ${shorten(expandedCard.answer, 240)}`}
1014            </Text>
1015          ) : null}
1016        </Box>
1017      ) : null
1018
1019    // ---- other loops (workflow agents, forks): ids that match no card
1020    const loopsPanel = (w: number) => {
1021      if (lp.length === 0) return null
1022      const active = lp.filter(l => isLoopActive(l, now)).length
1023      const dots = lp.slice(-Math.max(4, w - 38))
1024      return (
1025        <Box width={w}>
1026          <Text bold>other loops </Text>
1027          <Text dimColor>{`${lp.length} seen · ${active} active  `}</Text>
1028          {dots.map(l => (
1029            <Text color={isLoopActive(l, now) ? C.agent : l.isDone ? C.dim : C.faint}>{isLoopActive(l, now) ? '●' : l.isDone ? '✓' : '○'}</Text>
1030          ))}
1031        </Box>
1032      )
1033    }
1034
1035    // ---- receipt: the turn now, or the last one
1036    const receiptPanel = (w: number) => {
1037      const isReview = t.isReviewing
1038      return (
1039        <Box flexDirection="column" borderStyle="round" borderColor={C.main} borderDimColor={!m.isRunning && !isReview} paddingX={1} width={w}>
1040          {m.isRunning ? (
1041            <Box>
1042              <Text color={C.main} wrap="truncate">{`◐ back to ${modelName.toLowerCase()} · turn `}</Text>
1043              <Box flexShrink={0}>{clock('turn-clock', t.startedAt, null, C.main)}</Box>
1044              {w >= 60 ? <Text dimColor wrap="truncate">{` · ${plural(t.edits, 'edit')} · ${plural(t.errors, 'error')}`}</Text> : null}
1045            </Box>
1046          ) : r ? (
1047            <Text wrap="truncate">
1048              <Text color={r.reason === 'answer' ? C.gate : C.warn}>{r.reason === 'answer' ? '✓ ' : '✗ '}</Text>
1049              <Text>{`last reply ${fmtDuration(r.durationMs)} · ${plural(r.agents, 'agent')} · ${plural(r.edits, 'edit')} · ${plural(r.errors, 'error')}`}</Text>
1050              {r.costDelta !== null ? <Text color={C.main}>{` · +${fmtUsd(r.costDelta)}`}</Text> : null}
1051            </Text>
1052          ) : (
1053            <Text color={C.faint}>no turn finished yet</Text>
1054          )}
1055          {isReview ? <Text color={C.arch}>{`${cfg.architectLabel.toLowerCase()} reviewing before done (inferred)`}</Text> : null}
1056        </Box>
1057      )
1058    }
1059
1060    // ---- log: what you said, what an agent did, errors and architect consults. The engine's
1061    // deliveries and the main model's own tool calls are left out. Newest first.
1062    const relevant = lines.filter(l => l.who === 'you' || l.agentId !== null || l.kind === 'error' || l.kind === 'consult')
1063    const scoped = viewed ? relevant.filter(l => l.agentId === viewed) : relevant
1064    const newestFirst = [...scoped].reverse()
1065    const shownLines = newestFirst.slice(0, v.logOpen ? 30 : 5)
1066    const toggleLog = () => update($, view, x => ({ ...normalize(DEFAULT_VIEW, x), logOpen: !normalize(DEFAULT_VIEW, x).logOpen }))
1067    const colorOf = (l: LogLine) =>
1068      l.kind === 'error' ? C.warn : l.kind === 'consult' ? C.arch : l.who === 'main' ? C.main : l.who === 'gate' ? C.gate : l.who === 'you' ? C.text : C.agent
1069    const logPanel = (w: number) => !isOpen('log') ? foldedBox('log', 'activity', C.faint, `${newestFirst.length} events`, w) : (
1070      <Box flexDirection="column" borderStyle="round" borderColor={C.faint} paddingX={1} width={w}>
1071        <Box justifyContent="space-between">
1072          <Box>
1073            {arrow('log')}
1074            <Text dimColor>{viewed ? ' activity · this agent' : ' activity · newest first'}</Text>
1075          </Box>
1076          {newestFirst.length > 5 ? (
1077            <Button key="log-toggle" plain hotkey="l" label={v.logOpen ? '▾ show fewer' : `▸ show all ${newestFirst.length}`} onPress={toggleLog} />
1078          ) : null}
1079        </Box>
1080        {shownLines.length === 0 ? <Text color={C.faint}>nothing yet</Text> : null}
1081        {shownLines.map(l => (
1082          <Box>
1083            <Box width={9} flexShrink={0}>
1084              <Text color={C.faint}>{fmtClock(l.at)}</Text>
1085            </Box>
1086            <Box width={19} flexShrink={0}>
1087              <Text color={colorOf(l)} bold wrap="truncate">
1088                {l.who}
1089              </Text>
1090            </Box>
1091            {l.rowId ? (
1092              <Button
1093                key={`log-jump-${l.rowId}-${l.at}`}
1094                plain
1095                label={`${shorten(l.text, Math.max(12, w - 36))} ↗`}
1096                onPress={async () => {
1097                  const moved = await $.ui.scroll({ to: { requestId: l.rowId ?? '' }, block: 'center' })
1098                  if (moved.deny) $.ui.toast(`Can't jump to that message here: ${moved.deny}`)
1099                }}
1100              />
1101            ) : (
1102              <Text color={l.kind === 'error' ? C.warn : C.text} wrap="truncate">
1103                {l.text}
1104              </Text>
1105            )}
1106          </Box>
1107        ))}
1108      </Box>
1109    )
1110
1111    const foldedSummary: Record<Panel, [string, string, string]> = {
1112      main: [`${modelName} · main`, C.main, m.isRunning ? 'working' : 'idle'],
1113      architect: [`${cfg.architectLabel}`, C.arch, `${a.consults.length} consults`],
1114      gate: ['permissions', C.gate, `${gateSummary(g).total} checks`],
1115      agents: ['agents', C.agent, `${running.length} running · ${cards.length} total`],
1116      loops: ['other loops', C.faint, `${lp.length}`],
1117      receipt: ['last reply', C.main, ''],
1118      log: ['activity', C.faint, ''],
1119    }
1120    const agentsBox = (w: number) => (
1121      <Box flexDirection="column" borderStyle="round" borderColor={C.agent} paddingX={1} width={w}>
1122        {agentsPanel(w - 4)}
1123      </Box>
1124    )
1125    const draw = (p: Panel, w: number) =>
1126      !isOpen(p) && (p === 'main' || p === 'architect' || p === 'gate' || p === 'agents')
1127        ? foldedBox(p, foldedSummary[p][0], foldedSummary[p][1], foldedSummary[p][2], w)
1128        : p === 'main'
1129        ? mainPanel(w)
1130        : p === 'architect'
1131          ? architectPanel(w)
1132          : p === 'gate'
1133            ? gatePanel(w)
1134            : p === 'agents'
1135              ? agentsBox(w)
1136              : p === 'loops'
1137                ? loopsPanel(w)
1138                : p === 'receipt'
1139                  ? receiptPanel(w)
1140                  : logPanel(w)
1141
1142    const gitBox = (w: number) => gitPanel({ els, C, w, info: gitInfo, isOpen: isOpen('git'), onToggle: () => toggle('git') })
1143    const todoBox = (w: number) => todoPanel({ els, C, w, items: todoItems, isOpen: isOpen('todo'), onToggle: () => toggle('todo') })
1144    const routingBox = (w: number) => scoreboardPanel({ els, C, w, routing: route, totals, isOpen: isOpen('routing'), onToggle: () => toggle('routing') })
1145
1146    // Panels with the flow between them; the agents panel draws its own rails.
1147    const column = (ps: Panel[], w: number) => (
1148      <Box flexDirection="column" width={w}>
1149        {ps.map((p, i) => {
1150          const prev = ps[i - 1]
1151          const link =
1152            i === 0 || p === 'agents' || prev === 'agents' || p === 'log' || p === 'loops' || prev === 'loops'
1153              ? null
1154              : rail(`link-${p}`, p === 'architect' ? advising : m.isRunning, p === 'architect' ? C.arch : C.main, w)
1155          return (
1156            <Box flexDirection="column">
1157              {link}
1158              {draw(p, w)}
1159              {p === 'main' ? gitBox(w) : null}
1160              {p === 'agents' && isOpen('agents') ? expandedPanel(w) : null}
1161              {p === 'agents' ? todoBox(w) : null}
1162              {p === 'agents' ? routingBox(w) : null}
1163            </Box>
1164          )
1165        })}
1166      </Box>
1167    )
1168
1169    // Inline above the prompt (the terminal's main screen), the pane is a summary of at most 8 rows.
1170    const isMini = layout === 'mini' || (layout === 'auto' && e.props.placement === 'inline')
1171    const vi = await read($, vault)
1172    if (isMini) {
1173      const live = [...cards.filter(c => c.status === 'running'), ...cards.filter(c => c.status !== 'running').reverse()].slice(0, 3)
1174      const counts = ` ${s.rule} allowed · ${s.cleared} ${decider}${s.ask > 0 ? ` · ${s.ask} pending` : ''} · ${s.deny} denied`
1175      const strip = g.recent.slice(-Math.min(30, Math.max(4, W - cfg.gateLabel.length - 1 - counts.length)))
1176      const mg = u.pct !== null ? gauge(u.pct, 6) : null
1177      return (
1178        <Box flexDirection="column" width={W}>
1179          <Text wrap="truncate">
1180            <Text color={C.main} bold>
1181              {modelName}
1182            </Text>
1183            <Text color={m.isRunning ? C.main : C.dim}>{m.isRunning ? ' ● working' : ' ○ idle'}</Text>
1184            {mg ? <Text dimColor> · ctx </Text> : null}
1185            {mg ? <Text color={(u.pct ?? 0) >= 80 ? C.warn : C.main}>{mg.on}</Text> : null}
1186            {mg ? <Text color={C.faint}>{mg.off}</Text> : null}
1187            {mg ? <Text>{` ${Math.round(u.pct ?? 0)}%`}</Text> : null}
1188            {u.compactions > 0 ? <Text color={C.amber}>{` ⟲${u.compactions}`}</Text> : null}
1189            {u.costUsd !== null ? <Text dimColor>{` · ${fmtUsd(u.costUsd)}`}</Text> : null}
1190            {showArchitect ? <Text color={C.arch}>{` · ${cfg.architectLabel.toLowerCase()} ${advising ? 'advising' : a.consults.length}`}</Text> : null}
1191          </Text>
1192          {strip.length > 0 ? (
1193            <Box>
1194              <Text dimColor>{`${cfg.gateLabel.toLowerCase()} `}</Text>
1195              {runsOf(strip).map(r => (
1196                <Text color={r.color} dimColor={r.dim}>{r.text}</Text>
1197              ))}
1198              <Text color={s.deny > 0 ? C.warn : s.ask > 0 ? C.amber : C.dim} wrap="truncate">
1199                {counts}
1200              </Text>
hooks/core.ts 496 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, logOpen: false }
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      level: '',
80      runs: 1,
81    },
82    stored,
83  )
84
85export const normalizeLog = (stored: unknown): LogLine[] =>
86  listOf<Record<string, unknown>>(stored).map(l => ({
87    at: typeof l.at === 'number' ? l.at : 0,
88    who: String(l.who ?? ''),
89    text: String(l.text ?? ''),
90    agentId: typeof l.agentId === 'string' ? l.agentId : null,
91    kind: l.kind === 'error' || l.kind === 'consult' || l.kind === 'done' ? l.kind : 'info',
92    rowId: typeof l.rowId === 'string' ? l.rowId : null,
93  }))
94
95// ---------------------------------------------------------------- config
96
97export type Panel = 'main' | 'architect' | 'gate' | 'agents' | 'loops' | 'receipt' | 'log'
98const PANELS: readonly Panel[] = ['main', 'architect', 'gate', 'agents', 'loops', 'receipt', 'log']
99
100export type Config = {
101  architect: RegExp
102  architectLabel: string
103  gateLabel: string
104  panels: Panel[]
105  motion: boolean
106  moments: boolean
107  matchDescriptions: boolean
108  maxCards: number
109  layout: Layout
110  palette: Palette
111  openOnStart: boolean
112  statusLine: boolean
113}
114
115const safeRegExp = (source: string, fallback: string) => {
116  try {
117    return new RegExp(source || fallback, 'i')
118  } catch {
119    return new RegExp(fallback, 'i')
120  }
121}
122
123/** The plugin's `/config` values, read leniently: anything malformed falls back to the default. */
124export const parseConfig = (o: Readonly<Record<string, unknown>>): Config => {
125  const str = (k: string, d: string) => (typeof o[k] === 'string' && o[k] !== '' ? (o[k] as string) : d)
126  const bool = (k: string, d: boolean) => (typeof o[k] === 'boolean' ? (o[k] as boolean) : d)
127  const panels = str('panels', PANELS.join(','))
128    .split(',')
129    .map(s => s.trim())
130    .filter((p): p is Panel => (PANELS as readonly string[]).includes(p))
131  const layout = str('layout', 'auto')
132  const max = typeof o.maxCards === 'number' ? Math.round(o.maxCards) : 3
133  return {
134    architect: safeRegExp(str('architectPattern', ''), 'advisor|architect'),
135    architectLabel: str('architectLabel', 'ARCHITECT'),
136    gateLabel: str('gateLabel', 'GATE'),
137    panels: panels.length > 0 ? [...new Set(panels)] : [...PANELS],
138    motion: str('motion', 'while-active') !== 'off',
139    moments: bool('moments', true),
140    matchDescriptions: bool('matchDescriptions', false),
141    maxCards: Math.min(6, Math.max(1, max)),
142    layout: layout === 'compact' || layout === 'wide' || layout === 'mini' ? layout : 'auto',
143    palette: str('palette', 'theme') === 'pastel' ? 'pastel' : 'theme',
144    openOnStart: bool('openOnStart', true),
145    statusLine: bool('statusLine', true),
146  }
147}
148
149// ---------------------------------------------------------------- palette
150
151export type Palette = 'theme' | 'pastel'
152export type Colors = Record<'main' | 'agent' | 'gate' | 'cleared' | 'arch' | 'amber' | 'warn' | 'dim' | 'faint' | 'text', string>
153
154/**
155 * `theme` names the person's own theme colours (they follow light, dark and colour-blind themes);
156 * `pastel` is fixed hex tuned for dark terminals.
157 */
158export const PALETTES: Record<Palette, Colors> = {
159  theme: {
160    main: 'claude',
161    agent: 'suggestion',
162    gate: 'success',
163    cleared: 'permission',
164    arch: 'merged',
165    amber: 'warning',
166    warn: 'error',
167    dim: 'inactive',
168    faint: 'subtle',
169    text: 'text',
170  },
171  pastel: {
172    main: '#7dd3fc',
173    agent: '#93c5fd',
174    gate: '#86efac',
175    cleared: '#5eead4',
176    arch: '#c4b5fd',
177    amber: '#fcd34d',
178    warn: '#fca5a5',
179    dim: '#6b7280',
180    faint: '#3f4654',
181    text: '#e5e7eb',
182  },
183}
184
185/** SVG cannot name theme keys: mid-tone colours that read on light and dark backgrounds. */
186export const SVG_COLORS = { running: '#3b82f6', done: '#16a34a', failed: '#dc2626', other: '#8b5cf6', label: '#6b7280' }
187
188// ---------------------------------------------------------------- formatting
189
190/** `claude-opus-5-5` → `Opus 5.5`; anything else is shown as given. */
191/**
192 * A model id as people say it, from any provider's spelling: `claude-opus-5-5[1m]` → `Opus 5.5 1M`,
193 * `us.anthropic.claude-sonnet-4-5-20250929-v1:0` → `Sonnet 4.5`, `claude-3-5-haiku-20241022` →
194 * `Haiku 3.5`. Anything else is shown as given, cut to 22 characters.
195 */
196export const prettyModel = (id: string) => {
197  if (!id) return '—'
198  const cap = (f: string) => f.charAt(0).toUpperCase() + f.slice(1)
199  const big = /\[1m\]|-1m\b/i.test(id) ? ' 1M' : ''
200  const now = /claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(?![\d])/i.exec(id)
201  if (now?.[1] && !/^\d/.test(now[1])) return `${cap(now[1].toLowerCase())} ${now[2]}${now[3] ? `.${now[3]}` : ''}${big}`
202  const old = /claude-(\d+)(?:-(\d))?-([a-z]+)/i.exec(id)
203  if (old?.[3]) return `${cap(old[3].toLowerCase())} ${old[1]}${old[2] ? `.${old[2]}` : ''}${big}`
204  return shorten(id, 22)
205}
206
207export const shorten = (s: string, n: number) => {
208  const one = s.replace(/\s+/g, ' ').trim()
209  return n <= 0 ? '' : one.length > n ? `${one.slice(0, Math.max(0, n - 1)).trimEnd()}…` : one
210}
211
212export const kTokens = (n: number) =>
213  n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
214
215export const fmtDuration = (ms: number) => {
216  const s = Math.max(0, Math.round(ms / 1000))
217  if (s < 60) return `${s}s`
218  const m = Math.floor(s / 60)
219  return m < 60 ? `${m}m${String(s % 60).padStart(2, '0')}s` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
220}
221
222/** A running clock as the live cards draw it: m:ss, or XhYY past an hour. */
223export const fmtTimer = (ms: number) => {
224  const s = Math.max(0, Math.floor(ms / 1000))
225  const m = Math.floor(s / 60)
226  return m < 60 ? `${m}:${String(s % 60).padStart(2, '0')}` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
227}
228
229export const fmtClock = (ms: number) => (ms > 0 ? new Date(ms).toTimeString().slice(0, 8) : '--:--:--')
230
231/** `1 error`, `2 errors`. */
232export const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
233
234export const fmtUsd = (n: number) => (n >= 100 ? `$${Math.round(n)}` : `$${n.toFixed(2)}`)
235
236/** A gauge of `width` cells: ▰ filled, ▱ empty. */
237export const gauge = (pct: number, width: number) => {
238  const full = Math.max(0, Math.min(width, Math.round((pct / 100) * width)))
239  return { on: '▰'.repeat(full), off: '▱'.repeat(width - full) }
240}
241
242/** A rate-limit window's short name: `five_hour` → `5h`, `seven_day_opus` → `7d opus`. */
243export const limitLabel = (kind: string) =>
244  kind
245    .replace(/five[_ -]?hours?/i, '5h')
246    .replace(/seven[_ -]?days?/i, '7d')
247    .replace(/[_-]+/g, ' ')
248    .trim()
249
250// ---------------------------------------------------------------- redaction
251
252const SECRETS: [RegExp, string][] = [
253  [/(authorization\s*[:=]\s*)(bearer\s+|basic\s+)?\S+/gi, '$1$2•••'],
254  [/\b(bearer)\s+[A-Za-z0-9._~+/-]{8,}=*/gi, '$1 •••'],
255  [/\b(sk|pk|rk|ghp|gho|ghs|github_pat|xox[abprs])[-_][A-Za-z0-9_-]{8,}/g, '•••'],
256  [/((?:api[_-]?key|access[_-]?token|token|secret|password|passwd|pwd)\s*[=:]\s*)("[^"]*"|'[^']*'|\S+)/gi, '$1•••'],
257  [/(--(?:token|password|api-key|secret)[= ])\S+/gi, '$1•••'],
258  [/(\b[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)=)\S+/g, '$1•••'],
259  [/(\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:)[^\s@]+@/gi, '$1•••@'],
260]
261
262/** What the gate drill-down may store: credentials masked before anything is written to state. */
263export const redact = (s: string) => SECRETS.reduce((t, [re, to]) => t.replace(re, to), s)
264
265// ---------------------------------------------------------------- tools and gate
266
267const FILE_TOOLS = new Set(['Read', 'Edit', 'Write', 'NotebookEdit', 'Glob', 'Grep'])
268const SHELL_TOOLS = new Set(['Bash', 'PowerShell'])
269export const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
270
271export const bucketOf = (tool: string): Bucket =>
272  FILE_TOOLS.has(tool) ? 'file' : SHELL_TOOLS.has(tool) ? 'shell' : 'other'
273
274/** One line saying what a call was about: its command, path or pattern; redacted. */
275export const describeInput = (tool: string, input: unknown) => {
276  const i = isObject(input) ? input : {}
277  const path = typeof i.file_path === 'string' ? i.file_path.split(/[\\/]/).slice(-2).join('/') : ''
278  const what =
279    typeof i.command === 'string'
280      ? i.command
281      : path || (typeof i.pattern === 'string' ? i.pattern : typeof i.url === 'string' ? i.url : typeof i.description === 'string' ? i.description : '')
282  return redact(what ? `${tool} → ${what}` : tool)
283}
284
285/** Keeps the last `max` checks, but never drops a pending ask: its settle must still find it. */
286export const trimRecent = (list: Check[], max: number): Check[] => {
287  let extra = list.length - max
288  if (extra <= 0) return list
289  const out: Check[] = []
290  for (const c of list) {
291    if (extra > 0 && c.verdict !== 'ask') {
292      extra -= 1
293      continue
294    }
295    out.push(c)
296  }
297  return out.slice(-max * 2)
298}
299
300export const recordCheck = (g: Gate, c: Check): Gate => {
301  const t = g.totals[c.bucket]
302  return {
303    recent: trimRecent([...g.recent, c], 80),
304    totals: { ...g.totals, [c.bucket]: { ...t, [c.verdict]: t[c.verdict] + 1 } },
305  }
306}
307
308/** An `ask` settled by the call that followed it: it ran (cleared) or was refused (deny). */
309export const settleCheck = (g: Gate, id: string, didRun: boolean): Gate => {
310  const c = g.recent.find(r => r.id === id && r.verdict === 'ask')
311  if (!c) return g
312  const verdict = didRun ? 'cleared' : 'deny'
313  const t = g.totals[c.bucket]
314  return {
315    recent: g.recent.map(r => (r === c ? { ...r, verdict } : r)),
316    totals: { ...g.totals, [c.bucket]: { ...t, ask: Math.max(0, t.ask - 1), [verdict]: t[verdict] + 1 } },
317  }
318}
319
320export const gateSummary = (g: Gate) => {
321  const all = (['file', 'shell', 'other'] as const).reduce(
322    (s, k) => ({
323      rule: s.rule + g.totals[k].rule,
324      ask: s.ask + g.totals[k].ask,
325      cleared: s.cleared + g.totals[k].cleared,
326      deny: s.deny + g.totals[k].deny,
327    }),
328    { ...ZERO },
329  )
330  return { ...all, total: all.rule + all.ask + all.cleared + all.deny }
331}
332
333// ---------------------------------------------------------------- turn and architect
334
335/**
336 * The turn after one tool call. Errors count in the main loop only (a subagent's failure is its
337 * own); edits count from every loop, so delegated work still reaches "before done".
338 */
339export const afterCall = (t: Turn, c: { inSubagent: boolean; hasFailed: boolean; isEdit: boolean }): Turn => ({
340  ...t,
341  errorStreak: c.inSubagent ? t.errorStreak : c.hasFailed ? t.errorStreak + 1 : 0,
342  errors: t.errors + (!c.inSubagent && c.hasFailed ? 1 : 0),
343  edits: t.edits + (c.isEdit ? 1 : 0),
344})
345
346/** Which of the architect's three moments a consult falls at: an inference over this turn so far. */
347export const momentOf = (t: Pick<Turn, 'edits' | 'errorStreak'>): Moment =>
348  t.errorStreak >= 2 ? 'error repeats' : t.edits === 0 ? 'before a plan' : 'before done'
349
350export const startConsult = (a: Architect, c: { id: string; at: number; moment: Moment; via: string }): Architect =>
351  a.consults.some(x => x.id === c.id) ? a : { ...a, consults: [...a.consults, { ...c, endAt: null }].slice(-40) }
352
353/** Ends the open consult (the latest without an end), or the one named. */
354export const endConsult = (a: Architect, at: number, advice: string | null, id?: string): Architect => {
355  const open = [...a.consults].reverse().find(c => c.endAt === null && (id === undefined || c.id === id))
356  return {
357    ...a,
358    consults: a.consults.map(c => (c === open ? { ...c, endAt: at } : c)),
359    lastAdvice: advice ?? a.lastAdvice,
360  }
361}
362
363export const isAdvising = (a: Architect) => a.consults.some(c => c.endAt === null)
364
365/** A one-row timeline of consults across `width` cells: ◆ a consult, ━ while it ran. */
366export const consultTimeline = (a: Architect, now: number, width: number) => {
367  if (a.consults.length === 0 || width < 4) return '─'.repeat(Math.max(0, width))
368  const first = a.consults[0]?.at ?? now
369  const span = Math.max(1, now - first)
370  const cells = Array.from({ length: width }, () => '─')
371  for (const c of a.consults) {
372    const from = Math.min(width - 1, Math.floor(((c.at - first) / span) * (width - 1)))
373    const to = Math.min(width - 1, Math.floor((((c.endAt ?? now) - first) / span) * (width - 1)))
374    for (let i = from + 1; i <= to; i += 1) cells[i] = '━'
375    cells[from] = '◆'
376  }
377  return cells.join('')
378}
379
380export const receiptOf = (t: Turn, o: { durationMs: number; agentsSince: number; costNow: number | null; reason: string }): Receipt => ({
381  durationMs: o.durationMs,
382  agents: o.agentsSince,
383  edits: t.edits,
384  errors: t.errors,
385  costDelta: o.costNow !== null && t.costAtStart !== null && o.costNow - t.costAtStart >= 0.005 ? o.costNow - t.costAtStart : null,
386  reason: o.reason,
387})
388
389// ---------------------------------------------------------------- agents and loops
390
391type StepUsage = { input_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number; output_tokens?: number } | null
392
393/** A card after one of its model requests: its context is the latest step's whole input; output adds up. */
394export const applyStep = (c: AgentCard, s: { model: string; usage: StepUsage; stopReason: string | null }): AgentCard => {
395  const u = s.usage ?? {}
396  const ctx = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
397  return {
398    ...c,
399    model: c.model || s.model,
400    steps: c.steps + 1,
401    ctx: ctx > 0 ? ctx : c.ctx,
402    out: c.out + (u.output_tokens ?? 0),
403    lastStop: s.stopReason,
404  }
405}
406
407export const noteTool = (c: AgentCard, n: ToolNote): AgentCard => ({ ...c, tools: [...c.tools, n].slice(-3) })
408
409export const stepLoop = (loops: Loop[], id: string, at: number): Loop[] => {
410  const found = loops.find(l => l.id === id)
411  const next = found
412    ? loops.map(l => (l === found ? { ...l, steps: l.steps + 1, lastAt: at } : l))
413    : [...loops, { id, steps: 1, firstAt: at, lastAt: at, isDone: false }]
414  return next.slice(-60)
415}
416
417export const LOOP_ACTIVE_MS = 15_000
418export const isLoopActive = (l: Loop, now: number) => !l.isDone && now - l.lastAt < LOOP_ACTIVE_MS
419
420/** Swimlane geometry: each agent's bar on one shared axis from the first spawn to now. */
421export const lanes = (cards: AgentCard[], now: number, width: number) => {
422  const start = Math.min(...cards.map(c => c.spawnedAt).filter(n => n > 0), now)
423  const span = Math.max(1, now - start)
424  return cards.map(c => {
425    const from = Math.floor(((Math.max(c.spawnedAt, start) - start) / span) * width)
426    const to = Math.max(from + 1, Math.ceil((((c.endedAt ?? now) - start) / span) * width))
427    return { id: c.id, before: Math.min(from, width), bar: Math.min(to, width) - Math.min(from, width), after: width - Math.min(to, width) }
428  })
429}
430
431// ---------------------------------------------------------------- layout
432
433/** How many log lines fit: what the other panels leave, never fewer than 4 nor more than 8. */
434export const logRows = (bodyRows: number, used: number) => Math.max(4, Math.min(8, bodyRows - used - 3))
435
436/** Legend items that fit on one row of `width` cells, in order; the rest are dropped. */
437export const fitLegend = <T extends { label: string }>(items: T[], width: number) => {
438  const out: T[] = []
439  let used = 0
440  for (const it of items) {
441    const w = it.label.length + 4
442    if (used + w > width) break
443    out.push(it)
444    used += w
445  }
446  return out
447}
448
449/** A card's title row: the task in the agent's own words, the type only when there is none. */
450export const cardTitle = (c: AgentCard) => c.description || c.type
451
452/** A title split over two rows at a word boundary: `first` cells on row one, `rest` on row two. */
453export const titleLines = (title: string, first: number, rest: number): [string, string] => {
454  const t = title.replace(/\s+/g, ' ').trim()
455  if (t.length <= first) return [t, '']
456  const cut = t.lastIndexOf(' ', first)
457  const at = cut > 0 ? cut : first
458  return [t.slice(0, at).trim(), shorten(t.slice(at), rest)]
459}
460
461/**
462 * What a turn's opening text was, for the log: the person's words, or for a turn the engine
463 * opened with a tagged message (a subagent's hand-back, a task notification), that message's kind.
464 */
465export const promptLine = (text: string): { who: string; text: string } => {
466  const tag = /^\s*<([a-z][\w-]*)/i.exec(text)?.[1]
467  if (!tag) return { who: 'you', text: shorten(text, 70) }
468  const from = /\bfrom="([^"]+)"/.exec(text)?.[1]
469  return { who: 'engine', text: shorten(`${tag.replace(/[-_]/g, ' ')}${from ? ` from ${from.slice(0, 8)}` : ''}`, 70) }
470}
471
472/**
473 * A subagent's hand-back message: who sent it and the first line of what it said. The report
474 * follows a framing header in the message; without one, the first line after the opening tag.
475 */
476export const handbackOf = (text: string): { from: string; body: string } | null => {
477  const from = /^\s*<agent-message\s+from="([^"]+)"/.exec(text)?.[1]
478  if (!from) return null
479  const afterHeader = text.split(/The report follows:\s*\n/)[1]
480  const rest = afterHeader ?? text.replace(/^\s*<agent-message[^>]*>/, '')
481  const body =
482    rest
483      .split('\n')
484      .map(l => l.trim())
485      .find(l => l && !l.startsWith('[') && !l.startsWith('<') && !l.startsWith('</')) ?? ''
486  return body ? { from, body } : null
487}
488
489/** The advice line a pane shows for a report: its first real line, markdown markers stripped, cut to 160. */
490export const adviceLine = (report: string) => {
491  const first = report.split('\n').map(l => l.trim()).find(l => l && !l.startsWith('[') && !l.startsWith('<')) ?? ''
492  return shorten(first.replace(/\*\*|__/g, '').replace(/^[#>*\s-]+/, ''), 160)
493}
494
495export const elapsedOf = (c: AgentCard, now: number) => (c.endedAt ?? now) - c.spawnedAt
496
hooks/git.tsx 81 lines
1// Git box: branch, ahead/behind and the dirty counts of the session's folder. Read-only git only.
2import { atom, update } from 'claude-code'
3import type { EngineInterface, Register, RenderElement } from 'claude-code'
4
5import type { GitInfo } from '../types'
6const git = atom({ plugin: 'agent-deck', key: 'git' } as const, null as GitInfo | null)
7import { panel } from './ui'
8import type { Colors, Els } from './ui'
9
10const NONE: Omit<GitInfo, 'scannedMs' | 'error'> = { branch: null, ahead: 0, behind: 0, staged: 0, modified: 0, untracked: 0 }
11
12/** Reads `git status --porcelain=v2 --branch` into a GitInfo; never throws. */
13export async function refreshGit($: EngineInterface): Promise<void> {
14  const scannedMs = Date.now()
15  let info: GitInfo
16  try {
17    // --no-optional-locks: status must not write (it would refresh the index otherwise).
18    const r = await $.process.run(['git', '--no-optional-locks', 'status', '--porcelain=v2', '--branch'], { timeoutMs: 10000 })
19    if (r.exitCode !== 0) {
20      const msg = r.stderr.trim()
21      info = { ...NONE, scannedMs, error: /not a git repository/i.test(msg) ? 'not a git repo' : msg.split('\n')[0] || 'git failed' }
22    } else {
23      info = { ...NONE, scannedMs, error: null }
24      for (const line of r.stdout.split('\n')) {
25        if (line.startsWith('# branch.head ')) info.branch = line.slice(14).trim()
26        else if (line.startsWith('# branch.ab ')) {
27          const m = /\+(\d+) -(\d+)/.exec(line)
28          if (m) {
29            info.ahead = Number(m[1])
30            info.behind = Number(m[2])
31          }
32        } else if (line.startsWith('? ')) info.untracked++
33        else if (line.startsWith('1 ') || line.startsWith('2 ')) {
34          // XY after the marker: X is the index (staged), Y the worktree (modified); '.' = unchanged.
35          if (line[2] !== '.') info.staged++
36          if (line[3] !== '.') info.modified++
37        } else if (line.startsWith('u ')) info.modified++
38      }
39    }
40  } catch {
41    info = { ...NONE, scannedMs, error: 'git unavailable' }
42  }
43  await update($, git, () => info).catch(() => undefined)
44}
45
46export function gitPanel(p: { els: Els; C: Colors; w: number; info: GitInfo | null; isOpen: boolean; onToggle: () => void }): RenderElement {
47  const { Text } = p.els
48  const { C, info } = p
49  const isRepo = info !== null && info.error === null
50  const dirty = isRepo ? info.staged + info.modified + info.untracked : 0
51  const color = !info ? C.faint : !isRepo ? C.dim : dirty > 0 ? C.warn : C.gate
52  const summary = !info ? 'reading…' : !isRepo ? '' : dirty > 0 ? `${dirty} changed` : 'clean'
53
54  let body: RenderElement
55  if (!info) body = <Text color={C.faint}>reading…</Text>
56  else if (!isRepo) body = <Text color={C.dim} wrap="truncate">{info.error === 'not a git repo' ? 'not a git repo' : `git: ${info.error}`}</Text>
57  else {
58    const arrows = `${info.ahead > 0 ? ` ↑${info.ahead}` : ''}${info.behind > 0 ? ` ↓${info.behind}` : ''}`
59    body = (
60      <Text wrap="truncate">
61        <Text color={C.main} bold>{`⎇ ${info.branch ?? '?'}`}</Text>
62        <Text color={info.behind > 0 ? C.warn : C.dim}>{arrows}</Text>
63        <Text color={dirty > 0 ? C.warn : C.gate}>{dirty > 0 ? `   +${info.staged} ~${info.modified} ?${info.untracked}` : '   ✓ clean'}</Text>
64      </Text>
65    )
66  }
67  return panel(p.els, { id: 'git', title: 'git', color, summary, isOpen: p.isOpen, onToggle: p.onToggle, width: p.w }, body)
68}
69
70export function registerGit(on: Parameters<Register>[0]): void {
71  on('session.start', { cwd: /^/ }, async ($, e, next) => {
72    void refreshGit($)
73    return next(e)
74  })
75  on('turn.complete', { turnId: /^/ }, async ($, e, next) => {
76    const done = await next(e)
77    if (!e.agentId) await refreshGit($)
78    return done
79  })
80}
81
hooks/scoreboard.tsx 111 lines
1// Routing scoreboard: counts each finished subagent per model and level, keeps all-time totals in $.store.
2import { atom, read, update } from 'claude-code'
3import type { EngineInterface, Register, RenderElement } from 'claude-code'
4
5import type { AgentCard, Routing } from '../types'
6import { kTokens, prettyModel } from './core'
7const DEFAULT_ROUTING: Routing = { byModel: {}, byLevel: {} }
8const routingAtom = atom({ plugin: 'agent-deck', key: 'routing' } as const, DEFAULT_ROUTING)
9import { panel } from './ui'
10import type { Colors, Els } from './ui'
11
12export type Totals = { since: string; agents: number; routed: number; byModel: Record<string, number> }
13
14const agents = atom({ plugin: 'agent-deck', key: 'agents' } as const, [])
15const main = atom({ plugin: 'agent-deck', key: 'main' } as const, { model: '', effort: '', mode: '', steps: 0, isRunning: false })
16
17const TOTALS_KEY = 'agent-deck.totals'
18
19/** Agent ids already counted; a subagent can complete more than once. */
20const counted = new Set<string>()
21
22const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
23
24const asTotals = (raw: unknown): Totals | null => {
25  if (!raw || typeof raw !== 'object') return null
26  const r = raw as Partial<Totals>
27  const byModel: Record<string, number> = {}
28  for (const [k, v] of Object.entries(r.byModel ?? {})) byModel[k] = num(v)
29  return { since: typeof r.since === 'string' ? r.since : '', agents: num(r.agents), routed: num(r.routed), byModel }
30}
31
32export const readTotals = async ($: EngineInterface): Promise<Totals | null> => asTotals(await $.store.get(TOTALS_KEY))
33
34export function registerScoreboard(on: Parameters<Register>[0]): void {
35  on('turn.complete', { isAborted: [true, false] }, async ($, e, next) => {
36    const done = await next(e)
37    const id = e.agentId
38    if (!id || counted.has(id)) return done
39    const list = await read($, agents)
40    const card = (Array.isArray(list) ? (list as AgentCard[]) : []).find(c => c.id === id)
41    if (!card) return done
42    counted.add(id)
43    const m = await read($, main)
44    const model = card.model || 'unknown'
45    const level = card.level || ''
46    const routed = level !== '' && !!card.model && card.model !== (m?.model ?? '')
47    await update($, routingAtom, r => {
48      const cur: Routing = r ?? DEFAULT_ROUTING
49      const s = cur.byModel[model] ?? { agents: 0, routed: 0, ctx: 0, out: 0 }
50      return {
51        byModel: {
52          ...cur.byModel,
53          [model]: { agents: s.agents + 1, routed: s.routed + (routed ? 1 : 0), ctx: s.ctx + num(card.ctx), out: s.out + num(card.out) },
54        },
55        byLevel: level ? { ...cur.byLevel, [level]: (cur.byLevel[level] ?? 0) + 1 } : cur.byLevel,
56      }
57    })
58    const t = (await readTotals($)) ?? { since: new Date(await $.clock.now()).toISOString().slice(0, 10), agents: 0, routed: 0, byModel: {} }
59    await $.store.set(TOTALS_KEY, {
60      since: t.since,
61      agents: t.agents + 1,
62      routed: t.routed + (routed ? 1 : 0),
63      byModel: { ...t.byModel, [model]: (t.byModel[model] ?? 0) + 1 },
64    })
65    return done
66  })
67}
68
69export function scoreboardPanel(p: {
70  els: Els
71  C: Colors
72  w: number
73  routing: Routing
74  totals: Totals | null
75  isOpen: boolean
76  onToggle: () => void
77}): RenderElement {
78  const { Box, Text } = p.els
79  const { C } = p
80  const rows = Object.entries(p.routing.byModel).sort((a, b) => b[1].agents - a[1].agents)
81  const total = rows.reduce((n, [, s]) => n + s.agents, 0)
82  const routed = rows.reduce((n, [, s]) => n + s.routed, 0)
83  const max = Math.max(1, ...rows.map(([, s]) => s.agents))
84  const barW = Math.max(4, Math.min(16, p.w - 40))
85  const summary = total === 0 ? '' : `${routed} of ${total} agents on cheaper models`
86
87  const body =
88    total === 0 ? (
89      <Text dimColor>{'No subagents yet. Simple tasks get sent to cheaper models; the tally shows up here.'}</Text>
90    ) : (
91      <Box flexDirection="column">
92        {rows.map(([model, s]) => {
93          const n = Math.max(1, Math.round((s.agents / max) * barW))
94          return (
95            <Box key={model}>
96              <Text color={C.text} wrap="truncate">{`${prettyModel(model).padEnd(12)} ${String(s.agents).padStart(3)} `}</Text>
97              <Text color={s.routed > 0 ? C.main : C.faint}>{'█'.repeat(n)}</Text>
98              <Text color={C.faint}>{'░'.repeat(barW - n)}</Text>
99              <Text dimColor wrap="truncate">{` ${kTokens(s.ctx)} in · ${kTokens(s.out)} out`}</Text>
100            </Box>
101          )
102        })}
103        {p.totals && p.totals.agents > 0 ? (
104          <Text dimColor wrap="truncate">{`all time${p.totals.since ? ` since ${p.totals.since}` : ''}: ${p.totals.routed} of ${p.totals.agents} agents routed`}</Text>
105        ) : null}
106      </Box>
107    )
108
109  return panel(p.els, { id: 'routing', title: 'model routing', color: C.main, summary, isOpen: p.isOpen, onToggle: p.onToggle, width: p.w }, body)
110}
111
hooks/toasts.ts 82 lines
1import { atom, read } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4// Quiet toasts: subagent finished, call denied, context / rate limit crossing a threshold.
5// At most one per MIN_GAP_MS; module-level state is lost on reload, which is fine.
6
7const MIN_GAP_MS = 3000
8const CONTEXT_AT = 85
9const LIMIT_AT = 90
10
11const agents = atom({ plugin: 'agent-deck', key: 'agents' } as const, [])
12
13let lastToastAt = 0
14let contextArmed = true
15const limitArmed = new Map<string, boolean>()
16
17const short = (s: string, n: number) => {
18  const t = s.replace(/\s+/g, ' ').trim()
19  return t.length > n ? `${t.slice(0, n - 1)}…` : t
20}
21
22const dur = (ms: number) => {
23  const s = Math.max(0, Math.round(ms / 1000))
24  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`
25}
26
27// Returns whether the toast was shown, so a threshold held back by the gap is retried next time.
28async function show($: EngineInterface, text: string): Promise<boolean> {
29  const now = await $.clock.now()
30  if (now - lastToastAt < MIN_GAP_MS) return false
31  lastToastAt = now
32  $.ui.toast(text)
33  return true
34}
35
36export function registerToasts(on: Parameters<Register>[0]): void {
37  on('turn.complete', { durationMs: /^/ }, async ($, e, next) => {
38    const done = await next(e)
39    if (!e.agentId) return done
40    const id = e.agentId
41    const cards = (await read($, agents)) as unknown
42    const card = (Array.isArray(cards) ? cards : []).find((c: { id?: string }) => c?.id === id) as
43      | { description?: string; type?: string; spawnedAt?: number }
44      | undefined
45    if (!card) return done
46    const now = await $.clock.now()
47    const ms = typeof e.durationMs === 'number' ? e.durationMs : card.spawnedAt ? now - card.spawnedAt : 0
48    const name = short(card.description || card.type || 'agent', 50)
49    const verb = e.reason === 'answer' ? 'done' : e.reason === 'aborted' ? 'stopped' : 'failed'
50    await show($, `${name}: ${verb} in ${dur(ms)}`)
51    return done
52  })
53
54  on('tool.check', { tool: /^/ }, async ($, e, next) => {
55    const verdict = await next(e)
56    if (verdict.decision === 'deny' && e.tool_use_id) {
57      await show($, `Denied: ${e.tool}${verdict.reason ? ` (${short(verdict.reason, 60)})` : ''}`)
58    }
59    return verdict
60  })
61
62  // Denials by the user or the auto-mode classifier; the gap drops a duplicate of a tool.check toast.
63  on('classic.PermissionDenied', async ($, e, next) => {
64    await show($, `Denied: ${e.tool_name}${e.reason ? ` (${short(e.reason, 60)})` : ''}`)
65    return next(e)
66  })
67
68  on('session.measure', { context: { window: /^/ } }, async ($, e, next) => {
69    const pct = e.context.percent
70    if (typeof pct === 'number') {
71      if (pct < CONTEXT_AT) contextArmed = true
72      else if (contextArmed && (await show($, `Context at ${Math.round(pct)}%`))) contextArmed = false
73    }
74    for (const r of e.rateLimits) {
75      const armed = limitArmed.get(r.kind) ?? true
76      if (r.percentUsed < LIMIT_AT) limitArmed.set(r.kind, true)
77      else if (armed && (await show($, `Rate limit ${r.kind} at ${Math.round(r.percentUsed)}%`))) limitArmed.set(r.kind, false)
78    }
79    return next(e)
80  })
81}
82
hooks/todo.tsx 317 lines
1// The shared todo list: tools agents call, a planner that fills it, a prompt block that points
2// agents at it, a /todo command and a box for the dashboard.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, Register, RenderElement } from 'claude-code'
5
6import type { Todo } from '../types'
7import { panel } from './ui'
8import type { Colors, Els } from './ui'
9
10const todos = atom({ plugin: 'agent-deck', key: 'todos' } as const, [] as Todo[])
11
12const PLANNER = 'agent-deck:planner'
13const TOOL = (name: string) => `mcp__agent-deck__${name}`
14const TOGGLE = 'todo-toggle-'
15const ADD = 'todo-add'
16const STORE_KEY = 'todos'
17const STATUSES = ['open', 'doing', 'done'] as const
18
19const listOf = (v: unknown): Todo[] => (Array.isArray(v) ? (v as Todo[]) : [])
20const clamp = (n: number) => Math.max(0, Math.min(100, Math.round(n)))
21const GLYPH: Record<Todo['status'], string> = { open: '☐', doing: '◐', done: '☑' }
22
23/** Waiters for a planner's answer, keyed by agent id; `finished` covers an answer that beat the waiter. */
24const waiting = new Map<string, (answer: string) => void>()
25const finished = new Map<string, string>()
26
27/** Change the list, keep a copy in $.store (best effort), return the new list. */
28async function change($: EngineInterface, fn: (list: Todo[]) => Todo[]): Promise<Todo[]> {
29  const next = listOf(await update($, todos, l => fn(listOf(l))))
30  $.store.set(STORE_KEY, next).catch(() => undefined)
31  return next
32}
33
34const line = (t: Todo) => `${GLYPH[t.status]} ${t.id} ${t.title}${t.status === 'doing' ? ` (${t.progress}%)` : ''}`
35const summary = (list: Todo[]) => (list.length === 0 ? 'The todo list is empty.' : list.map(line).join('\n'))
36
37async function addTodo($: EngineInterface, title: string, source: Todo['source']): Promise<Todo> {
38  const createdAt = await $.clock.now()
39  let made!: Todo
40  await change($, list => {
41    const n = list.reduce((m, t) => Math.max(m, Number(t.id.slice(1)) || 0), 0) + 1
42    made = { id: `t${n}`, title, status: 'open', progress: 0, agentId: null, note: '', source, createdAt, doneAt: null }
43    return [...list, made]
44  })
45  return made
46}
47
48/** Set a todo's status the way a person does: done fills the bar, open empties it. */
49async function setStatus($: EngineInterface, id: string, status: Todo['status']): Promise<Todo | null> {
50  const now = await $.clock.now()
51  const hit: Todo[] = []
52  await change($, list =>
53    list.map(t => {
54      if (t.id !== id) return t
55      const found = { ...t, status, progress: status === 'done' ? 100 : status === 'open' ? 0 : t.progress, doneAt: status === 'done' ? now : null, agentId: status === 'open' ? null : t.agentId }
56      hit[0] = found
57      return found
58    }),
59  )
60  return hit[0] ?? null
61}
62
63export function registerTodo(on: Parameters<Register>[0]): void {
64  on('session.start', { isInteractive: [true, false] }, async ($, e, next) => {
65    await $.command.register({
66      name: 'todo',
67      description: 'The shared todo list: add, done, open, rm, clear, list, or plan a goal',
68      argumentHint: 'add <title> | done <id> | open <id> | rm <id> | clear | list | plan <goal>',
69    })
70    await $.tool.register({
71      name: 'todo_add',
72      description: 'Add a line to the shared todo list. Returns its id.',
73      inputSchema: { type: 'object', properties: { title: { type: 'string', description: 'What needs doing, one concrete line' } }, required: ['title'] },
74      isDeferred: false,
75    })
76    await $.tool.register({
77      name: 'todo_update',
78      description: "Update a todo: claim it with status 'doing', report progress 0-100 as you work, set status 'done' when finished.",
79      inputSchema: {
80        type: 'object',
81        properties: {
82          id: { type: 'string', description: "The todo's id, like t3" },
83          status: { type: 'string', enum: [...STATUSES] },
84          progress: { type: 'number', minimum: 0, maximum: 100 },
85          note: { type: 'string', description: 'A short remark to keep on the todo' },
86        },
87        required: ['id'],
88      },
89      isDeferred: false,
90    })
91    await $.tool.register({
92      name: 'todo_list',
93      description: 'List the shared todo list with each status and progress.',
94      inputSchema: { type: 'object', properties: {} },
95      isDeferred: false,
96    })
97    await $.agent.register({
98      name: 'planner',
99      description: 'Splits a goal into 3 to 8 concrete todos on the shared todo list.',
100      prompt:
101        'You plan work. You get a goal. Look at the code only if you must (read-only tools), then create 3 to 8 concrete, ' +
102        `separate, checkable todos by calling ${TOOL('todo_add')} once per todo, in the order they should be done. ` +
103        `Call ${TOOL('todo_list')} first and do not repeat what is on it. Finish with one line saying what you planned. Do not do the work yourself.`,
104      tools: ['Read', 'Glob', 'Grep', TOOL('todo_add'), TOOL('todo_list')],
105      omitClaudeMd: true,
106    })
107    // A fresh session starts empty here; bring back the copy kept in $.store.
108    if (listOf(await read($, todos)).length === 0) {
109      const kept = listOf(await $.store.get(STORE_KEY).catch(() => undefined))
110      if (kept.length > 0) await update($, todos, () => kept)
111    }
112    return next(e)
113  })
114
115  on('tool.call', { tool: TOOL('todo_add') }, async ($, e) => {
116    const title = String((e as unknown as { title?: unknown }).title ?? '').trim()
117    if (!title) return { result: 'todo_add needs a title.' }
118    const caller = e.agentId ? (await $.agent.list()).find(a => a.id === e.agentId) : undefined
119    const t = await addTodo($, title, caller?.type === PLANNER ? 'planner' : 'agent')
120    return { result: `Added ${t.id}: ${t.title}` }
121  })
122
123  on('tool.call', { tool: TOOL('todo_update') }, async ($, e) => {
124    const a = e as unknown as { id?: unknown; status?: unknown; progress?: unknown; note?: unknown }
125    const id = String(a.id ?? '')
126    const now = await $.clock.now()
127    const hit: Todo[] = []
128    await change($, list =>
129      list.map(t => {
130        if (t.id !== id) return t
131        const status = STATUSES.find(s => s === a.status) ?? t.status
132        let progress = typeof a.progress === 'number' && Number.isFinite(a.progress) ? clamp(a.progress) : t.progress
133        if (status === 'done') progress = 100
134        const found: Todo = {
135          ...t,
136          status,
137          progress,
138          note: typeof a.note === 'string' ? a.note : t.note,
139          agentId: a.status === 'doing' ? (e.agentId ?? null) : t.agentId,
140          doneAt: status === 'done' ? (t.doneAt ?? now) : status === t.status ? t.doneAt : null,
141        }
142        hit[0] = found
143        return found
144      }),
145    )
146    const found = hit[0]
147    if (!found) return { result: `No todo with id ${id}. Use todo_list to see them.` }
148    return { result: `Updated ${line(found)}` }
149  })
150
151  on('tool.call', { tool: TOOL('todo_list') }, async $ => ({ result: summary(listOf(await read($, todos))) }))
152
153  on('command.run', { command: 'todo' }, async ($, e) => ({ text: await todoCommand($, e.args) }))
154
155  // The panel's glyph buttons and add field, found by their keys.
156  on('ui.press', async ($, e, next) => {
157    if (e.element.startsWith(TOGGLE)) {
158      const id = e.element.slice(TOGGLE.length)
159      const t = listOf(await read($, todos)).find(x => x.id === id)
160      if (t) await setStatus($, id, t.status === 'done' ? 'open' : 'done')
161    }
162    return next(e)
163  })
164
165  on('ui.input', async ($, e, next) => {
166    if (e.element.startsWith(ADD) && e.kind === 'submit' && e.value.trim()) await addTodo($, e.value.trim(), 'you')
167    return next(e)
168  })
169
170  // Tell every agent about the open items so it can claim one. The planner makes the list, so it skips it.
171  on('agent.spawn', { prompt: /^/ }, async ($, e, next) => {
172    if (e.subagentType === PLANNER || e.workflow) return next(e)
173    const open = listOf(await read($, todos)).filter(t => t.status === 'open')
174    if (open.length === 0) return next(e)
175    const block =
176      `\n\nShared todo list (open items):\n${open.map(t => `- ${t.id}: ${t.title}`).join('\n')}\n` +
177      `If your work matches one of these, claim it with ${TOOL('todo_update')} (status 'doing'), report progress 0-100 as you go, ` +
178      "and set status 'done' when you are finished. If nothing matches, ignore this list."
179    return next({ ...e, prompt: e.prompt + block })
180  })
181
182  // The planner's answer is its turn.complete.
183  on('turn.complete', { answer: /^/ }, async ($, e, next) => {
184    const done = await next(e)
185    if (e.agentId) {
186      const wake = waiting.get(e.agentId)
187      if (wake) wake(e.answer)
188      else {
189        finished.set(e.agentId, e.answer)
190        if (finished.size > 20) finished.delete(finished.keys().next().value as string)
191      }
192    }
193    return done
194  })
195}
196
197/** Start the planner on a goal and resolve with its one-line reply. */
198async function planTodos($: EngineInterface, goal: string): Promise<string> {
199  const { agentId, deny } = await $.agent.spawn({ subagentType: PLANNER, description: 'Plan todos', prompt: `Goal: ${goal}` })
200  if (deny !== undefined) return `Planner refused: ${deny}`
201  if (!agentId) return 'Planner did not start.'
202  const early = finished.get(agentId)
203  if (early !== undefined) {
204    finished.delete(agentId)
205    return early.trim() || 'Planned.'
206  }
207  return new Promise<string>(resolve => {
208    const timer = $.clock.after(180_000, () => {
209      waiting.delete(agentId)
210      resolve('The planner took too long; what it added so far is on the list.')
211    })
212    waiting.set(agentId, answer => {
213      timer.cancel()
214      waiting.delete(agentId)
215      resolve(answer.trim() || 'Planned.')
216    })
217  })
218}
219
220async function todoCommand($: EngineInterface, args: string): Promise<string> {
221  const text = args.trim()
222  const [verb = 'list', ...rest] = text.split(/\s+/)
223  const arg = text.slice(verb.length).trim()
224  const id = rest[0] ?? ''
225  const need = (what: string) => `Usage: /todo ${what}`
226  switch (verb || 'list') {
227    case 'add': {
228      if (!arg) return need('add <title>')
229      const t = await addTodo($, arg, 'you')
230      return `Added ${t.id}: ${t.title}`
231    }
232    case 'done':
233    case 'open': {
234      if (!id) return need(`${verb} <id>`)
235      const t = await setStatus($, id, verb)
236      return t ? line(t) : `No todo with id ${id}.`
237    }
238    case 'rm': {
239      if (!id) return need('rm <id>')
240      const before = listOf(await read($, todos)).length
241      const after = await change($, l => l.filter(t => t.id !== id))
242      return after.length < before ? `Removed ${id}.` : `No todo with id ${id}.`
243    }
244    case 'clear': {
245      const before = listOf(await read($, todos)).length
246      const after = await change($, l => l.filter(t => t.status !== 'done'))
247      return `Cleared ${before - after.length} done.`
248    }
249    case 'plan':
250      return arg ? planTodos($, arg) : need('plan <goal>')
251    case 'list':
252      return summary(listOf(await read($, todos)))
253    default:
254      return need('add <title> | done <id> | open <id> | rm <id> | clear | list | plan <goal>')
255  }
256}
257
258export function todoPanel(p: {
259  els: Els
260  C: Colors
261  w: number
262  items: Todo[]
263  isOpen: boolean
264  onToggle: () => void
265}): RenderElement {
266  const { els, C, items } = p
267  const { Box, Text, Button } = els
268  const done = items.filter(t => t.status === 'done').length
269  const colorOf = (t: Todo) => (t.status === 'done' ? C.faint : t.status === 'doing' ? C.agent : C.text)
270  const body = (
271    <Box flexDirection="column">
272      {items.length === 0 ? <Text color={C.faint}>{'Nothing to do yet. Add a line below, or let the planner split a goal: /todo plan <goal>'}</Text> : null}
273      {items.map(t => {
274        const filled = Math.round((t.progress / 100) * 6)
275        return (
276          <Box>
277            <Button key={`${TOGGLE}${t.id}`} plain label={GLYPH[t.status]} onPress={() => undefined} />
278            <Text color={C.faint}>{` ${t.id} `}</Text>
279            <Box flexGrow={1} flexShrink={1}>
280              <Text color={colorOf(t)} dimColor={t.status === 'done'} wrap="truncate">
281                {t.title}
282              </Text>
283            </Box>
284            {t.status === 'doing' ? (
285              <Box flexShrink={0}>
286                <Text color={C.agent}>{` ${'█'.repeat(filled)}${'░'.repeat(6 - filled)} ${t.progress}%`}</Text>
287              </Box>
288            ) : null}
289            {t.agentId && t.status !== 'done' ? (
290              <Box flexShrink={0}>
291                <Text color={C.dim}>{` ${t.agentId.slice(0, 8)}`}</Text>
292              </Box>
293            ) : null}
294          </Box>
295        )
296      })}
297      {'Input' in els ? (
298        <els.Input
299          key={`${ADD}-${items.length}`}
300          placeholder="new todo"
301          submitLabel="add"
302          onSubmit={() => undefined}
303        />
304      ) : (
305        <Text color={C.faint}>{'add one with /todo add <title>'}</Text>
306      )}
307    </Box>
308  )
309  return panel(els, { id: 'todo', title: 'todo', color: C.agent, summary: `${done}/${items.length} done`, isOpen: p.isOpen, onToggle: p.onToggle, width: p.w }, body)
310}
311
312/** The todo an agent is working on, for its progress bar. */
313export function progressOf(items: Todo[], agentId: string): { id: string; title: string; progress: number } | null {
314  const t = items.find(x => x.status === 'doing' && x.agentId === agentId)
315  return t ? { id: t.id, title: t.title, progress: t.progress } : null
316}
317
hooks/ui.tsx 35 lines
1// Shared drawing helpers: a box with a fold arrow in its header.
2import type { EngineInterface, RenderElement } from 'claude-code'
3
4import { PALETTES } from './core'
5
6export type Els = ReturnType<EngineInterface['ui']['resolve']>
7export type Colors = (typeof PALETTES)[keyof typeof PALETTES]
8
9export type PanelOpts = {
10  /** Stable id of the box; what `folded` stores. */
11  id: string
12  title: string
13  /** A theme key or colour for the border. */
14  color: string
15  /** Short text on the right of the header, still readable when the box is folded. */
16  summary?: string
17  isOpen: boolean
18  onToggle: () => void
19  width: number
20}
21
22/** A rounded box whose header row carries the ▾/▸ fold button. A folded box is its header alone. */
23export const panel = (els: Els, o: PanelOpts, body: RenderElement | RenderElement[] | null) => {
24  const { Box, Text, Button } = els
25  return (
26    <Box flexDirection="column" borderStyle="round" borderColor={o.color} paddingX={1} width={o.width}>
27      <Box justifyContent="space-between">
28        <Button key={`fold-${o.id}`} plain label={`${o.isOpen ? '▾' : '▸'} ${o.title}`} onPress={o.onToggle} />
29        <Text dimColor wrap="truncate">{o.summary ?? ''}</Text>
30      </Box>
31      {o.isOpen ? body : null}
32    </Box>
33  )
34}
35
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 161 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  /** Difficulty the router rated the task, when it did: simple, moderate or complex. */
57  level: string
58  /** How many runs this box stands for: a repeat of the same task reuses the box. */
59  runs: number
60}
61
62/** A model loop whose id matches no card: a workflow agent, a compaction or a memory fork. */
63export type Loop = { id: string; steps: number; firstAt: number; lastAt: number; isDone: boolean }
64
65export type LogLine = {
66  at: number
67  who: string
68  text: string
69  agentId: string | null
70  kind: 'info' | 'error' | 'consult' | 'done'
71  /** The chat row this line is about (a tool call's id, a message id), so a press can scroll to it. */
72  rowId: string | null
73}
74
75export type Turn = {
76  edits: number
77  errorStreak: number
78  errors: number
79  isReviewing: boolean
80  startedAt: number
81  costAtStart: number | null
82}
83
84export type Receipt = {
85  durationMs: number
86  agents: number
87  edits: number
88  errors: number
89  costDelta: number | null
90  reason: string
91}
92
93export type Layout = 'auto' | 'compact' | 'wide' | 'mini'
94
95export type View = { expanded: string | null; gateOpen: Bucket | null; layout: Layout | null; logOpen: boolean }
96
97export type Roster = { architectTypes: string[] }
98
99export type VaultInfo = {
100  notes: number
101  areas: { name: string; notes: number }[]
102  recent: { name: string; mtimeMs: number }[]
103  scannedMs: number
104}
105
106/** One line of the shared todo list. Agents set progress and status through the todo tool. */
107export type Todo = {
108  id: string
109  title: string
110  status: 'open' | 'doing' | 'done'
111  /** 0-100, reported by the agent working on it. */
112  progress: number
113  /** The agent working on it, once one claimed it. */
114  agentId: string | null
115  note: string
116  source: 'you' | 'planner' | 'agent'
117  createdAt: number
118  doneAt: number | null
119}
120
121export type ModelStat = { agents: number; routed: number; ctx: number; out: number }
122
123/** Routing scoreboard for this session: per model and per difficulty level. */
124export type Routing = { byModel: Record<string, ModelStat>; byLevel: Record<string, number> }
125
126export type GitInfo = {
127  branch: string | null
128  ahead: number
129  behind: number
130  staged: number
131  modified: number
132  untracked: number
133  scannedMs: number
134  error: string | null
135}
136
137declare module 'claude-code' {
138  interface PluginState {
139    'agent-deck': {
140      meta: { schemaVersion: number }
141      main: Main
142      usage: Usage
143      architect: Architect
144      gate: Gate
145      agents: AgentCard[]
146      loops: Loop[]
147      log: LogLine[]
148      turn: Turn
149      receipt: Receipt | null
150      view: View
151      roster: Roster
152      vault: VaultInfo | null
153      todos: Todo[]
154      routing: Routing
155      git: GitInfo | null
156      /** Ids of boxes the person folded with the arrow button. */
157      folded: string[]
158    }
159  }
160}
161