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

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.
/todo add <title>, or let a planner agent split a goal with /todo plan <goal>. Agents claim items, report progress and check them off.git status.Commands: /deck (open, close, reset, vault, layout auto|compact|wide|mini) and /todo (add, done, open, rm, clear, list, plan).
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.
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.
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.
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.
vaultPath (file names and modification times only, no contents).git status --porcelain=v2 --branch in your session folder.todo_add, todo_update, todo_list) and one agent type (agent-deck:planner), and adds the open todos to each subagent's prompt.To switch mods off: claude --safe-mode for one session, or disable it in the Installed tab of /plugin.
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.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.MIT. See LICENSE. Flightdeck's copyright notice is kept as the license requires.
hooks/register.tsx 1295 lines1import { 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 lines1// Pure data: defaults, reducers, formatting and layout math. Nothing here touches `$`, so every
2// behaviour is testable directly (the test kit cannot raise a subagent's tool call or a
3// permission check inside a call; these functions are what the hooks apply).
4import type {
5 AgentCard,
6 Architect,
7 Bucket,
8 Check,
9 Gate,
10 Layout,
11 LogLine,
12 Loop,
13 Main,
14 Moment,
15 Receipt,
16 Roster,
17 Tally,
18 ToolNote,
19 Turn,
20 Usage,
21 View,
22} from '../types'
23
24export const SCHEMA_VERSION = 2
25
26// ---------------------------------------------------------------- defaults
27
28export const DEFAULT_MAIN: Main = { model: '', effort: '', mode: '', steps: 0, isRunning: false }
29export const DEFAULT_USAGE: Usage = {
30 pct: null,
31 tokens: null,
32 window: 0,
33 costUsd: null,
34 limits: [],
35 compactions: 0,
36 lastCompactAt: null,
37}
38export const DEFAULT_ARCHITECT: Architect = { consults: [], ids: [], seen: [], lastAdvice: '' }
39const ZERO: Tally = { rule: 0, ask: 0, cleared: 0, deny: 0 }
40export const DEFAULT_GATE: Gate = { recent: [], totals: { file: ZERO, shell: ZERO, other: ZERO } }
41export const DEFAULT_TURN: Turn = { edits: 0, errorStreak: 0, errors: 0, isReviewing: false, startedAt: 0, costAtStart: null }
42export const DEFAULT_VIEW: View = { expanded: null, gateOpen: null, layout: null, 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
496hooks/git.tsx 81 lines1// 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}
81hooks/scoreboard.tsx 111 lines1// 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}
111hooks/toasts.ts 82 lines1import { 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}
82hooks/todo.tsx 317 lines1// 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}
317hooks/ui.tsx 35 lines1// 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}
35hooks/rail.tsx 75 lines1// A connector drawn on the surface's own frame clock: packets travel along it while `active`,
2// and it rests as a plain dim line otherwise. Only this region redraws; the pane does not.
3import type { ClientModule } from 'claude-code'
4
5type Props = {
6 active: boolean
7 width: number
8 color: string
9 dim: string
10 /** Cells where a branch drops (┬); the rest of the line is ─. */
11 marks: number[]
12 /** Draw ┴ instead of ┬ at the marks: a merge into what is below. */
13 isMerge: boolean
14}
15
16type Ref = { phase: number; active: boolean }
17type State = { ref: Ref }
18
19const STEP_MS = 110
20
21const Rail: ClientModule<Props, State> = (props, surface) => {
22 const { Box, Text } = surface.elements
23 const ref = surface.state?.ref ?? { phase: 0, active: props.active }
24 ref.active = props.active
25 if (surface.state === undefined) {
26 surface.setState({ ref })
27 surface.every(STEP_MS, () => {
28 if (ref.active) {
29 ref.phase += 1
30 surface.setState({ ref })
31 }
32 })
33 }
34
35 const width = Math.max(1, surface.columns || props.width)
36 const cells = Array.from({ length: width }, () => '─')
37 for (const m of props.marks) if (m >= 0 && m < width) cells[m] = props.isMerge ? '┴' : '┬'
38
39 if (!props.active) {
40 return (
41 <Box>
42 <Text color={props.dim}>{cells.join('')}</Text>
43 </Box>
44 )
45 }
46
47 // Two packets per 24 cells, a bright head and a trailing dot, moving left to right.
48 const lit = new Map<number, string>()
49 for (let base = 0; base < width + 24; base += 24) {
50 const head = (base + ref.phase) % (width + 24)
51 if (head < width) lit.set(head, '●')
52 if (head - 1 >= 0 && head - 1 < width) lit.set(head - 1, '•')
53 }
54 // Runs, not cells: consecutive cells of one kind share a Text, a handful of nodes per frame.
55 const runs: { text: string; isLit: boolean }[] = []
56 cells.forEach((ch, i) => {
57 const isLit = lit.has(i)
58 const last = runs[runs.length - 1]
59 const glyph = lit.get(i) ?? ch
60 if (last && last.isLit === isLit) last.text += glyph
61 else runs.push({ text: glyph, isLit })
62 })
63 return (
64 <Box>
65 {runs.map(r => (
66 <Text color={r.isLit ? props.color : props.dim} bold={r.isLit}>
67 {r.text}
68 </Text>
69 ))}
70 </Box>
71 )
72}
73
74export default Rail
75hooks/elapsed.tsx 38 lines1// A running agent's clock, ticking on the surface's frame clock so the pane need not redraw.
2// `since` and `now` come from the hooks module's $.clock; between redraws it adds its own ticks.
3import type { ClientModule } from 'claude-code'
4
5type Props = { since: number; now: number; endAt: number | null; color: string }
6type Ref = { base: number; ticks: number; lastNow: number; isRunning: boolean }
7type State = { ref: Ref }
8
9const fmt = (ms: number) => {
10 const s = Math.max(0, Math.floor(ms / 1000))
11 const m = Math.floor(s / 60)
12 return m < 60 ? `${m}:${String(s % 60).padStart(2, '0')}` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
13}
14
15const Elapsed: ClientModule<Props, State> = (props, surface) => {
16 const { Text } = surface.elements
17 const ref = surface.state?.ref ?? { base: 0, ticks: 0, lastNow: -1, isRunning: true }
18 if (props.now !== ref.lastNow) {
19 // A fresh reading from the hooks module: restart the local count from it.
20 ref.lastNow = props.now
21 ref.base = (props.endAt ?? props.now) - props.since
22 ref.ticks = 0
23 }
24 ref.isRunning = props.endAt === null
25 if (surface.state === undefined) {
26 surface.setState({ ref })
27 surface.every(1000, () => {
28 if (ref.isRunning) {
29 ref.ticks += 1
30 surface.setState({ ref })
31 }
32 })
33 }
34 return <Text color={props.color}>{fmt(ref.base + ref.ticks * 1000)}</Text>
35}
36
37export default Elapsed
38types/index.d.ts 161 lines1export type Moment = 'before a plan' | 'error repeats' | 'before done'
2
3export type Bucket = 'file' | 'shell' | 'other'
4
5/** rule: settings allowed it. ask: put to the decider, outcome pending. cleared: asked, then ran. deny: refused. */
6export type Verdict = 'rule' | 'ask' | 'cleared' | 'deny'
7
8export type Main = { model: string; effort: string; mode: string; steps: number; isRunning: boolean }
9
10export type Usage = {
11 pct: number | null
12 tokens: number | null
13 window: number
14 costUsd: number | null
15 limits: { kind: string; pct: number }[]
16 compactions: number
17 lastCompactAt: number | null
18}
19
20export type Consult = { id: string; at: number; endAt: number | null; moment: Moment; via: string }
21
22export type Architect = { consults: Consult[]; ids: string[]; seen: string[]; lastAdvice: string }
23
24export type Check = {
25 id: string
26 tool: string
27 bucket: Bucket
28 verdict: Verdict
29 inSubagent: boolean
30 detail: string
31 at: number
32}
33
34export type Tally = { rule: number; ask: number; cleared: number; deny: number }
35
36export type Gate = { recent: Check[]; totals: Record<Bucket, Tally> }
37
38export type ToolNote = { tool: string; text: string; isError: boolean }
39
40export type AgentCard = {
41 id: string
42 type: string
43 model: string
44 description: string
45 status: string
46 spawnedAt: number
47 endedAt: number | null
48 /** The agent's context now: input + cache read + cache write of its latest step. */
49 ctx: number
50 /** Output tokens summed over its steps. */
51 out: number
52 steps: number
53 lastStop: string | null
54 tools: ToolNote[]
55 answer: string
56 /** 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