SLOPSHOPPER

flightdeck

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

newpaneguardcommandstatusagents
v0.3.2-mc.4MITupdated 2026-10-15aecheman27-gif/claude-mods/flightdeck
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · flightdeck
│ ┃ Flightdeck ✕ › fix the failing auth test and add an audit log call │ ┃ FLIGHTDECK · — IDLE │ ┃ ■ main ■ agents ■ gate ⏺ Read(src/auth.ts) │ ┃ ╭─────────────────────────────────────────── ⎿ Read 6 lines │ ┃ │ — · main ⏺ Update(src/auth.ts) │ ┃ │ effort ▯▯▯▯ — 0 req ⎿ Added 2 lines, removed 1 line │ ┃ │ ctx ▰▰▰▰▰▱▱▱▱▱ 49% 97k/200k ⏺ Bash(bun test) │ ┃ │ $0.42 5h ▰▰▱▱▱ 31% ⎿ 3 pass, 1 fail │ ┃ ╰─────────────────────────────────────────── │ ┃ ▣ client module ./rail.tsx ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ✓ last turn 42s · 0 agents · 3 edits · 1 e ✻ Worked for 42s · done 4:20 PM │ ┃ ╰─────────────────────────────────────────── │ ┃ ╭─────────────────────────────────────────── › /flightdeck │ ┃ │ session log ⎿ flightdeck: Flightdeck opened. Focus it with ctrl+x tab; 1-6 exp │ ┃ │ 08:53:20 main Edit → src/auth.ts │ ┃ │ 08:53:20 main Write → src/audit.ts │ ┃ │ 08:53:20 main Write → src/cache.ts │ ┃ │ 08:53:20 main Bash → bun test ✗ │ ┃ ╰─────────────────────────────────────────── │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ flightdeck: ctx 49%

Draws

Pane · Flightdeck
FLIGHTDECK · — IDLE ■ main ■ agents ■ gate ╭──────────────────────────────────────────────────────╮ │ — · main ○ idle │ │ effort ▯▯▯▯ — 0 req │ │ ctx ▰▰▰▰▰▱▱▱▱▱ 49% 97k/200k │ │ $0.42 5h ▰▰▱▱▱ 31% │ ╰──────────────────────────────────────────────────────╯ ▣ client module ./rail.tsx ╭──────────────────────────────────────────────────────╮ │ ✓ last turn 42s · 0 agents · 3 edits · 1 error │ ╰──────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │ session log │ │ 08:53:20 main Edit → src/auth.ts │ │ 08:53:20 main Write → src/audit.ts │ │ 08:53:20 main Write → src/cache.ts │ │ 08:53:20 main Bash → bun test ✗ │ ╰──────────────────────────────────────────────────────╯
README

Flightdeck

License: MIT Claude Code 2.1.287+

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>

Install

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.

What you see

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 transcriptInline 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.
PanelShowsFrom
mainmodel, effort, permission mode, request count; a context gauge with compactions (⟲); cost and the first two rate-limit windows when your plan reports themturn.step, session.measure, session.compact, $.session.usage()
architectconsults 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 advicea spawn of a matching agent type, or a matching server tool in the assistant's rows
gateone 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 maskedtool.check, settled by the tool.call around it
agentscards 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 axisagent.spawn, turn.step, tool.call, turn.complete
loopsmodel loops that match no card: workflow agents, compactions, memory forksturn.step ids no card claims
receiptthe running turn, or the last one: duration, agents, edits, errors, cost addedturn.start, turn.complete
logprompts, spawns, completions, consults, edits, errors and denials; filtered to one agent while you view its transcriptall 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.

Use

CommandDoes
/flightdeckopen the pane
/flightdeck closeclose it
/flightdeck resetclear 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:

KeyDoes
1, 2, …expand an agent card or lane: its full task, last 3 tool calls, start of its answer
f s oopen the gate's file / shell / other drill-down: the last 5 checks and their verdicts

/clear resets the pane along with the conversation.

Where it runs

  • Fullscreen terminal: docked beside the transcript; two columns from 110 columns wide.
  • Main-screen terminal: inline above the prompt, as the 8-row summary.
  • Desktop app, VS Code, mobile: the same panels, plus the agents drawn as an SVG time axis. VS Code and mobile can't animate, so connectors and clocks are static there.

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.

What it can reach

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 seesThrough
every tool call's name and input, and whether it failedtool.call
every permission verdicttool.check
subagent spawns, their model requests and token usage, and their final answersagent.spawn, turn.step, turn.complete
your prompts' first 70 characters, for the logturn.start
context, cost and rate-limit readingssession.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.

What is inferred, not measured

  • Architect moments. "Before a plan" means no edits yet this turn, "error repeats" means 2+ main-loop errors in a row, "before done" means edits were made. They are labelled (inferred); turn them off with moments: false.
  • Server-side advice is encrypted. For a server tool such as Claude Code's advisor, the pane counts and times the consult but cannot show what it said.
  • Per-agent context is the latest request's whole input (uncached + cache read + cache write). It is labelled ctx, not cost: the API has no per-agent cost.
  • Other loops can't tell a workflow agent from a compaction fork; both are model loops no card claims.
  • A background agent's first step can arrive before its card exists, so its usage may show one step late.

Configure

In /config, or under pluginConfigs["flightdeck"].options in settings.json:

OptionDefaultMeaning
architectPattern`advisor\architect`case-insensitive regex for agent types and server tools that count as the architect
matchDescriptionsfalsealso match agent descriptions, not just type names
architectLabelARCHITECTthe architect's name in the pane
gateLabelGATEthe permission panel's name
panelsmain,architect,gate,agents,loops,receipt,logwhich panels show, in order
layoutautomini, compact, wide, or auto (mini inline, wide from 110 columns docked)
maxCards3cards side by side before swimlanes (1–6); fewer if the pane is too narrow
motionwhile-activeoff keeps connectors still
momentstrueshow the inferred consult moments
palettethemepastel uses fixed colours tuned for dark terminals
openOnStarttrueask to open the pane when a session starts
statusLinetruecontext, running agents, consults and denials in the status line

Troubleshooting

The pane doesn't appear.

  • Check claude --version is 2.1.287 or later, then run /reload-plugins and /flightdeck.
  • Below 144 columns, Claude Code won't seat a pane nobody asked for; /flightdeck opens it at any width.
  • Look in the transcript for a dim line starting 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.

How it works

FileHolds
hooks/register.tsxthe event hooks, state access, and one function per panel
hooks/core.tsevery reducer, formatter and layout rule as pure functions, so behaviour is testable directly
hooks/rail.tsx, hooks/elapsed.tsxsurface modules: animated connectors and live clocks that redraw only themselves, on the surface's own frame clock
types/index.d.tsthe 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.

Related projects

Flightdeck works alongside these, and owes ideas to them:

  • claude-hud: context, limits, tools and agents in your status line. Use both: that's the status line, this is the pane.
  • zoetrope: a Claude Code or Codex session as a live flow graph.
  • ccusage: cost reports from your session logs.
  • awesome-claude-code-mods: the index of Claude Code mods.

Develop

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.

License

MIT

Source 5 files
hooks/register.tsx 1185 lines
1import { 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}
1185
hooks/core.ts 500 lines
1// 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
500
hooks/rail.tsx 75 lines
1// 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
75
hooks/elapsed.tsx 38 lines
1// 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
38
types/index.d.ts 117 lines
1export 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