Flightdeck: a live agent dashboard for Claude Code. Main model vitals, an on-call architect, every permission check, subagent cards and swimlanes, a turn…

A Claude Code mod that puts a live agent dashboard in your terminal: context and cost, an advisor timeline, every permission check, and your subagents as cards or swimlanes. Every number comes from a real session event, and nothing leaves your machine.
<img src="docs/media/demo.gif" alt="Flightdeck during a live session: five audit subagents fan out as cards, switch to swimlanes and finish, while the permission gate fills with checks" width="520">
<a href="#install">Install</a> · <a href="#what-you-see">What you see</a> · <a href="#what-it-can-reach">What it can reach</a> · <a href="#configure">Configure</a> · <a href="#troubleshooting">Troubleshooting</a>
Inside Claude Code (2.1.287 or later):
/plugin marketplace add scasella/claude-flightdeck
/plugin install flightdeck@claude-flightdeck
/reload-plugins
/flightdeck
claude plugin marketplace add scasella/claude-flightdeck
claude plugin install flightdeck@claude-flightdeck
Or load it straight from a clone, for one session:
git clone https://github.com/scasella/claude-flightdeck
claude --plugin-dir ./claude-flightdeck
The installer may say config options aren't set; the defaults are fine, and /config changes them.
Mods are an early-access Claude Code feature and their API can change between releases. If something breaks, see Troubleshooting.
https://github.com/user-attachments/assets/9ad0fcc3-c81c-427a-a743-f7b6c49f5885
<img src="docs/media/docked-session.png" alt="Claude Code in fullscreen with the Flightdeck pane docked beside the transcript" width="820">
| Docked beside the transcript | Inline above the prompt |
|---|---|
| <img src="docs/media/docked-pane.png" alt="The docked pane: main model vitals, architect timeline, permission gate, five agents as swimlanes, last-turn receipt and session log" width="380"> | <img src="docs/media/inline-mini.png" alt="The inline mini layout: the model, context gauge, session cost and architect consults; the permission gate strip and totals; the last turn's duration, agents, edits, errors and cost" width="420"><br><br>On the main screen, without fullscreen, the pane is a summary of at most 8 rows; up to 3 agents join it when the session has subagents. |
| Panel | Shows | From |
|---|---|---|
| main | model, effort, permission mode, request count; a context gauge with compactions (⟲); cost and the first two rate-limit windows when your plan reports them | turn.step, session.measure, session.compact, $.session.usage() |
| architect | consults on a timeline, whether one is running, how long the last took; optionally the moment of each consult; the first line of a subagent architect's advice | a spawn of a matching agent type, or a matching server tool in the assistant's rows |
| gate | one cell per permission check: green allowed without asking, blue decided by the auto-mode classifier or you and then run, amber pending, red ✗ denied, dim if made inside a subagent. Totals, and a drill-down per tool family with credentials masked | tool.check, settled by the tool.call around it |
| agents | cards side by side while they fit: the task, type, live context and output tokens, steps, a running clock, max_tokens in red. Beyond that, swimlanes on one time axis | agent.spawn, turn.step, tool.call, turn.complete |
| loops | model loops that match no card: workflow agents, compactions, memory forks | turn.step ids no card claims |
| receipt | the running turn, or the last one: duration, agents, edits, errors, cost added | turn.start, turn.complete |
| log | prompts, spawns, completions, consults, edits, errors and denials; filtered to one agent while you view its transcript | all of the above |
Connectors animate only while work flows: a turn is running, an agent is running, or a consult is open. Panels with nothing to show take no room, so a session without subagents shows just the main box and the log.
| Command | Does | |||
|---|---|---|---|---|
/flightdeck | open the pane | |||
/flightdeck close | close it | |||
/flightdeck reset | clear agents, checks, consults, log and the turn (cost, rate limits and compactions stay) | |||
| `/flightdeck layout auto\ | compact\ | wide\ | mini` | override the layout for this session |
Focus the pane with ctrl+x tab, then:
| Key | Does |
|---|---|
1, 2, … | expand an agent card or lane: its full task, last 3 tool calls, start of its answer |
f s o | open the gate's file / shell / other drill-down: the last 5 checks and their verdicts |
/clear resets the pane along with the conversation.
With openOnStart, the pane opens by itself when a session starts, in terminals at least 144 columns wide; below that, /flightdeck opens it. Colours come from your Claude Code theme, so light, dark and colour-blind themes all read.
Flightdeck only watches. Every hook passes its event on unchanged: it never denies, rewrites or delays a tool call, a prompt or a subagent.
| It sees | Through |
|---|---|
| every tool call's name and input, and whether it failed | tool.call |
| every permission verdict | tool.check |
| subagent spawns, their model requests and token usage, and their final answers | agent.spawn, turn.step, turn.complete |
| your prompts' first 70 characters, for the log | turn.start |
| context, cost and rate-limit readings | session.measure, $.session.usage() |
| advisor tool calls in the assistant's responses (their content is encrypted) | session.append |
What it keeps: short summaries (a tool name plus a path or command, with credentials masked) in session state, which ends with the session. It makes no network requests, runs no processes, reads and writes no files, stores nothing across sessions, and calls no model. claude plugin validate . prints exactly what it hooks and calls.
(inferred); turn them off with moments: false.advisor, the pane counts and times the consult but cannot show what it said.ctx, not cost: the API has no per-agent cost.In /config, or under pluginConfigs["flightdeck"].options in settings.json:
| Option | Default | Meaning | |
|---|---|---|---|
architectPattern | `advisor\ | architect` | case-insensitive regex for agent types and server tools that count as the architect |
matchDescriptions | false | also match agent descriptions, not just type names | |
architectLabel | ARCHITECT | the architect's name in the pane | |
gateLabel | GATE | the permission panel's name | |
panels | main,architect,gate,agents,loops,receipt,log | which panels show, in order | |
layout | auto | mini, compact, wide, or auto (mini inline, wide from 110 columns docked) | |
maxCards | 3 | cards side by side before swimlanes (1–6); fewer if the pane is too narrow | |
motion | while-active | off keeps connectors still | |
moments | true | show the inferred consult moments | |
palette | theme | pastel uses fixed colours tuned for dark terminals | |
openOnStart | true | ask to open the pane when a session starts | |
statusLine | true | context, running agents, consults and denials in the status line |
The pane doesn't appear.
claude --version is 2.1.287 or later, then run /reload-plugins and /flightdeck./flightdeck opens it at any width.flightdeck:. It names the hook that failed or the reason the pane was refused. Please open an issue with it.Colours look wrong. Set palette to pastel in /config.
It's too much motion. Set motion to off.
Counters look stale after an update. Run /flightdeck reset.
| File | Holds |
|---|---|
hooks/register.tsx | the event hooks, state access, and one function per panel |
hooks/core.ts | every reducer, formatter and layout rule as pure functions, so behaviour is testable directly |
hooks/rail.tsx, hooks/elapsed.tsx | surface modules: animated connectors and live clocks that redraw only themselves, on the surface's own frame clock |
types/index.d.ts | the state contract |
tests/ | 24 tests: pure behaviour, plus drawings mounted on every surface at 40–120 columns |
State lives in $.state atoms. Every read is merged over defaults, so a missing or older field never breaks the pane; an update that changes the state's shape may still reset its counters once. New to mods? Start with Claude Code mods and Getting started with Claude Code mods.
Flightdeck works alongside these, and owes ideas to them:
claude --plugin-dir . # load it; edits hot-reload
claude plugin validate .
claude plugin test .
npx -p typescript tsc -p . # after the first load, which writes .claude-plugin/types/
See CONTRIBUTING.md. Changes are listed in CHANGELOG.md.
hooks/register.tsx 1124 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { 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 logRows,
38 momentOf,
39 normalize,
40 normalizeCard,
41 normalizeGate,
42 normalizeLog,
43 noteTool,
44 PALETTES,
45 SVG_COLORS,
46 parseConfig,
47 prettyModel,
48 promptLine,
49 handbackOf,
50 adviceLine,
51 receiptOf,
52 recordCheck,
53 settleCheck,
54 shorten,
55 startConsult,
56 stepLoop,
57} from './core'
58import type { Config, Panel } from './core'
59
60const PANE = 'flightdeck'
61const TITLE = 'Flightdeck'
62const PANE_COLUMNS = 66
63
64
65// ---------------------------------------------------------------- state
66
67const meta = atom({ plugin: 'flightdeck', key: 'meta' } as const, { schemaVersion: SCHEMA_VERSION })
68const main = atom({ plugin: 'flightdeck', key: 'main' } as const, DEFAULT_MAIN)
69const usage = atom({ plugin: 'flightdeck', key: 'usage' } as const, DEFAULT_USAGE)
70const architect = atom({ plugin: 'flightdeck', key: 'architect' } as const, DEFAULT_ARCHITECT)
71const gate = atom({ plugin: 'flightdeck', key: 'gate' } as const, DEFAULT_GATE)
72const agents = atom({ plugin: 'flightdeck', key: 'agents' } as const, [])
73const loops = atom({ plugin: 'flightdeck', key: 'loops' } as const, [])
74const log = atom({ plugin: 'flightdeck', key: 'log' } as const, [])
75const turn = atom({ plugin: 'flightdeck', key: 'turn' } as const, DEFAULT_TURN)
76const receipt = atom({ plugin: 'flightdeck', key: 'receipt' } as const, null)
77const view = atom({ plugin: 'flightdeck', key: 'view' } as const, DEFAULT_VIEW)
78const roster = atom({ plugin: 'flightdeck', key: 'roster' } as const, DEFAULT_ROSTER)
79const paneOpen = atom({ plugin: 'flightdeck', key: 'paneOpen' } as const, false)
80
81type ServerBlock = { type: string; id?: string; name?: string; tool_use_id?: string }
82
83// Every read goes through these, so a value saved under an older shape still reads.
84async function getMain($: EngineInterface): Promise<Main> {
85 return normalize(DEFAULT_MAIN, await read($, main))
86}
87async function getUsage($: EngineInterface): Promise<Usage> {
88 return normalize(DEFAULT_USAGE, await read($, usage))
89}
90async function getArchitect($: EngineInterface): Promise<Architect> {
91 const a = normalize(DEFAULT_ARCHITECT, await read($, architect))
92 return { ...a, consults: listOf(a.consults), ids: listOf(a.ids), seen: listOf(a.seen) }
93}
94async function getGate($: EngineInterface): Promise<Gate> {
95 return normalizeGate(await read($, gate))
96}
97async function getCards($: EngineInterface): Promise<AgentCard[]> {
98 return listOf<unknown>(await read($, agents)).map(normalizeCard)
99}
100async function getLoops($: EngineInterface): Promise<Loop[]> {
101 return listOf<Loop>(await read($, loops))
102}
103async function getLog($: EngineInterface): Promise<LogLine[]> {
104 return normalizeLog(await read($, log))
105}
106async function getTurn($: EngineInterface): Promise<Turn> {
107 return normalize(DEFAULT_TURN, await read($, turn))
108}
109async function getView($: EngineInterface): Promise<View> {
110 return normalize(DEFAULT_VIEW, await read($, view))
111}
112async function getRoster($: EngineInterface): Promise<Roster> {
113 const r = normalize(DEFAULT_ROSTER, await read($, roster))
114 return { architectTypes: listOf(r.architectTypes) }
115}
116
117/** A stored shape older than this build's: drop what cannot be read, keep the rest. */
118async function migrate($: EngineInterface) {
119 const m = await read($, meta)
120 if ((m?.schemaVersion ?? 0) >= SCHEMA_VERSION) return
121 await update($, log, list => normalizeLog(list))
122 await update($, agents, list => listOf<unknown>(list).map(normalizeCard))
123 await update($, gate, g => normalizeGate(g))
124 await update($, meta, () => ({ schemaVersion: SCHEMA_VERSION }))
125}
126
127async function say($: EngineInterface, who: string, text: string, kind: LogLine['kind'] = 'info', agentId: string | null = null) {
128 const line: LogLine = { at: await $.clock.now(), who, text, kind, agentId }
129 await update($, log, list => [...normalizeLog(list), line].slice(-60))
130}
131
132async function refreshStatus($: EngineInterface, cfg: Config) {
133 if (!cfg.statusLine) return $.ui.status(undefined)
134 const [u, a, g, cards] = await Promise.all([getUsage($), getArchitect($), getGate($), getCards($)])
135 const running = cards.filter(c => c.status === 'running').length
136 const s = gateSummary(g)
137 const parts = [
138 u.pct !== null ? `ctx ${Math.round(u.pct)}%` : null,
139 cards.length > 0 ? `agents ${running}/${cards.length}` : null,
140 a.consults.length > 0 || a.ids.length > 0 ? `${cfg.architectLabel.toLowerCase()} ${isAdvising(a) ? 'advising' : a.consults.length}` : null,
141 s.deny > 0 ? `denied ${s.deny}` : null,
142 ]
143 // Only fields with something to say; with none, no status entry at all.
144 const shown = parts.filter(Boolean)
145 $.ui.status(shown.length > 0 ? shown.join(' · ') : undefined)
146}
147
148async function whoIs($: EngineInterface, agentId: string | undefined) {
149 if (!agentId) return 'main'
150 const card = (await getCards($)).find(c => c.id === agentId)
151 return card ? shorten(cardTitle(card), 14) : 'agent'
152}
153
154async function consultStarted($: EngineInterface, cfg: Config, id: string, via: string) {
155 const t = await getTurn($)
156 const moment = momentOf(t)
157 const at = await $.clock.now()
158 await update($, architect, a => startConsult(normalize(DEFAULT_ARCHITECT, a), { id, at, moment, via }))
159 if (moment === 'before done') await update($, turn, x => ({ ...normalize(DEFAULT_TURN, x), isReviewing: true }))
160 await say($, cfg.architectLabel.toLowerCase(), cfg.moments ? `${moment} · ${via}` : `consulted · ${via}`, 'consult')
161 await refreshStatus($, cfg)
162}
163
164async function consultEnded($: EngineInterface, cfg: Config, advice: string | null, id?: string) {
165 const at = await $.clock.now()
166 const first = advice?.split('\n').find(l => l.trim()) ?? null
167 const text = first ? shorten(first.replace(/^[#>*\s-]+/, ''), 160) : null
168 await update($, architect, a => endConsult(normalize(DEFAULT_ARCHITECT, a), at, text, id))
169 await update($, turn, t => ({ ...normalize(DEFAULT_TURN, t), isReviewing: false }))
170 await say($, cfg.architectLabel.toLowerCase(), text ? `advice: ${shorten(text, 60)}` : 'advice returned', 'consult')
171 await refreshStatus($, cfg)
172}
173
174async function noteAdvice($: EngineInterface, cfg: Config, advice: string) {
175 await update($, architect, x => ({ ...normalize(DEFAULT_ARCHITECT, x), lastAdvice: advice }))
176 await say($, cfg.architectLabel.toLowerCase(), `advice: ${shorten(advice, 60)}`, 'consult')
177}
178
179async function isArchitectType($: EngineInterface, cfg: Config, type: string) {
180 return cfg.architect.test(type) || (await getRoster($)).architectTypes.includes(type)
181}
182
183async function openPane($: EngineInterface) {
184 // columns apply when docked beside the transcript, rows when seated inline above the prompt.
185 return $.ui.open({ id: PANE, title: TITLE, columns: PANE_COLUMNS, rows: 8 })
186}
187
188async function resetAll($: EngineInterface) {
189 await update($, main, m => ({ ...DEFAULT_MAIN, model: normalize(DEFAULT_MAIN, m).model, mode: normalize(DEFAULT_MAIN, m).mode }))
190 await update($, architect, () => DEFAULT_ARCHITECT)
191 await update($, gate, () => DEFAULT_GATE)
192 await update($, agents, () => [])
193 await update($, loops, () => [])
194 await update($, log, () => [])
195 await update($, turn, () => DEFAULT_TURN)
196 await update($, receipt, () => null)
197 await update($, view, () => DEFAULT_VIEW)
198 // The context gauge waits for the next measurement rather than showing the pre-clear fill.
199 await update($, usage, x => ({ ...normalize(DEFAULT_USAGE, x), pct: null, tokens: null }))
200}
201
202/** The session's cost read fresh, not from the last measurement: the receipt subtracts two of these. */
203async function costNow($: EngineInterface): Promise<number | null> {
204 const u = await $.session.usage().catch(() => null)
205 return u?.cost?.usd ?? null
206}
207
208async function noteMode($: EngineInterface, mode: string | undefined) {
209 if (mode) await update($, main, m => (normalize(DEFAULT_MAIN, m).mode === mode ? normalize(DEFAULT_MAIN, m) : { ...normalize(DEFAULT_MAIN, m), mode }))
210}
211
212// ---------------------------------------------------------------- hooks
213
214export const register: Register = (on, options) => {
215 const cfg = parseConfig(options)
216 const C = PALETTES[cfg.palette]
217 // tool.check carries no loop id; the tool.call around it does, keyed by the call's id.
218 const callLoop = new Map<string, string | null>()
219
220 on('session.start', async ($, e, next) => {
221 await $.command.register({
222 name: 'flightdeck',
223 description: 'Flightdeck, the live agent dashboard: open, close, reset, or set the layout',
224 argumentHint: '[open|close|reset|layout auto|compact|wide|mini]',
225 })
226 await migrate($)
227 // A reload keeps the pane up but starts the module over: re-read whether it is open
228 const isUp = (await $.ui.panes().catch(() => [])).some(p => p.id === PANE)
229 await update($, paneOpen, () => isUp)
230 // A host without usage (headless, an SDK host, a session not yet bound) just starts without it.
231 const u = await $.session.usage().catch(() => null)
232 if (u) {
233 await update($, usage, x => ({
234 ...normalize(DEFAULT_USAGE, x),
235 pct: u.context.percent ?? null,
236 tokens: u.context.tokens ?? null,
237 window: u.context.window,
238 costUsd: u.cost?.usd ?? null,
239 limits: u.rateLimits.map(r => ({ kind: r.kind, pct: r.percentUsed })),
240 }))
241 }
242 if (cfg.openOnStart) void openPane($).catch(() => undefined)
243 await refreshStatus($, cfg)
244 return next(e)
245 })
246
247 // Mod Tools' Show column reads paneOpen; the engine lists a plugin's panes only to that plugin
248 on('ui.open', { id: PANE }, async ($, e, next) => {
249 const opened = await next(e)
250 await update($, paneOpen, () => true)
251 return opened
252 }).catch(($, e, next) => next(e))
253
254 on('ui.close', { id: PANE }, async ($, e, next) => {
255 const closed = await next(e)
256 await update($, paneOpen, () => false)
257 return closed
258 }).catch(($, e, next) => next(e))
259
260 on('session.end', async ($, e, next) => {
261 if (e.reason === 'clear') {
262 await resetAll($)
263 await refreshStatus($, cfg)
264 }
265 return next(e)
266 })
267
268 on('command.run', { command: 'flightdeck' }, async ($, e) => {
269 const [verb = 'open', arg = ''] = e.args.trim().split(/\s+/)
270 if (verb === 'close') {
271 await $.ui.close({ id: PANE })
272 return { text: 'Flightdeck closed.' }
273 }
274 if (verb === 'reset') {
275 await resetAll($)
276 await refreshStatus($, cfg)
277 return { text: 'Flightdeck reset.' }
278 }
279 if (verb === 'layout') {
280 const layout: Layout | null = arg === 'compact' || arg === 'wide' || arg === 'auto' || arg === 'mini' ? arg : null
281 if (!layout) return { text: 'Usage: /flightdeck layout auto|compact|wide|mini' }
282 await update($, view, v => ({ ...normalize(DEFAULT_VIEW, v), layout }))
283 const opened = await openPane($)
284 return { text: opened.isPlaced ? `Flightdeck layout: ${layout}.` : `Layout set to ${layout}; the pane is not shown yet: ${opened.reason}` }
285 }
286 const opened = await openPane($)
287 if (!opened.isPlaced) return { text: `Flightdeck is not shown yet: ${opened.reason}` }
288 return { text: 'Flightdeck opened. Focus it with ctrl+x tab; 1-6 expand cards, f/s/o open the gate rows.' }
289 })
290
291 on('classic.UserPromptSubmit', async ($, e, next) => {
292 await noteMode($, e.permission_mode)
293 return next(e)
294 })
295
296 on('agent.offer', async ($, e, next) => {
297 const offered = await next(e)
298 if (cfg.architect.test(e.agent) || (cfg.matchDescriptions && cfg.architect.test(e.description))) {
299 await update($, roster, r => {
300 const x = normalize(DEFAULT_ROSTER, r)
301 return x.architectTypes.includes(e.agent) ? x : { architectTypes: [...listOf<string>(x.architectTypes), e.agent].slice(-20) }
302 })
303 }
304 return offered
305 })
306
307 on('turn.start', async ($, e, next) => {
308 const [now, cost] = await Promise.all([$.clock.now(), costNow($)])
309 await update($, turn, () => ({ ...DEFAULT_TURN, startedAt: now, costAtStart: cost }))
310 await update($, main, m => ({ ...normalize(DEFAULT_MAIN, m), isRunning: true }))
311 // A background architect's report reaches the main loop as the text opening this turn. The
312 // SubagentHandback tool call (in tool.call) normally carries it first; this is the fallback.
313 const back = e.text ? handbackOf(e.text) : null
314 const a = back ? await getArchitect($) : null
315 if (back && a && a.ids.includes(back.from)) {
316 const advice = adviceLine(back.body)
317 if (advice && advice !== a.lastAdvice) await noteAdvice($, cfg, advice)
318 } else if (e.text) {
319 const p = promptLine(e.text)
320 await say($, p.who, p.text)
321 }
322 return next(e)
323 })
324
325 on('turn.step', async function* ($, e, next) {
326 // The main loop's model is known when its request starts; a long first request shouldn't read "—".
327 if (!e.agentId) {
328 await update($, main, m => {
329 const x = normalize(DEFAULT_MAIN, m)
330 return { ...x, model: e.model, effort: String(e.effort ?? x.effort), steps: x.steps + 1 }
331 })
332 return yield* next(e)
333 }
334 const result = yield* next(e)
335 const id = e.agentId
336 const [cards, a] = await Promise.all([getCards($), getArchitect($)])
337 if (cards.some(c => c.id === id)) {
338 const step = { model: e.model, usage: result.usage, stopReason: result.stopReason }
339 await update($, agents, list => listOf<unknown>(list).map(normalizeCard).map(c => (c.id === id ? applyStep(c, step) : c)))
340 if (result.stopReason === 'max_tokens') await say($, await whoIs($, id), 'hit max_tokens', 'error', id)
341 } else if (!a.ids.includes(id)) {
342 const now = await $.clock.now()
343 await update($, loops, l => stepLoop(listOf<Loop>(l), id, now))
344 }
345 return result
346 })
347
348 on('session.measure', async ($, e, next) => {
349 await update($, usage, x => ({
350 ...normalize(DEFAULT_USAGE, x),
351 pct: e.context.percent ?? null,
352 tokens: e.context.tokens ?? null,
353 window: e.context.window,
354 costUsd: e.cost?.usd ?? null,
355 limits: e.rateLimits.map(r => ({ kind: r.kind, pct: r.percentUsed })),
356 }))
357 if (e.changed.includes('context')) await refreshStatus($, cfg)
358 return next(e)
359 })
360
361 on('session.compact', async ($, e, next) => {
362 const done = await next(e)
363 if (!e.agentId && e.trigger !== 'precompute') {
364 const now = await $.clock.now()
365 await update($, usage, x => {
366 const u = normalize(DEFAULT_USAGE, x)
367 return { ...u, compactions: u.compactions + 1, lastCompactAt: now }
368 })
369 await say($, 'main', `context compacted (${e.trigger})`)
370 }
371 return done
372 })
373
374 on('tool.check', async ($, e, next) => {
375 const verdict = await next(e)
376 if (e.tool_use_id) {
377 const check: Check = {
378 id: e.tool_use_id,
379 tool: e.tool,
380 bucket: bucketOf(e.tool),
381 verdict: verdict.decision === 'allow' ? 'rule' : verdict.decision,
382 inSubagent: Boolean(callLoop.get(e.tool_use_id)),
383 detail: shorten(describeInput(e.tool, e.input), 90),
384 at: await $.clock.now(),
385 }
386 await update($, gate, g => recordCheck(normalizeGate(g), check))
387 // The status line updates when the call settles; only a refusal ends here.
388 if (verdict.decision === 'deny') {
389 await say($, 'gate', `denied by rule · ${check.detail}`, 'error')
390 await refreshStatus($, cfg)
391 }
392 }
393 return verdict
394 })
395
396 on('tool.call', async ($, e, next) => {
397 callLoop.set(e.tool_use_id, e.agentId ?? null)
398 const ran = await next(e).finally(() => callLoop.delete(e.tool_use_id))
399 const didRun = ran.deny === undefined
400 // Settle this call's pending ask, if it had one; skip the write (and the redraw) otherwise.
401 const g0 = await getGate($)
402 const isSettled = settleCheck(g0, e.tool_use_id, didRun) !== g0
403 if (isSettled) await update($, gate, g => settleCheck(normalizeGate(g), e.tool_use_id, didRun))
404 // A background agent hands its report back through this tool; an architect's report is its advice.
405 if (String(e.tool) === 'SubagentHandback') {
406 const message = (e as unknown as { message?: unknown }).message
407 const a = e.agentId ? await getArchitect($) : null
408 if (a && e.agentId && a.ids.includes(e.agentId) && typeof message === 'string') {
409 const advice = adviceLine(message)
410 if (advice && advice !== a.lastAdvice) await noteAdvice($, cfg, advice)
411 }
412 return ran
413 }
414 if (e.tool === 'Agent') {
415 if (isSettled) await refreshStatus($, cfg)
416 return ran
417 }
418 const hasFailed = didRun && ran.isError === true
419 const isEdit = !hasFailed && didRun && EDIT_TOOLS.has(e.tool)
420 const t0 = await getTurn($)
421 if (isEdit || hasFailed || (!e.agentId && t0.errorStreak > 0)) {
422 await update($, turn, t => afterCall(normalize(DEFAULT_TURN, t), { inSubagent: Boolean(e.agentId), hasFailed, isEdit }))
423 }
424 const text = shorten(describeInput(e.tool, e), 64)
425 if (e.agentId) {
426 const id = e.agentId
427 await update($, agents, list =>
428 listOf<unknown>(list)
429 .map(normalizeCard)
430 .map(c => (c.id === id ? noteTool(c, { tool: e.tool, text, isError: hasFailed || ran.deny !== undefined }) : c)),
431 )
432 }
433 // The log keeps what is worth a glance: refusals, errors and edits; the rest is on the cards.
434 if (ran.deny !== undefined) await say($, await whoIs($, e.agentId), `${text} denied`, 'error', e.agentId ?? null)
435 else if (hasFailed) await say($, await whoIs($, e.agentId), `${text} ✗`, 'error', e.agentId ?? null)
436 else if (isEdit) await say($, await whoIs($, e.agentId), text, 'info', e.agentId ?? null)
437 if (isSettled || !didRun) await refreshStatus($, cfg)
438 return ran
439 })
440
441 // A server-side review tool never reaches tool.call: it shows only in the assistant's rows.
442 on('session.append', async ($, e, next) => {
443 if (!e.agentId && e.message.type === 'assistant') {
444 const a = await getArchitect($)
445 // Consults this row opened: their result may be in the same row, after the stale read above.
446 const opened = new Set<string>()
447 for (const block of e.message.content as unknown as readonly ServerBlock[]) {
448 if (block.type === 'server_tool_use' && block.name && block.id && cfg.architect.test(block.name)) {
449 if (a.seen.includes(block.id)) continue
450 const id = block.id
451 await update($, architect, x => {
452 const y = normalize(DEFAULT_ARCHITECT, x)
453 return { ...y, seen: [...listOf<string>(y.seen), id].slice(-60) }
454 })
455 opened.add(id)
456 await consultStarted($, cfg, id, `${block.name} tool`)
457 } else if (block.type.endsWith('_tool_result') && block.tool_use_id) {
458 const id = block.tool_use_id
459 const isOpen = opened.has(id) || (await getArchitect($)).consults.some(c => c.id === id && c.endAt === null)
460 if (isOpen) await consultEnded($, cfg, null, id)
461 }
462 }
463 }
464 return next(e)
465 })
466
467 on('agent.spawn', async ($, e, next) => {
468 const started = await next(e)
469 if (!e.parentAgentId) await noteMode($, e.permissionMode)
470 if (started.deny !== undefined || !started.agentId) return started
471 const id = started.agentId
472 if (await isArchitectType($, cfg, e.subagentType)) {
473 await update($, architect, a => {
474 const x = normalize(DEFAULT_ARCHITECT, a)
475 return { ...x, ids: [...listOf<string>(x.ids), id].slice(-40) }
476 })
477 await update($, loops, l => listOf<Loop>(l).filter(x => x.id !== id))
478 await consultStarted($, cfg, id, e.subagentType.split(':').pop() ?? 'agent')
479 return started
480 }
481 const card: AgentCard = {
482 ...normalizeCard({}),
483 id,
484 type: e.name ?? e.subagentType,
485 model: started.model,
486 description: e.description,
487 spawnedAt: await $.clock.now(),
488 }
489 await update($, agents, list => [...listOf<unknown>(list).map(normalizeCard), card].slice(-24))
490 await update($, loops, l => listOf<Loop>(l).filter(x => x.id !== id))
491 await say($, shorten(cardTitle(card), 12), `spawned · ${card.type}`, 'info', id)
492 await refreshStatus($, cfg)
493 return started
494 })
495
496 on('turn.complete', async ($, e, next) => {
497 const done = await next(e)
498 const id = e.agentId
499 const now = await $.clock.now()
500 if (!id) {
501 const [t, cards, cost] = await Promise.all([getTurn($), getCards($), costNow($)])
502 const r = receiptOf(t, {
503 durationMs: e.durationMs,
504 agentsSince: cards.filter(c => c.spawnedAt >= t.startedAt).length,
505 costNow: cost,
506 reason: e.reason,
507 })
508 await update($, receipt, () => r)
509 await update($, main, m => ({ ...normalize(DEFAULT_MAIN, m), isRunning: false }))
510 await refreshStatus($, cfg)
511 return done
512 }
513 if ((await getArchitect($)).ids.includes(id)) {
514 await consultEnded($, cfg, e.answer, id)
515 return done
516 }
517 const cards = await getCards($)
518 if (cards.some(c => c.id === id)) {
519 const status = e.reason === 'answer' ? 'done' : e.reason === 'aborted' ? 'stopped' : 'failed'
520 await update($, agents, list =>
521 listOf<unknown>(list)
522 .map(normalizeCard)
523 .map(c => (c.id === id ? { ...c, status, endedAt: now, answer: shorten(e.answer, 400) } : c)),
524 )
525 const card = cards.find(c => c.id === id)
526 const took = card ? fmtDuration(now - card.spawnedAt) : ''
527 await say($, await whoIs($, id), status === 'done' ? `done · ${took}` : status, status === 'done' ? 'done' : 'error', id)
528 } else {
529 await update($, loops, l => listOf<Loop>(l).map(x => (x.id === id ? { ...x, isDone: true, lastAt: now } : x)))
530 }
531 await refreshStatus($, cfg)
532 return done
533 })
534
535 // ---------------------------------------------------------------- drawing
536
537 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
538 const els = $.ui.resolve(e)
539 const { Box, Text, Button } = els
540 const hasClient = 'Client' in els
541 const [m, u, a, g, cards, lp, lines, t, r, v, now] = await Promise.all([
542 getMain($),
543 getUsage($),
544 getArchitect($),
545 getGate($),
546 getCards($),
547 getLoops($),
548 getLog($),
549 getTurn($),
550 read($, receipt),
551 getView($),
552 $.clock.now(),
553 ])
554 const W = Math.max(40, e.props.bodyColumns)
555 const layout = v.layout ?? cfg.layout
556 const isWide = layout === 'wide' || (layout === 'auto' && W >= 110)
557 const colW = isWide ? Math.floor((W - 2) / 2) : W
558 const modelName = prettyModel(m.model)
559 const viewed = e.props.view?.agentId ?? null
560 const advising = isAdvising(a)
561 const running = cards.filter(c => c.status === 'running')
562 const showArchitect = a.consults.length > 0 || a.ids.length > 0
563 const motion = cfg.motion && hasClient
564 // A panel with nothing to show yet takes no room: most sessions never spawn an agent.
565 const isEmpty: Record<Panel, boolean> = {
566 main: false,
567 architect: !showArchitect,
568 gate: g.recent.length === 0 && gateSummary(g).total === 0,
569 agents: cards.length === 0,
570 loops: lp.length === 0,
571 receipt: !m.isRunning && !r,
572 log: false,
573 }
574 const panels = cfg.panels.filter(p => !isEmpty[p])
575 const decider = m.mode === 'auto' ? 'classifier' : 'you'
576
577 // A connector between panels: animated while its flow is live, a dim line otherwise.
578 const rail = (key: string, active: boolean, color: string, width: number, marks: number[] = [], isMerge = false) =>
579 motion ? (
580 <els.Client
581 key={key}
582 module="./rail.tsx"
583 width={width}
584 height={1}
585 props={{ active, width, color, dim: C.faint, marks, isMerge }}
586 />
587 ) : (
588 <Text color={C.faint}>{'─'.repeat(Math.max(1, width))}</Text>
589 )
590
591 // A start time of 0 is unknown (state saved before it was recorded): no clock, not decades.
592 const clock = (key: string, since: number, endAt: number | null, color: string) =>
593 since <= 0 ? (
594 <Text color={color}>—</Text>
595 ) : hasClient ? (
596 <els.Client key={key} module="./elapsed.tsx" props={{ since, now, endAt, color }} />
597 ) : (
598 <Text color={color}>{fmtTimer((endAt ?? now) - since)}</Text>
599 )
600
601 // ---- main
602 const effortN = { low: 1, medium: 2, high: 3, xhigh: 4, max: 4 }[m.effort] ?? 0
603 const ctxGauge = u.pct !== null ? gauge(u.pct, 10) : null
604 const mainPanel = (w: number) => (
605 <Box flexDirection="column" borderStyle="round" borderColor={C.main} paddingX={1} width={w}>
606 <Box justifyContent="space-between">
607 <Text color={C.main} bold>
608 {modelName} · main
609 </Text>
610 <Text color={m.isRunning ? C.main : C.dim}>{m.isRunning ? '● working' : '○ idle'}</Text>
611 </Box>
612 <Text wrap="truncate">
613 <Text dimColor>effort </Text>
614 <Text color={C.main}>{'▮'.repeat(effortN) + '▯'.repeat(4 - effortN)} </Text>
615 <Text color={C.main} bold>
616 {m.effort || '—'}
617 </Text>
618 {m.mode ? <Text dimColor>{` mode ${m.mode}`}</Text> : null}
619 <Text dimColor>{` ${m.steps} req`}</Text>
620 </Text>
621 {ctxGauge ? (
622 <Text wrap="truncate">
623 <Text dimColor>ctx </Text>
624 <Text color={u.pct !== null && u.pct >= 80 ? C.warn : C.main}>{ctxGauge.on}</Text>
625 <Text color={C.faint}>{ctxGauge.off}</Text>
626 <Text bold>{` ${Math.round(u.pct ?? 0)}%`}</Text>
627 {u.tokens !== null ? <Text dimColor>{` ${kTokens(u.tokens)}/${kTokens(u.window)}`}</Text> : null}
628 {u.compactions > 0 ? <Text color={C.amber}>{` ⟲${u.compactions}`}</Text> : null}
629 </Text>
630 ) : null}
631 {u.costUsd !== null || u.limits.length > 0 ? (
632 <Text wrap="truncate">
633 {u.costUsd !== null ? <Text color={C.text}>{`${fmtUsd(u.costUsd)} `}</Text> : null}
634 {u.limits.slice(0, 2).map(l => {
635 const lg = gauge(l.pct, 5)
636 return (
637 <Text wrap="truncate">
638 <Text dimColor>{`${limitLabel(l.kind)} `}</Text>
639 <Text color={l.pct >= 80 ? C.warn : C.main}>{lg.on}</Text>
640 <Text color={C.faint}>{lg.off}</Text>
641 <Text dimColor>{` ${Math.round(l.pct)}% `}</Text>
642 </Text>
643 )
644 })}
645 </Text>
646 ) : null}
647 </Box>
648 )
649
650 // ---- architect
651 const lastConsult = a.consults[a.consults.length - 1]
652 const architectPanel = (w: number) => {
653 const tl = consultTimeline(a, now, Math.max(8, w - 4))
654 return (
655 <Box flexDirection="column" borderStyle="round" borderColor={C.arch} paddingX={1} width={w}>
656 <Box justifyContent="space-between">
657 <Text color={C.arch} bold>
658 {cfg.architectLabel} · {advising ? 'advising' : 'on call'}
659 </Text>
660 <Text>
661 <Text dimColor>consults </Text>
662 <Text color={C.arch} bold>
663 {a.consults.length}
664 </Text>
665 </Text>
666 </Box>
667 <Text color={C.arch}>{tl}</Text>
668 {lastConsult ? (
669 <Text dimColor wrap="truncate">
670 {advising
671 ? `consulting since ${fmtClock(lastConsult.at)}`
672 : `last ${fmtDuration(now - (lastConsult.endAt ?? lastConsult.at))} ago · took ${fmtDuration((lastConsult.endAt ?? now) - lastConsult.at)}`}
673 </Text>
674 ) : (
675 <Text dimColor>not consulted yet</Text>
676 )}
677 {cfg.moments ? (
678 <Box flexWrap="wrap" columnGap={2}>
679 {(['before a plan', 'error repeats', 'before done'] as const).map(mo => {
680 const isOn = lastConsult?.moment === mo
681 return (
682 <Text color={isOn ? C.arch : C.dim} bold={isOn}>
683 {isOn ? '◆' : '◇'} {mo}
684 </Text>
685 )
686 })}
687 <Text color={C.faint}>(inferred)</Text>
688 </Box>
689 ) : null}
690 {a.lastAdvice ? (
691 <Text color={C.arch} wrap="truncate">
692 » {a.lastAdvice}
693 </Text>
694 ) : null}
695 </Box>
696 )
697 }
698
699 // ---- gate
700 const s = gateSummary(g)
701 const verdictColor = (c: Check) =>
702 c.verdict === 'rule' ? C.gate : c.verdict === 'cleared' ? C.cleared : c.verdict === 'ask' ? C.amber : C.warn
703 const gatePanel = (w: number) => {
704 const strip = g.recent.slice(-Math.max(8, w - 4))
705 const open = v.gateOpen
706 return (
707 <Box flexDirection="column" borderStyle="round" borderColor={C.gate} paddingX={1} width={w}>
708 <Box justifyContent="space-between">
709 <Text color={C.gate} bold>
710 {cfg.gateLabel} · permissions
711 </Text>
712 <Text dimColor>{`${s.total} checks`}</Text>
713 </Box>
714 <Box>
715 {strip.length === 0 ? <Text color={C.faint}>no checks yet</Text> : null}
716 {strip.map(c => (
717 <Text color={verdictColor(c)} dimColor={c.inSubagent}>
718 {c.verdict === 'deny' ? '✗' : '■'}
719 </Text>
720 ))}
721 </Box>
722 <Text wrap="truncate">
723 <Text color={C.gate}>■</Text>
724 <Text dimColor>{` ${s.rule} allowed `}</Text>
725 <Text color={C.cleared}>■</Text>
726 <Text dimColor>{` ${s.cleared} ${decider} `}</Text>
727 {s.ask > 0 ? <Text color={C.amber}>{`■ ${s.ask} pending `}</Text> : null}
728 <Text color={s.deny > 0 ? C.warn : C.dim}>{`✗ ${s.deny} denied`}</Text>
729 {w >= 80 && g.recent.some(c => c.inSubagent) ? <Text color={C.faint}>{' dim: in subagents'}</Text> : null}
730 </Text>
731 <Box columnGap={2}>
732 {(['file', 'shell', 'other'] as const).map((b: Bucket) => {
733 const tl = g.totals[b]
734 const n = tl.rule + tl.ask + tl.cleared + tl.deny
735 return (
736 <Button
737 key={`gate-${b}`}
738 plain
739 hotkey={b[0]}
740 label={`${b} ${n}${open === b ? ' ▾' : ''}`}
741 dimColor={n === 0}
742 onPress={() => update($, view, x => ({ ...normalize(DEFAULT_VIEW, x), gateOpen: normalize(DEFAULT_VIEW, x).gateOpen === b ? null : b }))}
743 />
744 )
745 })}
746 </Box>
747 {open
748 ? g.recent
749 .filter(c => c.bucket === open)
750 .slice(-5)
751 .map(c => (
752 <Text wrap="truncate">
753 <Text color={verdictColor(c)}>{c.verdict === 'deny' ? '✗ ' : '■ '}</Text>
754 <Text color={C.dim}>{`${(c.verdict === 'rule' ? 'allowed' : c.verdict === 'cleared' ? decider : c.verdict === 'ask' ? 'pending' : 'denied').padEnd(10)} `}</Text>
755 <Text dimColor={c.inSubagent}>{shorten(c.detail, Math.max(10, w - 18))}</Text>
756 </Text>
757 ))
758 : null}
759 </Box>
760 )
761 }
762
763 // ---- agents: cards up to the limit, swimlanes beyond it
764 const statusColor = (c: AgentCard) => (c.status === 'failed' ? C.warn : c.status === 'done' ? C.gate : C.agent)
765 const glyph = (c: AgentCard) => (c.status === 'running' ? '◐' : c.status === 'done' ? '✓' : c.status === 'failed' ? '✗' : '■')
766 const expandOnPress = (id: string) => () =>
767 update($, view, x => ({ ...normalize(DEFAULT_VIEW, x), expanded: normalize(DEFAULT_VIEW, x).expanded === id ? null : id }))
768
769 const agentsPanel = (w: number) => {
770 // Cards need 20 columns each; when the pane can't hold the limit, lanes take over.
771 const fit = Math.max(1, Math.min(cfg.maxCards, Math.floor((w + 1) / 21)))
772 const useLanes = cards.length > fit
773 const header = (
774 <Box justifyContent="space-between" width={w}>
775 <Text bold>{`agents · ${running.length} running · ${cards.length} total`}</Text>
776 {cards.length > 0 ? <Text color={C.faint}>1-{Math.min(cards.length, useLanes ? 6 : fit)} expand</Text> : null}
777 </Box>
778 )
779 if (cards.length === 0) {
780 return (
781 <Box flexDirection="column" width={w}>
782 {header}
783 <Text color={C.faint}>no subagents yet</Text>
784 </Box>
785 )
786 }
787 if (useLanes) {
788 const shown = cards.slice(-6)
789 const barW = Math.max(8, w - 28)
790 const geo = lanes(shown, now, barW)
791 return (
792 <Box flexDirection="column" width={w}>
793 {header}
794 {cards.length > shown.length ? <Text color={C.faint}>{`+${cards.length - shown.length} earlier`}</Text> : null}
795 {shown.map((c, i) => {
796 const gm = geo[i]
797 const isViewed = viewed === c.id
798 return (
799 <Box>
800 <Text color={statusColor(c)} bold={isViewed}>{`${isViewed ? '▶' : glyph(c)} `}</Text>
801 <Box width={17}>
802 <Button key={`card-${c.id}`} plain hotkey={String(i + 1)} label={shorten(cardTitle(c), 14)} onPress={expandOnPress(c.id)} />
803 </Box>
804 <Text color={C.faint}>{' ' + '·'.repeat(gm?.before ?? 0)}</Text>
805 <Text color={statusColor(c)}>{'━'.repeat(gm?.bar ?? 1)}</Text>
806 <Text color={C.faint}>{'·'.repeat(gm?.after ?? 0) + ' '}</Text>
807 {clock(`lane-clock-${c.id}`, c.spawnedAt, c.endedAt, C.dim)}
808 </Box>
809 )
810 })}
811 </Box>
812 )
813 }
814 const shown = cards.slice(-fit)
815 const cardW = Math.max(20, Math.floor((w - (shown.length - 1)) / shown.length))
816 const centers = shown.map((_, i) => i * (cardW + 1) + Math.floor(cardW / 2))
817 return (
818 <Box flexDirection="column" width={w}>
819 {header}
820 {rail('fan-out', running.length > 0, C.agent, w, centers)}
821 <Box columnGap={1}>
822 {shown.map((c, i) => {
823 const isViewed = viewed === c.id
824 const sameModel = !c.model || prettyModel(c.model) === modelName
825 return (
826 <Box
827 flexDirection="column"
828 borderStyle={isViewed ? 'double' : 'round'}
829 borderColor={c.lastStop === 'max_tokens' ? C.warn : C.agent}
830 borderDimColor={c.status !== 'running' && !isViewed}
831 width={cardW}
832 paddingX={1}
833 >
834 <Button key={`card-${c.id}`} plain hotkey={String(i + 1)} label={titleLines(cardTitle(c), cardW - 7, cardW - 4)[0]} onPress={expandOnPress(c.id)} />
835 <Text bold wrap="truncate">
836 {titleLines(cardTitle(c), cardW - 7, cardW - 4)[1]}
837 </Text>
838 <Text color={C.dim} wrap="truncate">
839 {sameModel ? c.type : `${c.type} · ${prettyModel(c.model)}`}
840 </Text>
841 <Text dimColor wrap="truncate">
842 {c.steps > 0 ? `ctx ${kTokens(c.ctx)} · out ${kTokens(c.out)} · ${c.steps} st` : 'starting…'}
843 </Text>
844 <Box>
845 <Text color={c.lastStop === 'max_tokens' ? C.warn : statusColor(c)}>
846 {cardW >= 26 ? `${glyph(c)} ${c.lastStop === 'max_tokens' ? 'max_tokens' : c.status} ` : `${glyph(c)} `}
847 </Text>
848 <Box flexShrink={0}>{clock(`card-clock-${c.id}`, c.spawnedAt, c.endedAt, C.dim)}</Box>
849 </Box>
850 </Box>
851 )
852 })}
853 </Box>
854 {rail('merge', running.length > 0, C.agent, w, centers, true)}
855 </Box>
856 )
857 }
858
859 const expandedCard = cards.find(c => c.id === v.expanded)
860 const expandedPanel = (w: number) =>
861 expandedCard ? (
862 <Box flexDirection="column" borderStyle="single" borderColor={C.agent} paddingX={1} width={w}>
863 <Text bold wrap="wrap">
864 {expandedCard.description || expandedCard.type}
865 </Text>
866 <Text dimColor wrap="truncate">{`${expandedCard.type} · ${prettyModel(expandedCard.model)} · ${expandedCard.status} · ${expandedCard.steps} steps`}</Text>
867 {expandedCard.tools.length === 0 ? <Text color={C.faint}>no tool calls yet</Text> : null}
868 {expandedCard.tools.map(n => (
869 <Text color={n.isError ? C.warn : C.text} wrap="truncate">
870 {`${n.isError ? '✗' : '·'} ${n.text}`}
871 </Text>
872 ))}
873 {expandedCard.answer ? (
874 <Text dimColor wrap="wrap">
875 {`» ${shorten(expandedCard.answer, 240)}`}
876 </Text>
877 ) : null}
878 </Box>
879 ) : null
880
881 // ---- other loops (workflow agents, forks): ids that match no card
882 const loopsPanel = (w: number) => {
883 if (lp.length === 0) return null
884 const active = lp.filter(l => isLoopActive(l, now)).length
885 const dots = lp.slice(-Math.max(4, w - 38))
886 return (
887 <Box width={w}>
888 <Text bold>other loops </Text>
889 <Text dimColor>{`${lp.length} seen · ${active} active `}</Text>
890 {dots.map(l => (
891 <Text color={isLoopActive(l, now) ? C.agent : l.isDone ? C.dim : C.faint}>{isLoopActive(l, now) ? '●' : l.isDone ? '✓' : '○'}</Text>
892 ))}
893 </Box>
894 )
895 }
896
897 // ---- receipt: the turn now, or the last one
898 const receiptPanel = (w: number) => {
899 const isReview = t.isReviewing
900 return (
901 <Box flexDirection="column" borderStyle="round" borderColor={C.main} borderDimColor={!m.isRunning && !isReview} paddingX={1} width={w}>
902 {m.isRunning ? (
903 <Box>
904 <Text color={C.main} wrap="truncate">{`◐ back to ${modelName.toLowerCase()} · turn `}</Text>
905 <Box flexShrink={0}>{clock('turn-clock', t.startedAt, null, C.main)}</Box>
906 {w >= 60 ? <Text dimColor wrap="truncate">{` · ${plural(t.edits, 'edit')} · ${plural(t.errors, 'error')}`}</Text> : null}
907 </Box>
908 ) : r ? (
909 <Text wrap="truncate">
910 <Text color={r.reason === 'answer' ? C.gate : C.warn}>{r.reason === 'answer' ? '✓ ' : '✗ '}</Text>
911 <Text>{`last turn ${fmtDuration(r.durationMs)} · ${plural(r.agents, 'agent')} · ${plural(r.edits, 'edit')} · ${plural(r.errors, 'error')}`}</Text>
912 {r.costDelta !== null ? <Text color={C.main}>{` · +${fmtUsd(r.costDelta)}`}</Text> : null}
913 </Text>
914 ) : (
915 <Text color={C.faint}>no turn finished yet</Text>
916 )}
917 {isReview ? <Text color={C.arch}>{`${cfg.architectLabel.toLowerCase()} reviewing before done (inferred)`}</Text> : null}
918 </Box>
919 )
920 }
921
922 // ---- log: whatever rows the other panels leave, 4 to 8
923 const used = 2 + 5 + (showArchitect ? 6 : 0) + 6 + (v.gateOpen ? 5 : 0) + (cards.length > cfg.maxCards ? 3 + Math.min(6, cards.length) : 8) + (expandedCard ? 8 : 0) + (lp.length ? 1 : 0) + 3
924 const bodyRows = e.props.scroll?.bodyRows ?? e.viewport?.rows ?? 40
925 const nLog = logRows(bodyRows, used)
926 const shownLines = (viewed ? lines.filter(l => l.agentId === viewed) : lines).slice(-nLog)
927 const colorOf = (l: LogLine) =>
928 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
929 const logPanel = (w: number) => (
930 <Box flexDirection="column" borderStyle="round" borderColor={C.faint} paddingX={1} width={w}>
931 <Text dimColor>{viewed ? 'session log · this agent' : 'session log'}</Text>
932 {shownLines.length === 0 ? <Text color={C.faint}>nothing yet</Text> : null}
933 {shownLines.map(l => (
934 <Box>
935 <Box width={9} flexShrink={0}>
936 <Text color={C.faint}>{fmtClock(l.at)}</Text>
937 </Box>
938 <Box width={13} flexShrink={0}>
939 <Text color={colorOf(l)} bold wrap="truncate">
940 {l.who}
941 </Text>
942 </Box>
943 <Text color={l.kind === 'error' ? C.warn : C.text} wrap="truncate">
944 {l.text}
945 </Text>
946 </Box>
947 ))}
948 </Box>
949 )
950
951 const draw = (p: Panel, w: number) =>
952 p === 'main'
953 ? mainPanel(w)
954 : p === 'architect'
955 ? architectPanel(w)
956 : p === 'gate'
957 ? gatePanel(w)
958 : p === 'agents'
959 ? agentsPanel(w)
960 : p === 'loops'
961 ? loopsPanel(w)
962 : p === 'receipt'
963 ? receiptPanel(w)
964 : logPanel(w)
965
966 // Panels with the flow between them; the agents panel draws its own rails.
967 const column = (ps: Panel[], w: number) => (
968 <Box flexDirection="column" width={w}>
969 {ps.map((p, i) => {
970 const prev = ps[i - 1]
971 const link =
972 i === 0 || p === 'agents' || prev === 'agents' || p === 'log' || p === 'loops' || prev === 'loops'
973 ? null
974 : rail(`link-${p}`, p === 'architect' ? advising : m.isRunning, p === 'architect' ? C.arch : C.main, w)
975 return (
976 <Box flexDirection="column">
977 {link}
978 {draw(p, w)}
979 {p === 'agents' ? expandedPanel(w) : null}
980 </Box>
981 )
982 })}
983 </Box>
984 )
985
986 // Desktop draws the agents' time axis as SVG under the panels.
987 const svgLanes =
988 e.surface !== 'terminal' && cards.length > 0
989 ? (() => {
990 const { Svg } = $.ui.resolve(e)
991 const rowH = 18
992 const pxW = 520
993 const geo = lanes(cards.slice(-10), now, 100)
994 const rects = cards
995 .slice(-10)
996 .map((c, i) => {
997 const gm = geo[i]
998 const x = 150 + ((gm?.before ?? 0) / 100) * (pxW - 160)
999 const wpx = Math.max(3, ((gm?.bar ?? 1) / 100) * (pxW - 160))
1000 const fill = c.status === 'running' ? SVG_COLORS.running : c.status === 'done' ? SVG_COLORS.done : c.status === 'failed' ? SVG_COLORS.failed : SVG_COLORS.other
1001 const label = shorten(cardTitle(c), 22).replace(/[<&>]/g, '')
1002 return `<text x="4" y="${i * rowH + 13}" font-size="11" fill="${SVG_COLORS.label}">${label}</text><rect x="${x}" y="${i * rowH + 4}" width="${wpx}" height="10" rx="3" fill="${fill}"/>`
1003 })
1004 .join('')
1005 const svgH = Math.min(10, cards.length) * rowH + 4
1006 return (
1007 <Svg
1008 source={`<svg xmlns="http://www.w3.org/2000/svg" width="${pxW}" height="${svgH}" viewBox="0 0 ${pxW} ${svgH}">${rects}</svg>`}
1009 alt={`${cards.length} agents on a time axis`}
1010 width={pxW}
1011 height={svgH}
1012 />
1013 )
1014 })()
1015 : null
1016
1017 // Inline above the prompt (the terminal's main screen), the pane is a summary of at most 8 rows.
1018 const isMini = layout === 'mini' || (layout === 'auto' && e.props.placement === 'inline')
1019 if (isMini) {
1020 const live = [...cards.filter(c => c.status === 'running'), ...cards.filter(c => c.status !== 'running').reverse()].slice(0, 3)
1021 const counts = ` ${s.rule} allowed · ${s.cleared} ${decider}${s.ask > 0 ? ` · ${s.ask} pending` : ''} · ${s.deny} denied`
1022 const strip = g.recent.slice(-Math.max(4, W - cfg.gateLabel.length - 1 - counts.length))
1023 const mg = u.pct !== null ? gauge(u.pct, 6) : null
1024 return (
1025 <Box flexDirection="column" width={W}>
1026 <Text wrap="truncate">
1027 <Text color={C.main} bold>
1028 {modelName}
1029 </Text>
1030 <Text color={m.isRunning ? C.main : C.dim}>{m.isRunning ? ' ● working' : ' ○ idle'}</Text>
1031 {mg ? <Text dimColor> · ctx </Text> : null}
1032 {mg ? <Text color={(u.pct ?? 0) >= 80 ? C.warn : C.main}>{mg.on}</Text> : null}
1033 {mg ? <Text color={C.faint}>{mg.off}</Text> : null}
1034 {mg ? <Text>{` ${Math.round(u.pct ?? 0)}%`}</Text> : null}
1035 {u.compactions > 0 ? <Text color={C.amber}>{` ⟲${u.compactions}`}</Text> : null}
1036 {u.costUsd !== null ? <Text dimColor>{` · ${fmtUsd(u.costUsd)}`}</Text> : null}
1037 {showArchitect ? <Text color={C.arch}>{` · ${cfg.architectLabel.toLowerCase()} ${advising ? 'advising' : a.consults.length}`}</Text> : null}
1038 </Text>
1039 {strip.length > 0 ? (
1040 <Box>
1041 <Text dimColor>{`${cfg.gateLabel.toLowerCase()} `}</Text>
1042 {strip.map(c => (
1043 <Text color={verdictColor(c)} dimColor={c.inSubagent}>
1044 {c.verdict === 'deny' ? '✗' : '■'}
1045 </Text>
1046 ))}
1047 <Text color={s.deny > 0 ? C.warn : s.ask > 0 ? C.amber : C.dim} wrap="truncate">
1048 {counts}
1049 </Text>
1050 </Box>
1051 ) : null}
1052 {live.map(c => (
1053 <Box>
1054 <Text color={statusColor(c)}>{`${glyph(c)} `}</Text>
1055 <Box width={Math.max(10, W - 30)}>
1056 <Text wrap="truncate">{cardTitle(c)}</Text>
1057 </Box>
1058 <Text dimColor>{c.steps > 0 ? ` ctx ${kTokens(c.ctx)} ` : ' '}</Text>
1059 {clock(`mini-clock-${c.id}`, c.spawnedAt, c.endedAt, C.dim)}
1060 </Box>
1061 ))}
1062 {cards.length > live.length ? (
1063 <Text color={C.faint} wrap="truncate">{`+${cards.length - live.length} more agents · /flightdeck layout compact for all`}</Text>
1064 ) : null}
1065 {lp.length > 0 ? <Text dimColor>{`other loops ${lp.length} · ${lp.filter(l => isLoopActive(l, now)).length} active`}</Text> : null}
1066 {!m.isRunning && r ? (
1067 <Text dimColor wrap="truncate">
1068 {`last turn ${fmtDuration(r.durationMs)} · ${plural(r.agents, 'agent')} · ${plural(r.edits, 'edit')} · ${plural(r.errors, 'error')}${r.costDelta !== null ? ` · +${fmtUsd(r.costDelta)}` : ''}`}
1069 </Text>
1070 ) : null}
1071 </Box>
1072 )
1073 }
1074
1075 const legend = fitLegend(
1076 [
1077 { label: 'main', color: C.main },
1078 { label: 'agents', color: C.agent },
1079 { label: cfg.gateLabel.toLowerCase(), color: C.gate },
1080 ...(showArchitect ? [{ label: cfg.architectLabel.toLowerCase(), color: C.arch }] : []),
1081 ],
1082 W,
1083 )
1084
1085 const body = isWide ? (
1086 <Box flexDirection="column">
1087 <Box columnGap={2}>
1088 {column(panels.filter(p => p === 'main' || p === 'architect' || p === 'gate'), colW)}
1089 {column(panels.filter(p => p === 'agents' || p === 'loops' || p === 'receipt'), colW)}
1090 </Box>
1091 {panels.includes('log') ? logPanel(W) : null}
1092 </Box>
1093 ) : (
1094 column(panels, W)
1095 )
1096
1097 return (
1098 <Box flexDirection="column" width={W}>
1099 <Box justifyContent="center">
1100 <Text bold wrap="truncate">
1101 <Text>FLIGHTDECK</Text>
1102 <Text color={C.dim}> · </Text>
1103 <Text color={C.main}>{modelName.toUpperCase()}</Text>
1104 <Text>{m.isRunning ? ' WORKS' : ' IDLE'}</Text>
1105 {showArchitect ? <Text color={C.dim}> · </Text> : null}
1106 {showArchitect ? <Text color={C.arch}>{cfg.architectLabel}</Text> : null}
1107 {showArchitect ? <Text>{advising ? ' ADVISING' : ' ON CALL'}</Text> : null}
1108 </Text>
1109 </Box>
1110 <Box justifyContent="center" columnGap={2}>
1111 {legend.map(l => (
1112 <Text>
1113 <Text color={l.color}>■</Text>
1114 <Text dimColor>{` ${l.label}`}</Text>
1115 </Text>
1116 ))}
1117 </Box>
1118 {body}
1119 {svgLanes}
1120 </Box>
1121 )
1122 })
1123}
1124hooks/core.ts 493 lines1// Pure data: defaults, reducers, formatting and layout math. Nothing here touches `$`, so every
2// behaviour is testable directly (the test kit cannot raise a subagent's tool call or a
3// permission check inside a call; these functions are what the hooks apply).
4import type {
5 AgentCard,
6 Architect,
7 Bucket,
8 Check,
9 Gate,
10 Layout,
11 LogLine,
12 Loop,
13 Main,
14 Moment,
15 Receipt,
16 Roster,
17 Tally,
18 ToolNote,
19 Turn,
20 Usage,
21 View,
22} from '../types'
23
24export const SCHEMA_VERSION = 2
25
26// ---------------------------------------------------------------- defaults
27
28export const DEFAULT_MAIN: Main = { model: '', effort: '', mode: '', steps: 0, isRunning: false }
29export const DEFAULT_USAGE: Usage = {
30 pct: null,
31 tokens: null,
32 window: 0,
33 costUsd: null,
34 limits: [],
35 compactions: 0,
36 lastCompactAt: null,
37}
38export const DEFAULT_ARCHITECT: Architect = { consults: [], ids: [], seen: [], lastAdvice: '' }
39const ZERO: Tally = { rule: 0, ask: 0, cleared: 0, deny: 0 }
40export const DEFAULT_GATE: Gate = { recent: [], totals: { file: ZERO, shell: ZERO, other: ZERO } }
41export const DEFAULT_TURN: Turn = { edits: 0, errorStreak: 0, errors: 0, isReviewing: false, startedAt: 0, costAtStart: null }
42export const DEFAULT_VIEW: View = { expanded: null, gateOpen: null, layout: null }
43export const DEFAULT_ROSTER: Roster = { architectTypes: [] }
44
45const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
46
47/** A stored object merged over its defaults, so a value saved under an older shape still reads. */
48export const normalize = <T extends object>(def: T, stored: unknown): T =>
49 isObject(stored) ? ({ ...def, ...stored } as T) : def
50
51/** A stored list, or empty when what is stored is not a list. */
52export const listOf = <T>(stored: unknown): T[] => (Array.isArray(stored) ? (stored as T[]) : [])
53
54export const normalizeGate = (stored: unknown): Gate => {
55 const g = normalize(DEFAULT_GATE, stored)
56 const totals = normalize(DEFAULT_GATE.totals, g.totals)
57 return {
58 recent: listOf<Check>(g.recent),
59 totals: { file: normalize(ZERO, totals.file), shell: normalize(ZERO, totals.shell), other: normalize(ZERO, totals.other) },
60 }
61}
62
63export const normalizeCard = (stored: unknown): AgentCard =>
64 normalize<AgentCard>(
65 {
66 id: '',
67 type: 'agent',
68 model: '',
69 description: '',
70 status: 'running',
71 spawnedAt: 0,
72 endedAt: null,
73 ctx: 0,
74 out: 0,
75 steps: 0,
76 lastStop: null,
77 tools: [],
78 answer: '',
79 },
80 stored,
81 )
82
83export const normalizeLog = (stored: unknown): LogLine[] =>
84 listOf<Record<string, unknown>>(stored).map(l => ({
85 at: typeof l.at === 'number' ? l.at : 0,
86 who: String(l.who ?? ''),
87 text: String(l.text ?? ''),
88 agentId: typeof l.agentId === 'string' ? l.agentId : null,
89 kind: l.kind === 'error' || l.kind === 'consult' || l.kind === 'done' ? l.kind : 'info',
90 }))
91
92// ---------------------------------------------------------------- config
93
94export type Panel = 'main' | 'architect' | 'gate' | 'agents' | 'loops' | 'receipt' | 'log'
95const PANELS: readonly Panel[] = ['main', 'architect', 'gate', 'agents', 'loops', 'receipt', 'log']
96
97export type Config = {
98 architect: RegExp
99 architectLabel: string
100 gateLabel: string
101 panels: Panel[]
102 motion: boolean
103 moments: boolean
104 matchDescriptions: boolean
105 maxCards: number
106 layout: Layout
107 palette: Palette
108 openOnStart: boolean
109 statusLine: boolean
110}
111
112const safeRegExp = (source: string, fallback: string) => {
113 try {
114 return new RegExp(source || fallback, 'i')
115 } catch {
116 return new RegExp(fallback, 'i')
117 }
118}
119
120/** The plugin's `/config` values, read leniently: anything malformed falls back to the default. */
121export const parseConfig = (o: Readonly<Record<string, unknown>>): Config => {
122 const str = (k: string, d: string) => (typeof o[k] === 'string' && o[k] !== '' ? (o[k] as string) : d)
123 const bool = (k: string, d: boolean) => (typeof o[k] === 'boolean' ? (o[k] as boolean) : d)
124 const panels = str('panels', PANELS.join(','))
125 .split(',')
126 .map(s => s.trim())
127 .filter((p): p is Panel => (PANELS as readonly string[]).includes(p))
128 const layout = str('layout', 'auto')
129 const max = typeof o.maxCards === 'number' ? Math.round(o.maxCards) : 3
130 return {
131 architect: safeRegExp(str('architectPattern', ''), 'advisor|architect'),
132 architectLabel: str('architectLabel', 'ARCHITECT'),
133 gateLabel: str('gateLabel', 'GATE'),
134 panels: panels.length > 0 ? [...new Set(panels)] : [...PANELS],
135 motion: str('motion', 'while-active') !== 'off',
136 moments: bool('moments', true),
137 matchDescriptions: bool('matchDescriptions', false),
138 maxCards: Math.min(6, Math.max(1, max)),
139 layout: layout === 'compact' || layout === 'wide' || layout === 'mini' ? layout : 'auto',
140 palette: str('palette', 'theme') === 'pastel' ? 'pastel' : 'theme',
141 openOnStart: bool('openOnStart', true),
142 statusLine: bool('statusLine', true),
143 }
144}
145
146// ---------------------------------------------------------------- palette
147
148export type Palette = 'theme' | 'pastel'
149export type Colors = Record<'main' | 'agent' | 'gate' | 'cleared' | 'arch' | 'amber' | 'warn' | 'dim' | 'faint' | 'text', string>
150
151/**
152 * `theme` names the person's own theme colours (they follow light, dark and colour-blind themes);
153 * `pastel` is fixed hex tuned for dark terminals.
154 */
155export const PALETTES: Record<Palette, Colors> = {
156 theme: {
157 main: 'claude',
158 agent: 'suggestion',
159 gate: 'success',
160 cleared: 'permission',
161 arch: 'merged',
162 amber: 'warning',
163 warn: 'error',
164 dim: 'inactive',
165 faint: 'subtle',
166 text: 'text',
167 },
168 pastel: {
169 main: '#7dd3fc',
170 agent: '#93c5fd',
171 gate: '#86efac',
172 cleared: '#5eead4',
173 arch: '#c4b5fd',
174 amber: '#fcd34d',
175 warn: '#fca5a5',
176 dim: '#6b7280',
177 faint: '#3f4654',
178 text: '#e5e7eb',
179 },
180}
181
182/** SVG cannot name theme keys: mid-tone colours that read on light and dark backgrounds. */
183export const SVG_COLORS = { running: '#3b82f6', done: '#16a34a', failed: '#dc2626', other: '#8b5cf6', label: '#6b7280' }
184
185// ---------------------------------------------------------------- formatting
186
187/** `claude-opus-5-5` → `Opus 5.5`; anything else is shown as given. */
188/**
189 * A model id as people say it, from any provider's spelling: `claude-opus-5-5[1m]` → `Opus 5.5 1M`,
190 * `us.anthropic.claude-sonnet-4-5-20250929-v1:0` → `Sonnet 4.5`, `claude-3-5-haiku-20241022` →
191 * `Haiku 3.5`. Anything else is shown as given, cut to 22 characters.
192 */
193export const prettyModel = (id: string) => {
194 if (!id) return '—'
195 const cap = (f: string) => f.charAt(0).toUpperCase() + f.slice(1)
196 const big = /\[1m\]|-1m\b/i.test(id) ? ' 1M' : ''
197 const now = /claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(?![\d])/i.exec(id)
198 if (now?.[1] && !/^\d/.test(now[1])) return `${cap(now[1].toLowerCase())} ${now[2]}${now[3] ? `.${now[3]}` : ''}${big}`
199 const old = /claude-(\d+)(?:-(\d))?-([a-z]+)/i.exec(id)
200 if (old?.[3]) return `${cap(old[3].toLowerCase())} ${old[1]}${old[2] ? `.${old[2]}` : ''}${big}`
201 return shorten(id, 22)
202}
203
204export const shorten = (s: string, n: number) => {
205 const one = s.replace(/\s+/g, ' ').trim()
206 return n <= 0 ? '' : one.length > n ? `${one.slice(0, Math.max(0, n - 1)).trimEnd()}…` : one
207}
208
209export const kTokens = (n: number) =>
210 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
211
212export const fmtDuration = (ms: number) => {
213 const s = Math.max(0, Math.round(ms / 1000))
214 if (s < 60) return `${s}s`
215 const m = Math.floor(s / 60)
216 return m < 60 ? `${m}m${String(s % 60).padStart(2, '0')}s` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
217}
218
219/** A running clock as the live cards draw it: m:ss, or XhYY past an hour. */
220export const fmtTimer = (ms: number) => {
221 const s = Math.max(0, Math.floor(ms / 1000))
222 const m = Math.floor(s / 60)
223 return m < 60 ? `${m}:${String(s % 60).padStart(2, '0')}` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
224}
225
226export const fmtClock = (ms: number) => (ms > 0 ? new Date(ms).toTimeString().slice(0, 8) : '--:--:--')
227
228/** `1 error`, `2 errors`. */
229export const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
230
231export const fmtUsd = (n: number) => (n >= 100 ? `$${Math.round(n)}` : `$${n.toFixed(2)}`)
232
233/** A gauge of `width` cells: ▰ filled, ▱ empty. */
234export const gauge = (pct: number, width: number) => {
235 const full = Math.max(0, Math.min(width, Math.round((pct / 100) * width)))
236 return { on: '▰'.repeat(full), off: '▱'.repeat(width - full) }
237}
238
239/** A rate-limit window's short name: `five_hour` → `5h`, `seven_day_opus` → `7d opus`. */
240export const limitLabel = (kind: string) =>
241 kind
242 .replace(/five[_ -]?hours?/i, '5h')
243 .replace(/seven[_ -]?days?/i, '7d')
244 .replace(/[_-]+/g, ' ')
245 .trim()
246
247// ---------------------------------------------------------------- redaction
248
249const SECRETS: [RegExp, string][] = [
250 [/(authorization\s*[:=]\s*)(bearer\s+|basic\s+)?\S+/gi, '$1$2•••'],
251 [/\b(bearer)\s+[A-Za-z0-9._~+/-]{8,}=*/gi, '$1 •••'],
252 [/\b(sk|pk|rk|ghp|gho|ghs|github_pat|xox[abprs])[-_][A-Za-z0-9_-]{8,}/g, '•••'],
253 [/((?:api[_-]?key|access[_-]?token|token|secret|password|passwd|pwd)\s*[=:]\s*)("[^"]*"|'[^']*'|\S+)/gi, '$1•••'],
254 [/(--(?:token|password|api-key|secret)[= ])\S+/gi, '$1•••'],
255 [/(\b[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)=)\S+/g, '$1•••'],
256 [/(\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:)[^\s@]+@/gi, '$1•••@'],
257]
258
259/** What the gate drill-down may store: credentials masked before anything is written to state. */
260export const redact = (s: string) => SECRETS.reduce((t, [re, to]) => t.replace(re, to), s)
261
262// ---------------------------------------------------------------- tools and gate
263
264const FILE_TOOLS = new Set(['Read', 'Edit', 'Write', 'NotebookEdit', 'Glob', 'Grep'])
265const SHELL_TOOLS = new Set(['Bash', 'PowerShell'])
266export const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
267
268export const bucketOf = (tool: string): Bucket =>
269 FILE_TOOLS.has(tool) ? 'file' : SHELL_TOOLS.has(tool) ? 'shell' : 'other'
270
271/** One line saying what a call was about: its command, path or pattern; redacted. */
272export const describeInput = (tool: string, input: unknown) => {
273 const i = isObject(input) ? input : {}
274 const path = typeof i.file_path === 'string' ? i.file_path.split(/[\\/]/).slice(-2).join('/') : ''
275 const what =
276 typeof i.command === 'string'
277 ? i.command
278 : path || (typeof i.pattern === 'string' ? i.pattern : typeof i.url === 'string' ? i.url : typeof i.description === 'string' ? i.description : '')
279 return redact(what ? `${tool} → ${what}` : tool)
280}
281
282/** Keeps the last `max` checks, but never drops a pending ask: its settle must still find it. */
283export const trimRecent = (list: Check[], max: number): Check[] => {
284 let extra = list.length - max
285 if (extra <= 0) return list
286 const out: Check[] = []
287 for (const c of list) {
288 if (extra > 0 && c.verdict !== 'ask') {
289 extra -= 1
290 continue
291 }
292 out.push(c)
293 }
294 return out.slice(-max * 2)
295}
296
297export const recordCheck = (g: Gate, c: Check): Gate => {
298 const t = g.totals[c.bucket]
299 return {
300 recent: trimRecent([...g.recent, c], 80),
301 totals: { ...g.totals, [c.bucket]: { ...t, [c.verdict]: t[c.verdict] + 1 } },
302 }
303}
304
305/** An `ask` settled by the call that followed it: it ran (cleared) or was refused (deny). */
306export const settleCheck = (g: Gate, id: string, didRun: boolean): Gate => {
307 const c = g.recent.find(r => r.id === id && r.verdict === 'ask')
308 if (!c) return g
309 const verdict = didRun ? 'cleared' : 'deny'
310 const t = g.totals[c.bucket]
311 return {
312 recent: g.recent.map(r => (r === c ? { ...r, verdict } : r)),
313 totals: { ...g.totals, [c.bucket]: { ...t, ask: Math.max(0, t.ask - 1), [verdict]: t[verdict] + 1 } },
314 }
315}
316
317export const gateSummary = (g: Gate) => {
318 const all = (['file', 'shell', 'other'] as const).reduce(
319 (s, k) => ({
320 rule: s.rule + g.totals[k].rule,
321 ask: s.ask + g.totals[k].ask,
322 cleared: s.cleared + g.totals[k].cleared,
323 deny: s.deny + g.totals[k].deny,
324 }),
325 { ...ZERO },
326 )
327 return { ...all, total: all.rule + all.ask + all.cleared + all.deny }
328}
329
330// ---------------------------------------------------------------- turn and architect
331
332/**
333 * The turn after one tool call. Errors count in the main loop only (a subagent's failure is its
334 * own); edits count from every loop, so delegated work still reaches "before done".
335 */
336export const afterCall = (t: Turn, c: { inSubagent: boolean; hasFailed: boolean; isEdit: boolean }): Turn => ({
337 ...t,
338 errorStreak: c.inSubagent ? t.errorStreak : c.hasFailed ? t.errorStreak + 1 : 0,
339 errors: t.errors + (!c.inSubagent && c.hasFailed ? 1 : 0),
340 edits: t.edits + (c.isEdit ? 1 : 0),
341})
342
343/** Which of the architect's three moments a consult falls at: an inference over this turn so far. */
344export const momentOf = (t: Pick<Turn, 'edits' | 'errorStreak'>): Moment =>
345 t.errorStreak >= 2 ? 'error repeats' : t.edits === 0 ? 'before a plan' : 'before done'
346
347export const startConsult = (a: Architect, c: { id: string; at: number; moment: Moment; via: string }): Architect =>
348 a.consults.some(x => x.id === c.id) ? a : { ...a, consults: [...a.consults, { ...c, endAt: null }].slice(-40) }
349
350/** Ends the open consult (the latest without an end), or the one named. */
351export const endConsult = (a: Architect, at: number, advice: string | null, id?: string): Architect => {
352 const open = [...a.consults].reverse().find(c => c.endAt === null && (id === undefined || c.id === id))
353 return {
354 ...a,
355 consults: a.consults.map(c => (c === open ? { ...c, endAt: at } : c)),
356 lastAdvice: advice ?? a.lastAdvice,
357 }
358}
359
360export const isAdvising = (a: Architect) => a.consults.some(c => c.endAt === null)
361
362/** A one-row timeline of consults across `width` cells: ◆ a consult, ━ while it ran. */
363export const consultTimeline = (a: Architect, now: number, width: number) => {
364 if (a.consults.length === 0 || width < 4) return '─'.repeat(Math.max(0, width))
365 const first = a.consults[0]?.at ?? now
366 const span = Math.max(1, now - first)
367 const cells = Array.from({ length: width }, () => '─')
368 for (const c of a.consults) {
369 const from = Math.min(width - 1, Math.floor(((c.at - first) / span) * (width - 1)))
370 const to = Math.min(width - 1, Math.floor((((c.endAt ?? now) - first) / span) * (width - 1)))
371 for (let i = from + 1; i <= to; i += 1) cells[i] = '━'
372 cells[from] = '◆'
373 }
374 return cells.join('')
375}
376
377export const receiptOf = (t: Turn, o: { durationMs: number; agentsSince: number; costNow: number | null; reason: string }): Receipt => ({
378 durationMs: o.durationMs,
379 agents: o.agentsSince,
380 edits: t.edits,
381 errors: t.errors,
382 costDelta: o.costNow !== null && t.costAtStart !== null && o.costNow - t.costAtStart >= 0.005 ? o.costNow - t.costAtStart : null,
383 reason: o.reason,
384})
385
386// ---------------------------------------------------------------- agents and loops
387
388type StepUsage = { input_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number; output_tokens?: number } | null
389
390/** A card after one of its model requests: its context is the latest step's whole input; output adds up. */
391export const applyStep = (c: AgentCard, s: { model: string; usage: StepUsage; stopReason: string | null }): AgentCard => {
392 const u = s.usage ?? {}
393 const ctx = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
394 return {
395 ...c,
396 model: c.model || s.model,
397 steps: c.steps + 1,
398 ctx: ctx > 0 ? ctx : c.ctx,
399 out: c.out + (u.output_tokens ?? 0),
400 lastStop: s.stopReason,
401 }
402}
403
404export const noteTool = (c: AgentCard, n: ToolNote): AgentCard => ({ ...c, tools: [...c.tools, n].slice(-3) })
405
406export const stepLoop = (loops: Loop[], id: string, at: number): Loop[] => {
407 const found = loops.find(l => l.id === id)
408 const next = found
409 ? loops.map(l => (l === found ? { ...l, steps: l.steps + 1, lastAt: at } : l))
410 : [...loops, { id, steps: 1, firstAt: at, lastAt: at, isDone: false }]
411 return next.slice(-60)
412}
413
414export const LOOP_ACTIVE_MS = 15_000
415export const isLoopActive = (l: Loop, now: number) => !l.isDone && now - l.lastAt < LOOP_ACTIVE_MS
416
417/** Swimlane geometry: each agent's bar on one shared axis from the first spawn to now. */
418export const lanes = (cards: AgentCard[], now: number, width: number) => {
419 const start = Math.min(...cards.map(c => c.spawnedAt).filter(n => n > 0), now)
420 const span = Math.max(1, now - start)
421 return cards.map(c => {
422 const from = Math.floor(((Math.max(c.spawnedAt, start) - start) / span) * width)
423 const to = Math.max(from + 1, Math.ceil((((c.endedAt ?? now) - start) / span) * width))
424 return { id: c.id, before: Math.min(from, width), bar: Math.min(to, width) - Math.min(from, width), after: width - Math.min(to, width) }
425 })
426}
427
428// ---------------------------------------------------------------- layout
429
430/** How many log lines fit: what the other panels leave, never fewer than 4 nor more than 8. */
431export const logRows = (bodyRows: number, used: number) => Math.max(4, Math.min(8, bodyRows - used - 3))
432
433/** Legend items that fit on one row of `width` cells, in order; the rest are dropped. */
434export const fitLegend = <T extends { label: string }>(items: T[], width: number) => {
435 const out: T[] = []
436 let used = 0
437 for (const it of items) {
438 const w = it.label.length + 4
439 if (used + w > width) break
440 out.push(it)
441 used += w
442 }
443 return out
444}
445
446/** A card's title row: the task in the agent's own words, the type only when there is none. */
447export const cardTitle = (c: AgentCard) => c.description || c.type
448
449/** A title split over two rows at a word boundary: `first` cells on row one, `rest` on row two. */
450export const titleLines = (title: string, first: number, rest: number): [string, string] => {
451 const t = title.replace(/\s+/g, ' ').trim()
452 if (t.length <= first) return [t, '']
453 const cut = t.lastIndexOf(' ', first)
454 const at = cut > 0 ? cut : first
455 return [t.slice(0, at).trim(), shorten(t.slice(at), rest)]
456}
457
458/**
459 * What a turn's opening text was, for the log: the person's words, or for a turn the engine
460 * opened with a tagged message (a subagent's hand-back, a task notification), that message's kind.
461 */
462export const promptLine = (text: string): { who: string; text: string } => {
463 const tag = /^\s*<([a-z][\w-]*)/i.exec(text)?.[1]
464 if (!tag) return { who: 'you', text: shorten(text, 70) }
465 const from = /\bfrom="([^"]+)"/.exec(text)?.[1]
466 return { who: 'engine', text: shorten(`${tag.replace(/[-_]/g, ' ')}${from ? ` from ${from.slice(0, 8)}` : ''}`, 70) }
467}
468
469/**
470 * A subagent's hand-back message: who sent it and the first line of what it said. The report
471 * follows a framing header in the message; without one, the first line after the opening tag.
472 */
473export const handbackOf = (text: string): { from: string; body: string } | null => {
474 const from = /^\s*<agent-message\s+from="([^"]+)"/.exec(text)?.[1]
475 if (!from) return null
476 const afterHeader = text.split(/The report follows:\s*\n/)[1]
477 const rest = afterHeader ?? text.replace(/^\s*<agent-message[^>]*>/, '')
478 const body =
479 rest
480 .split('\n')
481 .map(l => l.trim())
482 .find(l => l && !l.startsWith('[') && !l.startsWith('<') && !l.startsWith('</')) ?? ''
483 return body ? { from, body } : null
484}
485
486/** The advice line a pane shows for a report: its first real line, markdown markers stripped, cut to 160. */
487export const adviceLine = (report: string) => {
488 const first = report.split('\n').map(l => l.trim()).find(l => l && !l.startsWith('[') && !l.startsWith('<')) ?? ''
489 return shorten(first.replace(/\*\*|__/g, '').replace(/^[#>*\s-]+/, ''), 160)
490}
491
492export const elapsedOf = (c: AgentCard, now: number) => (c.endedAt ?? now) - c.spawnedAt
493hooks/rail.tsx 75 lines1// A connector drawn on the surface's own frame clock: packets travel along it while `active`,
2// and it rests as a plain dim line otherwise. Only this region redraws; the pane does not.
3import type { ClientModule } from 'claude-code'
4
5type Props = {
6 active: boolean
7 width: number
8 color: string
9 dim: string
10 /** Cells where a branch drops (┬); the rest of the line is ─. */
11 marks: number[]
12 /** Draw ┴ instead of ┬ at the marks: a merge into what is below. */
13 isMerge: boolean
14}
15
16type Ref = { phase: number; active: boolean }
17type State = { ref: Ref }
18
19const STEP_MS = 110
20
21const Rail: ClientModule<Props, State> = (props, surface) => {
22 const { Box, Text } = surface.elements
23 const ref = surface.state?.ref ?? { phase: 0, active: props.active }
24 ref.active = props.active
25 if (surface.state === undefined) {
26 surface.setState({ ref })
27 surface.every(STEP_MS, () => {
28 if (ref.active) {
29 ref.phase += 1
30 surface.setState({ ref })
31 }
32 })
33 }
34
35 const width = Math.max(1, surface.columns || props.width)
36 const cells = Array.from({ length: width }, () => '─')
37 for (const m of props.marks) if (m >= 0 && m < width) cells[m] = props.isMerge ? '┴' : '┬'
38
39 if (!props.active) {
40 return (
41 <Box>
42 <Text color={props.dim}>{cells.join('')}</Text>
43 </Box>
44 )
45 }
46
47 // Two packets per 24 cells, a bright head and a trailing dot, moving left to right.
48 const lit = new Map<number, string>()
49 for (let base = 0; base < width + 24; base += 24) {
50 const head = (base + ref.phase) % (width + 24)
51 if (head < width) lit.set(head, '●')
52 if (head - 1 >= 0 && head - 1 < width) lit.set(head - 1, '•')
53 }
54 // Runs, not cells: consecutive cells of one kind share a Text, a handful of nodes per frame.
55 const runs: { text: string; isLit: boolean }[] = []
56 cells.forEach((ch, i) => {
57 const isLit = lit.has(i)
58 const last = runs[runs.length - 1]
59 const glyph = lit.get(i) ?? ch
60 if (last && last.isLit === isLit) last.text += glyph
61 else runs.push({ text: glyph, isLit })
62 })
63 return (
64 <Box>
65 {runs.map(r => (
66 <Text color={r.isLit ? props.color : props.dim} bold={r.isLit}>
67 {r.text}
68 </Text>
69 ))}
70 </Box>
71 )
72}
73
74export default Rail
75hooks/elapsed.tsx 38 lines1// A running agent's clock, ticking on the surface's frame clock so the pane need not redraw.
2// `since` and `now` come from the hooks module's $.clock; between redraws it adds its own ticks.
3import type { ClientModule } from 'claude-code'
4
5type Props = { since: number; now: number; endAt: number | null; color: string }
6type Ref = { base: number; ticks: number; lastNow: number; isRunning: boolean }
7type State = { ref: Ref }
8
9const fmt = (ms: number) => {
10 const s = Math.max(0, Math.floor(ms / 1000))
11 const m = Math.floor(s / 60)
12 return m < 60 ? `${m}:${String(s % 60).padStart(2, '0')}` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
13}
14
15const Elapsed: ClientModule<Props, State> = (props, surface) => {
16 const { Text } = surface.elements
17 const ref = surface.state?.ref ?? { base: 0, ticks: 0, lastNow: -1, isRunning: true }
18 if (props.now !== ref.lastNow) {
19 // A fresh reading from the hooks module: restart the local count from it.
20 ref.lastNow = props.now
21 ref.base = (props.endAt ?? props.now) - props.since
22 ref.ticks = 0
23 }
24 ref.isRunning = props.endAt === null
25 if (surface.state === undefined) {
26 surface.setState({ ref })
27 surface.every(1000, () => {
28 if (ref.isRunning) {
29 ref.ticks += 1
30 surface.setState({ ref })
31 }
32 })
33 }
34 return <Text color={props.color}>{fmt(ref.base + ref.ticks * 1000)}</Text>
35}
36
37export default Elapsed
38types/index.d.ts 113 lines1export type Moment = 'before a plan' | 'error repeats' | 'before done'
2
3export type Bucket = 'file' | 'shell' | 'other'
4
5/** rule: settings allowed it. ask: put to the decider, outcome pending. cleared: asked, then ran. deny: refused. */
6export type Verdict = 'rule' | 'ask' | 'cleared' | 'deny'
7
8export type Main = { model: string; effort: string; mode: string; steps: number; isRunning: boolean }
9
10export type Usage = {
11 pct: number | null
12 tokens: number | null
13 window: number
14 costUsd: number | null
15 limits: { kind: string; pct: number }[]
16 compactions: number
17 lastCompactAt: number | null
18}
19
20export type Consult = { id: string; at: number; endAt: number | null; moment: Moment; via: string }
21
22export type Architect = { consults: Consult[]; ids: string[]; seen: string[]; lastAdvice: string }
23
24export type Check = {
25 id: string
26 tool: string
27 bucket: Bucket
28 verdict: Verdict
29 inSubagent: boolean
30 detail: string
31 at: number
32}
33
34export type Tally = { rule: number; ask: number; cleared: number; deny: number }
35
36export type Gate = { recent: Check[]; totals: Record<Bucket, Tally> }
37
38export type ToolNote = { tool: string; text: string; isError: boolean }
39
40export type AgentCard = {
41 id: string
42 type: string
43 model: string
44 description: string
45 status: string
46 spawnedAt: number
47 endedAt: number | null
48 /** The agent's context now: input + cache read + cache write of its latest step. */
49 ctx: number
50 /** Output tokens summed over its steps. */
51 out: number
52 steps: number
53 lastStop: string | null
54 tools: ToolNote[]
55 answer: string
56}
57
58/** A model loop whose id matches no card: a workflow agent, a compaction or a memory fork. */
59export type Loop = { id: string; steps: number; firstAt: number; lastAt: number; isDone: boolean }
60
61export type LogLine = {
62 at: number
63 who: string
64 text: string
65 agentId: string | null
66 kind: 'info' | 'error' | 'consult' | 'done'
67}
68
69export type Turn = {
70 edits: number
71 errorStreak: number
72 errors: number
73 isReviewing: boolean
74 startedAt: number
75 costAtStart: number | null
76}
77
78export type Receipt = {
79 durationMs: number
80 agents: number
81 edits: number
82 errors: number
83 costDelta: number | null
84 reason: string
85}
86
87export type Layout = 'auto' | 'compact' | 'wide' | 'mini'
88
89export type View = { expanded: string | null; gateOpen: Bucket | null; layout: Layout | null }
90
91export type Roster = { architectTypes: string[] }
92
93declare module 'claude-code' {
94 interface PluginState {
95 'flightdeck': {
96 meta: { schemaVersion: number }
97 main: Main
98 usage: Usage
99 architect: Architect
100 gate: Gate
101 agents: AgentCard[]
102 loops: Loop[]
103 log: LogLine[]
104 turn: Turn
105 receipt: Receipt | null
106 view: View
107 roster: Roster
108 /** Whether the pane is open; Mod Tools reads it for its Show column. */
109 paneOpen: boolean
110 }
111 }
112}
113