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