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