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