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…

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 · <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./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.
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.
hooks/register.tsx 1758 lines1// 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 lines1// 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
404hooks/apex.ts 614 lines1// 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}
614hooks/shells.ts 161 lines1// 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}
161hooks/signals.ts 68 lines1// 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}
68hooks/rail.tsx 75 lines1// 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
75hooks/elapsed.tsx 38 lines1// 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
38types/index.d.ts 190 lines1// 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