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 1107 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)
79
80type ServerBlock = { type: string; id?: string; name?: string; tool_use_id?: string }
81
82// Every read goes through these, so a value saved under an older shape still reads.
83async function getMain($: EngineInterface): Promise<Main> {
84 return normalize(DEFAULT_MAIN, await read($, main))
85}
86async function getUsage($: EngineInterface): Promise<Usage> {
87 return normalize(DEFAULT_USAGE, await read($, usage))
88}
89async function getArchitect($: EngineInterface): Promise<Architect> {
90 const a = normalize(DEFAULT_ARCHITECT, await read($, architect))
91 return { ...a, consults: listOf(a.consults), ids: listOf(a.ids), seen: listOf(a.seen) }
92}
93async function getGate($: EngineInterface): Promise<Gate> {
94 return normalizeGate(await read($, gate))
95}
96async function getCards($: EngineInterface): Promise<AgentCard[]> {
97 return listOf<unknown>(await read($, agents)).map(normalizeCard)
98}
99async function getLoops($: EngineInterface): Promise<Loop[]> {
100 return listOf<Loop>(await read($, loops))
101}
102async function getLog($: EngineInterface): Promise<LogLine[]> {
103 return normalizeLog(await read($, log))
104}
105async function getTurn($: EngineInterface): Promise<Turn> {
106 return normalize(DEFAULT_TURN, await read($, turn))
107}
108async function getView($: EngineInterface): Promise<View> {
109 return normalize(DEFAULT_VIEW, await read($, view))
110}
111async function getRoster($: EngineInterface): Promise<Roster> {
112 const r = normalize(DEFAULT_ROSTER, await read($, roster))
113 return { architectTypes: listOf(r.architectTypes) }
114}
115
116/** A stored shape older than this build's: drop what cannot be read, keep the rest. */
117async function migrate($: EngineInterface) {
118 const m = await read($, meta)
119 if ((m?.schemaVersion ?? 0) >= SCHEMA_VERSION) return
120 await update($, log, list => normalizeLog(list))
121 await update($, agents, list => listOf<unknown>(list).map(normalizeCard))
122 await update($, gate, g => normalizeGate(g))
123 await update($, meta, () => ({ schemaVersion: SCHEMA_VERSION }))
124}
125
126async function say($: EngineInterface, who: string, text: string, kind: LogLine['kind'] = 'info', agentId: string | null = null) {
127 const line: LogLine = { at: await $.clock.now(), who, text, kind, agentId }
128 await update($, log, list => [...normalizeLog(list), line].slice(-60))
129}
130
131async function refreshStatus($: EngineInterface, cfg: Config) {
132 if (!cfg.statusLine) return $.ui.status(undefined)
133 const [u, a, g, cards] = await Promise.all([getUsage($), getArchitect($), getGate($), getCards($)])
134 const running = cards.filter(c => c.status === 'running').length
135 const s = gateSummary(g)
136 const parts = [
137 u.pct !== null ? `ctx ${Math.round(u.pct)}%` : null,
138 cards.length > 0 ? `agents ${running}/${cards.length}` : null,
139 a.consults.length > 0 || a.ids.length > 0 ? `${cfg.architectLabel.toLowerCase()} ${isAdvising(a) ? 'advising' : a.consults.length}` : null,
140 s.deny > 0 ? `denied ${s.deny}` : null,
141 ]
142 // Only fields with something to say; with none, no status entry at all.
143 const shown = parts.filter(Boolean)
144 $.ui.status(shown.length > 0 ? shown.join(' · ') : undefined)
145}
146
147async function whoIs($: EngineInterface, agentId: string | undefined) {
148 if (!agentId) return 'main'
149 const card = (await getCards($)).find(c => c.id === agentId)
150 return card ? shorten(cardTitle(card), 14) : 'agent'
151}
152
153async function consultStarted($: EngineInterface, cfg: Config, id: string, via: string) {
154 const t = await getTurn($)
155 const moment = momentOf(t)
156 const at = await $.clock.now()
157 await update($, architect, a => startConsult(normalize(DEFAULT_ARCHITECT, a), { id, at, moment, via }))
158 if (moment === 'before done') await update($, turn, x => ({ ...normalize(DEFAULT_TURN, x), isReviewing: true }))
159 await say($, cfg.architectLabel.toLowerCase(), cfg.moments ? `${moment} · ${via}` : `consulted · ${via}`, 'consult')
160 await refreshStatus($, cfg)
161}
162
163async function consultEnded($: EngineInterface, cfg: Config, advice: string | null, id?: string) {
164 const at = await $.clock.now()
165 const first = advice?.split('\n').find(l => l.trim()) ?? null
166 const text = first ? shorten(first.replace(/^[#>*\s-]+/, ''), 160) : null
167 await update($, architect, a => endConsult(normalize(DEFAULT_ARCHITECT, a), at, text, id))
168 await update($, turn, t => ({ ...normalize(DEFAULT_TURN, t), isReviewing: false }))
169 await say($, cfg.architectLabel.toLowerCase(), text ? `advice: ${shorten(text, 60)}` : 'advice returned', 'consult')
170 await refreshStatus($, cfg)
171}
172
173async function noteAdvice($: EngineInterface, cfg: Config, advice: string) {
174 await update($, architect, x => ({ ...normalize(DEFAULT_ARCHITECT, x), lastAdvice: advice }))
175 await say($, cfg.architectLabel.toLowerCase(), `advice: ${shorten(advice, 60)}`, 'consult')
176}
177
178async function isArchitectType($: EngineInterface, cfg: Config, type: string) {
179 return cfg.architect.test(type) || (await getRoster($)).architectTypes.includes(type)
180}
181
182async function openPane($: EngineInterface) {
183 // columns apply when docked beside the transcript, rows when seated inline above the prompt.
184 return $.ui.open({ id: PANE, title: TITLE, columns: PANE_COLUMNS, rows: 8 })
185}
186
187async function resetAll($: EngineInterface) {
188 await update($, main, m => ({ ...DEFAULT_MAIN, model: normalize(DEFAULT_MAIN, m).model, mode: normalize(DEFAULT_MAIN, m).mode }))
189 await update($, architect, () => DEFAULT_ARCHITECT)
190 await update($, gate, () => DEFAULT_GATE)
191 await update($, agents, () => [])
192 await update($, loops, () => [])
193 await update($, log, () => [])
194 await update($, turn, () => DEFAULT_TURN)
195 await update($, receipt, () => null)
196 await update($, view, () => DEFAULT_VIEW)
197 // The context gauge waits for the next measurement rather than showing the pre-clear fill.
198 await update($, usage, x => ({ ...normalize(DEFAULT_USAGE, x), pct: null, tokens: null }))
199}
200
201/** The session's cost read fresh, not from the last measurement: the receipt subtracts two of these. */
202async function costNow($: EngineInterface): Promise<number | null> {
203 const u = await $.session.usage().catch(() => null)
204 return u?.cost?.usd ?? null
205}
206
207async function noteMode($: EngineInterface, mode: string | undefined) {
208 if (mode) await update($, main, m => (normalize(DEFAULT_MAIN, m).mode === mode ? normalize(DEFAULT_MAIN, m) : { ...normalize(DEFAULT_MAIN, m), mode }))
209}
210
211// ---------------------------------------------------------------- hooks
212
213export const register: Register = (on, options) => {
214 const cfg = parseConfig(options)
215 const C = PALETTES[cfg.palette]
216 // tool.check carries no loop id; the tool.call around it does, keyed by the call's id.
217 const callLoop = new Map<string, string | null>()
218
219 on('session.start', async ($, e, next) => {
220 await $.command.register({
221 name: 'flightdeck',
222 description: 'Flightdeck, the live agent dashboard: open, close, reset, or set the layout',
223 argumentHint: '[open|close|reset|layout auto|compact|wide|mini]',
224 })
225 await migrate($)
226 // A host without usage (headless, an SDK host, a session not yet bound) just starts without it.
227 const u = await $.session.usage().catch(() => null)
228 if (u) {
229 await update($, usage, x => ({
230 ...normalize(DEFAULT_USAGE, x),
231 pct: u.context.percent ?? null,
232 tokens: u.context.tokens ?? null,
233 window: u.context.window,
234 costUsd: u.cost?.usd ?? null,
235 limits: u.rateLimits.map(r => ({ kind: r.kind, pct: r.percentUsed })),
236 }))
237 }
238 if (cfg.openOnStart) void openPane($).catch(() => undefined)
239 await refreshStatus($, cfg)
240 return next(e)
241 })
242
243 on('session.end', async ($, e, next) => {
244 if (e.reason === 'clear') {
245 await resetAll($)
246 await refreshStatus($, cfg)
247 }
248 return next(e)
249 })
250
251 on('command.run', { command: 'flightdeck' }, async ($, e) => {
252 const [verb = 'open', arg = ''] = e.args.trim().split(/\s+/)
253 if (verb === 'close') {
254 await $.ui.close({ id: PANE })
255 return { text: 'Flightdeck closed.' }
256 }
257 if (verb === 'reset') {
258 await resetAll($)
259 await refreshStatus($, cfg)
260 return { text: 'Flightdeck reset.' }
261 }
262 if (verb === 'layout') {
263 const layout: Layout | null = arg === 'compact' || arg === 'wide' || arg === 'auto' || arg === 'mini' ? arg : null
264 if (!layout) return { text: 'Usage: /flightdeck layout auto|compact|wide|mini' }
265 await update($, view, v => ({ ...normalize(DEFAULT_VIEW, v), layout }))
266 const opened = await openPane($)
267 return { text: opened.isPlaced ? `Flightdeck layout: ${layout}.` : `Layout set to ${layout}; the pane is not shown yet: ${opened.reason}` }
268 }
269 const opened = await openPane($)
270 if (!opened.isPlaced) return { text: `Flightdeck is not shown yet: ${opened.reason}` }
271 return { text: 'Flightdeck opened. Focus it with ctrl+x tab; 1-6 expand cards, f/s/o open the gate rows.' }
272 })
273
274 on('classic.UserPromptSubmit', async ($, e, next) => {
275 await noteMode($, e.permission_mode)
276 return next(e)
277 })
278
279 on('agent.offer', async ($, e, next) => {
280 const offered = await next(e)
281 if (cfg.architect.test(e.agent) || (cfg.matchDescriptions && cfg.architect.test(e.description))) {
282 await update($, roster, r => {
283 const x = normalize(DEFAULT_ROSTER, r)
284 return x.architectTypes.includes(e.agent) ? x : { architectTypes: [...listOf<string>(x.architectTypes), e.agent].slice(-20) }
285 })
286 }
287 return offered
288 })
289
290 on('turn.start', async ($, e, next) => {
291 const [now, cost] = await Promise.all([$.clock.now(), costNow($)])
292 await update($, turn, () => ({ ...DEFAULT_TURN, startedAt: now, costAtStart: cost }))
293 await update($, main, m => ({ ...normalize(DEFAULT_MAIN, m), isRunning: true }))
294 // A background architect's report reaches the main loop as the text opening this turn. The
295 // SubagentHandback tool call (in tool.call) normally carries it first; this is the fallback.
296 const back = e.text ? handbackOf(e.text) : null
297 const a = back ? await getArchitect($) : null
298 if (back && a && a.ids.includes(back.from)) {
299 const advice = adviceLine(back.body)
300 if (advice && advice !== a.lastAdvice) await noteAdvice($, cfg, advice)
301 } else if (e.text) {
302 const p = promptLine(e.text)
303 await say($, p.who, p.text)
304 }
305 return next(e)
306 })
307
308 on('turn.step', async function* ($, e, next) {
309 // The main loop's model is known when its request starts; a long first request shouldn't read "—".
310 if (!e.agentId) {
311 await update($, main, m => {
312 const x = normalize(DEFAULT_MAIN, m)
313 return { ...x, model: e.model, effort: String(e.effort ?? x.effort), steps: x.steps + 1 }
314 })
315 return yield* next(e)
316 }
317 const result = yield* next(e)
318 const id = e.agentId
319 const [cards, a] = await Promise.all([getCards($), getArchitect($)])
320 if (cards.some(c => c.id === id)) {
321 const step = { model: e.model, usage: result.usage, stopReason: result.stopReason }
322 await update($, agents, list => listOf<unknown>(list).map(normalizeCard).map(c => (c.id === id ? applyStep(c, step) : c)))
323 if (result.stopReason === 'max_tokens') await say($, await whoIs($, id), 'hit max_tokens', 'error', id)
324 } else if (!a.ids.includes(id)) {
325 const now = await $.clock.now()
326 await update($, loops, l => stepLoop(listOf<Loop>(l), id, now))
327 }
328 return result
329 })
330
331 on('session.measure', async ($, e, next) => {
332 await update($, usage, x => ({
333 ...normalize(DEFAULT_USAGE, x),
334 pct: e.context.percent ?? null,
335 tokens: e.context.tokens ?? null,
336 window: e.context.window,
337 costUsd: e.cost?.usd ?? null,
338 limits: e.rateLimits.map(r => ({ kind: r.kind, pct: r.percentUsed })),
339 }))
340 if (e.changed.includes('context')) await refreshStatus($, cfg)
341 return next(e)
342 })
343
344 on('session.compact', async ($, e, next) => {
345 const done = await next(e)
346 if (!e.agentId && e.trigger !== 'precompute') {
347 const now = await $.clock.now()
348 await update($, usage, x => {
349 const u = normalize(DEFAULT_USAGE, x)
350 return { ...u, compactions: u.compactions + 1, lastCompactAt: now }
351 })
352 await say($, 'main', `context compacted (${e.trigger})`)
353 }
354 return done
355 })
356
357 on('tool.check', async ($, e, next) => {
358 const verdict = await next(e)
359 if (e.tool_use_id) {
360 const check: Check = {
361 id: e.tool_use_id,
362 tool: e.tool,
363 bucket: bucketOf(e.tool),
364 verdict: verdict.decision === 'allow' ? 'rule' : verdict.decision,
365 inSubagent: Boolean(callLoop.get(e.tool_use_id)),
366 detail: shorten(describeInput(e.tool, e.input), 90),
367 at: await $.clock.now(),
368 }
369 await update($, gate, g => recordCheck(normalizeGate(g), check))
370 // The status line updates when the call settles; only a refusal ends here.
371 if (verdict.decision === 'deny') {
372 await say($, 'gate', `denied by rule · ${check.detail}`, 'error')
373 await refreshStatus($, cfg)
374 }
375 }
376 return verdict
377 })
378
379 on('tool.call', async ($, e, next) => {
380 callLoop.set(e.tool_use_id, e.agentId ?? null)
381 const ran = await next(e).finally(() => callLoop.delete(e.tool_use_id))
382 const didRun = ran.deny === undefined
383 // Settle this call's pending ask, if it had one; skip the write (and the redraw) otherwise.
384 const g0 = await getGate($)
385 const isSettled = settleCheck(g0, e.tool_use_id, didRun) !== g0
386 if (isSettled) await update($, gate, g => settleCheck(normalizeGate(g), e.tool_use_id, didRun))
387 // A background agent hands its report back through this tool; an architect's report is its advice.
388 if (String(e.tool) === 'SubagentHandback') {
389 const message = (e as unknown as { message?: unknown }).message
390 const a = e.agentId ? await getArchitect($) : null
391 if (a && e.agentId && a.ids.includes(e.agentId) && typeof message === 'string') {
392 const advice = adviceLine(message)
393 if (advice && advice !== a.lastAdvice) await noteAdvice($, cfg, advice)
394 }
395 return ran
396 }
397 if (e.tool === 'Agent') {
398 if (isSettled) await refreshStatus($, cfg)
399 return ran
400 }
401 const hasFailed = didRun && ran.isError === true
402 const isEdit = !hasFailed && didRun && EDIT_TOOLS.has(e.tool)
403 const t0 = await getTurn($)
404 if (isEdit || hasFailed || (!e.agentId && t0.errorStreak > 0)) {
405 await update($, turn, t => afterCall(normalize(DEFAULT_TURN, t), { inSubagent: Boolean(e.agentId), hasFailed, isEdit }))
406 }
407 const text = shorten(describeInput(e.tool, e), 64)
408 if (e.agentId) {
409 const id = e.agentId
410 await update($, agents, list =>
411 listOf<unknown>(list)
412 .map(normalizeCard)
413 .map(c => (c.id === id ? noteTool(c, { tool: e.tool, text, isError: hasFailed || ran.deny !== undefined }) : c)),
414 )
415 }
416 // The log keeps what is worth a glance: refusals, errors and edits; the rest is on the cards.
417 if (ran.deny !== undefined) await say($, await whoIs($, e.agentId), `${text} denied`, 'error', e.agentId ?? null)
418 else if (hasFailed) await say($, await whoIs($, e.agentId), `${text} ✗`, 'error', e.agentId ?? null)
419 else if (isEdit) await say($, await whoIs($, e.agentId), text, 'info', e.agentId ?? null)
420 if (isSettled || !didRun) await refreshStatus($, cfg)
421 return ran
422 })
423
424 // A server-side review tool never reaches tool.call: it shows only in the assistant's rows.
425 on('session.append', async ($, e, next) => {
426 if (!e.agentId && e.message.type === 'assistant') {
427 const a = await getArchitect($)
428 // Consults this row opened: their result may be in the same row, after the stale read above.
429 const opened = new Set<string>()
430 for (const block of e.message.content as unknown as readonly ServerBlock[]) {
431 if (block.type === 'server_tool_use' && block.name && block.id && cfg.architect.test(block.name)) {
432 if (a.seen.includes(block.id)) continue
433 const id = block.id
434 await update($, architect, x => {
435 const y = normalize(DEFAULT_ARCHITECT, x)
436 return { ...y, seen: [...listOf<string>(y.seen), id].slice(-60) }
437 })
438 opened.add(id)
439 await consultStarted($, cfg, id, `${block.name} tool`)
440 } else if (block.type.endsWith('_tool_result') && block.tool_use_id) {
441 const id = block.tool_use_id
442 const isOpen = opened.has(id) || (await getArchitect($)).consults.some(c => c.id === id && c.endAt === null)
443 if (isOpen) await consultEnded($, cfg, null, id)
444 }
445 }
446 }
447 return next(e)
448 })
449
450 on('agent.spawn', async ($, e, next) => {
451 const started = await next(e)
452 if (!e.parentAgentId) await noteMode($, e.permissionMode)
453 if (started.deny !== undefined || !started.agentId) return started
454 const id = started.agentId
455 if (await isArchitectType($, cfg, e.subagentType)) {
456 await update($, architect, a => {
457 const x = normalize(DEFAULT_ARCHITECT, a)
458 return { ...x, ids: [...listOf<string>(x.ids), id].slice(-40) }
459 })
460 await update($, loops, l => listOf<Loop>(l).filter(x => x.id !== id))
461 await consultStarted($, cfg, id, e.subagentType.split(':').pop() ?? 'agent')
462 return started
463 }
464 const card: AgentCard = {
465 ...normalizeCard({}),
466 id,
467 type: e.name ?? e.subagentType,
468 model: started.model,
469 description: e.description,
470 spawnedAt: await $.clock.now(),
471 }
472 await update($, agents, list => [...listOf<unknown>(list).map(normalizeCard), card].slice(-24))
473 await update($, loops, l => listOf<Loop>(l).filter(x => x.id !== id))
474 await say($, shorten(cardTitle(card), 12), `spawned · ${card.type}`, 'info', id)
475 await refreshStatus($, cfg)
476 return started
477 })
478
479 on('turn.complete', async ($, e, next) => {
480 const done = await next(e)
481 const id = e.agentId
482 const now = await $.clock.now()
483 if (!id) {
484 const [t, cards, cost] = await Promise.all([getTurn($), getCards($), costNow($)])
485 const r = receiptOf(t, {
486 durationMs: e.durationMs,
487 agentsSince: cards.filter(c => c.spawnedAt >= t.startedAt).length,
488 costNow: cost,
489 reason: e.reason,
490 })
491 await update($, receipt, () => r)
492 await update($, main, m => ({ ...normalize(DEFAULT_MAIN, m), isRunning: false }))
493 await refreshStatus($, cfg)
494 return done
495 }
496 if ((await getArchitect($)).ids.includes(id)) {
497 await consultEnded($, cfg, e.answer, id)
498 return done
499 }
500 const cards = await getCards($)
501 if (cards.some(c => c.id === id)) {
502 const status = e.reason === 'answer' ? 'done' : e.reason === 'aborted' ? 'stopped' : 'failed'
503 await update($, agents, list =>
504 listOf<unknown>(list)
505 .map(normalizeCard)
506 .map(c => (c.id === id ? { ...c, status, endedAt: now, answer: shorten(e.answer, 400) } : c)),
507 )
508 const card = cards.find(c => c.id === id)
509 const took = card ? fmtDuration(now - card.spawnedAt) : ''
510 await say($, await whoIs($, id), status === 'done' ? `done · ${took}` : status, status === 'done' ? 'done' : 'error', id)
511 } else {
512 await update($, loops, l => listOf<Loop>(l).map(x => (x.id === id ? { ...x, isDone: true, lastAt: now } : x)))
513 }
514 await refreshStatus($, cfg)
515 return done
516 })
517
518 // ---------------------------------------------------------------- drawing
519
520 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
521 const els = $.ui.resolve(e)
522 const { Box, Text, Button } = els
523 const hasClient = 'Client' in els
524 const [m, u, a, g, cards, lp, lines, t, r, v, now] = await Promise.all([
525 getMain($),
526 getUsage($),
527 getArchitect($),
528 getGate($),
529 getCards($),
530 getLoops($),
531 getLog($),
532 getTurn($),
533 read($, receipt),
534 getView($),
535 $.clock.now(),
536 ])
537 const W = Math.max(40, e.props.bodyColumns)
538 const layout = v.layout ?? cfg.layout
539 const isWide = layout === 'wide' || (layout === 'auto' && W >= 110)
540 const colW = isWide ? Math.floor((W - 2) / 2) : W
541 const modelName = prettyModel(m.model)
542 const viewed = e.props.view?.agentId ?? null
543 const advising = isAdvising(a)
544 const running = cards.filter(c => c.status === 'running')
545 const showArchitect = a.consults.length > 0 || a.ids.length > 0
546 const motion = cfg.motion && hasClient
547 // A panel with nothing to show yet takes no room: most sessions never spawn an agent.
548 const isEmpty: Record<Panel, boolean> = {
549 main: false,
550 architect: !showArchitect,
551 gate: g.recent.length === 0 && gateSummary(g).total === 0,
552 agents: cards.length === 0,
553 loops: lp.length === 0,
554 receipt: !m.isRunning && !r,
555 log: false,
556 }
557 const panels = cfg.panels.filter(p => !isEmpty[p])
558 const decider = m.mode === 'auto' ? 'classifier' : 'you'
559
560 // A connector between panels: animated while its flow is live, a dim line otherwise.
561 const rail = (key: string, active: boolean, color: string, width: number, marks: number[] = [], isMerge = false) =>
562 motion ? (
563 <els.Client
564 key={key}
565 module="./rail.tsx"
566 width={width}
567 height={1}
568 props={{ active, width, color, dim: C.faint, marks, isMerge }}
569 />
570 ) : (
571 <Text color={C.faint}>{'─'.repeat(Math.max(1, width))}</Text>
572 )
573
574 // A start time of 0 is unknown (state saved before it was recorded): no clock, not decades.
575 const clock = (key: string, since: number, endAt: number | null, color: string) =>
576 since <= 0 ? (
577 <Text color={color}>—</Text>
578 ) : hasClient ? (
579 <els.Client key={key} module="./elapsed.tsx" props={{ since, now, endAt, color }} />
580 ) : (
581 <Text color={color}>{fmtTimer((endAt ?? now) - since)}</Text>
582 )
583
584 // ---- main
585 const effortN = { low: 1, medium: 2, high: 3, xhigh: 4, max: 4 }[m.effort] ?? 0
586 const ctxGauge = u.pct !== null ? gauge(u.pct, 10) : null
587 const mainPanel = (w: number) => (
588 <Box flexDirection="column" borderStyle="round" borderColor={C.main} paddingX={1} width={w}>
589 <Box justifyContent="space-between">
590 <Text color={C.main} bold>
591 {modelName} · main
592 </Text>
593 <Text color={m.isRunning ? C.main : C.dim}>{m.isRunning ? '● working' : '○ idle'}</Text>
594 </Box>
595 <Text wrap="truncate">
596 <Text dimColor>effort </Text>
597 <Text color={C.main}>{'▮'.repeat(effortN) + '▯'.repeat(4 - effortN)} </Text>
598 <Text color={C.main} bold>
599 {m.effort || '—'}
600 </Text>
601 {m.mode ? <Text dimColor>{` mode ${m.mode}`}</Text> : null}
602 <Text dimColor>{` ${m.steps} req`}</Text>
603 </Text>
604 {ctxGauge ? (
605 <Text wrap="truncate">
606 <Text dimColor>ctx </Text>
607 <Text color={u.pct !== null && u.pct >= 80 ? C.warn : C.main}>{ctxGauge.on}</Text>
608 <Text color={C.faint}>{ctxGauge.off}</Text>
609 <Text bold>{` ${Math.round(u.pct ?? 0)}%`}</Text>
610 {u.tokens !== null ? <Text dimColor>{` ${kTokens(u.tokens)}/${kTokens(u.window)}`}</Text> : null}
611 {u.compactions > 0 ? <Text color={C.amber}>{` ⟲${u.compactions}`}</Text> : null}
612 </Text>
613 ) : null}
614 {u.costUsd !== null || u.limits.length > 0 ? (
615 <Text wrap="truncate">
616 {u.costUsd !== null ? <Text color={C.text}>{`${fmtUsd(u.costUsd)} `}</Text> : null}
617 {u.limits.slice(0, 2).map(l => {
618 const lg = gauge(l.pct, 5)
619 return (
620 <Text wrap="truncate">
621 <Text dimColor>{`${limitLabel(l.kind)} `}</Text>
622 <Text color={l.pct >= 80 ? C.warn : C.main}>{lg.on}</Text>
623 <Text color={C.faint}>{lg.off}</Text>
624 <Text dimColor>{` ${Math.round(l.pct)}% `}</Text>
625 </Text>
626 )
627 })}
628 </Text>
629 ) : null}
630 </Box>
631 )
632
633 // ---- architect
634 const lastConsult = a.consults[a.consults.length - 1]
635 const architectPanel = (w: number) => {
636 const tl = consultTimeline(a, now, Math.max(8, w - 4))
637 return (
638 <Box flexDirection="column" borderStyle="round" borderColor={C.arch} paddingX={1} width={w}>
639 <Box justifyContent="space-between">
640 <Text color={C.arch} bold>
641 {cfg.architectLabel} · {advising ? 'advising' : 'on call'}
642 </Text>
643 <Text>
644 <Text dimColor>consults </Text>
645 <Text color={C.arch} bold>
646 {a.consults.length}
647 </Text>
648 </Text>
649 </Box>
650 <Text color={C.arch}>{tl}</Text>
651 {lastConsult ? (
652 <Text dimColor wrap="truncate">
653 {advising
654 ? `consulting since ${fmtClock(lastConsult.at)}`
655 : `last ${fmtDuration(now - (lastConsult.endAt ?? lastConsult.at))} ago · took ${fmtDuration((lastConsult.endAt ?? now) - lastConsult.at)}`}
656 </Text>
657 ) : (
658 <Text dimColor>not consulted yet</Text>
659 )}
660 {cfg.moments ? (
661 <Box flexWrap="wrap" columnGap={2}>
662 {(['before a plan', 'error repeats', 'before done'] as const).map(mo => {
663 const isOn = lastConsult?.moment === mo
664 return (
665 <Text color={isOn ? C.arch : C.dim} bold={isOn}>
666 {isOn ? '◆' : '◇'} {mo}
667 </Text>
668 )
669 })}
670 <Text color={C.faint}>(inferred)</Text>
671 </Box>
672 ) : null}
673 {a.lastAdvice ? (
674 <Text color={C.arch} wrap="truncate">
675 » {a.lastAdvice}
676 </Text>
677 ) : null}
678 </Box>
679 )
680 }
681
682 // ---- gate
683 const s = gateSummary(g)
684 const verdictColor = (c: Check) =>
685 c.verdict === 'rule' ? C.gate : c.verdict === 'cleared' ? C.cleared : c.verdict === 'ask' ? C.amber : C.warn
686 const gatePanel = (w: number) => {
687 const strip = g.recent.slice(-Math.max(8, w - 4))
688 const open = v.gateOpen
689 return (
690 <Box flexDirection="column" borderStyle="round" borderColor={C.gate} paddingX={1} width={w}>
691 <Box justifyContent="space-between">
692 <Text color={C.gate} bold>
693 {cfg.gateLabel} · permissions
694 </Text>
695 <Text dimColor>{`${s.total} checks`}</Text>
696 </Box>
697 <Box>
698 {strip.length === 0 ? <Text color={C.faint}>no checks yet</Text> : null}
699 {strip.map(c => (
700 <Text color={verdictColor(c)} dimColor={c.inSubagent}>
701 {c.verdict === 'deny' ? '✗' : '■'}
702 </Text>
703 ))}
704 </Box>
705 <Text wrap="truncate">
706 <Text color={C.gate}>■</Text>
707 <Text dimColor>{` ${s.rule} allowed `}</Text>
708 <Text color={C.cleared}>■</Text>
709 <Text dimColor>{` ${s.cleared} ${decider} `}</Text>
710 {s.ask > 0 ? <Text color={C.amber}>{`■ ${s.ask} pending `}</Text> : null}
711 <Text color={s.deny > 0 ? C.warn : C.dim}>{`✗ ${s.deny} denied`}</Text>
712 {w >= 80 && g.recent.some(c => c.inSubagent) ? <Text color={C.faint}>{' dim: in subagents'}</Text> : null}
713 </Text>
714 <Box columnGap={2}>
715 {(['file', 'shell', 'other'] as const).map((b: Bucket) => {
716 const tl = g.totals[b]
717 const n = tl.rule + tl.ask + tl.cleared + tl.deny
718 return (
719 <Button
720 key={`gate-${b}`}
721 plain
722 hotkey={b[0]}
723 label={`${b} ${n}${open === b ? ' ▾' : ''}`}
724 dimColor={n === 0}
725 onPress={() => update($, view, x => ({ ...normalize(DEFAULT_VIEW, x), gateOpen: normalize(DEFAULT_VIEW, x).gateOpen === b ? null : b }))}
726 />
727 )
728 })}
729 </Box>
730 {open
731 ? g.recent
732 .filter(c => c.bucket === open)
733 .slice(-5)
734 .map(c => (
735 <Text wrap="truncate">
736 <Text color={verdictColor(c)}>{c.verdict === 'deny' ? '✗ ' : '■ '}</Text>
737 <Text color={C.dim}>{`${(c.verdict === 'rule' ? 'allowed' : c.verdict === 'cleared' ? decider : c.verdict === 'ask' ? 'pending' : 'denied').padEnd(10)} `}</Text>
738 <Text dimColor={c.inSubagent}>{shorten(c.detail, Math.max(10, w - 18))}</Text>
739 </Text>
740 ))
741 : null}
742 </Box>
743 )
744 }
745
746 // ---- agents: cards up to the limit, swimlanes beyond it
747 const statusColor = (c: AgentCard) => (c.status === 'failed' ? C.warn : c.status === 'done' ? C.gate : C.agent)
748 const glyph = (c: AgentCard) => (c.status === 'running' ? '◐' : c.status === 'done' ? '✓' : c.status === 'failed' ? '✗' : '■')
749 const expandOnPress = (id: string) => () =>
750 update($, view, x => ({ ...normalize(DEFAULT_VIEW, x), expanded: normalize(DEFAULT_VIEW, x).expanded === id ? null : id }))
751
752 const agentsPanel = (w: number) => {
753 // Cards need 20 columns each; when the pane can't hold the limit, lanes take over.
754 const fit = Math.max(1, Math.min(cfg.maxCards, Math.floor((w + 1) / 21)))
755 const useLanes = cards.length > fit
756 const header = (
757 <Box justifyContent="space-between" width={w}>
758 <Text bold>{`agents · ${running.length} running · ${cards.length} total`}</Text>
759 {cards.length > 0 ? <Text color={C.faint}>1-{Math.min(cards.length, useLanes ? 6 : fit)} expand</Text> : null}
760 </Box>
761 )
762 if (cards.length === 0) {
763 return (
764 <Box flexDirection="column" width={w}>
765 {header}
766 <Text color={C.faint}>no subagents yet</Text>
767 </Box>
768 )
769 }
770 if (useLanes) {
771 const shown = cards.slice(-6)
772 const barW = Math.max(8, w - 28)
773 const geo = lanes(shown, now, barW)
774 return (
775 <Box flexDirection="column" width={w}>
776 {header}
777 {cards.length > shown.length ? <Text color={C.faint}>{`+${cards.length - shown.length} earlier`}</Text> : null}
778 {shown.map((c, i) => {
779 const gm = geo[i]
780 const isViewed = viewed === c.id
781 return (
782 <Box>
783 <Text color={statusColor(c)} bold={isViewed}>{`${isViewed ? '▶' : glyph(c)} `}</Text>
784 <Box width={17}>
785 <Button key={`card-${c.id}`} plain hotkey={String(i + 1)} label={shorten(cardTitle(c), 14)} onPress={expandOnPress(c.id)} />
786 </Box>
787 <Text color={C.faint}>{' ' + '·'.repeat(gm?.before ?? 0)}</Text>
788 <Text color={statusColor(c)}>{'━'.repeat(gm?.bar ?? 1)}</Text>
789 <Text color={C.faint}>{'·'.repeat(gm?.after ?? 0) + ' '}</Text>
790 {clock(`lane-clock-${c.id}`, c.spawnedAt, c.endedAt, C.dim)}
791 </Box>
792 )
793 })}
794 </Box>
795 )
796 }
797 const shown = cards.slice(-fit)
798 const cardW = Math.max(20, Math.floor((w - (shown.length - 1)) / shown.length))
799 const centers = shown.map((_, i) => i * (cardW + 1) + Math.floor(cardW / 2))
800 return (
801 <Box flexDirection="column" width={w}>
802 {header}
803 {rail('fan-out', running.length > 0, C.agent, w, centers)}
804 <Box columnGap={1}>
805 {shown.map((c, i) => {
806 const isViewed = viewed === c.id
807 const sameModel = !c.model || prettyModel(c.model) === modelName
808 return (
809 <Box
810 flexDirection="column"
811 borderStyle={isViewed ? 'double' : 'round'}
812 borderColor={c.lastStop === 'max_tokens' ? C.warn : C.agent}
813 borderDimColor={c.status !== 'running' && !isViewed}
814 width={cardW}
815 paddingX={1}
816 >
817 <Button key={`card-${c.id}`} plain hotkey={String(i + 1)} label={titleLines(cardTitle(c), cardW - 7, cardW - 4)[0]} onPress={expandOnPress(c.id)} />
818 <Text bold wrap="truncate">
819 {titleLines(cardTitle(c), cardW - 7, cardW - 4)[1]}
820 </Text>
821 <Text color={C.dim} wrap="truncate">
822 {sameModel ? c.type : `${c.type} · ${prettyModel(c.model)}`}
823 </Text>
824 <Text dimColor wrap="truncate">
825 {c.steps > 0 ? `ctx ${kTokens(c.ctx)} · out ${kTokens(c.out)} · ${c.steps} st` : 'starting…'}
826 </Text>
827 <Box>
828 <Text color={c.lastStop === 'max_tokens' ? C.warn : statusColor(c)}>
829 {cardW >= 26 ? `${glyph(c)} ${c.lastStop === 'max_tokens' ? 'max_tokens' : c.status} ` : `${glyph(c)} `}
830 </Text>
831 <Box flexShrink={0}>{clock(`card-clock-${c.id}`, c.spawnedAt, c.endedAt, C.dim)}</Box>
832 </Box>
833 </Box>
834 )
835 })}
836 </Box>
837 {rail('merge', running.length > 0, C.agent, w, centers, true)}
838 </Box>
839 )
840 }
841
842 const expandedCard = cards.find(c => c.id === v.expanded)
843 const expandedPanel = (w: number) =>
844 expandedCard ? (
845 <Box flexDirection="column" borderStyle="single" borderColor={C.agent} paddingX={1} width={w}>
846 <Text bold wrap="wrap">
847 {expandedCard.description || expandedCard.type}
848 </Text>
849 <Text dimColor wrap="truncate">{`${expandedCard.type} · ${prettyModel(expandedCard.model)} · ${expandedCard.status} · ${expandedCard.steps} steps`}</Text>
850 {expandedCard.tools.length === 0 ? <Text color={C.faint}>no tool calls yet</Text> : null}
851 {expandedCard.tools.map(n => (
852 <Text color={n.isError ? C.warn : C.text} wrap="truncate">
853 {`${n.isError ? '✗' : '·'} ${n.text}`}
854 </Text>
855 ))}
856 {expandedCard.answer ? (
857 <Text dimColor wrap="wrap">
858 {`» ${shorten(expandedCard.answer, 240)}`}
859 </Text>
860 ) : null}
861 </Box>
862 ) : null
863
864 // ---- other loops (workflow agents, forks): ids that match no card
865 const loopsPanel = (w: number) => {
866 if (lp.length === 0) return null
867 const active = lp.filter(l => isLoopActive(l, now)).length
868 const dots = lp.slice(-Math.max(4, w - 38))
869 return (
870 <Box width={w}>
871 <Text bold>other loops </Text>
872 <Text dimColor>{`${lp.length} seen · ${active} active `}</Text>
873 {dots.map(l => (
874 <Text color={isLoopActive(l, now) ? C.agent : l.isDone ? C.dim : C.faint}>{isLoopActive(l, now) ? '●' : l.isDone ? '✓' : '○'}</Text>
875 ))}
876 </Box>
877 )
878 }
879
880 // ---- receipt: the turn now, or the last one
881 const receiptPanel = (w: number) => {
882 const isReview = t.isReviewing
883 return (
884 <Box flexDirection="column" borderStyle="round" borderColor={C.main} borderDimColor={!m.isRunning && !isReview} paddingX={1} width={w}>
885 {m.isRunning ? (
886 <Box>
887 <Text color={C.main} wrap="truncate">{`◐ back to ${modelName.toLowerCase()} · turn `}</Text>
888 <Box flexShrink={0}>{clock('turn-clock', t.startedAt, null, C.main)}</Box>
889 {w >= 60 ? <Text dimColor wrap="truncate">{` · ${plural(t.edits, 'edit')} · ${plural(t.errors, 'error')}`}</Text> : null}
890 </Box>
891 ) : r ? (
892 <Text wrap="truncate">
893 <Text color={r.reason === 'answer' ? C.gate : C.warn}>{r.reason === 'answer' ? '✓ ' : '✗ '}</Text>
894 <Text>{`last turn ${fmtDuration(r.durationMs)} · ${plural(r.agents, 'agent')} · ${plural(r.edits, 'edit')} · ${plural(r.errors, 'error')}`}</Text>
895 {r.costDelta !== null ? <Text color={C.main}>{` · +${fmtUsd(r.costDelta)}`}</Text> : null}
896 </Text>
897 ) : (
898 <Text color={C.faint}>no turn finished yet</Text>
899 )}
900 {isReview ? <Text color={C.arch}>{`${cfg.architectLabel.toLowerCase()} reviewing before done (inferred)`}</Text> : null}
901 </Box>
902 )
903 }
904
905 // ---- log: whatever rows the other panels leave, 4 to 8
906 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
907 const bodyRows = e.props.scroll?.bodyRows ?? e.viewport?.rows ?? 40
908 const nLog = logRows(bodyRows, used)
909 const shownLines = (viewed ? lines.filter(l => l.agentId === viewed) : lines).slice(-nLog)
910 const colorOf = (l: LogLine) =>
911 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
912 const logPanel = (w: number) => (
913 <Box flexDirection="column" borderStyle="round" borderColor={C.faint} paddingX={1} width={w}>
914 <Text dimColor>{viewed ? 'session log · this agent' : 'session log'}</Text>
915 {shownLines.length === 0 ? <Text color={C.faint}>nothing yet</Text> : null}
916 {shownLines.map(l => (
917 <Box>
918 <Box width={9} flexShrink={0}>
919 <Text color={C.faint}>{fmtClock(l.at)}</Text>
920 </Box>
921 <Box width={13} flexShrink={0}>
922 <Text color={colorOf(l)} bold wrap="truncate">
923 {l.who}
924 </Text>
925 </Box>
926 <Text color={l.kind === 'error' ? C.warn : C.text} wrap="truncate">
927 {l.text}
928 </Text>
929 </Box>
930 ))}
931 </Box>
932 )
933
934 const draw = (p: Panel, w: number) =>
935 p === 'main'
936 ? mainPanel(w)
937 : p === 'architect'
938 ? architectPanel(w)
939 : p === 'gate'
940 ? gatePanel(w)
941 : p === 'agents'
942 ? agentsPanel(w)
943 : p === 'loops'
944 ? loopsPanel(w)
945 : p === 'receipt'
946 ? receiptPanel(w)
947 : logPanel(w)
948
949 // Panels with the flow between them; the agents panel draws its own rails.
950 const column = (ps: Panel[], w: number) => (
951 <Box flexDirection="column" width={w}>
952 {ps.map((p, i) => {
953 const prev = ps[i - 1]
954 const link =
955 i === 0 || p === 'agents' || prev === 'agents' || p === 'log' || p === 'loops' || prev === 'loops'
956 ? null
957 : rail(`link-${p}`, p === 'architect' ? advising : m.isRunning, p === 'architect' ? C.arch : C.main, w)
958 return (
959 <Box flexDirection="column">
960 {link}
961 {draw(p, w)}
962 {p === 'agents' ? expandedPanel(w) : null}
963 </Box>
964 )
965 })}
966 </Box>
967 )
968
969 // Desktop draws the agents' time axis as SVG under the panels.
970 const svgLanes =
971 e.surface !== 'terminal' && cards.length > 0
972 ? (() => {
973 const { Svg } = $.ui.resolve(e)
974 const rowH = 18
975 const pxW = 520
976 const geo = lanes(cards.slice(-10), now, 100)
977 const rects = cards
978 .slice(-10)
979 .map((c, i) => {
980 const gm = geo[i]
981 const x = 150 + ((gm?.before ?? 0) / 100) * (pxW - 160)
982 const wpx = Math.max(3, ((gm?.bar ?? 1) / 100) * (pxW - 160))
983 const fill = c.status === 'running' ? SVG_COLORS.running : c.status === 'done' ? SVG_COLORS.done : c.status === 'failed' ? SVG_COLORS.failed : SVG_COLORS.other
984 const label = shorten(cardTitle(c), 22).replace(/[<&>]/g, '')
985 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}"/>`
986 })
987 .join('')
988 const svgH = Math.min(10, cards.length) * rowH + 4
989 return (
990 <Svg
991 source={`<svg xmlns="http://www.w3.org/2000/svg" width="${pxW}" height="${svgH}" viewBox="0 0 ${pxW} ${svgH}">${rects}</svg>`}
992 alt={`${cards.length} agents on a time axis`}
993 width={pxW}
994 height={svgH}
995 />
996 )
997 })()
998 : null
999
1000 // Inline above the prompt (the terminal's main screen), the pane is a summary of at most 8 rows.
1001 const isMini = layout === 'mini' || (layout === 'auto' && e.props.placement === 'inline')
1002 if (isMini) {
1003 const live = [...cards.filter(c => c.status === 'running'), ...cards.filter(c => c.status !== 'running').reverse()].slice(0, 3)
1004 const counts = ` ${s.rule} allowed · ${s.cleared} ${decider}${s.ask > 0 ? ` · ${s.ask} pending` : ''} · ${s.deny} denied`
1005 const strip = g.recent.slice(-Math.max(4, W - cfg.gateLabel.length - 1 - counts.length))
1006 const mg = u.pct !== null ? gauge(u.pct, 6) : null
1007 return (
1008 <Box flexDirection="column" width={W}>
1009 <Text wrap="truncate">
1010 <Text color={C.main} bold>
1011 {modelName}
1012 </Text>
1013 <Text color={m.isRunning ? C.main : C.dim}>{m.isRunning ? ' ● working' : ' ○ idle'}</Text>
1014 {mg ? <Text dimColor> · ctx </Text> : null}
1015 {mg ? <Text color={(u.pct ?? 0) >= 80 ? C.warn : C.main}>{mg.on}</Text> : null}
1016 {mg ? <Text color={C.faint}>{mg.off}</Text> : null}
1017 {mg ? <Text>{` ${Math.round(u.pct ?? 0)}%`}</Text> : null}
1018 {u.compactions > 0 ? <Text color={C.amber}>{` ⟲${u.compactions}`}</Text> : null}
1019 {u.costUsd !== null ? <Text dimColor>{` · ${fmtUsd(u.costUsd)}`}</Text> : null}
1020 {showArchitect ? <Text color={C.arch}>{` · ${cfg.architectLabel.toLowerCase()} ${advising ? 'advising' : a.consults.length}`}</Text> : null}
1021 </Text>
1022 {strip.length > 0 ? (
1023 <Box>
1024 <Text dimColor>{`${cfg.gateLabel.toLowerCase()} `}</Text>
1025 {strip.map(c => (
1026 <Text color={verdictColor(c)} dimColor={c.inSubagent}>
1027 {c.verdict === 'deny' ? '✗' : '■'}
1028 </Text>
1029 ))}
1030 <Text color={s.deny > 0 ? C.warn : s.ask > 0 ? C.amber : C.dim} wrap="truncate">
1031 {counts}
1032 </Text>
1033 </Box>
1034 ) : null}
1035 {live.map(c => (
1036 <Box>
1037 <Text color={statusColor(c)}>{`${glyph(c)} `}</Text>
1038 <Box width={Math.max(10, W - 30)}>
1039 <Text wrap="truncate">{cardTitle(c)}</Text>
1040 </Box>
1041 <Text dimColor>{c.steps > 0 ? ` ctx ${kTokens(c.ctx)} ` : ' '}</Text>
1042 {clock(`mini-clock-${c.id}`, c.spawnedAt, c.endedAt, C.dim)}
1043 </Box>
1044 ))}
1045 {cards.length > live.length ? (
1046 <Text color={C.faint} wrap="truncate">{`+${cards.length - live.length} more agents · /flightdeck layout compact for all`}</Text>
1047 ) : null}
1048 {lp.length > 0 ? <Text dimColor>{`other loops ${lp.length} · ${lp.filter(l => isLoopActive(l, now)).length} active`}</Text> : null}
1049 {!m.isRunning && r ? (
1050 <Text dimColor wrap="truncate">
1051 {`last turn ${fmtDuration(r.durationMs)} · ${plural(r.agents, 'agent')} · ${plural(r.edits, 'edit')} · ${plural(r.errors, 'error')}${r.costDelta !== null ? ` · +${fmtUsd(r.costDelta)}` : ''}`}
1052 </Text>
1053 ) : null}
1054 </Box>
1055 )
1056 }
1057
1058 const legend = fitLegend(
1059 [
1060 { label: 'main', color: C.main },
1061 { label: 'agents', color: C.agent },
1062 { label: cfg.gateLabel.toLowerCase(), color: C.gate },
1063 ...(showArchitect ? [{ label: cfg.architectLabel.toLowerCase(), color: C.arch }] : []),
1064 ],
1065 W,
1066 )
1067
1068 const body = isWide ? (
1069 <Box flexDirection="column">
1070 <Box columnGap={2}>
1071 {column(panels.filter(p => p === 'main' || p === 'architect' || p === 'gate'), colW)}
1072 {column(panels.filter(p => p === 'agents' || p === 'loops' || p === 'receipt'), colW)}
1073 </Box>
1074 {panels.includes('log') ? logPanel(W) : null}
1075 </Box>
1076 ) : (
1077 column(panels, W)
1078 )
1079
1080 return (
1081 <Box flexDirection="column" width={W}>
1082 <Box justifyContent="center">
1083 <Text bold wrap="truncate">
1084 <Text>FLIGHTDECK</Text>
1085 <Text color={C.dim}> · </Text>
1086 <Text color={C.main}>{modelName.toUpperCase()}</Text>
1087 <Text>{m.isRunning ? ' WORKS' : ' IDLE'}</Text>
1088 {showArchitect ? <Text color={C.dim}> · </Text> : null}
1089 {showArchitect ? <Text color={C.arch}>{cfg.architectLabel}</Text> : null}
1090 {showArchitect ? <Text>{advising ? ' ADVISING' : ' ON CALL'}</Text> : null}
1091 </Text>
1092 </Box>
1093 <Box justifyContent="center" columnGap={2}>
1094 {legend.map(l => (
1095 <Text>
1096 <Text color={l.color}>■</Text>
1097 <Text dimColor>{` ${l.label}`}</Text>
1098 </Text>
1099 ))}
1100 </Box>
1101 {body}
1102 {svgLanes}
1103 </Box>
1104 )
1105 })
1106}
1107hooks/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 111 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 }
109 }
110}
111