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