SLOPSHOPPER

deck

Deck: live agent and APEX dashboard on demand (/deck, aliases /apex-pane and /task-board). The APEX run on top while one is live (phases, alerts, background…

newpanerowsguardcommandprompt
★ 1v0.1.0MITupdated 2026-10-10AlxWrtl/NixConfig/home/claude-code/mods/deck
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · deck
│ ┃ Deck ✕ › fix the failing auth test and add an audit log call │ ┃ DECK · — IDLE │ ┃ ■ main ■ agents ⏺ 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 │ ┃ ╰─────────────────────────────────────────── │ ┃ ╭─────────────────────────────────────────── › /deck │ ┃ │ session log ⎿ deck: Deck opened. Focus it with ctrl+x tab; 1-6 expand cards. │ ┃ │ 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

Draws

Pane · Deck
DECK · — IDLE ■ main ■ agents ╭──────────────────────────────────────────────────────╮ │ — · 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

deck

A live dashboard pane for Claude Code, opened on demand only: /deck (aliases /apex-pane, /task-board). It never opens by itself and draws no status line: nothing above the prompt unless you open it.

  • APEX block (first, full width, only while an APEX run of the session's folder is live): APEX · <branch or title> · <tier>, one dot per phase (● done, ◐ current, ○ pending, ✗ failed, · skipped) with its approximate tokens, what asks you to act (a failed step, a red external verification, a spent correction budget) and the background shells with their live duration. Each phase move is written to the session log as apex.
  • Panels (from Flightdeck): main model vitals, the on-call architect, subagent cards and swimlanes (keys 1-6 expand a card), the turn receipt, the session log.

/deck close, /deck reset, /deck layout auto|compact|wide|mini. Options (/config, or pluginConfigs.deck.options): architectPattern, architectLabel, panels, motion, moments, matchDescriptions, maxCards, layout, palette.

It only observes: every hook returns the event's result unchanged, and it never writes a file. It reads <cwd>/.claude/output/apex/*/00-context.md, the run's external-verify.json, ~/.claude/apex-correction-budget/ and .git/HEAD.

Attribution

Built from Flightdeck v0.3.2 by Stephen Casella, MIT licensed (see LICENSE). The permission gate and other-loops panels were removed; the APEX block is ours.

Source 8 files
hooks/register.tsx 1758 lines
1// deck: one live dashboard pane, opened on demand only (/deck, aliases /apex-pane and
2// /task-board); it never opens by itself and draws no status line: nothing above the prompt
3// unless you open it.
4// From Flightdeck v0.3.2 (MIT, Stephen Casella): main model vitals, the on-call architect,
5// subagent cards and swimlanes, a turn receipt and a session log. Ours: the APEX block on top,
6// drawn only while an APEX run of the session's folder is live (header, phase dots, what asks
7// the user to act, background shells), its phase changes written to the log as `apex`.
8// Observes only: every hook but the commands and the pane returns next(e)'s result unchanged.
9// Read-only: it never writes a file. The run folders are polled every 5 s only while the pane is
10// open, and once per prompt and per finished main turn otherwise; a poll writes nothing that
11// did not change.
12import { atom, read, update } from 'claude-code'
13import type { EngineInterface, Register, Timer } from 'claude-code'
14
15import type {
16  DeckAgentCard,
17  DeckApexAlert,
18  DeckApexBudget,
19  DeckApexRun,
20  DeckApexSessionRun,
21  DeckApexShell,
22  DeckApexSteps,
23  DeckApexVerdict,
24  DeckArchitect,
25  DeckLayout,
26  DeckLogLine,
27  DeckMain,
28  DeckRoster,
29  DeckTurn,
30  DeckUsage,
31  DeckView,
32  DeckApexStepName,
33} from '../types'
34import {
35  DEFAULT_ARCHITECT,
36  DEFAULT_MAIN,
37  DEFAULT_ROSTER,
38  DEFAULT_TURN,
39  DEFAULT_USAGE,
40  DEFAULT_VIEW,
41  EDIT_TOOLS,
42  afterCall,
43  applyStep,
44  cardTitle,
45  titleLines,
46  consultTimeline,
47  describeInput,
48  endConsult,
49  fitLegend,
50  fmtClock,
51  fmtDuration,
52  fmtTimer,
53  fmtUsd,
54  plural,
55  gauge,
56  isAdvising,
57  kTokens,
58  lanes,
59  limitLabel,
60  listOf,
61  logRows,
62  momentOf,
63  normalize,
64  normalizeCard,
65  normalizeLog,
66  noteTool,
67  PALETTES,
68  SVG_COLORS,
69  parseConfig,
70  prettyModel,
71  promptLine,
72  handbackOf,
73  adviceLine,
74  receiptOf,
75  shorten,
76  startConsult,
77} from './core'
78import type { Config, Panel } from './core'
79import {
80  NO_STEPS,
81  PHASE_GLYPH,
82  apexHeader,
83  bareShown,
84  callSwitch,
85  classifyAgent,
86  classifyCall,
87  endAgent,
88  headBranch,
89  isLive,
90  modelFamily,
91  newestStep,
92  onBranch,
93  parseCodex,
94  parseContext,
95  parseReview,
96  parseVerdict,
97  phaseNote,
98  runFromFiles,
99  runSwitch,
100  isSessionDir,
101  seeStep,
102  sessionKey,
103  sessionRunOf,
104  settleRun,
105  startSteps,
106  stepCells,
107  stepDetail,
108  answerLines,
109  explainStep,
110  gateEvidence,
111  isMergeCall,
112  pathTail,
113  reviewLines,
114  runEndedAt,
115  shipEvidence,
116} from './apex'
117import type { StepCell, StepMark } from './apex'
118import { STEP_ORDER as STEP_NAMES } from './apex'
119import {
120  addShell,
121  clearFinished,
122  closeBySnapshot,
123  shellsAfterTurn,
124  finishByNotification,
125  parseTaskNotifications,
126  runningShells,
127  stopShell,
128} from './shells'
129import type { Notification } from './shells'
130import { alertsOf, budgetOf, pickVerdict } from './signals'
131
132const PANE = 'deck'
133const TITLE = 'Deck'
134const PANE_COLUMNS = 66
135const POLL_MS = 5000
136// The two names the external verification is written under, in a run dir.
137const VERIFY_NAMES = ['04-external-verify.json', 'external-verify.json']
138// Finished shells shown under the running ones; the rest fold into « +N earlier ».
139const DONE_SHELLS = 3
140
141// ---------------------------------------------------------------- state
142
143const main = atom({ plugin: 'deck', key: 'main' } as const, DEFAULT_MAIN)
144const usage = atom({ plugin: 'deck', key: 'usage' } as const, DEFAULT_USAGE)
145const architect = atom({ plugin: 'deck', key: 'architect' } as const, DEFAULT_ARCHITECT)
146const agents = atom({ plugin: 'deck', key: 'agents' } as const, [])
147const log = atom({ plugin: 'deck', key: 'log' } as const, [])
148const turn = atom({ plugin: 'deck', key: 'turn' } as const, DEFAULT_TURN)
149const receipt = atom({ plugin: 'deck', key: 'receipt' } as const, null)
150const view = atom({ plugin: 'deck', key: 'view' } as const, DEFAULT_VIEW)
151const roster = atom({ plugin: 'deck', key: 'roster' } as const, DEFAULT_ROSTER)
152const run = atom({ plugin: 'deck', key: 'run' } as const, null)
153const apexSteps = atom({ plugin: 'deck', key: 'apexSteps' } as const, NO_STEPS)
154const verdict = atom({ plugin: 'deck', key: 'verdict' } as const, null)
155const budget = atom({ plugin: 'deck', key: 'budget' } as const, null)
156const shells = atom({ plugin: 'deck', key: 'shells' } as const, [])
157const sessionRun = atom({ plugin: 'deck', key: 'sessionRun' } as const, null)
158const openStep = atom({ plugin: 'deck', key: 'openStep' } as const, null)
159
160// Module-level: a hot reload drops the environment and its timer with it.
161let timer: Timer | undefined
162// The pane is open: the 5 s timer runs only then.
163let isPaneOpen = false
164// Polls in a row that missed the run shown (settleRun).
165let misses = 0
166// The last run dir a poll found this session (kept through a miss or an ended run), or null:
167// a different one is a new run, which clears the finished cards and shells.
168let lastRunDir: string | null = null
169// The poll under way (its number), or null: a second one waits, unless the first is stuck for
170// STUCK_TICKS ticks, when it is given up and polling goes on.
171let pollCount = 0
172let pollUnderWay: number | null = null
173let skippedTicks = 0
174const STUCK_TICKS = 6
175
176type ServerBlock = { type: string; id?: string; name?: string; tool_use_id?: string }
177
178// Every read goes through these, so a value saved under an older shape still reads.
179async function getMain($: EngineInterface): Promise<DeckMain> {
180  return normalize(DEFAULT_MAIN, await read($, main))
181}
182async function getUsage($: EngineInterface): Promise<DeckUsage> {
183  return normalize(DEFAULT_USAGE, await read($, usage))
184}
185async function getArchitect($: EngineInterface): Promise<DeckArchitect> {
186  const a = normalize(DEFAULT_ARCHITECT, await read($, architect))
187  return { ...a, consults: listOf(a.consults), ids: listOf(a.ids), seen: listOf(a.seen) }
188}
189async function getCards($: EngineInterface): Promise<DeckAgentCard[]> {
190  return listOf<unknown>(await read($, agents)).map(normalizeCard)
191}
192async function getLog($: EngineInterface): Promise<DeckLogLine[]> {
193  return normalizeLog(await read($, log))
194}
195async function getTurn($: EngineInterface): Promise<DeckTurn> {
196  return normalize(DEFAULT_TURN, await read($, turn))
197}
198async function getView($: EngineInterface): Promise<DeckView> {
199  return normalize(DEFAULT_VIEW, await read($, view))
200}
201async function getRoster($: EngineInterface): Promise<DeckRoster> {
202  const r = normalize(DEFAULT_ROSTER, await read($, roster))
203  return { architectTypes: listOf(r.architectTypes) }
204}
205async function getShells($: EngineInterface): Promise<DeckApexShell[]> {
206  return listOf<DeckApexShell>(await read($, shells))
207}
208
209async function say($: EngineInterface, who: string, text: string, kind: DeckLogLine['kind'] = 'info', agentId: string | null = null) {
210  const line: DeckLogLine = { at: await $.clock.now(), who, text, kind, agentId }
211  await update($, log, list => [...normalizeLog(list), line].slice(-60))
212}
213
214async function whoIs($: EngineInterface, agentId: string | undefined) {
215  if (!agentId) return 'main'
216  const card = (await getCards($)).find(c => c.id === agentId)
217  return card ? shorten(cardTitle(card), 14) : 'agent'
218}
219
220async function consultStarted($: EngineInterface, cfg: Config, id: string, via: string) {
221  const t = await getTurn($)
222  const moment = momentOf(t)
223  const at = await $.clock.now()
224  await update($, architect, a => startConsult(normalize(DEFAULT_ARCHITECT, a), { id, at, moment, via }))
225  if (moment === 'before done') await update($, turn, x => ({ ...normalize(DEFAULT_TURN, x), isReviewing: true }))
226  await say($, cfg.architectLabel.toLowerCase(), cfg.moments ? `${moment} · ${via}` : `consulted · ${via}`, 'consult')
227}
228
229async function consultEnded($: EngineInterface, cfg: Config, advice: string | null, id?: string) {
230  const at = await $.clock.now()
231  const first = advice?.split('\n').find(l => l.trim()) ?? null
232  const text = first ? shorten(first.replace(/^[#>*\s-]+/, ''), 160) : null
233  await update($, architect, a => endConsult(normalize(DEFAULT_ARCHITECT, a), at, text, id))
234  await update($, turn, t => ({ ...normalize(DEFAULT_TURN, t), isReviewing: false }))
235  await say($, cfg.architectLabel.toLowerCase(), text ? `advice: ${shorten(text, 60)}` : 'advice returned', 'consult')
236}
237
238async function noteAdvice($: EngineInterface, cfg: Config, advice: string) {
239  await update($, architect, x => ({ ...normalize(DEFAULT_ARCHITECT, x), lastAdvice: advice }))
240  await say($, cfg.architectLabel.toLowerCase(), `advice: ${shorten(advice, 60)}`, 'consult')
241}
242
243async function isArchitectType($: EngineInterface, cfg: Config, type: string) {
244  return cfg.architect.test(type) || (await getRoster($)).architectTypes.includes(type)
245}
246
247async function openPane($: EngineInterface) {
248  // columns apply when docked beside the transcript, rows when seated inline above the prompt.
249  const opened = await $.ui.open({ id: PANE, title: TITLE, columns: PANE_COLUMNS, rows: 8 })
250  // Open, even when not placed yet (seated once a surface places it): polled from now on.
251  isPaneOpen = true
252  startPolling($)
253  return opened
254}
255
256async function resetAll($: EngineInterface) {
257  await update($, main, m => ({ ...DEFAULT_MAIN, model: normalize(DEFAULT_MAIN, m).model, mode: normalize(DEFAULT_MAIN, m).mode }))
258  await update($, architect, () => DEFAULT_ARCHITECT)
259  await update($, agents, () => [])
260  await update($, log, () => [])
261  await update($, turn, () => DEFAULT_TURN)
262  await update($, receipt, () => null)
263  await update($, view, () => DEFAULT_VIEW)
264  // The context gauge waits for the next measurement rather than showing the pre-clear fill.
265  await update($, usage, x => ({ ...normalize(DEFAULT_USAGE, x), pct: null, tokens: null }))
266  // The run comes back on the next poll; its live steps start over.
267  await update($, run, () => null)
268  await update($, apexSteps, () => NO_STEPS)
269  await update($, verdict, () => null)
270  await update($, budget, () => null)
271  await update($, shells, () => [])
272  await update($, sessionRun, () => null)
273  await update($, openStep, () => null)
274}
275
276/** The session's cost read fresh, not from the last measurement: the receipt subtracts two of these. */
277async function costNow($: EngineInterface): Promise<number | null> {
278  const u = await $.session.usage().catch(() => null)
279  return u?.cost?.usd ?? null
280}
281
282async function noteMode($: EngineInterface, mode: string | undefined) {
283  if (mode) await update($, main, m => (normalize(DEFAULT_MAIN, m).mode === mode ? normalize(DEFAULT_MAIN, m) : { ...normalize(DEFAULT_MAIN, m), mode }))
284}
285
286// ---------------------------------------------------------------- APEX run (polled)
287
288// Reads a field of an engine record whose shape varies per tool; a value that is not a
289// non-empty string reads as undefined.
290function stringField(record: unknown, key: string): string | undefined {
291  if (typeof record !== 'object' || record === null) return undefined
292  const value: unknown = Reflect.get(record, key)
293  return typeof value === 'string' && value !== '' ? value : undefined
294}
295
296function numberField(record: unknown, key: string): number | undefined {
297  if (typeof record !== 'object' || record === null) return undefined
298  const value: unknown = Reflect.get(record, key)
299  return typeof value === 'number' && Number.isFinite(value) ? value : undefined
300}
301
302// A row's text: a string as is, else its text blocks joined; another shape reads as empty.
303function rowText(content: unknown): string {
304  if (typeof content === 'string') return content
305  if (!Array.isArray(content)) return ''
306  const parts: string[] = []
307  for (const block of content) {
308    const text = stringField(block, 'type') === 'text' ? stringField(block, 'text') : undefined
309    if (text !== undefined) parts.push(text)
310  }
311  return parts.join('\n')
312}
313
314// A run folder: its name, its newest time (with a context file: that file's or its newest .md
315// file's; without: its newest step file's), its context file's mtime when it has one, its .md files.
316type RunDir = { dir: string; mtimeMs: number; context?: number; files: { name: string; mtimeMs: number }[] }
317
318// Folders listed per poll: the LISTED_DIRS ranked newest that are runs, plus the newest with a
319// context file wherever it ranks. Cost before that: 1 stat per folder with a context file, 2 per
320// folder without (the failed context stat, then the folder's own).
321const LISTED_DIRS = 3
322
323// The newest run folder, or undefined. Ranked by its context file's mtime, else by the folder's
324// own (adding a file moves it; editing one does not), then listed in rank order and the newest
325// time of each decides. A folder without a context file and without a step file is no run: it
326// takes no slot and is never chosen.
327async function newestRun($: EngineInterface, root: string): Promise<RunDir | undefined> {
328  let entries
329  try {
330    entries = await $.fs.list(root)
331  } catch {
332    // No .claude/output/apex here (or unreadable): no run to show.
333    return undefined
334  }
335  const ranked: { dir: string; key: number; context?: number }[] = []
336  for (const entry of entries) {
337    if (entry.kind !== 'dir') continue
338    try {
339      const stat = await $.fs.stat(`${root}/${entry.name}/00-context.md`)
340      ranked.push({ dir: entry.name, key: stat.mtimeMs, context: stat.mtimeMs })
341      continue
342    } catch {
343      // No context file: the folder ranks by its own mtime, read below.
344    }
345    try {
346      ranked.push({ dir: entry.name, key: (await $.fs.stat(`${root}/${entry.name}`)).mtimeMs })
347    } catch {
348      // Vanished between list and stat: skipped.
349    }
350  }
351  ranked.sort((a, b) => b.key - a.key)
352  const newestContext = ranked.find(r => r.context !== undefined)
353  let best: RunDir | undefined
354  let slots = 0
355  for (const candidate of ranked) {
356    if (slots >= LISTED_DIRS && candidate !== newestContext) continue
357    let files: { name: string; mtimeMs: number }[]
358    try {
359      files = (await $.fs.list(`${root}/${candidate.dir}`))
360        .filter(e => e.kind === 'file' && e.name.endsWith('.md'))
361        .map(e => ({ name: e.name, mtimeMs: e.mtimeMs }))
362    } catch {
363      // Unreadable folder: its context file's time alone, if any.
364      files = []
365    }
366    const mtimeMs =
367      candidate.context === undefined ? newestStep(files) : Math.max(candidate.context, ...files.map(f => f.mtimeMs))
368    // Without a context file, a folder with no step file is not a run: no slot, never chosen.
369    if (mtimeMs === -Infinity) continue
370    slots += 1
371    if (best !== undefined && mtimeMs <= best.mtimeMs) continue
372    best = { dir: candidate.dir, mtimeMs, files, ...(candidate.context === undefined ? {} : { context: candidate.context }) }
373  }
374  return best
375}
376
377// HEAD's branch, or undefined: no repo here, or a worktree whose .git is a file.
378async function readHead($: EngineInterface, cwd: string): Promise<string | undefined> {
379  try {
380    return headBranch(await $.fs.read(`${cwd}/.git/HEAD`))
381  } catch {
382    // Unreadable HEAD: branch unknown, shown.
383    return undefined
384  }
385}
386
387// The session's last main-loop Skill(apex) call, or null (an older shape reads as none).
388async function getSessionRun($: EngineInterface): Promise<DeckApexSessionRun | null> {
389  const value: unknown = await read($, sessionRun)
390  const startedAt = numberField(value, 'startedAt')
391  const lastAt = numberField(value, 'lastAt')
392  if (startedAt === undefined || lastAt === undefined) return null
393  const turnEndAt = numberField(value, 'turnEndAt')
394  const merged = Reflect.get(Object(value), 'merged') === true
395  return { startedAt, lastAt, args: stringField(value, 'args') ?? '', turnEndAt: turnEndAt ?? null, merged }
396}
397
398// One change to the session run, written only when there is one and the change moved something.
399async function changeSessionRun($: EngineInterface, fn: (value: DeckApexSessionRun) => DeckApexSessionRun): Promise<void> {
400  await record(async () => {
401    const current = await getSessionRun($)
402    if (current === null) return
403    const next = fn(current)
404    if (JSON.stringify(next) !== JSON.stringify(current)) await update($, sessionRun, () => next)
405  })
406}
407
408// New activity (a main turn start, a tool call, an agent spawn): the run clock runs again.
409const wakeRun = ($: EngineInterface): Promise<void> =>
410  changeSessionRun($, x => (x.turnEndAt === null || x.turnEndAt === undefined ? x : { ...x, turnEndAt: null }))
411
412// The open step, or none (an unknown name reads as none).
413async function getOpenStep($: EngineInterface): Promise<DeckApexStepName | null> {
414  const value: unknown = await read($, openStep)
415  return STEP_NAMES.find(n => n === value) ?? null
416}
417
418// The live run folder, else the session's Skill(apex) call (HEAD read only when there is one).
419async function scan($: EngineInterface): Promise<DeckApexRun | null> {
420  const cwd = await $.session.cwd()
421  const folder = await scanFolder($, cwd)
422  if (folder !== null) return folder
423  const at = await getSessionRun($)
424  if (at === null) return null
425  return sessionRunOf(at, await readHead($, cwd), await $.clock.now())
426}
427
428async function scanFolder($: EngineInterface, cwd: string): Promise<DeckApexRun | null> {
429  const root = `${cwd}/.claude/output/apex`
430  const newest = await newestRun($, root)
431  if (newest === undefined) return null
432  let parsed: DeckApexRun
433  let liveMs: number
434  if (newest.context !== undefined) {
435    let text: string
436    try {
437      text = await $.fs.read(`${root}/${newest.dir}/00-context.md`)
438    } catch {
439      // Vanished between stat and read, or over 4 MiB: nothing shown this round.
440      return null
441    }
442    parsed = parseContext(text, newest.dir)
443    liveMs = newest.context
444  } else {
445    parsed = runFromFiles(newest.dir, newest.files)
446    // Only a finish step file: the run ended.
447    if (parsed.currentStep === undefined) return null
448    liveMs = newest.mtimeMs
449  }
450  const now = await $.clock.now()
451  if (!isLive(parsed, liveMs, now)) return null
452  const head = await readHead($, cwd)
453  // Without a context file the header names the folder (no branch); hidden on the trunk, live 1 h.
454  if (newest.context === undefined && !bareShown(parsed, liveMs, head, now)) return null
455  return onBranch(parsed, head) ? { ...parsed, dir: newest.dir } : null
456}
457
458// The state library reads and writes named atoms only: one writer per atom, each writing
459// only when the value changed.
460async function putVerdict($: EngineInterface, value: DeckApexVerdict | null): Promise<void> {
461  if (JSON.stringify(await read($, verdict)) === JSON.stringify(value)) return
462  await update($, verdict, () => value)
463}
464
465async function putBudget($: EngineInterface, value: DeckApexBudget | null): Promise<void> {
466  if (JSON.stringify(await read($, budget)) === JSON.stringify(value)) return
467  await update($, budget, () => value)
468}
469
470// The live steps, or none (an older shape reads as none).
471async function getSteps($: EngineInterface): Promise<DeckApexSteps> {
472  const s = normalize(NO_STEPS, await read($, apexSteps))
473  return { runKey: typeof s.runKey === 'string' ? s.runKey : null, steps: listOf(s.steps), current: s.current ?? null }
474}
475
476// One change to the live steps, written only when it changed something. Steps count only while
477// a run is live: a Skill(apex) call set the key, else the shown run folder gives it.
478async function changeSteps($: EngineInterface, fn: (value: DeckApexSteps, runKey: string) => DeckApexSteps): Promise<void> {
479  await record(async () => {
480    const current = await getSteps($)
481    const runKey = current.runKey ?? (await read($, run))?.dir ?? null
482    if (runKey === null) return
483    const next = fn(current, runKey)
484    if (next !== current) await update($, apexSteps, () => next)
485  })
486}
487
488async function changeShells($: EngineInterface, fn: (value: DeckApexShell[]) => DeckApexShell[]): Promise<void> {
489  const current = await getShells($)
490  if (fn(current) === current) return
491  await update($, shells, list => fn(listOf<DeckApexShell>(list)))
492}
493
494// The run's external verification, from whichever of its two names was written last;
495// re-read only when that file's mtime moved.
496async function refreshVerdict($: EngineInterface, found: DeckApexRun | null): Promise<void> {
497  const dir = found?.dir
498  // A session run has no folder: nothing to list.
499  if (dir === undefined || isSessionDir(dir)) return putVerdict($, null)
500  const runDir = `${await $.session.cwd()}/.claude/output/apex/${dir}`
501  let newest: { name: string; mtimeMs: number } | null = null
502  try {
503    for (const entry of await $.fs.list(runDir)) {
504      if (entry.kind !== 'file' || !VERIFY_NAMES.includes(entry.name)) continue
505      newest = pickVerdict(newest, { name: entry.name, mtimeMs: entry.mtimeMs })
506    }
507  } catch {
508    // The run directory vanished: no verification to show.
509    newest = null
510  }
511  if (newest === null) return putVerdict($, null)
512  const current = await read($, verdict)
513  if (current !== null && current.dir === dir && current.mtimeMs === newest.mtimeMs) return
514  let next: DeckApexVerdict | null = null
515  try {
516    const parsed = parseVerdict(await $.fs.read(`${runDir}/${newest.name}`))
517    if (parsed !== null) next = { dir, mtimeMs: newest.mtimeMs, ...parsed }
518  } catch {
519    // Vanished between list and read, or over 4 MiB: shown as pending.
520    next = null
521  }
522  await putVerdict($, next)
523}
524
525// The run's correction budget, from the names in ~/.claude/apex-correction-budget (one file
526// per round used or granted).
527async function refreshBudget($: EngineInterface, found: DeckApexRun | null): Promise<void> {
528  const dir = found?.dir
529  const home = await $.env.get('HOME')
530  if (dir === undefined || isSessionDir(dir) || home === undefined || home === '') return putBudget($, null)
531  let names: string[]
532  try {
533    names = (await $.fs.list(`${home}/.claude/apex-correction-budget`)).map(entry => entry.name)
534  } catch {
535    // No round was ever claimed on this machine: no budget to show.
536    names = []
537  }
538  await putBudget($, budgetOf(names, dir))
539}
540
541// A subagent's still running shells close once it is killed, fails or leaves the agent list:
542// their notification would only ever reach that loop. Read only while such a shell runs.
543async function snapshotShells($: EngineInterface): Promise<void> {
544  const list = await getShells($)
545  if (!list.some(s => s.status === 'running' && s.ownerAgentId !== undefined)) return
546  const listed = await $.agent.list()
547  const owners = listed.map(a => ({ id: a.id, type: a.type, status: a.status }))
548  const [cards, a] = await Promise.all([getCards($), getArchitect($)])
549  const known = [...cards.map(c => c.id), ...a.ids]
550  const at = await $.clock.now()
551  await changeShells($, current => closeBySnapshot(current, owners, known, at))
552}
553
554// A new run: the finished cards and shells go, the running ones stay; receipt, log, main untouched.
555async function clearForNewRun($: EngineInterface): Promise<void> {
556  const cards = await getCards($)
557  if (clearFinished(cards, []).cards !== cards) {
558    await update($, agents, list => clearFinished(listOf<unknown>(list).map(normalizeCard), []).cards)
559  }
560  await changeShells($, current => clearFinished([], current).shells)
561  await update($, openStep, () => null)
562}
563
564// One poll: run (its phase moves to the log), phases, verdict, budget, owned shells.
565async function refresh($: EngineInterface): Promise<void> {
566  const scanned = await scan($)
567  const prev = await read($, run)
568  const settled = settleRun(prev, scanned, misses)
569  misses = settled.misses
570  const found = settled.run
571  if (JSON.stringify(prev) !== JSON.stringify(found)) await update($, run, () => found)
572  const note = phaseNote(prev, found)
573  if (note !== null) await say($, 'apex', note)
574  const sw = runSwitch(lastRunDir, found?.dir ?? null)
575  lastRunDir = sw.last
576  if (sw.isNew) await clearForNewRun($)
577  await refreshVerdict($, found)
578  await refreshBudget($, found)
579  await snapshotShells($)
580}
581
582function onTick($: EngineInterface): void {
583  if (pollUnderWay !== null && skippedTicks < STUCK_TICKS) {
584    skippedTicks += 1
585    return
586  }
587  pollCount += 1
588  const mine = pollCount
589  pollUnderWay = mine
590  skippedTicks = 0
591  refresh($)
592    .catch(() => {
593      // A failed scan or a refused write leaves the figures as they were; the next tick tries again.
594    })
595    .finally(() => {
596      // A poll given up as stuck no longer owns the slot when it settles at last.
597      if (pollUnderWay === mine) pollUnderWay = null
598    })
599}
600
601// One poll now, and the 5 s timer while the pane is open.
602function startPolling($: EngineInterface): void {
603  if (timer === undefined) timer = $.clock.every(POLL_MS, () => onTick($))
604  pollOnce($)
605}
606
607function stopPolling(): void {
608  timer?.cancel()
609  timer = undefined
610}
611
612// A single poll (a prompt, a finished main turn): the log's apex lines fill with the pane closed.
613function pollOnce($: EngineInterface): void {
614  $.clock.after(0, () => onTick($))
615}
616
617// Runs `write`; a refused state write leaves the figures as they were (an observer never fails
618// the event it watches).
619async function record(write: () => Promise<void>): Promise<void> {
620  try {
621    await write()
622  } catch {
623    // Figures only: the next step, call or poll writes again.
624  }
625}
626
627/**
628 * The run's live step a tool call stands for: edits from every loop, gate / Codex / ship from the
629 * main loop's Bash only. A refused call, or a failed edit, is no step. Called from the one
630 * unmatched tool.call observer (the engine allows one per event).
631 */
632async function noteCallStep($: EngineInterface, e: unknown, ran: { result?: unknown; isError?: boolean }): Promise<void> {
633  const isRefused = ran.result === undefined && ran.isError !== true
634  const tool = stringField(e, 'tool') ?? ''
635  const input = { file_path: stringField(e, 'file_path'), notebook_path: stringField(e, 'notebook_path'), command: stringField(e, 'command') }
636  const name = isRefused ? null : classifyCall(tool, input, stringField(e, 'agentId') !== undefined)
637  const isFailedEdit = (name === 'edit' || name === 'plan') && ran.isError === true
638  if (name === null || isFailedEdit) return
639  const stdout = stringField(ran.result, 'stdout') ?? ''
640  const command = input.command ?? ''
641  const path = input.file_path ?? input.notebook_path
642  const codex = name === 'Codex' ? parseCodex(stdout) : null
643  const out = { stdout, stderr: stringField(ran.result, 'stderr') ?? '', returnCodeInterpretation: stringField(ran.result, 'returnCodeInterpretation') ?? '' }
644  const evidence =
645    name === 'Codex'
646      ? (codex ?? {})
647      : name === 'gate'
648        ? gateEvidence(command, out, ran.isError === true)
649        : name === 'ship'
650          ? shipEvidence(command, stdout)
651          : path === undefined
652            ? {}
653            : { files: [pathTail(path)] }
654  const at = await $.clock.now()
655  await changeSteps($, (s, key) => seeStep(s, key, name, at, evidence))
656  if (name === 'ship' && ran.isError !== true && isMergeCall(command)) await changeSessionRun($, x => (x.merged === true ? x : { ...x, merged: true }))
657}
658
659/** An implementer, test-runner or reviewer started: its step, with what it does and on which model. */
660async function noteAgentStep($: EngineInterface, subagentType: string, description: string, agentId: string, model: string): Promise<void> {
661  const name = classifyAgent(subagentType)
662  if (name === null) return
663  const at = await $.clock.now()
664  const detail = `${description} (${modelFamily(model)})`
665  await changeSteps($, (s, key) => seeStep(s, key, name, at, { agentId, detail }))
666}
667
668/** A step's agent ended: a reviewer's verdict (anchored) and the lines after it, else its answer's first lines. */
669async function endAgentStep($: EngineInterface, agentId: string, answer: string, reason: string): Promise<void> {
670  const at = await $.clock.now()
671  const isReview = (await getSteps($)).steps.some(x => x.agentId === agentId && x.name === 'review')
672  const verdict = isReview ? parseReview(answer) : null
673  const lines = isReview ? reviewLines(answer) : answerLines(answer)
674  await changeSteps($, s => endAgent(s, agentId, at, verdict, { lines, isFailed: reason !== 'answer' }))
675}
676
677const alertText = (a: DeckApexAlert) =>
678  a.kind === 'step'
679    ? `✗ step ${a.step} failed`
680    : a.kind === 'verify'
681      ? `✗ external verify ${a.verdict} · ${plural(a.findings, 'finding')}`
682      : `■ correction budget spent · ${a.rounds}/${a.cap} rounds`
683
684/** /deck and its aliases: open (the default), close, reset, layout <auto|compact|wide|mini>. */
685async function runCommand($: EngineInterface, args: string) {
686  const [verb = 'open', arg = ''] = args.trim().split(/\s+/)
687  if (verb === 'close') {
688    await $.ui.close({ id: PANE })
689    isPaneOpen = false
690    stopPolling()
691    return { text: 'Deck closed.' }
692  }
693  if (verb === 'reset') {
694    await resetAll($)
695    return { text: 'Deck reset.' }
696  }
697  if (verb === 'layout') {
698    const layout: DeckLayout | null = arg === 'compact' || arg === 'wide' || arg === 'auto' || arg === 'mini' ? arg : null
699    if (!layout) return { text: 'Usage: /deck layout auto|compact|wide|mini' }
700    await update($, view, v => ({ ...normalize(DEFAULT_VIEW, v), layout }))
701    const opened = await openPane($)
702    return { text: opened.isPlaced ? `Deck layout: ${layout}.` : `Layout set to ${layout}; the pane is not shown yet: ${opened.reason}` }
703  }
704  const opened = await openPane($)
705  if (!opened.isPlaced) return { text: `Deck is not shown yet: ${opened.reason}` }
706  return { text: 'Deck opened. Focus it with ctrl+x tab; 1-6 expand cards.' }
707}
708
709// ---------------------------------------------------------------- hooks
710
711export const register: Register = (on, options) => {
712  const cfg = parseConfig(options)
713  const C = PALETTES[cfg.palette]
714
715  on('session.start', async ($, e, next) => {
716    // Each command on its own: one refused leaves the others registered.
717    try {
718      await $.command.register({
719        name: 'deck',
720        description: 'Deck, the live agent and APEX dashboard: open, close, reset, or set the layout',
721        argumentHint: '[open|close|reset|layout auto|compact|wide|mini]',
722      })
723    } catch {
724      // Refused: no /deck this session; the aliases and the observers still run.
725    }
726    try {
727      await $.command.register({ name: 'apex-pane', description: 'Opens the deck pane (alias of /deck)' })
728    } catch {
729      // Refused: no /apex-pane this session; /deck still opens the pane.
730    }
731    try {
732      await $.command.register({ name: 'task-board', description: 'Opens the deck pane (alias of /deck)' })
733    } catch {
734      // Refused: no /task-board this session; /deck still opens the pane.
735    }
736    // A host without usage (headless, an SDK host, a session not yet bound) just starts without it.
737    const u = await $.session.usage().catch(() => null)
738    if (u) {
739      await update($, usage, x => ({
740        ...normalize(DEFAULT_USAGE, x),
741        pct: u.context.percent ?? null,
742        tokens: u.context.tokens ?? null,
743        window: u.context.window,
744        costUsd: u.cost?.usd ?? null,
745        limits: u.rateLimits.map(r => ({ kind: r.kind, pct: r.percentUsed })),
746      }))
747    }
748    return next(e)
749  })
750
751  on('session.end', async ($, e, next) => {
752    // The next session's first run is no new run.
753    lastRunDir = null
754    if (e.reason === 'clear') {
755      // A /clear starts every figure over; the poll is kept for it.
756      misses = 0
757      await resetAll($)
758    } else {
759      isPaneOpen = false
760      stopPolling()
761    }
762    return next(e)
763  })
764
765  // The pane closed (its close button, /deck close, another plugin): the 5 s poll stops.
766  on('ui.close', async ($, e, next) => {
767    const closed = await next(e)
768    if (e.id === PANE) {
769      isPaneOpen = false
770      stopPolling()
771    }
772    return closed
773  })
774
775  on('command.run', { command: 'deck' }, async ($, e) => runCommand($, e.args))
776  on('command.run', { command: 'apex-pane' }, async ($, e) => runCommand($, e.args))
777  on('command.run', { command: 'task-board' }, async ($, e) => runCommand($, e.args))
778
779  on('prompt.submit', ($, e, next) => {
780    pollOnce($)
781    return next(e)
782  })
783
784  on('classic.UserPromptSubmit', async ($, e, next) => {
785    await noteMode($, e.permission_mode)
786    return next(e)
787  })
788
789  on('agent.offer', async ($, e, next) => {
790    const offered = await next(e)
791    if (cfg.architect.test(e.agent) || (cfg.matchDescriptions && cfg.architect.test(e.description))) {
792      await update($, roster, r => {
793        const x = normalize(DEFAULT_ROSTER, r)
794        return x.architectTypes.includes(e.agent) ? x : { architectTypes: [...listOf<string>(x.architectTypes), e.agent].slice(-20) }
795      })
796    }
797    return offered
798  })
799
800  on('turn.start', async ($, e, next) => {
801    const [now, cost] = await Promise.all([$.clock.now(), costNow($)])
802    await update($, turn, () => ({ ...DEFAULT_TURN, startedAt: now, costAtStart: cost }))
803    await update($, main, m => ({ ...normalize(DEFAULT_MAIN, m), isRunning: true }))
804    if (stringField(e, 'agentId') === undefined) await wakeRun($)
805    // A background architect's report reaches the main loop as the text opening this turn. The
806    // SubagentHandback tool call (in tool.call) normally carries it first; this is the fallback.
807    const back = e.text ? handbackOf(e.text) : null
808    const a = back ? await getArchitect($) : null
809    if (back && a && a.ids.includes(back.from)) {
810      const advice = adviceLine(back.body)
811      if (advice && advice !== a.lastAdvice) await noteAdvice($, cfg, advice)
812    } else if (e.text) {
813      const p = promptLine(e.text)
814      await say($, p.who, p.text)
815    }
816    return next(e)
817  })
818
819  on('turn.step', async function* ($, e, next) {
820    // The main loop's model is known when its request starts; a long first request shouldn't read "—".
821    if (!e.agentId) {
822      await update($, main, m => {
823        const x = normalize(DEFAULT_MAIN, m)
824        return { ...x, model: e.model, effort: String(e.effort ?? x.effort), steps: x.steps + 1 }
825      })
826    }
827    const result = yield* next(e)
828    const id = e.agentId
829    if (!id) return result
830    const cards = await getCards($)
831    if (cards.some(c => c.id === id)) {
832      const step = { model: e.model, usage: result.usage, stopReason: result.stopReason }
833      await update($, agents, list => listOf<unknown>(list).map(normalizeCard).map(c => (c.id === id ? applyStep(c, step) : c)))
834      if (result.stopReason === 'max_tokens') await say($, await whoIs($, id), 'hit max_tokens', 'error', id)
835    }
836    return result
837  })
838
839  on('session.measure', async ($, e, next) => {
840    await update($, usage, x => ({
841      ...normalize(DEFAULT_USAGE, x),
842      pct: e.context.percent ?? null,
843      tokens: e.context.tokens ?? null,
844      window: e.context.window,
845      costUsd: e.cost?.usd ?? null,
846      limits: e.rateLimits.map(r => ({ kind: r.kind, pct: r.percentUsed })),
847    }))
848    return next(e)
849  })
850
851  on('session.compact', async ($, e, next) => {
852    const done = await next(e)
853    if (!e.agentId && e.trigger !== 'precompute') {
854      const now = await $.clock.now()
855      await update($, usage, x => {
856        const u = normalize(DEFAULT_USAGE, x)
857        return { ...u, compactions: u.compactions + 1, lastCompactAt: now }
858      })
859      await say($, 'main', `context compacted (${e.trigger})`)
860    }
861    return done
862  })
863
864  on('tool.call', async ($, e, next) => {
865    const ran = await next(e)
866    await wakeRun($)
867    await noteCallStep($, e, ran)
868    // A refused call carries neither a result nor an error. An inference, not the refusal's own
869    // field: a tool that answers with an undefined result would read as refused too (log text only).
870    const isRefused = ran.result === undefined && ran.isError !== true
871    // A background agent hands its report back through this tool; an architect's report is its advice.
872    if (String(e.tool) === 'SubagentHandback') {
873      const message = (e as unknown as { message?: unknown }).message
874      const a = e.agentId ? await getArchitect($) : null
875      if (a && e.agentId && a.ids.includes(e.agentId) && typeof message === 'string') {
876        const advice = adviceLine(message)
877        if (advice && advice !== a.lastAdvice) await noteAdvice($, cfg, advice)
878      }
879      return ran
880    }
881    if (e.tool === 'Agent') return ran
882    const hasFailed = !isRefused && ran.isError === true
883    const isEdit = !hasFailed && !isRefused && EDIT_TOOLS.has(e.tool)
884    const t0 = await getTurn($)
885    if (isEdit || hasFailed || (!e.agentId && t0.errorStreak > 0)) {
886      await update($, turn, t => afterCall(normalize(DEFAULT_TURN, t), { inSubagent: Boolean(e.agentId), hasFailed, isEdit }))
887    }
888    const text = shorten(describeInput(e.tool, e), 64)
889    if (e.agentId) {
890      const id = e.agentId
891      await update($, agents, list =>
892        listOf<unknown>(list)
893          .map(normalizeCard)
894          .map(c => (c.id === id ? noteTool(c, { tool: e.tool, text, isError: hasFailed || isRefused }) : c)),
895      )
896    }
897    // The log keeps what is worth a glance: refusals, errors and edits; the rest is on the cards.
898    if (isRefused) await say($, await whoIs($, e.agentId), `${text}  refused`, 'error', e.agentId ?? null)
899    else if (hasFailed) await say($, await whoIs($, e.agentId), `${text}  ✗`, 'error', e.agentId ?? null)
900    else if (isEdit) await say($, await whoIs($, e.agentId), text, 'info', e.agentId ?? null)
901    return ran
902  })
903
904  // Background shell: run_in_background, or ctrl+B / auto-background, all answer a backgroundTaskId.
905  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
906    const ran = await next(e)
907    const id = stringField(ran.result, 'backgroundTaskId')
908    if (id !== undefined) {
909      await record(async () => {
910        const startedAt = await $.clock.now()
911        const label = stringField(e, 'description') ?? stringField(e, 'command') ?? 'shell'
912        const toolUseId = stringField(e, 'tool_use_id')
913        // Set only inside a subagent loop: its shells close with it.
914        const ownerAgentId = stringField(e, 'agentId')
915        await changeShells($, current =>
916          addShell(current, {
917            id,
918            label,
919            startedAt,
920            ...(toolUseId === undefined ? {} : { toolUseId }),
921            ...(ownerAgentId === undefined ? {} : { ownerAgentId }),
922          }),
923        )
924      })
925    }
926    return ran
927  }).catch(($, e, next) => next(e))
928
929  // A main-loop Skill(apex) call that ran: a run without a folder (yet), a new run once any run
930  // was seen. A subagent's call, another skill, a failed or refused call: nothing.
931  on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
932    const ran = await next(e)
933    if (stringField(e, 'skill') === 'apex' && !e.agentId && ran.isError !== true && ran.result !== undefined) {
934      await record(async () => {
935        const now = await $.clock.now()
936        const sw = callSwitch(lastRunDir, sessionKey(now))
937        lastRunDir = sw.last
938        if (sw.isNew) await clearForNewRun($)
939        await update($, sessionRun, () => ({ startedAt: now, lastAt: now, args: shorten(stringField(e, 'args') ?? '', 40) }))
940        // Each call starts the run's live steps over, keyed as the session run.
941        await update($, apexSteps, () => startSteps(sessionKey(now)))
942        await update($, openStep, () => null)
943      })
944      pollOnce($)
945    }
946    return ran
947  }).catch(($, e, next) => next(e))
948
949  // A shell stopped by TaskStop gets no notification row: closed as killed. TaskStop also stops
950  // agents; stopShell leaves those alone.
951  on('tool.call', { tool: 'TaskStop' }, async ($, e, next) => {
952    const ran = await next(e)
953    if (ran.isError !== true && ran.result !== undefined) {
954      const id = stringField(ran.result, 'task_id') ?? stringField(e, 'task_id') ?? stringField(e, 'shell_id')
955      if (id !== undefined) {
956        await record(async () => {
957          const at = await $.clock.now()
958          await changeShells($, current => stopShell(current, id, at))
959        })
960      }
961    }
962    return ran
963  }).catch(($, e, next) => next(e))
964
965  // A background shell's notification row: the one completion signal a shell has. A render hook
966  // never writes state, so the write is deferred to a timer; the row itself is drawn unchanged.
967  on('ui.render', { component: 'UserMessage', props: { origin: { kind: 'task-notification' } } }, ($, e, next) => {
968    const task: unknown = e.props.task
969    const note: Notification = {}
970    const id = stringField(task, 'id')
971    const toolUseId = stringField(task, 'toolUseId')
972    const status = stringField(task, 'status')
973    const durationMs = numberField(task, 'durationMs')
974    if (id !== undefined) note.id = id
975    if (toolUseId !== undefined) note.toolUseId = toolUseId
976    if (status !== undefined) note.status = status
977    if (durationMs !== undefined) note.durationMs = durationMs
978    if (note.status !== undefined) {
979      $.clock.after(0, () => {
980        $.clock
981          .now()
982          .then(at => changeShells($, current => finishByNotification(current, note, at)))
983          .catch(() => {
984            // State refused: the shell stays running until its owner's end or a later redraw.
985          })
986      })
987    }
988    return next(e)
989  })
990
991  // A server-side review tool never reaches tool.call: it shows only in the assistant's rows.
992  // A subagent's shell notifies that subagent's loop only: its row never reaches the main
993  // transcript, so it is read here. Read before next: the row is relayed unchanged.
994  on('session.append', async ($, e, next) => {
995    const agentId = e.agentId
996    if (agentId !== undefined && agentId !== '' && e.origin.kind === 'task-notification') {
997      const notes = parseTaskNotifications(rowText(e.message.content))
998      if (notes.length > 0) {
999        await record(async () => {
1000          const at = await $.clock.now()
1001          await changeShells($, current => notes.reduce((list, note) => finishByNotification(list, note, at), current))
1002        })
1003      }
1004    }
1005    if (!e.agentId && e.message.type === 'assistant') {
1006      const a = await getArchitect($)
1007      // Consults this row opened: their result may be in the same row, after the stale read above.
1008      const opened = new Set<string>()
1009      for (const block of e.message.content as unknown as readonly ServerBlock[]) {
1010        if (block.type === 'server_tool_use' && block.name && block.id && cfg.architect.test(block.name)) {
1011          if (a.seen.includes(block.id)) continue
1012          const id = block.id
1013          await update($, architect, x => {
1014            const y = normalize(DEFAULT_ARCHITECT, x)
1015            return { ...y, seen: [...listOf<string>(y.seen), id].slice(-60) }
1016          })
1017          opened.add(id)
1018          await consultStarted($, cfg, id, `${block.name} tool`)
1019        } else if (block.type.endsWith('_tool_result') && block.tool_use_id) {
1020          const id = block.tool_use_id
1021          const isOpen = opened.has(id) || (await getArchitect($)).consults.some(c => c.id === id && c.endAt === null)
1022          if (isOpen) await consultEnded($, cfg, null, id)
1023        }
1024      }
1025    }
1026    return next(e)
1027  })
1028
1029  on('agent.spawn', async ($, e, next) => {
1030    const started = await next(e)
1031    if (!e.parentAgentId) await noteMode($, e.permissionMode)
1032    if (!started.agentId) return started
1033    const id = started.agentId
1034    await wakeRun($)
1035    await noteAgentStep($, e.subagentType, e.description, id, started.model)
1036    if (await isArchitectType($, cfg, e.subagentType)) {
1037      await update($, architect, a => {
1038        const x = normalize(DEFAULT_ARCHITECT, a)
1039        return { ...x, ids: [...listOf<string>(x.ids), id].slice(-40) }
1040      })
1041      await consultStarted($, cfg, id, e.subagentType.split(':').pop() ?? 'agent')
1042      return started
1043    }
1044    const card: DeckAgentCard = {
1045      ...normalizeCard({}),
1046      id,
1047      type: e.name ?? e.subagentType,
1048      model: started.model,
1049      description: e.description,
1050      spawnedAt: await $.clock.now(),
1051    }
1052    await update($, agents, list => [...listOf<unknown>(list).map(normalizeCard), card].slice(-24))
1053    await say($, shorten(cardTitle(card), 12), `spawned · ${card.type}`, 'info', id)
1054    return started
1055  })
1056
1057  on('turn.complete', async ($, e, next) => {
1058    const done = await next(e)
1059    const id = e.agentId
1060    const now = await $.clock.now()
1061    if (!id) {
1062      const [t, cards, cost] = await Promise.all([getTurn($), getCards($), costNow($)])
1063      const r = receiptOf(t, {
1064        durationMs: e.durationMs,
1065        agentsSince: cards.filter(c => c.spawnedAt >= t.startedAt).length,
1066        costNow: cost,
1067        reason: e.reason,
1068      })
1069      await update($, receipt, () => r)
1070      await update($, main, m => ({ ...normalize(DEFAULT_MAIN, m), isRunning: false }))
1071      // The main turn's end: the run clock stops here once no agent or shell of the run runs.
1072      await changeSessionRun($, x => ({ ...x, turnEndAt: now }))
1073      pollOnce($)
1074      return done
1075    }
1076    // Any subagent, the architect too: its still running shells could only ever notify it.
1077    await record(() => changeShells($, current => shellsAfterTurn(current, id, e.reason, now)))
1078    await endAgentStep($, id, e.answer, e.reason)
1079    if ((await getArchitect($)).ids.includes(id)) {
1080      await consultEnded($, cfg, e.answer, id)
1081      return done
1082    }
1083    const cards = await getCards($)
1084    if (cards.some(c => c.id === id)) {
1085      const status = e.reason === 'answer' ? 'done' : e.reason === 'aborted' ? 'stopped' : 'failed'
1086      await update($, agents, list =>
1087        listOf<unknown>(list)
1088          .map(normalizeCard)
1089          .map(c => (c.id === id ? { ...c, status, endedAt: now, answer: shorten(e.answer, 400) } : c)),
1090      )
1091      const card = cards.find(c => c.id === id)
1092      const took = card ? fmtDuration(now - card.spawnedAt) : ''
1093      await say($, await whoIs($, id), status === 'done' ? `done · ${took}` : status, status === 'done' ? 'done' : 'error', id)
1094    }
1095    return done
1096  })
1097
1098  // ---------------------------------------------------------------- drawing
1099
1100  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1101    // Drawn means open (a hot reload forgets the flag; the surface does not): polled while it is.
1102    if (!isPaneOpen) {
1103      isPaneOpen = true
1104      startPolling($)
1105    }
1106    const els = $.ui.resolve(e)
1107    const { Box, Text, Button } = els
1108    // Clients draw on terminal and desktop only; elsewhere the same frame as static text.
1109    const hasClient = 'Client' in els && (e.surface === 'terminal' || e.surface === 'desktop')
1110    const [m, u, a, cards, lines, t, r, v, apexRun, st, sr, vd, bg, sh, now, open] = await Promise.all([
1111      getMain($),
1112      getUsage($),
1113      getArchitect($),
1114      getCards($),
1115      getLog($),
1116      getTurn($),
1117      read($, receipt),
1118      getView($),
1119      read($, run),
1120      getSteps($),
1121      getSessionRun($),
1122      read($, verdict),
1123      read($, budget),
1124      getShells($),
1125      $.clock.now(),
1126      getOpenStep($),
1127    ])
1128    const W = Math.max(40, e.props.bodyColumns)
1129    const layout = v.layout ?? cfg.layout
1130    const isWide = layout === 'wide' || (layout === 'auto' && W >= 110)
1131    const colW = isWide ? Math.floor((W - 2) / 2) : W
1132    const modelName = prettyModel(m.model)
1133    const viewed = e.props.view?.agentId ?? null
1134    const advising = isAdvising(a)
1135    const running = cards.filter(c => c.status === 'running')
1136    const showArchitect = a.consults.length > 0 || a.ids.length > 0
1137    const motion = cfg.motion && hasClient
1138    // A panel with nothing to show yet takes no room: most sessions never spawn an agent.
1139    const isEmpty: Record<Panel, boolean> = {
1140      main: false,
1141      architect: !showArchitect,
1142      agents: cards.length === 0,
1143      receipt: !m.isRunning && !r,
1144      log: false,
1145    }
1146    const panels = cfg.panels.filter(p => !isEmpty[p])
1147
1148    // A connector between panels: animated while its flow is live, a dim line otherwise.
1149    const rail = (key: string, active: boolean, color: string, width: number, marks: number[] = [], isMerge = false) =>
1150      motion ? (
1151        <els.Client
1152          key={key}
1153          module="./rail.tsx"
1154          width={width}
1155          height={1}
1156          props={{ active, width, color, dim: C.faint, marks, isMerge }}
1157        />
1158      ) : (
1159        <Text color={C.faint}>{'─'.repeat(Math.max(1, width))}</Text>
1160      )
1161
1162    // A start time of 0 is unknown (state saved before it was recorded): no clock, not decades.
1163    const clock = (key: string, since: number, endAt: number | null, color: string) =>
1164      since <= 0 ? (
1165        <Text color={color}>—</Text>
1166      ) : hasClient ? (
1167        <els.Client key={key} module="./elapsed.tsx" props={{ since, now, endAt, color }} />
1168      ) : (
1169        <Text color={color}>{fmtTimer((endAt ?? now) - since)}</Text>
1170      )
1171
1172    // ---- APEX: drawn only while a run is live, first and full width
1173    const alerts = alertsOf(apexRun, vd, bg)
1174    const shellsRunning = runningShells(sh)
1175    const doneShells = sh.filter(s => s.status !== 'running')
1176    const shownShells = [...sh.filter(s => s.status === 'running'), ...doneShells.slice(0, DONE_SHELLS)]
1177    const hiddenShells = sh.length - shownShells.length
1178    const markColor: Record<StepMark, string> = { done: C.apex, current: C.apex, pending: C.dim }
1179    const shellMark = (s: DeckApexShell) =>
1180      s.status === 'running'
1181        ? { glyph: '◐', color: C.agent }
1182        : s.status === 'completed'
1183          ? { glyph: '✓', color: C.ok }
1184          : s.status === 'failed'
1185            ? { glyph: '✗', color: C.warn }
1186            : { glyph: '■', color: C.dim }
1187    // A shell's row: mark, label, live duration (frozen once ended), status.
1188    const shellRow = (s: DeckApexShell, w: number) => {
1189      const sm = shellMark(s)
1190      return (
1191        <Box>
1192          <Text color={sm.color}>{`${sm.glyph} `}</Text>
1193          <Box width={Math.max(10, w - 24)}>
1194            <Text wrap="truncate">{s.label}</Text>
1195          </Box>
1196          <Text> </Text>
1197          <Box flexShrink={0}>{clock(`shell-clock-${s.id}`, s.startedAt, s.endedAt ?? null, C.dim)}</Box>
1198          <Text color={sm.color}>{` ${s.status}`}</Text>
1199        </Box>
1200      )
hooks/core.ts 404 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; these
3// functions are what the hooks apply). From Flightdeck v0.3.2 (MIT, Stephen Casella).
4import type {
5  DeckAgentCard,
6  DeckArchitect,
7  DeckLayout,
8  DeckLogLine,
9  DeckMain,
10  DeckMoment,
11  DeckReceipt,
12  DeckRoster,
13  DeckToolNote,
14  DeckTurn,
15  DeckUsage,
16  DeckView,
17} from '../types'
18
19// ---------------------------------------------------------------- defaults
20
21export const DEFAULT_MAIN: DeckMain = { model: '', effort: '', mode: '', steps: 0, isRunning: false }
22export const DEFAULT_USAGE: DeckUsage = {
23  pct: null,
24  tokens: null,
25  window: 0,
26  costUsd: null,
27  limits: [],
28  compactions: 0,
29  lastCompactAt: null,
30}
31export const DEFAULT_ARCHITECT: DeckArchitect = { consults: [], ids: [], seen: [], lastAdvice: '' }
32export const DEFAULT_TURN: DeckTurn = { edits: 0, errorStreak: 0, errors: 0, isReviewing: false, startedAt: 0, costAtStart: null }
33export const DEFAULT_VIEW: DeckView = { expanded: null, layout: null }
34export const DEFAULT_ROSTER: DeckRoster = { architectTypes: [] }
35
36const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
37
38/** A stored object merged over its defaults, so a value saved under an older shape still reads. */
39export const normalize = <T extends object>(def: T, stored: unknown): T =>
40  isObject(stored) ? ({ ...def, ...stored } as T) : def
41
42/** A stored list, or empty when what is stored is not a list. */
43export const listOf = <T>(stored: unknown): T[] => (Array.isArray(stored) ? (stored as T[]) : [])
44
45export const normalizeCard = (stored: unknown): DeckAgentCard =>
46  normalize<DeckAgentCard>(
47    {
48      id: '',
49      type: 'agent',
50      model: '',
51      description: '',
52      status: 'running',
53      spawnedAt: 0,
54      endedAt: null,
55      ctx: 0,
56      out: 0,
57      steps: 0,
58      lastStop: null,
59      tools: [],
60      answer: '',
61    },
62    stored,
63  )
64
65export const normalizeLog = (stored: unknown): DeckLogLine[] =>
66  listOf<Record<string, unknown>>(stored).map(l => ({
67    at: typeof l.at === 'number' ? l.at : 0,
68    who: String(l.who ?? ''),
69    text: String(l.text ?? ''),
70    agentId: typeof l.agentId === 'string' ? l.agentId : null,
71    kind: l.kind === 'error' || l.kind === 'consult' || l.kind === 'done' ? l.kind : 'info',
72  }))
73
74// ---------------------------------------------------------------- config
75
76export type Panel = 'main' | 'architect' | 'agents' | 'receipt' | 'log'
77const PANELS: readonly Panel[] = ['main', 'architect', 'agents', 'receipt', 'log']
78
79export type Config = {
80  architect: RegExp
81  architectLabel: string
82  panels: Panel[]
83  motion: boolean
84  moments: boolean
85  matchDescriptions: boolean
86  maxCards: number
87  layout: DeckLayout
88  palette: Palette
89}
90
91const safeRegExp = (source: string, fallback: string) => {
92  try {
93    return new RegExp(source || fallback, 'i')
94  } catch {
95    return new RegExp(fallback, 'i')
96  }
97}
98
99/** The plugin's `/config` values, read leniently: anything malformed falls back to the default. */
100export const parseConfig = (o: Readonly<Record<string, unknown>>): Config => {
101  const str = (k: string, d: string) => (typeof o[k] === 'string' && o[k] !== '' ? (o[k] as string) : d)
102  const bool = (k: string, d: boolean) => (typeof o[k] === 'boolean' ? (o[k] as boolean) : d)
103  const panels = str('panels', PANELS.join(','))
104    .split(',')
105    .map(s => s.trim())
106    .filter((p): p is Panel => (PANELS as readonly string[]).includes(p))
107  const layout = str('layout', 'auto')
108  const max = typeof o.maxCards === 'number' ? Math.round(o.maxCards) : 3
109  return {
110    architect: safeRegExp(str('architectPattern', ''), 'advisor|architect'),
111    architectLabel: str('architectLabel', 'ARCHITECT'),
112    panels: panels.length > 0 ? [...new Set(panels)] : [...PANELS],
113    motion: str('motion', 'while-active') !== 'off',
114    moments: bool('moments', true),
115    matchDescriptions: bool('matchDescriptions', false),
116    maxCards: Math.min(6, Math.max(1, max)),
117    layout: layout === 'compact' || layout === 'wide' || layout === 'mini' ? layout : 'auto',
118    palette: str('palette', 'theme') === 'pastel' ? 'pastel' : 'theme',
119  }
120}
121
122// ---------------------------------------------------------------- palette
123
124export type Palette = 'theme' | 'pastel'
125export type Colors = Record<'main' | 'agent' | 'ok' | 'arch' | 'apex' | 'amber' | 'warn' | 'dim' | 'faint' | 'text', string>
126
127/**
128 * `theme` names the person's own theme colours (they follow light, dark and colour-blind themes);
129 * `pastel` is fixed hex tuned for dark terminals.
130 */
131export const PALETTES: Record<Palette, Colors> = {
132  theme: {
133    main: 'claude',
134    agent: 'suggestion',
135    ok: 'success',
136    arch: 'merged',
137    apex: 'planMode',
138    amber: 'warning',
139    warn: 'error',
140    dim: 'inactive',
141    faint: 'subtle',
142    text: 'text',
143  },
144  pastel: {
145    main: '#7dd3fc',
146    agent: '#93c5fd',
147    ok: '#86efac',
148    arch: '#c4b5fd',
149    apex: '#fdba74',
150    amber: '#fcd34d',
151    warn: '#fca5a5',
152    dim: '#6b7280',
153    faint: '#3f4654',
154    text: '#e5e7eb',
155  },
156}
157
158/** SVG cannot name theme keys: mid-tone colours that read on light and dark backgrounds. */
159export const SVG_COLORS = { running: '#3b82f6', done: '#16a34a', failed: '#dc2626', other: '#8b5cf6', label: '#6b7280' }
160
161// ---------------------------------------------------------------- formatting
162
163/**
164 * A model id as people say it, from any provider's spelling: `claude-opus-5-5[1m]` → `Opus 5.5 1M`,
165 * `us.anthropic.claude-sonnet-4-5-20250929-v1:0` → `Sonnet 4.5`, `claude-3-5-haiku-20241022` →
166 * `Haiku 3.5`. Anything else is shown as given, cut to 22 characters.
167 */
168export const prettyModel = (id: string) => {
169  if (!id) return '—'
170  const cap = (f: string) => f.charAt(0).toUpperCase() + f.slice(1)
171  const big = /\[1m\]|-1m\b/i.test(id) ? ' 1M' : ''
172  const now = /claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(?![\d])/i.exec(id)
173  if (now?.[1] && !/^\d/.test(now[1])) return `${cap(now[1].toLowerCase())} ${now[2]}${now[3] ? `.${now[3]}` : ''}${big}`
174  const old = /claude-(\d+)(?:-(\d))?-([a-z]+)/i.exec(id)
175  if (old?.[3]) return `${cap(old[3].toLowerCase())} ${old[1]}${old[2] ? `.${old[2]}` : ''}${big}`
176  return shorten(id, 22)
177}
178
179export const shorten = (s: string, n: number) => {
180  const one = s.replace(/\s+/g, ' ').trim()
181  return n <= 0 ? '' : one.length > n ? `${one.slice(0, Math.max(0, n - 1)).trimEnd()}…` : one
182}
183
184export const kTokens = (n: number) =>
185  n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
186
187export const fmtDuration = (ms: number) => {
188  const s = Math.max(0, Math.round(ms / 1000))
189  if (s < 60) return `${s}s`
190  const m = Math.floor(s / 60)
191  return m < 60 ? `${m}m${String(s % 60).padStart(2, '0')}s` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
192}
193
194/** A running clock as the live cards draw it: m:ss, or XhYY past an hour. */
195export const fmtTimer = (ms: number) => {
196  const s = Math.max(0, Math.floor(ms / 1000))
197  const m = Math.floor(s / 60)
198  return m < 60 ? `${m}:${String(s % 60).padStart(2, '0')}` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
199}
200
201export const fmtClock = (ms: number) => (ms > 0 ? new Date(ms).toTimeString().slice(0, 8) : '--:--:--')
202
203/** `1 error`, `2 errors`. */
204export const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
205
206export const fmtUsd = (n: number) => (n >= 100 ? `$${Math.round(n)}` : `$${n.toFixed(2)}`)
207
208/** A gauge of `width` cells: ▰ filled, ▱ empty. */
209export const gauge = (pct: number, width: number) => {
210  const full = Math.max(0, Math.min(width, Math.round((pct / 100) * width)))
211  return { on: '▰'.repeat(full), off: '▱'.repeat(width - full) }
212}
213
214/** A rate-limit window's short name: `five_hour` → `5h`, `seven_day_opus` → `7d opus`. */
215export const limitLabel = (kind: string) =>
216  kind
217    .replace(/five[_ -]?hours?/i, '5h')
218    .replace(/seven[_ -]?days?/i, '7d')
219    .replace(/[_-]+/g, ' ')
220    .trim()
221
222// ---------------------------------------------------------------- redaction
223
224const SECRETS: [RegExp, string][] = [
225  [/(authorization\s*[:=]\s*)(bearer\s+|basic\s+)?\S+/gi, '$1$2•••'],
226  [/\b(bearer)\s+[A-Za-z0-9._~+/-]{8,}=*/gi, '$1 •••'],
227  [/\b(sk|pk|rk|ghp|gho|ghs|github_pat|xox[abprs])[-_][A-Za-z0-9_-]{8,}/g, '•••'],
228  [/((?:api[_-]?key|access[_-]?token|token|secret|password|passwd|pwd)\s*[=:]\s*)("[^"]*"|'[^']*'|\S+)/gi, '$1•••'],
229  [/(--(?:token|password|api-key|secret)[= ])\S+/gi, '$1•••'],
230  [/(\b[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)=)\S+/g, '$1•••'],
231  [/(\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:)[^\s@]+@/gi, '$1•••@'],
232]
233
234/** What the log and the cards may store: credentials masked before anything is written to state. */
235export const redact = (s: string) => SECRETS.reduce((t, [re, to]) => t.replace(re, to), s)
236
237// ---------------------------------------------------------------- tools
238
239export const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
240
241/** One line saying what a call was about: its command, path or pattern; redacted. */
242export const describeInput = (tool: string, input: unknown) => {
243  const i = isObject(input) ? input : {}
244  const path = typeof i.file_path === 'string' ? i.file_path.split(/[\\/]/).slice(-2).join('/') : ''
245  const what =
246    typeof i.command === 'string'
247      ? i.command
248      : path || (typeof i.pattern === 'string' ? i.pattern : typeof i.url === 'string' ? i.url : typeof i.description === 'string' ? i.description : '')
249  return redact(what ? `${tool} → ${what}` : tool)
250}
251
252// ---------------------------------------------------------------- turn and architect
253
254/**
255 * The turn after one tool call. Errors count in the main loop only (a subagent's failure is its
256 * own); edits count from every loop, so delegated work still reaches "before done".
257 */
258export const afterCall = (t: DeckTurn, c: { inSubagent: boolean; hasFailed: boolean; isEdit: boolean }): DeckTurn => ({
259  ...t,
260  errorStreak: c.inSubagent ? t.errorStreak : c.hasFailed ? t.errorStreak + 1 : 0,
261  errors: t.errors + (!c.inSubagent && c.hasFailed ? 1 : 0),
262  edits: t.edits + (c.isEdit ? 1 : 0),
263})
264
265/** Which of the architect's three moments a consult falls at: an inference over this turn so far. */
266export const momentOf = (t: Pick<DeckTurn, 'edits' | 'errorStreak'>): DeckMoment =>
267  t.errorStreak >= 2 ? 'error repeats' : t.edits === 0 ? 'before a plan' : 'before done'
268
269export const startConsult = (a: DeckArchitect, c: { id: string; at: number; moment: DeckMoment; via: string }): DeckArchitect =>
270  a.consults.some(x => x.id === c.id) ? a : { ...a, consults: [...a.consults, { ...c, endAt: null }].slice(-40) }
271
272/** Ends the open consult (the latest without an end), or the one named. */
273export const endConsult = (a: DeckArchitect, at: number, advice: string | null, id?: string): DeckArchitect => {
274  const open = [...a.consults].reverse().find(c => c.endAt === null && (id === undefined || c.id === id))
275  return {
276    ...a,
277    consults: a.consults.map(c => (c === open ? { ...c, endAt: at } : c)),
278    lastAdvice: advice ?? a.lastAdvice,
279  }
280}
281
282export const isAdvising = (a: DeckArchitect) => a.consults.some(c => c.endAt === null)
283
284/** A one-row timeline of consults across `width` cells: ◆ a consult, ━ while it ran. */
285export const consultTimeline = (a: DeckArchitect, now: number, width: number) => {
286  if (a.consults.length === 0 || width < 4) return '─'.repeat(Math.max(0, width))
287  const first = a.consults[0]?.at ?? now
288  const span = Math.max(1, now - first)
289  const cells = Array.from({ length: width }, () => '─')
290  for (const c of a.consults) {
291    const from = Math.min(width - 1, Math.floor(((c.at - first) / span) * (width - 1)))
292    const to = Math.min(width - 1, Math.floor((((c.endAt ?? now) - first) / span) * (width - 1)))
293    for (let i = from + 1; i <= to; i += 1) cells[i] = '━'
294    cells[from] = '◆'
295  }
296  return cells.join('')
297}
298
299export const receiptOf = (t: DeckTurn, o: { durationMs: number; agentsSince: number; costNow: number | null; reason: string }): DeckReceipt => ({
300  durationMs: o.durationMs,
301  agents: o.agentsSince,
302  edits: t.edits,
303  errors: t.errors,
304  costDelta: o.costNow !== null && t.costAtStart !== null && o.costNow - t.costAtStart >= 0.005 ? o.costNow - t.costAtStart : null,
305  reason: o.reason,
306})
307
308// ---------------------------------------------------------------- agents
309
310type StepUsage = { input_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number; output_tokens?: number } | null
311
312/** A card after one of its model requests: its context is the latest step's whole input; output adds up. */
313export const applyStep = (c: DeckAgentCard, s: { model: string; usage: StepUsage; stopReason: string | null }): DeckAgentCard => {
314  const u = s.usage ?? {}
315  const ctx = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
316  return {
317    ...c,
318    model: c.model || s.model,
319    steps: c.steps + 1,
320    ctx: ctx > 0 ? ctx : c.ctx,
321    out: c.out + (u.output_tokens ?? 0),
322    lastStop: s.stopReason,
323  }
324}
325
326export const noteTool = (c: DeckAgentCard, n: DeckToolNote): DeckAgentCard => ({ ...c, tools: [...c.tools, n].slice(-3) })
327
328/** Swimlane geometry: each agent's bar on one shared axis from the first spawn to now. */
329export const lanes = (cards: DeckAgentCard[], now: number, width: number) => {
330  const start = Math.min(...cards.map(c => c.spawnedAt).filter(n => n > 0), now)
331  const span = Math.max(1, now - start)
332  return cards.map(c => {
333    const from = Math.floor(((Math.max(c.spawnedAt, start) - start) / span) * width)
334    const to = Math.max(from + 1, Math.ceil((((c.endedAt ?? now) - start) / span) * width))
335    return { id: c.id, before: Math.min(from, width), bar: Math.min(to, width) - Math.min(from, width), after: width - Math.min(to, width) }
336  })
337}
338
339// ---------------------------------------------------------------- layout
340
341/** How many log lines fit: what the other panels leave, never fewer than 4 nor more than 8. */
342export const logRows = (bodyRows: number, used: number) => Math.max(4, Math.min(8, bodyRows - used - 3))
343
344/** Legend items that fit on one row of `width` cells, in order; the rest are dropped. */
345export const fitLegend = <T extends { label: string }>(items: T[], width: number) => {
346  const out: T[] = []
347  let used = 0
348  for (const it of items) {
349    const w = it.label.length + 4
350    if (used + w > width) break
351    out.push(it)
352    used += w
353  }
354  return out
355}
356
357/** A card's title row: the task in the agent's own words, the type only when there is none. */
358export const cardTitle = (c: DeckAgentCard) => c.description || c.type
359
360/** A title split over two rows at a word boundary: `first` cells on row one, `rest` on row two. */
361export const titleLines = (title: string, first: number, rest: number): [string, string] => {
362  const t = title.replace(/\s+/g, ' ').trim()
363  if (t.length <= first) return [t, '']
364  const cut = t.lastIndexOf(' ', first)
365  const at = cut > 0 ? cut : first
366  return [t.slice(0, at).trim(), shorten(t.slice(at), rest)]
367}
368
369/**
370 * What a turn's opening text was, for the log: the person's words, or for a turn the engine
371 * opened with a tagged message (a subagent's hand-back, a task notification), that message's kind.
372 */
373export const promptLine = (text: string): { who: string; text: string } => {
374  const tag = /^\s*<([a-z][\w-]*)/i.exec(text)?.[1]
375  if (!tag) return { who: 'you', text: shorten(text, 70) }
376  const from = /\bfrom="([^"]+)"/.exec(text)?.[1]
377  return { who: 'engine', text: shorten(`${tag.replace(/[-_]/g, ' ')}${from ? ` from ${from.slice(0, 8)}` : ''}`, 70) }
378}
379
380/**
381 * A subagent's hand-back message: who sent it and the first line of what it said. The report
382 * follows a framing header in the message; without one, the first line after the opening tag.
383 */
384export const handbackOf = (text: string): { from: string; body: string } | null => {
385  const from = /^\s*<agent-message\s+from="([^"]+)"/.exec(text)?.[1]
386  if (!from) return null
387  const afterHeader = text.split(/The report follows:\s*\n/)[1]
388  const rest = afterHeader ?? text.replace(/^\s*<agent-message[^>]*>/, '')
389  const body =
390    rest
391      .split('\n')
392      .map(l => l.trim())
393      .find(l => l && !l.startsWith('[') && !l.startsWith('<') && !l.startsWith('</')) ?? ''
394  return body ? { from, body } : null
395}
396
397/** The advice line a pane shows for a report: its first real line, markdown markers stripped, cut to 160. */
398export const adviceLine = (report: string) => {
399  const first = report.split('\n').map(l => l.trim()).find(l => l && !l.startsWith('[') && !l.startsWith('<')) ?? ''
400  return shorten(first.replace(/\*\*|__/g, '').replace(/^[#>*\s-]+/, ''), 160)
401}
402
403export const elapsedOf = (c: DeckAgentCard, now: number) => (c.endedAt ?? now) - c.spawnedAt
404
hooks/apex.ts 614 lines
1// Pure reading of an APEX run: its 00-context.md (header and Progress table, every drifted
2// spelling tolerated), its liveness rule, the live steps its tool calls stand for, the external
3// verdict file, and what the block and the log show of it. No `$`, no clock, no I/O.
4
5import type {
6  DeckApexLiveStep,
7  DeckApexRun,
8  DeckApexSessionRun,
9  DeckApexStep,
10  DeckApexStepKind,
11  DeckApexStepName,
12  DeckApexSteps,
13} from '../types'
14import { fmtTimer, shorten } from './core'
15
16// ---------------------------------------------------------------- context file
17
18/** A run is live only while its context file moved in the last 6 hours. */
19export const STALE_MS = 6 * 60 * 60 * 1000
20
21const clean = (value: string): string => value.replace(/\*\*/g, '').replace(/`/g, '').trim()
22
23export function statusKind(status: string): DeckApexStepKind {
24  const s = clean(status).toLowerCase()
25  if (/^(complete|done|✅)/.test(s)) return 'done'
26  if (/^pending/.test(s)) return 'pending'
27  if (/^(in[ _]progress|en cours|running)/.test(s)) return 'running'
28  if (/^(skipped|supprimé|n\/a)/.test(s)) return 'skipped'
29  if (/^(red|failed|échoué)(?![a-z])/.test(s)) return 'failed'
30  return 'other'
31}
32
33// "Key: value", "**Key:** value", "**Key**: value", "- Key: value".
34const FIELD = /^\s*(?:[-*]\s+)?\**\s*([A-Za-zÀ-ÿ][A-Za-zÀ-ÿ ]*?)\s*\**\s*:\s*(.*)$/
35
36/** A header value's head: `Standard (mods UI) — files…` → `Standard`. */
37function headOf(value: string): string {
38  const head = value.split(/\s+—\s+|\s+→\s+|\s*\(|;|\.\s/)[0] ?? value
39  return head.trim()
40}
41
42function readSteps(lines: readonly string[]): DeckApexStep[] {
43  const start = lines.findIndex(l => /^##\s+progress\b/i.test(l))
44  if (start < 0) return []
45  const steps: DeckApexStep[] = []
46  for (const line of lines.slice(start + 1)) {
47    if (/^##\s/.test(line)) break
48    if (!line.trim().startsWith('|')) continue
49    if (/^[\s|:-]+$/.test(line)) continue
50    const cells = line.split('|').slice(1, -1).map(clean)
51    const step = cells[0] ?? ''
52    const status = cells[1] ?? ''
53    if (step === '' || /^step$/i.test(step)) continue
54    steps.push({ step, status, kind: statusKind(status) })
55  }
56  return steps
57}
58
59export function currentStep(steps: readonly DeckApexStep[]): string | undefined {
60  return (steps.find(s => s.kind === 'running') ?? steps.find(s => s.kind === 'pending'))?.step
61}
62
63export function parseContext(text: string, dirName: string): DeckApexRun {
64  const lines = text.replace(/\r\n?/g, '\n').split('\n')
65  const headerEnd = lines.findIndex(l => /^##\s/.test(l))
66  const header = headerEnd < 0 ? lines : lines.slice(0, headerEnd)
67
68  const heading = header.find(l => /^#\s+\S/.test(l))
69  const headingText = heading === undefined ? undefined : clean(heading.replace(/^#\s+/, ''))
70  const apexTitle = headingText?.match(/^APEX\s*[:—–-]\s*(.+)$/i)?.[1]
71  const title = clean(apexTitle ?? headingText ?? '') || dirName
72
73  const fields = new Map<string, string>()
74  for (const line of header) {
75    const match = FIELD.exec(line)
76    const key = match?.[1]?.trim().toLowerCase()
77    const value = match?.[2]
78    if (key === undefined || value === undefined || fields.has(key)) continue
79    fields.set(key, clean(value))
80  }
81
82  const tierField = fields.get('tier')
83  const modeField = fields.get('mode')
84  const flags = fields.get('flags') ?? fields.get('flags résolus')
85  const tier =
86    tierField !== undefined && tierField !== ''
87      ? headOf(tierField)
88      : modeField !== undefined && modeField !== ''
89        ? headOf(modeField)
90        : flags?.match(/\bmode\s+([^\s,;)]+)/i)?.[1]
91
92  const branch = (fields.get('branch') ?? fields.get('branche'))?.split(/\s+/)[0]
93
94  const run: DeckApexRun = { title, steps: readSteps(lines) }
95  if (tier !== undefined && tier !== '') run.tier = tier
96  if (branch !== undefined && branch !== '') run.branch = branch
97  const current = currentStep(run.steps)
98  if (current !== undefined) run.currentStep = current
99  return run
100}
101
102/**
103 * Live: modified in the last `staleMs` (STALE_MS by default), its 09-finish row neither done nor
104 * skipped, and, when it has a Progress table, a pending or running row left.
105 */
106export function isLive(run: DeckApexRun, mtimeMs: number, now: number, staleMs: number = STALE_MS): boolean {
107  if (now - mtimeMs >= staleMs) return false
108  const finish = run.steps.find(s => /finish/i.test(s.step))
109  if (finish !== undefined && (finish.kind === 'done' || finish.kind === 'skipped')) return false
110  if (run.steps.length === 0) return true
111  return run.steps.some(s => s.kind === 'pending' || s.kind === 'running')
112}
113
114/** APEX's main steps in order: the pending dots of a run read from its files. */
115export const KNOWN_STEPS: readonly string[] = [
116  '00-init',
117  '01-analyze',
118  '02-plan',
119  '03-execute',
120  '04-validate',
121  '05-examine',
122  '06-resolve',
123  '07-tests',
124  '08-run-tests',
125  '09-finish',
126]
127
128// A step file: `NN-name.md` or `NNx-name.md`; 00-context.md is the run's header, not a step.
129const STEP_FILE = /^(?!00-context\.md$)(\d\d[a-z]?-.+)\.md$/
130const FINISH = /^\d\d[a-z]?-finish$/
131
132/** A run folder without 00-context.md is live only while one of its step files moved in the last hour. */
133export const BARE_STALE_MS = 60 * 60 * 1000
134
135/** The newest step file's mtime among `files`, or -Infinity when none is a step file. */
136export function newestStep(files: readonly { name: string; mtimeMs: number }[]): number {
137  return Math.max(-Infinity, ...files.filter(f => STEP_FILE.test(f.name)).map(f => f.mtimeMs))
138}
139
140/**
141 * A run folder without 00-context.md is shown only off the trunk (HEAD neither master nor main)
142 * and while its newest step file is under BARE_STALE_MS old; the rest is isLive's rule.
143 */
144export function bareShown(run: DeckApexRun, stepMs: number, head: string | undefined, now: number): boolean {
145  if (head === 'master' || head === 'main') return false
146  return isLive(run, stepMs, now, BARE_STALE_MS)
147}
148
149/**
150 * The run dir a poll found against the last one seen this session: new only when a previous run
151 * was seen and the dir differs. `last` survives a poll with no run (a miss, an ended run), so the
152 * same run coming back is not new. From a session key the poll never finds a new run: the call
153 * that set it already counted (callSwitch), and the folder it then writes is that same run.
154 */
155export function runSwitch(last: string | null, dir: string | null): { isNew: boolean; last: string | null } {
156  if (dir === null) return { isNew: false, last }
157  return { isNew: last !== null && last !== dir && !isSessionDir(last), last: dir }
158}
159
160// ---------------------------------------------------------------- session run
161
162// A session run's key, in the run dir's place: never a folder name (a folder holds no colon here).
163const SESSION_PREFIX = 'session:'
164
165/** The run key of the main-loop Skill(apex) call started at `startedAt`. */
166export const sessionKey = (startedAt: number): string => `${SESSION_PREFIX}${startedAt}`
167
168/** A run key set by a Skill(apex) call rather than by a folder under .claude/output/apex. */
169export const isSessionDir = (dir: string): boolean => dir.startsWith(SESSION_PREFIX)
170
171/** A main-loop Skill(apex) call: a new run once any run was seen this session. */
172export function callSwitch(last: string | null, key: string): { isNew: boolean; last: string } {
173  return { isNew: last !== null, last: key }
174}
175
176/**
177 * The run a main-loop Skill(apex) call stands for while no run folder is live: shown off the
178 * trunk (HEAD neither master nor main) and under BARE_STALE_MS after the last call; HEAD as
179 * header, no tier, one current `apex` row.
180 */
181export function sessionRunOf(at: DeckApexSessionRun | null, head: string | undefined, now: number): DeckApexRun | null {
182  if (at === null || head === 'master' || head === 'main' || now - at.lastAt >= BARE_STALE_MS) return null
183  const run: DeckApexRun = {
184    title: 'apex',
185    steps: [{ step: 'apex', status: 'in progress', kind: 'running' }],
186    currentStep: 'apex',
187    dir: sessionKey(at.startedAt),
188  }
189  if (head !== undefined) run.branch = head
190  return run
191}
192
193/**
194 * A run folder without 00-context.md, read from its step files: title the folder, no tier, each
195 * present step done, the most recently modified one current (a finish file never: the run ended),
196 * the known steps after it pending.
197 */
198export function runFromFiles(dirName: string, files: readonly { name: string; mtimeMs: number }[]): DeckApexRun {
199  const present = new Map<string, number>()
200  for (const file of files) {
201    const step = STEP_FILE.exec(file.name)?.[1]
202    if (step !== undefined) present.set(step, Math.max(present.get(step) ?? 0, file.mtimeMs))
203  }
204  let current: string | undefined
205  let newest = -Infinity
206  for (const [step, mtimeMs] of present) {
207    // A finish file ends the run: done, never current.
208    if (FINISH.test(step)) continue
209    if (mtimeMs > newest || (mtimeMs === newest && current !== undefined && step > current)) {
210      current = step
211      newest = mtimeMs
212    }
213  }
214  const names = new Set(present.keys())
215  if (current !== undefined) for (const step of KNOWN_STEPS) if (step > current) names.add(step)
216  const steps: DeckApexStep[] = [...names].sort().map(step =>
217    step === current
218      ? { step, status: 'in progress', kind: 'running' }
219      : present.has(step)
220        ? { step, status: 'complete', kind: 'done' }
221        : { step, status: 'pending', kind: 'pending' },
222  )
223  const run: DeckApexRun = { title: dirName, steps }
224  if (current !== undefined) run.currentStep = current
225  return run
226}
227
228/** The branch a .git/HEAD file names, or undefined (detached HEAD, anything unrecognised). */
229export function headBranch(text: string): string | undefined {
230  const name = /^ref:\s*refs\/heads\/(\S+)\s*$/.exec(text.trim())?.[1]
231  return name === undefined || name === '' ? undefined : name
232}
233
234/** Off-branch only when both the run's branch and HEAD's are known and differ. */
235export function onBranch(run: DeckApexRun, head: string | undefined): boolean {
236  return run.branch === undefined || head === undefined || run.branch === head
237}
238
239// ---------------------------------------------------------------- live steps
240
241/** The steps in the order the row shows them; edit, gate and ship always, the rest once seen. */
242export const STEP_ORDER: readonly DeckApexStepName[] = ['plan', 'edit', 'implement', 'tests', 'gate', 'Codex', 'review', 'ship']
243const ALWAYS: ReadonlySet<DeckApexStepName> = new Set<DeckApexStepName>(['edit', 'gate', 'ship'])
244
245const EDITING = new Set(['Edit', 'Write', 'NotebookEdit'])
246const PLAN_PATH = /(^|\/)\.claude\/output\/apex\//
247const CODEX = /apex-verify-external/
248const SHIP = /\bgit\s+(commit|push)\b|\bgh\s+pr\s+(create|merge)\b/
249const GATE = /nix flake check|pnpm (verify|test|typecheck|lint)|claude plugin (test|validate)|\btsc\b|cargo (check|test)/
250
251/**
252 * The step a tool call stands for, or null. Edits count from every loop; Bash (gate, Codex, ship)
253 * from the main loop only: a reviewer's probes are no gate.
254 */
255export function classifyCall(
256  tool: string,
257  input: { file_path?: string; notebook_path?: string; command?: string },
258  inSubagent: boolean,
259): DeckApexStepName | null {
260  if (EDITING.has(tool)) {
261    const path = input.file_path ?? input.notebook_path ?? ''
262    return PLAN_PATH.test(path) ? 'plan' : 'edit'
263  }
264  if (tool !== 'Bash' || inSubagent) return null
265  const command = input.command ?? ''
266  if (CODEX.test(command)) return 'Codex'
267  if (SHIP.test(command)) return 'ship'
268  if (GATE.test(command)) return 'gate'
269  return null
270}
271
272const IMPLEMENTERS = new Set(['frontend-expert', 'backend-expert', 'nix-expert', 'debugger', 'quick-fix'])
273const REVIEWERS = new Set(['code-reviewer', 'security-auditor'])
274
275/** The step a spawned agent stands for (its type's last `:` segment), or null. */
276export function classifyAgent(subagentType: string): 'implement' | 'tests' | 'review' | null {
277  const type = subagentType.split(':').pop() ?? subagentType
278  if (IMPLEMENTERS.has(type)) return 'implement'
279  if (type === 'test-runner') return 'tests'
280  if (REVIEWERS.has(type)) return 'review'
281  return null
282}
283
284/** The external verification's verdict line in a Bash output, or null. */
285export function parseCodex(output: string): { verdict: string; findings: number } | null {
286  const match = /EXTERNAL-VERIFY (PASS|FAIL|BLOCKED)\b(?:.*?findings=(\d+))?/.exec(output)
287  const verdict = match?.[1]
288  if (verdict === undefined) return null
289  return { verdict, findings: Number(match?.[2] ?? 0) }
290}
291
292// A report line without its markdown: `**`, heading `#`s, list bullets' leading spaces.
293const bare = (line: string): string => line.replace(/\*\*/g, '').replace(/^\s*#+\s*/, '').trim()
294const LABELLED = /^\s*verdict\s*:\s*(APPROVED|NEEDS_FIXES|BLOCKED)\b/i
295const LEADING = /^(APPROVED|NEEDS_FIXES|BLOCKED)\b/
296
297const nonEmpty = (text: string): string[] => text.replace(/\r\n?/g, '\n').split('\n').filter(l => l.trim() !== '')
298
299// The verdict and the index (among the non-empty lines) of the line that carries it.
300function reviewVerdict(lines: readonly string[]): { verdict: string; at: number } | null {
301  // A labelled `Verdict: X` line wins: a first line may open with the word as prose ("BLOCKED by X").
302  for (const [at, line] of lines.entries()) {
303    const word = LABELLED.exec(bare(line))?.[1]
304    if (word !== undefined) return { verdict: word.toUpperCase(), at }
305  }
306  const first = lines[0]
307  const word = first === undefined ? undefined : LEADING.exec(bare(first))?.[1]
308  return word === undefined ? null : { verdict: word, at: 0 }
309}
310
311/**
312 * A reviewer's verdict, anchored: a `Verdict: X` line, else a first non-empty line that is (or
313 * starts with) APPROVED, NEEDS_FIXES or BLOCKED; else null. The word in later prose is ignored.
314 */
315export function parseReview(answer: string): string | null {
316  return reviewVerdict(nonEmpty(answer))?.verdict ?? null
317}
318
319/** The first 3 non-empty lines of a review after its verdict line (from the top without one), shortened. */
320export function reviewLines(answer: string): string[] {
321  const lines = nonEmpty(answer)
322  const v = reviewVerdict(lines)
323  return lines.slice(v === null ? 0 : v.at + 1, (v === null ? 0 : v.at + 1) + 3).map(l => shorten(l, 100))
324}
325
326/** The first 2 non-empty lines of an agent's final answer, shortened. */
327export function answerLines(answer: string): string[] {
328  return nonEmpty(answer)
329    .slice(0, 2)
330    .map(l => shorten(l, 100))
331}
332
333/** A path's last two segments: `hooks/apex.ts`. */
334export function pathTail(path: string): string {
335  return path.split('/').filter(p => p !== '').slice(-2).join('/')
336}
337
338type BashOutput = { stdout?: string; stderr?: string; returnCodeInterpretation?: string }
339
340const lastLineOf = (text: string): string | undefined => {
341  const last = nonEmpty(text).pop()
342  return last === undefined ? undefined : shorten(last, 100)
343}
344
345/** A gate call's evidence: its command, the exit code its output names, its error flag, its last output line. */
346export function gateEvidence(
347  command: string,
348  out: BashOutput,
349  isError: boolean,
350): Pick<DeckApexLiveStep, 'command' | 'exitCode' | 'isError' | 'lastLine'> {
351  const all = [out.stdout ?? '', out.stderr ?? '', out.returnCodeInterpretation ?? ''].join('\n')
352  const code = /\bexit(?:ed with)?(?: code| status)?[\s:]+(\d+)\b/i.exec(all)?.[1]
353  const last = lastLineOf(`${out.stdout ?? ''}\n${out.stderr ?? ''}`)
354  return {
355    command: shorten(command, 80),
356    ...(code === undefined ? {} : { exitCode: Number(code) }),
357    isError,
358    ...(last === undefined ? {} : { lastLine: last }),
359  }
360}
361
362/** A ship call's evidence: its command, the commit subject `-m` gives (a heredoc's first line too), a PR URL. */
363export function shipEvidence(command: string, stdout: string): Pick<DeckApexLiveStep, 'command' | 'subject' | 'url'> {
364  const heredoc = /\bgit\s+commit\b[\s\S]*?-m\s+["']?\$\(cat\s+<<-?\s*['"]?\w+['"]?\s*\n\s*([^\n]+)/.exec(command)?.[1]
365  const quoted = /\bgit\s+commit\b[^\n]*?\s-m\s*(?:"([^"\n]*)"|'([^'\n]*)'|([^\s"'$][^\s]*))/.exec(command)
366  const subject = heredoc ?? quoted?.[1] ?? quoted?.[2] ?? quoted?.[3]
367  const url = /https:\/\/github\.com\/[^\s/]+\/[^\s/]+\/pull\/\d+/.exec(stdout)?.[0]
368  return {
369    command: shorten(command.split('\n')[0] ?? command, 80),
370    ...(subject === undefined || subject.trim() === '' ? {} : { subject: shorten(subject, 80) }),
371    ...(url === undefined ? {} : { url }),
372  }
373}
374
375/** A `gh pr merge` call: the run is at rest once its turn completes. */
376export const isMergeCall = (command: string): boolean => /\bgh\s+pr\s+merge\b/.test(command)
377
378/**
379 * When the run came to rest, or null while it is busy: the main turn completed (`turnEndAt`) and
380 * no agent card or shell of the run (started since `startedAt`) still runs, or a merge was seen.
381 * The rest time is the latest of the turn end, an agent end, a shell end.
382 */
383export function runEndedAt(
384  run: { startedAt: number; turnEndAt?: number | null; merged?: boolean },
385  cards: readonly { spawnedAt: number; endedAt: number | null; status: string }[],
386  shells: readonly { startedAt: number; endedAt?: number; status: string }[],
387): number | null {
388  const turnEnd = run.turnEndAt
389  if (turnEnd === undefined || turnEnd === null) return null
390  const ownCards = cards.filter(c => c.spawnedAt >= run.startedAt)
391  const ownShells = shells.filter(x => x.startedAt >= run.startedAt)
392  const isBusy = ownCards.some(c => c.status === 'running') || ownShells.some(x => x.status === 'running')
393  if (isBusy && run.merged !== true) return null
394  const ends = [...ownCards.map(c => c.endedAt), ...ownShells.map(x => x.endedAt)].filter((t): t is number => typeof t === 'number')
395  return Math.max(turnEnd, ...ends)
396}
397
398/** A model id's family (`Opus`), else the id itself. */
399export function modelFamily(id: string): string {
400  const family = /(opus|sonnet|haiku|fable)/i.exec(id)?.[1]
401  return family === undefined ? id : family.charAt(0).toUpperCase() + family.slice(1).toLowerCase()
402}
403
404export const NO_STEPS: DeckApexSteps = { runKey: null, steps: [], current: null }
405
406/** A run's steps, none seen yet. */
407export const startSteps = (runKey: string): DeckApexSteps => ({ runKey, steps: [], current: null })
408
409/** Step `name` seen at `at` in run `runKey` (another run's steps start over); it becomes current. */
410export function seeStep(
411  s: DeckApexSteps,
412  runKey: string,
413  name: DeckApexStepName,
414  at: number,
415  extra: Omit<DeckApexLiveStep, 'name' | 'status' | 'at'> = {},
416): DeckApexSteps {
417  const base = s.runKey === runKey ? s : startSteps(runKey)
418  const prev = base.steps.find(x => x.name === name)
419  // Files add up (distinct, bounded); a ship's subject and PR URL survive its later calls.
420  const files = [...new Set([...(prev?.files ?? []), ...(extra.files ?? [])])].slice(0, MAX_FILES)
421  const subject = extra.subject ?? prev?.subject
422  const url = extra.url ?? prev?.url
423  const step: DeckApexLiveStep = {
424    name,
425    status: 'seen',
426    at,
427    ...extra,
428    ...(files.length === 0 ? {} : { files }),
429    ...(subject === undefined ? {} : { subject }),
430    ...(url === undefined ? {} : { url }),
431  }
432  return { runKey, steps: [...base.steps.filter(x => x.name !== name), step], current: name }
433}
434
435// Distinct files a step keeps: enough to count « +N more » past the 6 shown.
436const MAX_FILES = 40
437const SHOWN_FILES = 6
438
439/**
440 * Agent `agentId`'s step ended at `at`, with its verdict if any and what its answer said; the same
441 * reference when no step is its.
442 */
443export function endAgent(
444  s: DeckApexSteps,
445  agentId: string,
446  at: number,
447  verdict: string | null,
448  end: Pick<DeckApexLiveStep, 'lines' | 'isFailed'> = {},
449): DeckApexSteps {
450  if (!s.steps.some(x => x.agentId === agentId && x.endedAt === undefined)) return s
451  return {
452    ...s,
453    steps: s.steps.map(x =>
454      x.agentId === agentId && x.endedAt === undefined ? { ...x, endedAt: at, ...(verdict === null ? {} : { verdict }), ...end } : x,
455    ),
456  }
457}
458
459const WARN_VERDICTS = new Set(['FAIL', 'BLOCKED', 'ERROR', 'NEEDS_FIXES'])
460
461export type StepMark = 'done' | 'current' | 'pending'
462
463export type StepCell = { name: DeckApexStepName; label: string; mark: StepMark; isWarn: boolean }
464
465const isRunning = (x: DeckApexLiveStep): boolean => x.agentId !== undefined && x.endedAt === undefined
466
467// A step with a verdict is done; the current one, or one whose agent still runs, is ◐.
468const markOf = (s: DeckApexSteps, x: DeckApexLiveStep): StepMark =>
469  x.verdict !== undefined ? 'done' : x.name === s.current || isRunning(x) ? 'current' : 'done'
470
471/** The step row: label (with its verdict and findings), mark, warn colour. */
472export function stepCells(s: DeckApexSteps): StepCell[] {
473  const cells: StepCell[] = []
474  for (const name of STEP_ORDER) {
475    const x = s.steps.find(y => y.name === name)
476    if (x === undefined) {
477      if (ALWAYS.has(name)) cells.push({ name, label: name, mark: 'pending', isWarn: false })
478      continue
479    }
480    const findings = x.findings !== undefined && x.findings > 0 ? ` ${x.findings}` : ''
481    const label = x.verdict === undefined ? name : `${name} ${x.verdict}${findings}`
482    cells.push({ name, label, mark: markOf(s, x), isWarn: x.verdict !== undefined && WARN_VERDICTS.has(x.verdict) })
483  }
484  return cells
485}
486
487export type StepDetail = { name: DeckApexStepName; mark: StepMark; text: string; since?: number }
488
489const detailOf = (s: DeckApexSteps, x: DeckApexLiveStep): StepDetail | null => {
490  if (isRunning(x) && x.detail !== undefined) return { name: x.name, mark: markOf(s, x), text: `${x.name} : ${x.detail}`, since: x.at }
491  if (x.verdict === undefined) return null
492  const findings = x.findings !== undefined && x.findings > 0 ? ` · ${x.findings} findings` : ''
493  return { name: x.name, mark: markOf(s, x), text: `${x.name} : ${x.verdict}${findings}` }
494}
495
496/** The line under the row: the current step's agent (running) or verdict, else a still running agent's. */
497export function stepDetail(s: DeckApexSteps): StepDetail | null {
498  const current = s.steps.find(x => x.name === s.current)
499  const own = current === undefined ? null : detailOf(s, current)
500  if (own !== null) return own
501  const running = [...s.steps].reverse().find(x => isRunning(x) && x.detail !== undefined)
502  return running === undefined ? null : detailOf(s, running)
503}
504
505/** A step's explanation box: its lines, and when a still running agent started (its live clock). */
506export type StepExplain = { lines: string[]; since?: number }
507
508// An agent step's head line: description (model), status, duration once ended.
509const agentLine = (x: DeckApexLiveStep): string => {
510  const what = x.detail ?? x.name
511  if (x.endedAt === undefined) return `${what} · running`
512  return `${what} · ${x.isFailed === true ? 'failed' : 'done'} · ${fmtTimer(x.endedAt - x.at)}`
513}
514
515/** What a step did, as its explanation box shows it; a step never seen is not reached yet. */
516export function explainStep(s: DeckApexSteps, name: DeckApexStepName): StepExplain {
517  const x = s.steps.find(y => y.name === name)
518  if (x === undefined) return { lines: ['not reached yet'] }
519  const running = x.agentId !== undefined && x.endedAt === undefined ? { since: x.at } : {}
520  switch (name) {
521    case 'plan':
522    case 'edit': {
523      const files = x.files ?? []
524      if (files.length === 0) return { lines: [name === 'plan' ? 'run files written' : 'files edited'] }
525      const more = files.length > SHOWN_FILES ? ` +${files.length - SHOWN_FILES} more` : ''
526      return { lines: [`${files.slice(0, SHOWN_FILES).join(', ')}${more}`] }
527    }
528    case 'implement':
529    case 'tests':
530      return { lines: [agentLine(x), ...(x.lines ?? []).slice(0, 2)], ...running }
531    case 'review': {
532      const head = x.endedAt === undefined ? agentLine(x) : `${x.verdict ?? 'no verdict'} · ${agentLine(x)}`
533      return { lines: [head, ...(x.lines ?? []).slice(0, 3)], ...running }
534    }
535    case 'gate': {
536      const status = x.exitCode !== undefined ? `exit ${x.exitCode}` : x.isError === true ? 'failed' : 'ok'
537      const tail = x.lastLine === undefined ? '' : ` · ${x.lastLine}`
538      return { lines: [`$ ${x.command ?? 'gate'}`, `${status}${tail}`] }
539    }
540    case 'Codex': {
541      if (x.verdict === undefined) return { lines: ['no verdict (ran in background)'] }
542      return { lines: [`${x.verdict} · ${x.findings ?? 0} findings`] }
543    }
544    case 'ship': {
545      const head = x.subject !== undefined ? `commit: ${x.subject}` : `$ ${x.command ?? 'ship'}`
546      return { lines: [head, ...(x.url === undefined ? [] : [`PR: ${x.url}`])] }
547    }
548  }
549}
550
551// ---------------------------------------------------------------- verdict
552
553const VERDICTS = new Set(['PASS', 'FAIL', 'BLOCKED', 'ERROR'])
554
555/** The verdict word and findings count of an external-verify.json text, or null when it is not one. */
556export function parseVerdict(text: string): { verdict: string; findings: number } | null {
557  let data: unknown
558  try {
559    data = JSON.parse(text)
560  } catch {
561    // Not JSON (half-written, or another file): no verdict to show.
562    return null
563  }
564  if (typeof data !== 'object' || data === null) return null
565  const word: unknown = Reflect.get(data, 'verdict')
566  const findings: unknown = Reflect.get(data, 'findings')
567  if (typeof word !== 'string' || !VERDICTS.has(word)) return null
568  return { verdict: word, findings: Array.isArray(findings) ? findings.length : 0 }
569}
570
571// ---------------------------------------------------------------- what the block and the log show
572
573export type PhaseMark = 'done' | 'current' | 'pending' | 'failed' | 'skipped'
574
575/** A step's dot: ● done, ◐ current, ○ pending; a failed step ✗, a skipped one ·. */
576export function phaseMark(step: DeckApexStep, current: string | undefined): PhaseMark {
577  if (step.kind === 'failed') return 'failed'
578  if (step.step === current || step.kind === 'running') return 'current'
579  if (step.kind === 'done') return 'done'
580  if (step.kind === 'skipped') return 'skipped'
581  return 'pending'
582}
583
584export const PHASE_GLYPH: Record<PhaseMark, string> = { done: '●', current: '◐', pending: '○', failed: '✗', skipped: '·' }
585
586/** The block's header: `APEX · <branch, else title> · <tier>`. */
587export function apexHeader(run: DeckApexRun): string {
588  return ['APEX', run.branch ?? run.title, run.tier].filter((p): p is string => p !== undefined && p !== '').join(' · ')
589}
590
591/**
592 * The log line a poll owes: a run found (its current step), a step that moved, a run gone; null
593 * when nothing moved. Only the current step and the run's directory count.
594 */
595export function phaseNote(prev: DeckApexRun | null, next: DeckApexRun | null): string | null {
596  if (next === null) return prev === null ? null : 'run ended'
597  if (prev === null || prev.dir !== next.dir) return `${next.branch ?? next.title} · ${next.currentStep ?? 'started'}`
598  if (prev.currentStep === next.currentStep) return null
599  return `${prev.currentStep ?? '—'} → ${next.currentStep ?? 'done'}`
600}
601
602/** Polls in a row that must miss a live run before it counts as ended: one failed read is not an end. */
603export const END_MISSES = 2
604
605/**
606 * The run a poll keeps: what it found, or, while a run shown before is missed fewer than
607 * END_MISSES times in a row, that run still; `misses` is the count carried to the next poll.
608 */
609export function settleRun(prev: DeckApexRun | null, found: DeckApexRun | null, misses: number): { run: DeckApexRun | null; misses: number } {
610  if (found !== null || prev === null) return { run: found, misses: 0 }
611  const missed = misses + 1
612  return missed >= END_MISSES ? { run: null, misses: 0 } : { run: prev, misses: missed }
613}
614
hooks/shells.ts 161 lines
1// Pure reducer of the session's background shells: no `$`, no clock, no I/O.
2// Every function returns the SAME array reference when nothing changed, so a
3// caller can skip the state write (and the redraw it causes).
4
5import type { DeckApexShell, DeckApexShellStatus } from '../types'
6
7export type Shell = DeckApexShell
8
9export const CAP = 50
10
11// What a task-notification row carries (UserMessage `e.props.task`).
12export type Notification = {
13  id?: string
14  toolUseId?: string
15  status?: string
16  durationMs?: number
17}
18
19// One agent of `$.agent.list()`, reduced to what the shells read.
20export type OwnerSnap = { id: string; type: string; status: string }
21
22const TEAMMATE = 'teammate'
23
24// An ended status word, or undefined for anything else (still running, or a
25// word this build does not name: the shell keeps `running`).
26export type EndedStatus = Exclude<DeckApexShellStatus, 'running'>
27
28function endedStatus(word: string | undefined): EndedStatus | undefined {
29  if (word === 'completed' || word === 'failed' || word === 'killed') return word
30  return undefined
31}
32
33// Running first (oldest first), then finished (most recent end first); past
34// CAP the oldest finished go, then the oldest running.
35function normalize(shells: Shell[]): Shell[] {
36  const running = shells.filter(t => t.status === 'running').sort((a, b) => a.startedAt - b.startedAt)
37  const done = shells
38    .filter(t => t.status !== 'running')
39    .sort((a, b) => (b.endedAt ?? b.startedAt) - (a.endedAt ?? a.startedAt))
40  return [...running.slice(-CAP), ...done].slice(0, CAP)
41}
42
43export function addShell(
44  shells: Shell[],
45  shell: { id: string; label: string; startedAt: number; toolUseId?: string; ownerAgentId?: string },
46): Shell[] {
47  if (shells.some(t => t.id === shell.id)) return shells
48  const row: Shell = {
49    id: shell.id,
50    label: shell.label,
51    startedAt: shell.startedAt,
52    status: 'running',
53    ...(shell.toolUseId === undefined ? {} : { toolUseId: shell.toolUseId }),
54    ...(shell.ownerAgentId === undefined ? {} : { ownerAgentId: shell.ownerAgentId }),
55  }
56  return normalize([...shells, row])
57}
58
59function replaceAt(shells: Shell[], index: number, shell: Shell): Shell[] {
60  return normalize(shells.map((t, i) => (i === index ? shell : t)))
61}
62
63// A background shell's notification: matched by id, else by tool_use_id.
64// Unknown shell, unknown status word, or a shell already ended: no change
65// (a notification row is drawn again on every redraw and on a resume).
66export function finishByNotification(shells: Shell[], note: Notification, now: number): Shell[] {
67  const status = endedStatus(note.status)
68  if (status === undefined) return shells
69  let index = note.id === undefined ? -1 : shells.findIndex(t => t.id === note.id)
70  if (index < 0 && note.toolUseId !== undefined) {
71    index = shells.findIndex(t => t.toolUseId === note.toolUseId)
72  }
73  const shell = index < 0 ? undefined : shells[index]
74  if (shell === undefined || shell.status !== 'running') return shells
75  const endedAt = note.durationMs === undefined ? now : shell.startedAt + note.durationMs
76  return replaceAt(shells, index, { ...shell, status, endedAt })
77}
78
79// A task-notification row's text, as a subagent's transcript keeps it:
80// `<task-id>…</task-id>` and `<status>…</status>`. A missing id, or a status
81// word that is not an end, reads as undefined.
82export function parseTaskNotification(text: string): { id: string; status: EndedStatus } | undefined {
83  const id = /<task-id>([^<]*)<\/task-id>/.exec(text)?.[1]?.trim()
84  const status = endedStatus(/<status>([^<]*)<\/status>/.exec(text)?.[1]?.trim())
85  if (id === undefined || id === '' || status === undefined) return undefined
86  return { id, status }
87}
88
89// Every `<task-notification>…</task-notification>` block of a row: a row
90// delivered while the loop was busy may batch several. A malformed block is
91// skipped.
92export function parseTaskNotifications(text: string): Array<{ id: string; status: EndedStatus }> {
93  const notes: Array<{ id: string; status: EndedStatus }> = []
94  for (const block of text.matchAll(/<task-notification>([\s\S]*?)<\/task-notification>/g)) {
95    const note = parseTaskNotification(block[1] ?? '')
96    if (note !== undefined) notes.push(note)
97  }
98  return notes
99}
100
101// A shell stopped by TaskStop: only a running shell closes, as killed; an
102// agent id (TaskStop accepts both) or an unknown one is a no-op.
103export function stopShell(shells: Shell[], id: string, now: number): Shell[] {
104  const index = shells.findIndex(t => t.id === id)
105  const shell = shells[index]
106  if (shell === undefined || shell.status !== 'running') return shells
107  return replaceAt(shells, index, { ...shell, status: 'killed', endedAt: now })
108}
109
110// A subagent's background shell notifies that subagent's loop, never the
111// main one: once the owner ends killed or failed, or leaves the agent list,
112// its still running shells are closed as `killed`. An owner that completed
113// keeps them (it may resume on the shell's notification).
114export function closeOrphanShells(shells: Shell[], ownerId: string, endedAt: number): Shell[] {
115  const isOrphan = (t: Shell): boolean => t.status === 'running' && t.ownerAgentId === ownerId
116  if (!shells.some(isOrphan)) return shells
117  return normalize(shells.map(t => (isOrphan(t) ? { ...t, status: 'killed', endedAt } : t)))
118}
119
120// A `$.agent.list()` snapshot applied to the shells: a listed owner ended
121// killed or failed (a teammate aside), or an owner among `known` (the
122// subagent loops this session saw) gone from the list, closes its shells.
123export function closeBySnapshot(
124  shells: Shell[],
125  agents: readonly OwnerSnap[],
126  known: readonly string[],
127  now: number,
128): Shell[] {
129  let out = shells
130  for (const agent of agents) {
131    const ended = endedStatus(agent.status)
132    if ((ended === 'killed' || ended === 'failed') && agent.type !== TEAMMATE) out = closeOrphanShells(out, agent.id, now)
133  }
134  const listed = new Set(agents.map(a => a.id))
135  for (const id of known) {
136    if (!listed.has(id)) out = closeOrphanShells(out, id, now)
137  }
138  return out
139}
140
141export function runningShells(shells: readonly Shell[]): number {
142  return shells.filter(t => t.status === 'running').length
143}
144
145// A new run began: finished agent cards (any status but running) and ended shells go, running
146// ones stay. Each list is the SAME reference when nothing went.
147export function clearFinished<C extends { status: string }>(cards: C[], shells: Shell[]): { cards: C[]; shells: Shell[] } {
148  const keptCards = cards.filter(c => c.status === 'running')
149  const keptShells = shells.filter(t => t.status === 'running')
150  return {
151    cards: keptCards.length === cards.length ? cards : keptCards,
152    shells: keptShells.length === shells.length ? shells : keptShells,
153  }
154}
155
156// A subagent's turn ended with `reason`: unless it answered, its still running shells close as
157// killed (their notification could only ever reach that loop). Any subagent, the architect too.
158export function shellsAfterTurn(shells: Shell[], agentId: string, reason: string, at: number): Shell[] {
159  return reason === 'answer' ? shells : closeOrphanShells(shells, agentId, at)
160}
161
hooks/signals.ts 68 lines
1// Pure reading of what asks the user to act (a failed step, a red external
2// verification, a spent correction budget).
3// No `$`, no clock, no I/O: the hooks module lists and reads, this decides.
4
5import type { DeckApexAlert, DeckApexBudget, DeckApexRun, DeckApexVerdict } from '../types'
6
7// Rounds every run gets before the user must grant one more
8// (hooks/correction-budget.js MAX_ROUNDS).
9export const MAX_ROUNDS = 2
10
11const ROUND = /^(.+)\.round([1-9][0-9]*)$/
12const GRANT = /^(.+)\.grant([1-9][0-9]*)$/
13
14// The correction budget of run `dir`, from the names of the budget folder:
15// one `<dir>.round<n>` file per round used, one `<dir>.grant<n>` per round
16// the user granted. Another run's files (even a longer name sharing the
17// prefix) are not counted; null when the run has none.
18export function budgetOf(names: readonly string[], dir: string): DeckApexBudget | null {
19  let rounds = 0
20  let grants = 0
21  for (const name of names) {
22    if (ROUND.exec(name)?.[1] === dir) rounds += 1
23    else if (GRANT.exec(name)?.[1] === dir) grants += 1
24  }
25  return rounds === 0 && grants === 0 ? null : { dir, rounds, grants }
26}
27
28export const budgetCap = (b: DeckApexBudget): number => MAX_ROUNDS + b.grants
29
30// True once every round the run may take is used.
31export function isBudgetSpent(b: DeckApexBudget | null): boolean {
32  return b !== null && b.rounds >= budgetCap(b)
33}
34
35// The newer of two candidates by mtime (the verify file has two names:
36// <run>/04-external-verify.json and <run>/external-verify.json); on a tie
37// the first.
38export function pickVerdict<T extends { mtimeMs: number }>(a: T | null, b: T | null): T | null {
39  if (a === null) return b
40  if (b === null) return a
41  return b.mtimeMs > a.mtimeMs ? b : a
42}
43
44type RedVerdict = 'FAIL' | 'BLOCKED' | 'ERROR'
45
46function redVerdict(word: string): RedVerdict | undefined {
47  if (word === 'FAIL' || word === 'BLOCKED' || word === 'ERROR') return word
48  return undefined
49}
50
51// What the user must act on for `run`, most urgent first: each failed step,
52// then a red external verification, then a spent correction budget. A
53// verdict or budget of another run directory is ignored; no run, no alert.
54export function alertsOf(
55  run: DeckApexRun | null,
56  verdict: DeckApexVerdict | null,
57  budget: DeckApexBudget | null,
58): DeckApexAlert[] {
59  if (run === null) return []
60  const alerts: DeckApexAlert[] = run.steps.filter(s => s.kind === 'failed').map((s): DeckApexAlert => ({ kind: 'step', step: s.step }))
61  const red = verdict !== null && verdict.dir === run.dir ? redVerdict(verdict.verdict) : undefined
62  if (red !== undefined && verdict !== null) alerts.push({ kind: 'verify', verdict: red, findings: verdict.findings })
63  if (budget !== null && budget.dir === run.dir && isBudgetSpent(budget)) {
64    alerts.push({ kind: 'budget', rounds: budget.rounds, cap: budgetCap(budget) })
65  }
66  return alerts
67}
68
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 190 lines
1// deck state contract: Flightdeck's panels (main, architect, agents, receipt,
2// log) and the APEX block (the live run, its live steps, alerts and background
3// shells). Self-contained (no import), as the plugin-authoring reference asks.
4
5export type DeckMoment = 'before a plan' | 'error repeats' | 'before done'
6
7export type DeckMain = { model: string; effort: string; mode: string; steps: number; isRunning: boolean }
8
9export type DeckUsage = {
10  pct: number | null
11  tokens: number | null
12  window: number
13  costUsd: number | null
14  limits: { kind: string; pct: number }[]
15  compactions: number
16  lastCompactAt: number | null
17}
18
19export type DeckConsult = { id: string; at: number; endAt: number | null; moment: DeckMoment; via: string }
20
21export type DeckArchitect = { consults: DeckConsult[]; ids: string[]; seen: string[]; lastAdvice: string }
22
23export type DeckToolNote = { tool: string; text: string; isError: boolean }
24
25export type DeckAgentCard = {
26  id: string
27  type: string
28  model: string
29  description: string
30  status: string
31  spawnedAt: number
32  endedAt: number | null
33  /** The agent's context now: input + cache read + cache write of its latest step. */
34  ctx: number
35  /** Output tokens summed over its steps. */
36  out: number
37  steps: number
38  lastStop: string | null
39  tools: DeckToolNote[]
40  answer: string
41}
42
43export type DeckLogLine = {
44  at: number
45  who: string
46  text: string
47  agentId: string | null
48  kind: 'info' | 'error' | 'consult' | 'done'
49}
50
51export type DeckTurn = {
52  edits: number
53  errorStreak: number
54  errors: number
55  isReviewing: boolean
56  startedAt: number
57  costAtStart: number | null
58}
59
60export type DeckReceipt = {
61  durationMs: number
62  agents: number
63  edits: number
64  errors: number
65  costDelta: number | null
66  reason: string
67}
68
69export type DeckLayout = 'auto' | 'compact' | 'wide' | 'mini'
70
71export type DeckView = { expanded: string | null; layout: DeckLayout | null }
72
73export type DeckRoster = { architectTypes: string[] }
74
75// ---------------------------------------------------------------- APEX
76
77export type DeckApexStepKind = 'done' | 'pending' | 'running' | 'skipped' | 'failed' | 'other'
78
79export type DeckApexStep = { step: string; status: string; kind: DeckApexStepKind }
80
81/** A live APEX run, read from <cwd>/.claude/output/apex/<dir>/00-context.md. */
82export type DeckApexRun = {
83  title: string
84  /** `Tier:` of the context file (first word), else its older `Mode:`. */
85  tier?: string
86  branch?: string
87  steps: DeckApexStep[]
88  currentStep?: string
89  /** The run's directory under .claude/output/apex, set by the poll. */
90  dir?: string
91}
92
93/** A live step of an APEX run, read from the run's tool calls. */
94export type DeckApexStepName = 'plan' | 'edit' | 'implement' | 'tests' | 'gate' | 'Codex' | 'review' | 'ship'
95
96export type DeckApexLiveStep = {
97  name: DeckApexStepName
98  status: 'seen'
99  /** When it was last seen. */
100  at: number
101  /** An agent step's description and model family. */
102  detail?: string
103  /** Codex's PASS/FAIL/BLOCKED or a reviewer's APPROVED/NEEDS_FIXES/BLOCKED. */
104  verdict?: string
105  findings?: number
106  /** The agent this step started, and when it ended. */
107  agentId?: string
108  endedAt?: number
109  /** plan / edit: the distinct path tails touched (bounded). */
110  files?: string[]
111  /** gate / ship: the last call's command, shortened. */
112  command?: string
113  /** gate: the exit code its output names, its error flag, its last non-empty output line. */
114  exitCode?: number
115  isError?: boolean
116  lastLine?: string
117  /** ship: the commit subject and the PR URL, kept across its calls. */
118  subject?: string
119  url?: string
120  /** An agent step: the first lines of its final answer (a reviewer's: those after its verdict). */
121  lines?: string[]
122  /** An agent step whose turn ended otherwise than with an answer. */
123  isFailed?: boolean
124}
125
126/** The live steps of the run `runKey` (a session key, else the run dir); `current` was seen last. */
127export type DeckApexSteps = { runKey: string | null; steps: DeckApexLiveStep[]; current: DeckApexStepName | null }
128
129/** `killed` is its own word (stopped, drawn ■), not a failure. */
130export type DeckApexShellStatus = 'running' | 'completed' | 'failed' | 'killed'
131
132/** One background shell of this session (its backgroundTaskId). */
133export type DeckApexShell = {
134  id: string
135  label: string
136  startedAt: number
137  endedAt?: number
138  status: DeckApexShellStatus
139  toolUseId?: string
140  /** The subagent whose loop started it (absent on the main loop). */
141  ownerAgentId?: string
142}
143
144/** The run's correction rounds used and granted (~/.claude/apex-correction-budget). */
145export type DeckApexBudget = { dir: string; rounds: number; grants: number }
146
147/** What the user must act on, most urgent kind first. */
148export type DeckApexAlert =
149  | { kind: 'step'; step: string }
150  | { kind: 'verify'; verdict: 'FAIL' | 'BLOCKED' | 'ERROR'; findings: number }
151  | { kind: 'budget'; rounds: number; cap: number }
152
153/** The session's last main-loop Skill(apex) call: a run without a folder (yet). */
154export type DeckApexSessionRun = {
155  startedAt: number
156  lastAt: number
157  args: string
158  /** When the main turn last completed; cleared by any new activity (null: the run is busy). */
159  turnEndAt?: number | null
160  /** A `gh pr merge` ship call was seen: at rest once the turn completes. */
161  merged?: boolean
162}
163
164/** The run's external verification file, as far as the block shows it. */
165export type DeckApexVerdict = { dir: string; verdict: string; findings: number; mtimeMs: number }
166
167declare module 'claude-code' {
168  interface PluginState {
169    'deck': {
170      main: DeckMain
171      usage: DeckUsage
172      architect: DeckArchitect
173      agents: DeckAgentCard[]
174      log: DeckLogLine[]
175      turn: DeckTurn
176      receipt: DeckReceipt | null
177      view: DeckView
178      roster: DeckRoster
179      run: DeckApexRun | null
180      apexSteps: DeckApexSteps
181      verdict: DeckApexVerdict | null
182      budget: DeckApexBudget | null
183      shells: DeckApexShell[]
184      sessionRun: DeckApexSessionRun | null
185      /** The step whose explanation box is open under the step row. */
186      openStep: DeckApexStepName | null
187    }
188  }
189}
190