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