A live pane beside the chat: what every subagent and workflow agent is doing right now, at what effort, how long it has been at it, and where its time went

A live pane for Claude Code that shows what your subagents and workflow agents are doing while they work: what each one is doing right now, at what effort, how long it has been at it, and where its time went. When a workflow runs, the top of the pane becomes a sprint dashboard with a flight plan of every ticket, a map of how the tickets depend on each other, a finish forecast, and a scrubber that replays the run.
The repo also carries the sprint workflow that Mission Control was built with and for (workflows/sprint.js). Much of the pane was built and fixed by that workflow running on this repo.

git clone https://github.com/kevin-huff/claude-code-mission-control ~/.claude/skills/mission-control
Start a new Claude Code session. claude plugin list should show mission-control@skills-dir as loaded. The pane opens by itself the first time a subagent starts in a session, and /mission-control opens it at any time. It adds nothing to your conversation's context.
Built and tested on Claude Code 2.1.306. Mods are an early-access part of Claude Code, so a later release may change the API underneath it.
While a workflow sprint runs, and for five minutes after it ends, the top of the pane follows it:
Waiting on F3 (sonnet·high, 4:10 in) · 2 tickets behind it · 5 of 9 passed · about 12 min left.If a subagent fails two commands in a row, Mission Control raises its effort one level for its next request (never past max) and says so in the feed. Press e to turn that off.
| Key | What it does |
|---|---|
b / n | Step back or forward through a sprint |
l | Back to live |
f | Show or hide finished sprints |
c | Clear finished sprints |
e | Auto-raise on or off |
m | Leave an agent's detail view |
In the scrubber, the left and right arrows step through the run, and l, Enter or End go back to live.
Mission Control reads your usage from the JSON that Claude Code hands your status line command. It looks for a copy at /tmp/.claude-statusline-last.json, so save one from your status line script:
input=$(cat)
echo "$input" > /tmp/.claude-statusline-last.json
# ...the rest of your status line
Without that file everything else works; the limit line just doesn't show.
workflows/sprint.js runs build or change work as a sprint through Claude Code's Workflow tool. A top-tier planner cuts the work into small tickets, fast models build them, a stronger model reviews each one, and a ticket that fails review retries one tier up in its own lane. A final review at max effort sorts what it finds into blocking problems and polish, and only blocking problems start a fix round.
Install it for your user:
mkdir -p ~/.claude/workflows
cp ~/.claude/skills/mission-control/workflows/sprint.js ~/.claude/workflows/
Claude Code reads saved workflows when a session starts, so open a new session, then ask for it by name ("use the sprint workflow to ..."). Arguments:
goal (required), context, and checks: commands that prove the change works, one per line.issues: a list of known problems. Skips the planner and goes straight to fix tickets.maxTickets (default 16), fixRounds (default 1, at most 3), reviewers (1 to 3 parallel final reviewers), cap (a token cap for the sprint).Each lane runs in its own parallel() slot, so resuming a stopped run with the Workflow tool's resumeFromRunId reuses every finished step. Sprints run a lot of agents (15 to 52 in the runs it was tested on) and spend tokens to match.
claude plugin validate .
claude plugin test . # the whole suite; it takes a folder, not a file
node workflows/sprint.test.mjs workflows/sprint.js # workflow scenarios A to V
npx -y -p typescript@5.6.3 tsc -p .
The type check needs .claude-plugin/types/, which Claude Code writes the first time a session loads the mod, so start a session once after cloning.
Unlicense (public domain). See LICENSE.
hooks/register.tsx 417 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Cursor, Prefs, ScrubProps } from '../types'
5import type { El } from './art'
6import * as D from './deck'
7import { drawPane, replayRunOf } from './view'
8
9const PANE = 'mission-control'
10const OPEN = { id: PANE, title: 'Mission Control', columns: 84, rows: 40 }
11const STATUS_CAPTURE = '/tmp/.claude-statusline-last.json'
12const PREFS: Prefs = { showDone: true, autoOpen: true, autoEscalate: true }
13const deckAtom = atom({ plugin: 'mission-control', key: 'deck' } as const, D.emptyDeck())
14const prefsAtom = atom({ plugin: 'mission-control', key: 'prefs' } as const, PREFS)
15const cursorAtom = atom({ plugin: 'mission-control', key: 'cursor' } as const, null as Cursor)
16
17type Runtime = {
18 store: D.Store
19 timer: Timer | undefined
20 beats: number
21 busy: boolean
22 wasLive: boolean
23 autoOpened: boolean
24 mirror: string | null
25 journalBeats: number
26 sessionId: string | null
27 journalSizes: Map<string, number>
28 journalGone: Set<string>
29}
30
31async function prefsOf($: EngineInterface): Promise<Prefs> {
32 return { ...PREFS, ...(await read($, prefsAtom)) }
33}
34
35// Claude Code's tool calls carry no transcript path; its classic hook events do. The workflow journals sit in the
36// folder beside the main session's transcript, <session id>.jsonl, so only that file is taken.
37function learnTranscript(rt: Runtime, path: unknown) {
38 if (typeof path !== 'string' || !path.endsWith('.jsonl')) return
39 if (rt.sessionId && !path.endsWith(`/${rt.sessionId}.jsonl`)) return
40 const dir = path.slice(0, -'.jsonl'.length)
41 if (rt.store.transcriptDir === dir) return
42 rt.store.transcriptDir = dir
43 rt.store.dirty = true
44}
45
46// Each flush also lands in /tmp/claude-<uid>/mission-control-<session>.json, inside the
47// per-user Claude temp folder (mode 700, so only this account reads it), so scripts and other
48// sessions of the same user can read what the pane shows. Where that folder can't be made or
49// vouched for, no mirror is written.
50async function mirrorPathOf($: EngineInterface, sessionId: string | null): Promise<string | null> {
51 if (!sessionId || !/^[\w.-]+$/.test(sessionId)) return null
52 try {
53 const uid = (await $.process.run(['id', '-u'])).stdout.trim()
54 if (!/^\d+$/.test(uid)) return null
55 const dir = `/tmp/claude-${uid}`
56 const made = await $.process.run(['mkdir', '-p', '-m', '700', dir])
57 if (made.exitCode !== 0) return null
58 const seen = await $.process.run(['stat', '-c', '%u %a', dir])
59 const [owner, mode] = seen.stdout.trim().split(' ')
60 if (seen.exitCode !== 0 || owner !== uid || !/^[0-7]00$/.test(mode ?? '')) return null
61 return `${dir}/mission-control-${sessionId}.json`
62 } catch {
63 return null
64 }
65}
66
67async function flush($: EngineInterface, rt: Runtime, now: number) {
68 rt.store.dirty = false
69 const deck = D.snapshot(rt.store, now)
70 await update($, deckAtom, () => deck)
71 if (rt.mirror) await $.fs.write(rt.mirror, JSON.stringify(deck)).catch(() => undefined)
72}
73
74async function readJournals($: EngineInterface, rt: Runtime, now: number) {
75 for (const run of rt.store.runs.values()) {
76 if (!D.journalWanted(rt.store, run) || rt.journalGone.has(run.id)) continue
77 D.adoptDir(rt.store, run)
78 const path = D.journalPath(run)
79 if (!path) continue
80 const live = run.endedAt === null
81 try {
82 const stat = await $.fs.stat(path)
83 const size = stat.kind === 'file' ? stat.size : -1
84 if (live && size === rt.journalSizes.get(run.id)) continue
85 const text = await $.fs.read(path)
86 D.absorbJournal(rt.store, run.id, text, now)
87 rt.journalSizes.set(run.id, size)
88 } catch {
89 if (!live) rt.journalGone.add(run.id)
90 }
91 }
92}
93
94export function liveCursor(runs: { id: string }[], cursor: Cursor): Cursor {
95 return cursor && runs.some(run => run.id === cursor.runId) ? cursor : null
96}
97
98async function moveCursor($: EngineInterface, dir: -1 | 1) {
99 const deck = await read($, deckAtom)
100 const cursor = liveCursor(deck.runs, await read($, cursorAtom))
101 const run = replayRunOf(deck.runs, cursor)
102 if (!run) return
103 if (dir === 1 && !cursor) return
104 const end = run.endedAt ?? deck.at
105 const step = Math.max(10_000, Math.round((end - run.startedAt) / 40))
106 const base = cursor?.at ?? end
107 const at = Math.min(end, Math.max(run.startedAt, base + dir * step))
108 const value: Cursor = dir === 1 && at >= end && run.endedAt === null ? null : { runId: run.id, at }
109 await update($, cursorAtom, () => value)
110}
111
112async function goLive($: EngineInterface) {
113 await update($, cursorAtom, () => null)
114}
115
116async function takeScrub($: EngineInterface, data: unknown) {
117 if (typeof data !== 'object' || data === null) return
118 const { runId, at } = data as { runId?: unknown; at?: unknown }
119 if (typeof runId !== 'string' || !runId) return
120 const deck = await read($, deckAtom)
121 const run = deck.runs.find(r => r.id === runId)
122 if (!run) return
123 if (at === null) await update($, cursorAtom, () => null)
124 else if (typeof at === 'number' && Number.isFinite(at)) {
125 const clamped = Math.min(run.endedAt ?? deck.at, Math.max(run.startedAt, Math.round(at)))
126 await update($, cursorAtom, () => ({ runId, at: clamped }))
127 }
128}
129
130// A reviewer's verdict reaches the journal a moment after its agent ends, so the next few beats
131// re-read the journal of a run that is still going.
132function noteEnded(rt: Runtime, agentId: string | undefined) {
133 const runId = agentId ? rt.store.agents.get(agentId)?.runId : null
134 if (runId && rt.store.runs.get(runId)?.endedAt === null) rt.journalBeats = 3
135}
136
137async function reconcile($: EngineInterface, rt: Runtime, now: number) {
138 for (const info of await $.agent.list()) {
139 const row = rt.store.agents.get(info.id)
140 if (!row || row.endedAt !== null) continue
141 if (info.status === 'completed' || info.status === 'failed' || info.status === 'killed') {
142 D.turnEnded(rt.store, info.id, info.status === 'completed' ? 'answer' : info.status === 'killed' ? 'aborted' : 'error', now)
143 noteEnded(rt, info.id)
144 }
145 }
146}
147
148async function readStatus($: EngineInterface, rt: Runtime) {
149 D.absorbStatus(rt.store, await $.fs.read(STATUS_CAPTURE), rt.sessionId)
150}
151
152async function beat($: EngineInterface, rt: Runtime) {
153 if (rt.busy) return
154 rt.busy = true
155 try {
156 rt.beats += 1
157 const now = await $.clock.now()
158 for (const text of D.tick(rt.store, now)) $.ui.toast(text, { timeoutMs: 8_000 })
159 const draining = rt.journalBeats > 0
160 if (draining) rt.journalBeats -= 1
161 if (draining || rt.beats % 3 === 0) await readJournals($, rt, now).catch(() => undefined)
162 if (rt.beats % 10 === 0) await reconcile($, rt, now).catch(() => undefined)
163 if (rt.beats % 15 === 1) await readStatus($, rt).catch(() => undefined)
164 const live = D.anyLive(rt.store)
165 if (rt.store.dirty || live || rt.wasLive) await flush($, rt, now)
166 rt.wasLive = live
167 } catch {
168 rt.store.dirty = true
169 } finally {
170 rt.busy = false
171 }
172}
173
174async function noteWorkflow($: EngineInterface, rt: Runtime, toolUseId: string, fields: Record<string, unknown>) {
175 let name = D.workflowName(typeof fields['script'] === 'string' ? fields['script'] : '')
176 const path = fields['scriptPath']
177 if (!name && typeof path === 'string') name = D.workflowName(await $.fs.read(path).catch(() => ''))
178 rt.store.runNames.set(toolUseId, name || D.toolLabel(fields) || 'workflow')
179 D.noteLimit(rt.store, toolUseId, fields['args'])
180}
181
182async function openAgent($: EngineInterface, agentId: string) {
183 const moved = await $.ui.view({ agentId })
184 if (moved.deny) $.ui.toast(`Mission Control can't show that agent here: ${moved.deny}`)
185}
186
187async function clearDone($: EngineInterface, rt: Runtime) {
188 D.clearFinished(rt.store)
189 await flush($, rt, await $.clock.now())
190 const cursor = await read($, cursorAtom)
191 if (cursor && !liveCursor((await read($, deckAtom)).runs, cursor)) await update($, cursorAtom, () => null)
192}
193
194async function toggleEscalate($: EngineInterface, rt: Runtime) {
195 await update($, prefsAtom, prefs => ({ ...PREFS, ...prefs, autoEscalate: prefs.autoEscalate === false }))
196 rt.store.autoEscalate = (await prefsOf($)).autoEscalate
197 $.ui.toast(`Mission Control will ${rt.store.autoEscalate ? 'raise' : 'no longer raise'} a struggling subagent's effort`)
198}
199
200// Every hook here watches and passes the event on. The one change it makes: a subagent that
201// keeps failing commands gets its next requests at a higher effort, when auto-raise is on.
202export const register: Register = on => {
203 const rt: Runtime = {
204 store: D.createStore(),
205 timer: undefined,
206 beats: 0,
207 busy: false,
208 wasLive: false,
209 autoOpened: false,
210 mirror: null,
211 journalBeats: 0,
212 sessionId: null,
213 journalSizes: new Map(),
214 journalGone: new Set(),
215 }
216 const store = rt.store
217
218 on('session.start', async ($, e, next) => {
219 const result = await next(e)
220 await $.command.register({
221 name: 'mission-control',
222 description: 'Open Mission Control: a live view of what every subagent and workflow agent is doing',
223 })
224 if (store.agents.size === 0) D.restore(store, await read($, deckAtom))
225 store.autoEscalate = (await prefsOf($)).autoEscalate
226 rt.sessionId = await $.session.id().then(
227 id => id,
228 () => null,
229 )
230 rt.mirror = await mirrorPathOf($, rt.sessionId)
231 store.dirty = true
232 rt.timer?.cancel()
233 rt.timer = $.clock.every(1_000, () => void beat($, rt))
234 return result
235 })
236
237 // These events gate the session and the prompt, so a fault here must never stop either.
238 on('classic.SessionStart', async ($, e, next) => {
239 try {
240 if (e.agent_type === undefined) learnTranscript(rt, e.transcript_path)
241 } catch {}
242 return next(e)
243 })
244
245 on('classic.UserPromptSubmit', async ($, e, next) => {
246 try {
247 learnTranscript(rt, e.transcript_path)
248 } catch {}
249 return next(e)
250 })
251
252 on('session.end', async ($, e, next) => {
253 if (e.reason !== 'clear') {
254 rt.timer?.cancel()
255 rt.timer = undefined
256 }
257 return next(e)
258 })
259
260 on('agent.spawn', async ($, e, next) => {
261 const result = await next(e)
262 if (result.agentId) {
263 try {
264 D.spawned(
265 store,
266 {
267 agentId: result.agentId,
268 description: e.description,
269 subagentType: e.subagentType,
270 model: result.model ?? '',
271 runId: e.workflow?.runId || null,
272 index: e.workflow?.agentIndex ?? null,
273 toolUseId: e.tool_use_id,
274 isTeammate: e.isTeammate === true,
275 },
276 await $.clock.now(),
277 )
278 if (!rt.autoOpened) {
279 rt.autoOpened = true
280 if ((await prefsOf($)).autoOpen) void $.ui.open(OPEN).catch(() => undefined)
281 }
282 } catch {
283 store.dirty = true
284 }
285 }
286 return result
287 })
288
289 on('turn.start', async ($, e, next) => {
290 const result = await next(e)
291 D.turnStarted(store, e.agentId, await $.clock.now())
292 return result
293 })
294
295 on('turn.step', async function* ($, e, next) {
296 let mode = ''
297 let raised: D.Level | undefined
298 try {
299 D.stepStarted(store, e.agentId, { effort: e.effort, model: e.model }, await $.clock.now())
300 raised = D.effortToSend(store, e.agentId, e.effort)
301 } catch {
302 raised = undefined
303 }
304 for await (const chunk of next(raised ? { ...e, effort: raised } : e)) {
305 try {
306 if (chunk.kind === 'thinking' || chunk.kind === 'text') {
307 if (mode !== chunk.kind) {
308 mode = chunk.kind
309 D.streamed(store, e.agentId, chunk.kind, await $.clock.now())
310 }
311 } else if (chunk.kind === 'tool') {
312 mode = 'tool'
313 D.composing(store, e.agentId, chunk.name, await $.clock.now())
314 } else if (chunk.kind === 'input') {
315 D.composed(store, e.agentId, chunk.json)
316 } else if (chunk.kind === 'stop') {
317 D.stepStopped(store, e.agentId, chunk.usage, await $.clock.now())
318 }
319 } catch {
320 mode = ''
321 }
322 yield chunk
323 }
324 })
325
326 on('tool.call', async ($, e, next) => {
327 const fields = e as unknown as Record<string, unknown>
328 let tracked = false
329 try {
330 const now = await $.clock.now()
331 const transcript = fields['transcript_path']
332 if (e.agentId === undefined && typeof transcript === 'string') store.transcriptDir = transcript.replace(/\.jsonl$/, '')
333 if (String(e.tool) === 'Workflow') await noteWorkflow($, rt, e.tool_use_id, fields)
334 D.toolStarted(
335 store,
336 e.agentId,
337 { id: e.tool_use_id, tool: String(e.tool), label: D.toolLabel(fields), since: now, limitMs: D.toolLimit(fields) },
338 now,
339 )
340 tracked = true
341 } catch {
342 tracked = false
343 }
344 const result = await next(e)
345 if (tracked) {
346 try {
347 const denied = 'deny' in result && typeof result.deny === 'string' ? result.deny : ''
348 const failed = Boolean(denied) || ('isError' in result && result.isError === true)
349 const text = denied || ('text' in result && typeof result.text === 'string' ? result.text : '')
350 const raised = D.toolEnded(store, e.agentId, e.tool_use_id, failed, await $.clock.now(), failed ? text : '')
351 if (raised) $.ui.toast(`Mission Control: ${raised}`, { timeoutMs: 8_000 })
352 } catch {
353 store.dirty = true
354 }
355 }
356 return result
357 })
358
359 on('turn.complete', async ($, e, next) => {
360 const result = await next(e)
361 D.turnEnded(store, e.agentId, e.reason, await $.clock.now())
362 noteEnded(rt, e.agentId)
363 return result
364 })
365
366 on('ui.close', async ($, e, next) => {
367 if (e.id === PANE && e.origin.kind === 'person') await update($, prefsAtom, prefs => ({ ...PREFS, ...prefs, autoOpen: false }))
368 return next(e)
369 })
370
371 on('command.run', { command: 'mission-control' }, async $ => {
372 await update($, prefsAtom, prefs => ({ ...PREFS, ...prefs, autoOpen: true }))
373 const opened = await $.ui.open(OPEN)
374 return {
375 text: opened.isPlaced
376 ? 'Mission Control is open beside the chat.'
377 : `Mission Control is open and waiting for room to draw: ${opened.reason}`,
378 }
379 })
380
381 on('ui.message', { element: 'scrubber' }, async ($, e, next) => {
382 await takeScrub($, e.data).catch(() => undefined)
383 return next(e)
384 })
385
386 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
387 const deck = await read($, deckAtom)
388 const prefs = await prefsOf($)
389 const cursor = liveCursor(deck.runs, await read($, cursorAtom))
390 const el = $.ui.resolve(e)
391 const Client = (el as El)['Client']
392 const scrubber = Client
393 ? (p: ScrubProps) => <Client key="scrubber" module="./scrubber.tsx" props={p} width={p.cols} height={1} />
394 : null
395 return drawPane(el, {
396 deck,
397 prefs,
398 width: e.props.bodyColumns,
399 rows: e.props.scroll.bodyRows,
400 surface: e.surface,
401 viewing: e.props.view.agentId,
402 cursor,
403 scrubber,
404 act: {
405 open: (agentId: string) => openAgent($, agentId),
406 main: () => $.ui.view({}),
407 toggleDone: () => update($, prefsAtom, p => ({ ...PREFS, ...p, showDone: !p.showDone })),
408 clearDone: () => clearDone($, rt),
409 toggleEscalate: () => toggleEscalate($, rt),
410 back: () => moveCursor($, -1),
411 next: () => moveCursor($, 1),
412 live: () => goLive($),
413 },
414 })
415 })
416}
417hooks/art.tsx 241 lines1import { base64 } from './deck'
2
3export const DEFAULT = 0x01000000
4
5// The surface's element table, from $.ui.resolve(e).
6export type El = Record<string, any>
7
8export function hex(color: string): number {
9 return parseInt(color.replace('#', ''), 16)
10}
11
12export function toHex(color: number): string {
13 return `#${color.toString(16).padStart(6, '0')}`
14}
15
16export function mix(a: number, b: number, t: number): number {
17 const channel = (shift: number) => {
18 const x = (a >> shift) & 255
19 const y = (b >> shift) & 255
20 return Math.round(x + (y - x) * t) & 255
21 }
22 return (channel(16) << 16) | (channel(8) << 8) | channel(0)
23}
24
25export function along(stops: readonly number[], t: number): number {
26 if (stops.length < 2) return stops[0] ?? DEFAULT
27 const x = Math.max(0, Math.min(1, t)) * (stops.length - 1)
28 const i = Math.min(stops.length - 2, Math.floor(x))
29 return mix(stops[i] ?? DEFAULT, stops[i + 1] ?? DEFAULT, x - i)
30}
31
32function hash(n: number): number {
33 let x = n | 0
34 x = (x ^ 61) ^ (x >>> 16)
35 x = x + (x << 3)
36 x = x ^ (x >>> 4)
37 x = Math.imul(x, 0x27d4eb2d)
38 return (x ^ (x >>> 15)) >>> 0
39}
40
41// A grid of cells, each a width-1 glyph with a foreground and a background.
42export class Canvas {
43 readonly words: Uint32Array
44
45 constructor(
46 readonly cols: number,
47 readonly rows: number,
48 bg = DEFAULT,
49 ) {
50 this.words = new Uint32Array(Math.max(1, cols * rows) * 3)
51 for (let i = 0; i < cols * rows; i++) {
52 this.words[i * 3] = 0x20
53 this.words[i * 3 + 1] = DEFAULT
54 this.words[i * 3 + 2] = bg
55 }
56 }
57
58 put(x: number, y: number, ch: string | number, fg: number, bg?: number): void {
59 if (x < 0 || y < 0 || x >= this.cols || y >= this.rows) return
60 const i = (y * this.cols + x) * 3
61 this.words[i] = typeof ch === 'number' ? ch : ch.codePointAt(0) ?? 0x20
62 this.words[i + 1] = fg
63 if (bg !== undefined) this.words[i + 2] = bg
64 }
65
66 text(x: number, y: number, s: string, fg: number | ((i: number) => number), bg?: number): void {
67 let i = 0
68 for (const ch of s) {
69 this.put(x + i, y, ch, typeof fg === 'number' ? fg : fg(i), bg)
70 i += 1
71 }
72 }
73
74 encode(): string {
75 return base64(new Uint8Array(this.words.buffer))
76 }
77}
78
79// The terminal draws a canvas as one Raster; other surfaces get the same cells as colored text.
80export function paint(el: El, canvas: Canvas, key: string, terminal: boolean) {
81 const { Raster, Box, Text } = el
82 if (terminal && Raster) return <Raster key={key} columns={canvas.cols} rows={canvas.rows} cells={canvas.encode()} />
83 const lines = []
84 for (let y = 0; y < canvas.rows; y++) {
85 const runs: { fg: number; bg: number; s: string }[] = []
86 for (let x = 0; x < canvas.cols; x++) {
87 const i = (y * canvas.cols + x) * 3
88 const ch = String.fromCodePoint(canvas.words[i] ?? 0x20)
89 const fg = canvas.words[i + 1] ?? DEFAULT
90 const bg = canvas.words[i + 2] ?? DEFAULT
91 const last = runs[runs.length - 1]
92 if (last && last.fg === fg && last.bg === bg) last.s += ch
93 else runs.push({ fg, bg, s: ch })
94 }
95 lines.push(
96 <Text mono>
97 {runs.map(run => {
98 const props: Record<string, string | boolean> = { mono: true }
99 if (run.fg !== DEFAULT) props['color'] = toHex(run.fg)
100 if (run.bg !== DEFAULT) props['backgroundColor'] = toHex(run.bg)
101 return <Text {...props}>{run.s}</Text>
102 })}
103 </Text>,
104 )
105 }
106 return (
107 <Box key={key} flexDirection="column">
108 {lines}
109 </Box>
110 )
111}
112
113const STARS = [0x2801, 0x2802, 0x2804, 0x2808, 0x2810, 0x2820, 0xb7]
114
115// The banner: a dark band of stars that twinkle with the clock, the title in a gradient.
116export function banner(cols: number, at: number, title: string, left: string, right: string, rightColor: number): Canvas {
117 const bg = hex('#11111b')
118 const c = new Canvas(cols, 3, bg)
119 const beat = Math.floor(at / 1000)
120 for (let y = 0; y < 3; y++) {
121 for (let x = 0; x < cols; x++) {
122 const h = hash(x * 73856093 + y * 19349663)
123 if (h % 13 !== 0) continue
124 const bright = (h + beat) % 9 === 0
125 const glyph = bright ? 0x2b : STARS[h % STARS.length] ?? 0xb7
126 c.put(x, y, glyph, bright ? hex('#f5e0dc') : mix(hex('#45475a'), hex('#9399b2'), ((h >> 5) % 10) / 10), bg)
127 }
128 }
129 const spaced = cols >= 60 ? [...title].join(' ') : title
130 const stops = [hex('#cba6f7'), hex('#89b4fa'), hex('#94e2d5')]
131 for (let x = 1; x < Math.min(cols, spaced.length + 4); x++) c.put(x, 1, 0x20, DEFAULT, bg)
132 c.text(2, 1, spaced, i => along(stops, i / Math.max(1, spaced.length - 1)), bg)
133 for (let x = 1; x < Math.min(cols, left.length + 4); x++) c.put(x, 2, 0x20, DEFAULT, bg)
134 c.text(2, 2, left, hex('#7f849c'), bg)
135 const rx = Math.max(spaced.length + 4, cols - right.length - 2)
136 for (let x = rx - 1; x < Math.min(cols, rx + right.length + 1); x++) c.put(x, 1, 0x20, DEFAULT, bg)
137 c.text(rx, 1, right, rightColor, bg)
138 return c
139}
140
141// A 3x5 pixel font for the tiles' numbers, drawn with half blocks: three rows of cells.
142const FONT: Record<string, string[]> = {
143 '0': ['###', '#.#', '#.#', '#.#', '###'],
144 '1': ['.#.', '##.', '.#.', '.#.', '###'],
145 '2': ['###', '..#', '###', '#..', '###'],
146 '3': ['###', '..#', '.##', '..#', '###'],
147 '4': ['#.#', '#.#', '###', '..#', '..#'],
148 '5': ['###', '#..', '###', '..#', '###'],
149 '6': ['###', '#..', '###', '#.#', '###'],
150 '7': ['###', '..#', '..#', '.#.', '.#.'],
151 '8': ['###', '#.#', '###', '#.#', '###'],
152 '9': ['###', '#.#', '###', '..#', '###'],
153 k: ['#..', '#.#', '##.', '#.#', '#.#'],
154 M: ['#.#', '###', '###', '#.#', '#.#'],
155 '%': ['#.#', '..#', '.#.', '#..', '#.#'],
156 '.': ['.', '.', '.', '.', '#'],
157 '-': ['...', '...', '###', '...', '...'],
158}
159
160export function bigWidth(text: string): number {
161 let cols = 0
162 for (const ch of text) cols += (FONT[ch] ?? FONT['-'] ?? ['...'])[0]?.length ?? 3
163 return Math.max(1, cols + Math.max(0, [...text].length - 1))
164}
165
166export function bigText(text: string, fg: number, width = bigWidth(text)): Canvas {
167 const c = new Canvas(width, 3)
168 let x0 = Math.max(0, Math.floor((width - bigWidth(text)) / 2))
169 for (const ch of text) {
170 const glyph = FONT[ch] ?? FONT['-'] ?? []
171 const w = glyph[0]?.length ?? 3
172 for (let gx = 0; gx < w; gx++) {
173 for (let cy = 0; cy < 3; cy++) {
174 const top = glyph[cy * 2]?.charAt(gx) === '#'
175 const bottom = glyph[cy * 2 + 1]?.charAt(gx) === '#'
176 if (top || bottom) c.put(x0 + gx, cy, top && bottom ? 0x2588 : top ? 0x2580 : 0x2584, fg)
177 }
178 }
179 x0 += w + 1
180 }
181 return c
182}
183
184const EIGHTHS = [0x258f, 0x258e, 0x258d, 0x258c, 0x258b, 0x258a, 0x2589]
185
186// A smooth bar: whole cells, then a left-eighths block for the part, on a dim track.
187export function meter(cols: number, fraction: number, fg: number, track = hex('#313244')): Canvas {
188 const c = new Canvas(cols, 1)
189 const filled = Math.max(0, Math.min(cols, fraction * cols))
190 for (let x = 0; x < cols; x++) {
191 const part = filled - x
192 if (part >= 1) c.put(x, 0, 0x2588, fg)
193 else if (part > 0.06) c.put(x, 0, EIGHTHS[Math.min(6, Math.floor(part * 7))] ?? 0x258f, fg, track)
194 else c.put(x, 0, 0x2588, track)
195 }
196 return c
197}
198
199// Shares of one bar, each cell to whichever part it falls in (largest remainders first).
200export function stacked(cols: number, parts: readonly { value: number; color: number }[], track = hex('#313244')): Canvas {
201 const c = new Canvas(cols, 1)
202 const total = parts.reduce((n, p) => n + Math.max(0, p.value), 0)
203 if (total <= 0) {
204 for (let x = 0; x < cols; x++) c.put(x, 0, 0x2588, track)
205 return c
206 }
207 const exact = parts.map(p => (Math.max(0, p.value) / total) * cols)
208 const whole = exact.map(Math.floor)
209 let left = cols - whole.reduce((n, w) => n + w, 0)
210 const order = exact.map((v, i) => [v - Math.floor(v), i] as const).sort((a, b) => b[0] - a[0])
211 for (const [, i] of order) {
212 if (left <= 0) break
213 whole[i] = (whole[i] ?? 0) + 1
214 left -= 1
215 }
216 let x = 0
217 parts.forEach((p, i) => {
218 for (let k = 0; k < (whole[i] ?? 0); k++) c.put(x++, 0, 0x2588, p.color)
219 })
220 return c
221}
222
223const LEVELS = [0x20, 0x2581, 0x2582, 0x2583, 0x2584, 0x2585, 0x2586, 0x2587, 0x2588]
224
225// A sparkline `rows` tall, newest value at the right edge.
226export function spark(cols: number, rows: number, values: readonly number[], color: (v: number, max: number) => number): Canvas {
227 const c = new Canvas(cols, rows)
228 const series = values.slice(-cols)
229 const offset = cols - series.length
230 const max = Math.max(1, ...series)
231 series.forEach((v, i) => {
232 let eighths = Math.round((v / max) * rows * 8)
233 if (v > 0 && eighths === 0) eighths = 1
234 for (let r = 0; r < rows; r++) {
235 const level = Math.max(0, Math.min(8, eighths - r * 8))
236 if (level > 0) c.put(offset + i, rows - 1 - r, LEVELS[level] ?? 0x2588, color(v, max))
237 }
238 })
239 return c
240}
241hooks/deck.ts 954 lines1import type { AgentRow, Code, Deck, FeedItem, FeedKind, Guards, Limit, Minute, PlanTicket, RunRow, Timeline, ToolRun, Usage } from '../types'
2
3export const MAIN = 'main'
4export const LADDER = ['low', 'medium', 'high', 'xhigh', 'max'] as const
5export type Level = (typeof LADDER)[number]
6
7const BUCKET_MS = 2_000
8const MAX_CODES = 480
9const HIDDEN_THINK_MS = 5_000
10const LONG_TOOL_MS = 60_000
11const STUCK_SHARE = 0.85
12const LONG_THINK_MS = 6 * 60_000
13const BASH_LIMIT_MS = 120_000
14const RUN_SETTLE_MS = 15_000
15const KEEP_RUNS = 6
16const KEEP_LOOSE = 24
17const HEAD_MAX = 8_192
18const ESCALATE_AFTER = 2
19const FEED_MAX = 60
20const MINUTES_KEPT = 60
21const RECENT_MAX = 4
22const RANK: Record<Code, number> = { s: 7, l: 6, w: 5, t: 4, b: 3, x: 2, q: 1, i: 0 }
23const WRITERS = new Set(['Write', 'Edit', 'NotebookEdit', 'MultiEdit'])
24const GUARDS: [keyof Guards, RegExp, string][] = [
25 ['search', /Blocked by the search guard/, 'search guard'],
26 ['effort', /Blocked by the effort guard/, 'effort guard'],
27 ['disk', /Blocked by the Windows disk guard/, 'disk guard'],
28]
29
30type Counts = Partial<Record<Code, number>>
31type Acc = { bucket: number; counts: Counts }
32
33export type Store = {
34 agents: Map<string, AgentRow>
35 runs: Map<string, RunRow>
36 runNames: Map<string, string>
37 runLimits: Map<string, number>
38 journals: Map<string, { seen: number; done: boolean; labels: Map<string, string> }>
39 heads: Map<string, string>
40 acc: Map<string, Acc>
41 warned: Set<string>
42 transcriptDir: string | null
43 feed: FeedItem[]
44 minutes: Map<number, { tokens: number; live: number }>
45 usage: Usage | null
46 guards: Guards
47 autoEscalate: boolean
48 dirty: boolean
49}
50
51export type SpawnFacts = {
52 agentId: string
53 description: string
54 subagentType: string
55 model: string
56 runId: string | null
57 index: number | null
58 toolUseId: string
59 isTeammate: boolean
60}
61
62export type Usage4 = {
63 input_tokens?: number | null
64 output_tokens?: number | null
65 cache_read_input_tokens?: number | null
66 cache_creation_input_tokens?: number | null
67} | null
68
69export function createStore(): Store {
70 return {
71 agents: new Map(),
72 runs: new Map(),
73 runNames: new Map(),
74 runLimits: new Map(),
75 journals: new Map(),
76 heads: new Map(),
77 acc: new Map(),
78 warned: new Set(),
79 transcriptDir: null,
80 feed: [],
81 minutes: new Map(),
82 usage: null,
83 guards: { search: 0, effort: 0, disk: 0 },
84 autoEscalate: true,
85 dirty: false,
86 }
87}
88
89export function emptyDeck(): Deck {
90 return { at: 0, main: null, agents: [], runs: [], transcriptDir: null, feed: [], minutes: [], usage: null, guards: { search: 0, effort: 0, disk: 0 } }
91}
92
93function healRow(row: AgentRow): AgentRow {
94 row.baseEffort ??= row.effort ?? null
95 row.bumps ??= 0
96 row.fails ??= 0
97 row.window ??= 200_000
98 row.lastRate ??= 0
99 row.recent ??= []
100 return row
101}
102
103// Rows come back from $.state frozen and possibly from an older shape: copy and fill.
104export function restore(store: Store, deck: Deck): void {
105 const copy = JSON.parse(JSON.stringify(deck)) as Partial<Deck>
106 for (const row of copy.agents ?? []) store.agents.set(row.id, healRow(row))
107 if (copy.main) store.agents.set(MAIN, healRow(copy.main))
108 for (const run of copy.runs ?? []) store.runs.set(run.id, { ...run, draft: run.draft ?? (run.planFrom === 'planner' && run.plan ? run.plan.filter(t => t.from === undefined) : null), plan: run.plan ?? null, planFrom: run.planFrom ?? null, verdicts: run.verdicts ?? {}, final: run.final ?? null, finals: run.finals ?? {}, limitBase: run.limitBase ?? null, maxTickets: run.maxTickets ?? null })
109 store.transcriptDir ??= copy.transcriptDir ?? null
110 store.feed = copy.feed ?? []
111 for (const [minute, tokens, live] of copy.minutes ?? []) store.minutes.set(minute, { tokens, live })
112 store.usage = copy.usage ?? null
113 store.guards = { search: 0, effort: 0, disk: 0, ...(copy.guards ?? {}) }
114}
115
116function say(store: Store, kind: FeedKind, text: string, at: number): void {
117 store.feed.push({ at, kind, text })
118 if (store.feed.length > FEED_MAX) store.feed.splice(0, store.feed.length - FEED_MAX)
119 store.dirty = true
120}
121
122function blankRow(id: string, label: string, type: string, now: number): AgentRow {
123 return {
124 id, label, type,
125 runId: null, index: 0, model: null, effort: null, baseEffort: null, bumps: 0, fails: 0,
126 startedAt: now, endedAt: null, outcome: null,
127 code: 'q', since: now, step: 0, stepSince: null,
128 compose: null, tools: [], toolCalls: 0, errors: 0, ctx: 0, window: 200_000, out: 0, lastRate: 0, note: '',
129 recent: [], tl: { origin: now, size: BUCKET_MS, codes: '' },
130 }
131}
132
133function rowFor(store: Store, agentId: string | undefined, now: number): AgentRow {
134 const id = agentId ?? MAIN
135 let row = store.agents.get(id)
136 if (!row) {
137 row = id === MAIN ? blankRow(MAIN, 'this chat', 'main', now) : blankRow(id, `agent ${id.slice(0, 7)}`, 'agent', now)
138 if (id === MAIN) row.code = 'i'
139 store.agents.set(id, row)
140 }
141 return row
142}
143
144function setCode(row: AgentRow, code: Code, now: number, keepSince = false): void {
145 if (row.code === code) return
146 row.code = code
147 if (!keepSince) row.since = now
148}
149
150export function isLevel(x: unknown): x is Level {
151 return typeof x === 'string' && (LADDER as readonly string[]).includes(x)
152}
153
154export function modelShort(model: string | null): string {
155 if (!model) return ''
156 const m = /(haiku|sonnet|opus|fable)/i.exec(model)
157 return m?.[1] ? m[1].toLowerCase() : model.replace(/^claude-/, '').slice(0, 8)
158}
159
160export function oldestTool(row: AgentRow): ToolRun | undefined {
161 let oldest: ToolRun | undefined
162 for (const tool of row.tools) if (!oldest || tool.since < oldest.since) oldest = tool
163 return oldest
164}
165
166// What the pane shows: the raw code, sharpened by how long it has lasted. A request with
167// nothing streamed after a few seconds is hidden thinking, since replies start fast.
168export function displayCode(row: AgentRow, now: number): Code {
169 const tool = oldestTool(row)
170 if (tool) {
171 const ran = now - tool.since
172 if (tool.limitMs !== null && tool.limitMs >= LONG_TOOL_MS && ran >= STUCK_SHARE * tool.limitMs) return 's'
173 return ran >= LONG_TOOL_MS ? 'l' : 'b'
174 }
175 if (row.code === 'q' && row.stepSince !== null && now - row.since >= HIDDEN_THINK_MS) {
176 return now - row.since >= LONG_THINK_MS ? 's' : 't'
177 }
178 if (row.code === 't' && now - row.since >= LONG_THINK_MS) return 's'
179 return row.code
180}
181
182export function isLive(row: AgentRow): boolean {
183 return row.endedAt === null && !(row.id === MAIN && row.code === 'i')
184}
185
186export function anyLive(store: Store): boolean {
187 for (const row of store.agents.values()) if (isLive(row)) return true
188 return false
189}
190
191function majority(counts: Counts): Code | undefined {
192 let best: Code | undefined
193 let most = 0
194 for (const key of Object.keys(counts) as Code[]) {
195 const n = counts[key] ?? 0
196 if (n > most || (n === most && best !== undefined && RANK[key] > RANK[best])) {
197 best = key
198 most = n
199 }
200 }
201 return best
202}
203
204export function stronger(a: Code | null, b: Code | null): Code | null {
205 if (a === null) return b
206 if (b === null) return a
207 return RANK[b] > RANK[a] ? b : a
208}
209
210function halve(tl: Timeline): void {
211 let out = ''
212 for (let i = 0; i < tl.codes.length; i += 2) {
213 const a = tl.codes.charAt(i) as Code
214 const b = tl.codes.charAt(i + 1)
215 out += b !== '' && RANK[b as Code] > RANK[a] ? b : a
216 }
217 tl.codes = out
218 tl.size *= 2
219}
220
221function seal(tl: Timeline, bucket: number, code: Code): void {
222 if (bucket < tl.codes.length) return
223 tl.codes += code.repeat(bucket - tl.codes.length + 1)
224 while (tl.codes.length > MAX_CODES) halve(tl)
225}
226
227export function sample(store: Store, row: AgentRow, code: Code, now: number): void {
228 const bucket = Math.floor((now - row.tl.origin) / row.tl.size)
229 const acc = store.acc.get(row.id)
230 if (!acc) {
231 store.acc.set(row.id, { bucket, counts: { [code]: 1 } })
232 return
233 }
234 if (bucket > acc.bucket) {
235 seal(row.tl, acc.bucket, majority(acc.counts) ?? code)
236 acc.bucket = Math.floor((now - row.tl.origin) / row.tl.size)
237 acc.counts = {}
238 }
239 acc.counts[code] = (acc.counts[code] ?? 0) + 1
240}
241
242function closeOut(store: Store, row: AgentRow, now: number): void {
243 const acc = store.acc.get(row.id)
244 if (acc) seal(row.tl, acc.bucket, majority(acc.counts) ?? displayCode(row, now))
245 store.acc.delete(row.id)
246}
247
248// The strip: `width` cells over [from, to], each the commonest state of the buckets under it.
249export function cells(row: AgentRow, from: number, to: number, width: number, now: number): (Code | null)[] {
250 const out: (Code | null)[] = []
251 const span = Math.max(1, to - from)
252 const end = row.endedAt ?? now
253 const tl = row.tl
254 const tail = row.endedAt === null ? displayCode(row, now) : undefined
255 for (let c = 0; c < width; c++) {
256 const a = Math.max(from + (c * span) / width, row.startedAt)
257 const b = Math.min(from + ((c + 1) * span) / width, end)
258 if (b <= a) {
259 out.push(null)
260 continue
261 }
262 const lo = Math.max(0, Math.floor((a - tl.origin) / tl.size))
263 const hi = Math.max(lo, Math.floor((b - tl.origin - 1) / tl.size))
264 const counts: Counts = {}
265 for (let i = lo; i <= Math.min(hi, tl.codes.length - 1); i++) {
266 const k = tl.codes.charAt(i) as Code
267 counts[k] = (counts[k] ?? 0) + 1
268 }
269 if (hi >= tl.codes.length && tail) counts[tail] = (counts[tail] ?? 0) + 1
270 out.push(majority(counts) ?? null)
271 }
272 return out
273}
274
275export type Shares = { think: number; write: number; text: number; tools: number; stuck: number; wait: number }
276
277const SHARE_OF: Partial<Record<Code, keyof Shares>> = { t: 'think', w: 'write', x: 'text', b: 'tools', l: 'tools', s: 'stuck', q: 'wait' }
278
279// Where the agents' time went, from their timelines, idle time left out.
280export function shares(rows: readonly AgentRow[]): Shares | null {
281 const ms: Shares = { think: 0, write: 0, text: 0, tools: 0, stuck: 0, wait: 0 }
282 let total = 0
283 for (const row of rows) {
284 for (const ch of row.tl.codes) {
285 const key = SHARE_OF[ch as Code]
286 if (!key) continue
287 ms[key] += row.tl.size
288 total += row.tl.size
289 }
290 }
291 if (total < 10_000) return null
292 for (const key of Object.keys(ms) as (keyof Shares)[]) ms[key] = Math.round((100 * ms[key]) / total)
293 return ms
294}
295
296function settleRuns(store: Store, now: number): void {
297 for (const run of store.runs.values()) {
298 let live = false
299 let count = 0
300 let last = 0
301 for (const row of store.agents.values()) {
302 if (row.runId !== run.id) continue
303 count += 1
304 if (row.endedAt === null) live = true
305 else last = Math.max(last, row.endedAt)
306 }
307 if (live) {
308 run.endedAt = null
309 } else if (count && run.endedAt === null && now - last >= RUN_SETTLE_MS) {
310 run.endedAt = last
311 say(store, 'run', `${run.name} finished in ${clock(last - run.startedAt)} · ${count} agents`, now)
312 }
313 }
314}
315
316function minuteSlot(store: Store, now: number): { tokens: number; live: number } {
317 const minute = Math.floor(now / 60_000)
318 let slot = store.minutes.get(minute)
319 if (!slot) {
320 slot = { tokens: 0, live: 0 }
321 store.minutes.set(minute, slot)
322 for (const key of store.minutes.keys()) if (key < minute - MINUTES_KEPT) store.minutes.delete(key)
323 }
324 return slot
325}
326
327// Once a second: sample each live agent's state into its timeline, count who is live this
328// minute, and say which tools have just passed most of their time limit.
329export function tick(store: Store, now: number): string[] {
330 const warnings: string[] = []
331 let live = 0
332 for (const row of store.agents.values()) {
333 if (row.endedAt !== null || row.id === MAIN) continue
334 live += 1
335 const code = displayCode(row, now)
336 sample(store, row, code, now)
337 const tool = code === 's' ? oldestTool(row) : undefined
338 if (tool && tool.limitMs !== null && !store.warned.has(tool.id)) {
339 store.warned.add(tool.id)
340 const text = `${row.label}: "${tool.label || tool.tool}" has run ${clock(now - tool.since)} of its ${clock(tool.limitMs)} limit`
341 warnings.push(text)
342 say(store, 'warn', text, now)
343 }
344 }
345 const slot = minuteSlot(store, now)
346 slot.live = Math.max(slot.live, live)
347 settleRuns(store, now)
348 return warnings
349}
350
351function dropRow(store: Store, id: string): void {
352 store.agents.delete(id)
353 store.acc.delete(id)
354 store.heads.delete(id)
355}
356
357function prune(store: Store): void {
358 const runs = [...store.runs.values()].sort((a, b) => b.startedAt - a.startedAt)
359 for (const run of runs.slice(KEEP_RUNS)) {
360 store.runs.delete(run.id)
361 store.journals.delete(run.id)
362 for (const row of [...store.agents.values()]) if (row.runId === run.id) dropRow(store, row.id)
363 }
364 const loose = [...store.agents.values()]
365 .filter(row => row.runId === null && row.id !== MAIN && row.endedAt !== null)
366 .sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))
367 for (const row of loose.slice(KEEP_LOOSE)) dropRow(store, row.id)
368}
369
370export function clearFinished(store: Store): void {
371 for (const run of [...store.runs.values()]) {
372 if (run.endedAt === null) continue
373 store.runs.delete(run.id)
374 store.journals.delete(run.id)
375 for (const row of [...store.agents.values()]) if (row.runId === run.id) dropRow(store, row.id)
376 }
377 for (const row of [...store.agents.values()]) {
378 if (row.runId === null && row.id !== MAIN && row.endedAt !== null) dropRow(store, row.id)
379 }
380 store.dirty = true
381}
382
383// The workflow call's maxTickets argument, clamped the way the sprint workflow clamps it, for spawned() to copy onto its run.
384export function noteLimit(store: Store, toolUseId: string, args: unknown): void {
385 // The workflow reads string args as its goal, so a string carries no ticket limit.
386 const o: unknown = typeof args === 'string' ? null : args
387 const asked = o && typeof o === 'object' ? (o as Record<string, unknown>)['maxTickets'] : undefined
388 store.runLimits.set(toolUseId, Math.max(1, Math.min(40, Number(asked) || 16)))
389}
390
391export function spawned(store: Store, f: SpawnFacts, now: number): void {
392 if (f.runId) {
393 const run = store.runs.get(f.runId)
394 if (run) {
395 run.endedAt = null
396 run.maxTickets ??= store.runLimits.get(f.toolUseId) ?? null
397 } else {
398 const name = store.runNames.get(f.toolUseId) ?? 'workflow'
399 store.runs.set(f.runId, { id: f.runId, name, dir: store.transcriptDir, startedAt: now, endedAt: null, phases: {},
400 plan: null, draft: null, planFrom: null, verdicts: {}, final: null, finals: {},
401 limitBase: store.usage?.fiveHour ? { ...store.usage.fiveHour } : null,
402 maxTickets: store.runLimits.get(f.toolUseId) ?? null,
403 })
404 say(store, 'run', name === 'workflow' ? 'a workflow started' : `workflow ${name} started`, now)
405 }
406 }
407 const known = store.agents.get(f.agentId)
408 if (known) {
409 known.endedAt = null
410 known.outcome = null
411 setCode(known, 'q', now)
412 } else {
413 const row = blankRow(f.agentId, f.description || f.subagentType || 'agent', f.isTeammate ? 'teammate' : f.subagentType, now)
414 row.runId = f.runId
415 row.index = f.index ?? store.agents.size
416 row.model = f.model || null
417 store.agents.set(f.agentId, row)
418 say(store, 'start', `${row.label} started${row.model ? ` · ${modelShort(row.model)}` : ''}`, now)
419 }
420 prune(store)
421 store.dirty = true
422}
423
424export function turnStarted(store: Store, agentId: string | undefined, now: number): void {
425 const row = rowFor(store, agentId, now)
426 if (row.endedAt !== null) {
427 row.endedAt = null
428 row.outcome = null
429 }
430 setCode(row, 'q', now)
431 store.dirty = true
432}
433
434export function stepStarted(store: Store, agentId: string | undefined, facts: { effort?: string | number; model: string }, now: number): void {
435 const row = rowFor(store, agentId, now)
436 row.step += 1
437 row.stepSince = now
438 row.compose = null
439 if (facts.effort !== undefined) {
440 const asked = String(facts.effort)
441 row.baseEffort ??= asked
442 if (!row.bumps) row.effort = asked
443 }
444 row.model = facts.model
445 if (/\[1m\]/i.test(facts.model)) row.window = 1_000_000
446 row.code = 'q'
447 row.since = now
448 store.dirty = true
449}
450
451// The effort a subagent's next request should carry when Mission Control has raised it.
452export function effortToSend(store: Store, agentId: string | undefined, asked: unknown): Level | undefined {
453 if (agentId === undefined) return undefined
454 const row = store.agents.get(agentId)
455 if (!row || !row.bumps || !isLevel(row.effort)) return undefined
456 if (isLevel(asked) && LADDER.indexOf(asked) >= LADDER.indexOf(row.effort)) return undefined
457 return row.effort
458}
459
460export function streamed(store: Store, agentId: string | undefined, kind: 'thinking' | 'text', now: number): void {
461 const row = rowFor(store, agentId, now)
462 if (kind === 'thinking') setCode(row, 't', now, row.code === 'q')
463 else setCode(row, 'x', now)
464 store.dirty = true
465}
466
467export function composing(store: Store, agentId: string | undefined, tool: string, now: number): void {
468 const row = rowFor(store, agentId, now)
469 row.compose = { tool, target: '', bytes: 0 }
470 store.heads.set(row.id, '')
471 setCode(row, 'w', now)
472 store.dirty = true
473}
474
475export function composed(store: Store, agentId: string | undefined, json: string): void {
476 const row = store.agents.get(agentId ?? MAIN)
477 if (!row || !row.compose) return
478 row.compose.bytes += json.length
479 if (row.compose.target) return
480 const head = (store.heads.get(row.id) ?? '') + json
481 if (head.length > HEAD_MAX) return
482 store.heads.set(row.id, head)
483 row.compose.target = targetOf(head)
484}
485
486export function stepStopped(store: Store, agentId: string | undefined, usage: Usage4, now: number): void {
487 const row = store.agents.get(agentId ?? MAIN)
488 if (!row || !usage) return
489 row.ctx = (usage.input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0)
490 if (row.ctx > row.window) row.window = 1_000_000
491 const out = usage.output_tokens ?? 0
492 row.out += out
493 if (row.stepSince !== null && now > row.stepSince) row.lastRate = Math.round(out / ((now - row.stepSince) / 1000))
494 minuteSlot(store, now).tokens += out
495 store.dirty = true
496}
497
498export function toolStarted(store: Store, agentId: string | undefined, run: ToolRun, now: number): void {
499 const row = rowFor(store, agentId, now)
500 row.toolCalls += 1
501 row.tools = [...row.tools.filter(tool => tool.id !== run.id), run]
502 row.compose = null
503 if (run.label) row.note = run.label
504 setCode(row, 'b', now)
505 store.dirty = true
506}
507
508// A tool call came back. Returns a sentence when this failure raised the agent's effort.
509export function toolEnded(store: Store, agentId: string | undefined, toolUseId: string, failed: boolean, now: number, text = ''): string | null {
510 const row = store.agents.get(agentId ?? MAIN)
511 if (!row) return null
512 const run = row.tools.find(tool => tool.id === toolUseId)
513 row.tools = row.tools.filter(tool => tool.id !== toolUseId)
514 store.warned.delete(toolUseId)
515 if (run) {
516 row.recent.push({ tool: run.tool, label: run.label, ok: !failed, ms: now - run.since })
517 if (row.recent.length > RECENT_MAX) row.recent.splice(0, row.recent.length - RECENT_MAX)
518 }
519 let raised: string | null = null
520 if (failed) {
521 row.errors += 1
522 row.fails += 1
523 const guard = GUARDS.find(([, re]) => re.test(text))
524 if (guard) {
525 store.guards[guard[0]] += 1
526 say(store, 'guard', `${guard[2]} stopped ${row.label}${run?.label ? `: ${run.label}` : ''}`, now)
527 } else if (row.id !== MAIN && run) {
528 say(store, 'fail', `${row.label}: ${run.tool}${run.label ? ` · ${run.label}` : ''} failed`, now)
529 }
530 if (store.autoEscalate && row.id !== MAIN && row.endedAt === null && row.fails >= ESCALATE_AFTER && isLevel(row.effort) && row.effort !== 'max') {
531 const from = row.effort
532 const to = LADDER[LADDER.indexOf(from) + 1] ?? 'max'
533 row.effort = to
534 row.bumps += 1
535 row.fails = 0
536 raised = `${row.label}: effort raised ${from} → ${to} after ${ESCALATE_AFTER} failed commands in a row`
537 say(store, 'up', raised, now)
538 }
539 } else {
540 row.fails = 0
541 }
542 if (row.tools.length === 0 && row.endedAt === null) setCode(row, 'q', now)
543 store.dirty = true
544 return raised
545}
546
547export function turnEnded(store: Store, agentId: string | undefined, reason: string, now: number): void {
548 const row = rowFor(store, agentId, now)
549 row.tools = []
550 row.compose = null
551 row.stepSince = null
552 if (row.id !== MAIN && row.type !== 'teammate') {
553 closeOut(store, row, now)
554 row.endedAt = now
555 row.outcome = reason === 'answer' ? 'done' : reason === 'aborted' ? 'stopped' : 'failed'
556 const took = clock(now - row.startedAt)
557 say(
558 store,
559 row.outcome === 'done' ? 'done' : 'fail',
560 row.outcome === 'done'
561 ? `${row.label} finished in ${took} · ${row.step} steps · ${row.toolCalls} commands`
562 : `${row.label} ${row.outcome} after ${took}`,
563 now,
564 )
565 }
566 setCode(row, 'i', now)
567 store.dirty = true
568}
569
570function limitOf(v: unknown): Limit | null {
571 if (!v || typeof v !== 'object') return null
572 const o = v as { used_percentage?: unknown; resets_at?: unknown }
573 if (typeof o.used_percentage !== 'number') return null
574 const raw = o.resets_at
575 const resetsAt = typeof raw === 'number' ? (raw < 1e12 ? raw * 1000 : raw) : typeof raw === 'string' ? Date.parse(raw) || 0 : 0
576 return { pct: o.used_percentage, resetsAt }
577}
578
579// The status line script saves Claude Code's status JSON; the usage limits are the account's,
580// the cost and context only this session's when the capture is ours.
581export function absorbStatus(store: Store, text: string, sessionId: string | null): void {
582 let d: { rate_limits?: { five_hour?: unknown; seven_day?: unknown }; cost?: { total_cost_usd?: unknown }; context_window?: { used_percentage?: unknown }; session_id?: unknown }
583 try {
584 d = JSON.parse(text) as typeof d
585 } catch {
586 return
587 }
588 const ours = sessionId !== null && d.session_id === sessionId
589 const prior = store.usage
590 store.usage = {
591 fiveHour: limitOf(d.rate_limits?.five_hour) ?? prior?.fiveHour ?? null,
592 sevenDay: limitOf(d.rate_limits?.seven_day) ?? prior?.sevenDay ?? null,
593 costUsd: ours && typeof d.cost?.total_cost_usd === 'number' ? d.cost.total_cost_usd : prior?.costUsd ?? null,
594 ctxPct: ours && typeof d.context_window?.used_percentage === 'number' ? d.context_window.used_percentage : prior?.ctxPct ?? null,
595 }
596 const five = store.usage.fiveHour
597 if (five) for (const run of store.runs.values()) if (run.endedAt === null && run.limitBase === null) run.limitBase = { ...five }
598 store.dirty = true
599}
600
601function copyRow(row: AgentRow): AgentRow {
602 return {
603 ...row,
604 tools: row.tools.map(tool => ({ ...tool })),
605 compose: row.compose ? { ...row.compose } : null,
606 recent: row.recent.map(r => ({ ...r })),
607 tl: { ...row.tl },
608 }
609}
610
611export function snapshot(store: Store, now: number): Deck {
612 const main = store.agents.get(MAIN)
613 const minutes: Minute[] = [...store.minutes.entries()].sort((a, b) => a[0] - b[0]).map(([m, s]) => [m, s.tokens, s.live])
614 return {
615 at: now,
616 main: main ? copyRow(main) : null,
617 agents: [...store.agents.values()].filter(row => row.id !== MAIN).map(copyRow),
618 runs: [...store.runs.values()].map(run => ({
619 ...run,
620 phases: { ...run.phases },
621 plan: run.plan ? run.plan.map(t => ({ ...t, dependsOn: [...t.dependsOn] })) : null,
622 draft: run.draft ? run.draft.map(t => ({ ...t, dependsOn: [...t.dependsOn] })) : null,
623 verdicts: Object.fromEntries(Object.entries(run.verdicts).map(([k, v]) => [k, { ...v }])),
624 final: run.final ? { ...run.final } : null,
625 finals: Object.fromEntries(Object.entries(run.finals).map(([k, v]) => [k, { ...v }])),
626 limitBase: run.limitBase ? { ...run.limitBase } : null,
627 })),
628 transcriptDir: store.transcriptDir,
629 feed: store.feed.map(item => ({ ...item })),
630 minutes,
631 usage: store.usage ? { ...store.usage } : null,
632 guards: { ...store.guards },
633 }
634}
635
636export function journalPath(run: RunRow): string | null {
637 return run.dir ? `${run.dir}/subagents/workflows/${run.id}/journal.jsonl` : null
638}
639
640// A run has no folder when it began before this session's folder was known, or when an older pane recorded
641// it. Its journal is most likely in this session's folder, so it adopts that one; a miss marks the journal gone.
642export function adoptDir(store: Store, run: RunRow): void {
643 if (!run.dir && store.transcriptDir) run.dir = store.transcriptDir
644}
645
646const PLAN_MAX = 40
647const FIX_MAX = 2 * PLAN_MAX
648const DEFAULT_TICKETS = 16
649const REVIEW_RE = /^(\S+) review(?: (\d+)\/(\d+))? \(/
650
651function cut(s: string, n: number): string {
652 return s.length > n ? s.slice(0, n) : s
653}
654
655function record(raw: unknown): Record<string, unknown> {
656 return (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
657}
658
659function slimTicket(t: Record<string, unknown>, id: string, dependsOn: string[]): PlanTicket {
660 const est = t['estimateMin']
661 return {
662 id,
663 title: typeof t['title'] === 'string' ? cut(t['title'], 80) : id,
664 kind: typeof t['kind'] === 'string' ? t['kind'] : 'judgment',
665 dependsOn,
666 estimateMin: typeof est === 'number' && est > 0 ? est : null,
667 }
668}
669
670function slimPlan(tickets: unknown[]): PlanTicket[] {
671 const out: PlanTicket[] = []
672 const seen = new Set<string>()
673 for (const raw of tickets) {
674 if (out.length >= PLAN_MAX) break
675 const t = record(raw)
676 let id = typeof t['id'] === 'string' ? t['id'].trim() : ''
677 if (!id) continue
678 while (seen.has(id)) id += "'"
679 seen.add(id)
680 out.push(slimTicket(t, id, Array.isArray(t['dependsOn']) ? t['dependsOn'].filter((d): d is string => typeof d === 'string') : []))
681 }
682 return out
683}
684
685// The workflow runs only the first maxTickets of each plan; the rest stay, marked, so numbering matches it.
686function cap(run: RunRow, plan: PlanTicket[]): PlanTicket[] {
687 const limit = run.maxTickets ?? DEFAULT_TICKETS
688 plan.forEach((t, i) => {
689 if (i >= limit) t.dropped = true
690 })
691 return plan
692}
693
694const kept = (plan: PlanTicket[]) => plan.filter(t => !t.dropped).length
695
696const copyTickets = (plan: PlanTicket[]): PlanTicket[] => plan.map(t => ({ ...t, dependsOn: [...t.dependsOn] }))
697
698function takePlan(store: Store, run: RunRow, from: 'planner' | 'critic', tickets: unknown[], now: number): void {
699 if (from === 'planner' && run.planFrom === 'critic') return
700 const plan = cap(run, slimPlan(tickets))
701 const fixes = (run.plan ?? []).filter(t => t.from !== undefined)
702 const before = (run.plan ?? []).filter(t => t.from === undefined)
703 if (from === 'critic' && run.draft === null && run.planFrom === 'planner') run.draft = copyTickets(before)
704 if (run.planFrom === from && JSON.stringify(plan) === JSON.stringify(before)) return
705 run.plan = [...plan, ...fixes]
706 if (from === 'planner') run.draft = copyTickets(plan)
707 run.planFrom = from
708 store.dirty = true
709 const n = kept(plan)
710 say(store, 'run', from === 'critic' ? `plan checked: ${n} tickets` : `draft plan ready: ${n} tickets`, now)
711}
712
713// A triage agent's tickets, renamed F1, F2, ... past every id already used, the way the workflow's normalizeTickets does.
714function takeFixPlan(store: Store, run: RunRow, agentId: string, tickets: unknown[], now: number): void {
715 const others = (run.plan ?? []).filter(t => t.from !== agentId)
716 const used = new Set(others.map(t => t.id))
717 const rename = new Map<string, string>()
718 const raws = tickets.slice(0, FIX_MAX).map(record)
719 const ids: string[] = []
720 for (const t of raws) {
721 const asked = String(t['id'] ?? '').trim()
722 let n = 1
723 while (used.has(`F${n}`)) n += 1
724 const id = `F${n}`
725 used.add(id)
726 ids.push(id)
727 if (asked && !rename.has(asked)) rename.set(asked, id)
728 }
729 const mine = new Set(ids)
730 const plan = cap(
731 run,
732 raws.map((t, i) => {
733 const id = ids[i]!
734 const asked = Array.isArray(t['dependsOn']) ? t['dependsOn'].map(String) : []
735 const deps = [...new Set(asked.map(d => rename.get(d) ?? d))].filter(d => d !== id && mine.has(d))
736 return { ...slimTicket(t, id, deps), from: agentId }
737 }),
738 )
739 const before = (run.plan ?? []).filter(t => t.from === agentId)
740 if (JSON.stringify(plan) === JSON.stringify(before)) return
741 run.plan = [...others, ...plan]
742 store.dirty = true
743 say(store, 'run', `fix plan ready: ${kept(plan)} tickets`, now)
744}
745
746function takeResult(store: Store, run: RunRow, agentId: string, label: string, result: Record<string, unknown>, now: number): void {
747 if (label.startsWith('fix plan (')) {
748 if (Array.isArray(result['tickets'])) takeFixPlan(store, run, agentId, result['tickets'], now)
749 } else if (label.startsWith('plan critic (')) {
750 if (Array.isArray(result['tickets'])) takePlan(store, run, 'critic', result['tickets'], now)
751 } else if (label.startsWith('plan (')) {
752 if (Array.isArray(result['tickets'])) takePlan(store, run, 'planner', result['tickets'], now)
753 } else if (label.startsWith('final review (')) {
754 if (typeof result['verdict'] !== 'string') return
755 const next = { verdict: result['verdict'], summary: cut(String(result['summary'] ?? ''), 140) }
756 const had = run.finals[agentId]
757 if (had && had.verdict === next.verdict && had.summary === next.summary) return
758 run.finals[agentId] = next
759 run.final = next
760 store.dirty = true
761 if (!had) say(store, 'run', `final review: ${next.verdict} \u00b7 ${next.summary}`, now)
762 } else {
763 const m = REVIEW_RE.exec(label)
764 if (!m || typeof result['pass'] !== 'boolean' || run.verdicts[agentId]) return
765 const issues = result['issues']
766 const issue = Array.isArray(issues) && issues.length > 0 ? cut(String(issues[0]), 160) : ''
767 const verdict = { pass: result['pass'], summary: cut(String(result['summary'] ?? ''), 140), issue }
768 run.verdicts[agentId] = verdict
769 store.dirty = true
770 if (verdict.pass) say(store, 'done', `${m[1]} passed review${m[2] ? ` (try ${m[2]} of ${m[3]})` : ''}`, now)
771 else say(store, 'fail', `${m[1]} failed review: ${issue || verdict.summary}`, now)
772 }
773}
774
775// The run's journal says which phase each agent belongs to, its label as the script wrote it, and what each agent handed back.
776// Safe to call again with the whole file: only whole lines are read, and only from where the last read stopped.
777export function absorbJournal(store: Store, runId: string, text: string, now = 0): void {
778 const run = store.runs.get(runId)
779 if (!run) return
780 let entry = store.journals.get(runId)
781 if (!entry) {
782 entry = { seen: 0, done: false, labels: new Map() }
783 store.journals.set(runId, entry)
784 }
785 if (text.length < entry.seen) {
786 entry.seen = 0
787 entry.labels.clear()
788 }
789 const end = text.lastIndexOf('\n') + 1
790 if (end > entry.seen) {
791 for (const line of text.slice(entry.seen, end).split('\n')) {
792 if (!line) continue
793 try {
794 const e = JSON.parse(line) as { type?: string; agentId?: string; label?: string; phase?: string; result?: unknown }
795 if (!e || !e.agentId) continue
796 if (e.type === 'started') {
797 if (e.phase && run.phases[e.agentId] !== e.phase) {
798 run.phases[e.agentId] = e.phase
799 store.dirty = true
800 }
801 if (e.label) entry.labels.set(e.agentId, e.label)
802 const row = store.agents.get(e.agentId)
803 if (row && e.label && row.label !== e.label) {
804 row.label = e.label
805 store.dirty = true
806 }
807 } else if (e.type === 'result' && e.result && typeof e.result === 'object') {
808 const label = entry.labels.get(e.agentId) ?? store.agents.get(e.agentId)?.label ?? ''
809 takeResult(store, run, e.agentId, label, e.result as Record<string, unknown>, now)
810 }
811 } catch {
812 continue
813 }
814 }
815 entry.seen = end
816 }
817 entry.done = run.endedAt !== null
818}
819
820export function journalWanted(store: Store, run: RunRow): boolean {
821 return run.endedAt === null || !store.journals.get(run.id)?.done
822}
823
824const PATH_RE = /"(?:file_path|notebook_path)"\s*:\s*"((?:[^"\\]|\\.)*)"/
825const DESC_RE = /"(?:description|pattern|query|url|skill)"\s*:\s*"((?:[^"\\]|\\.)*)"/
826
827function unjson(s: string): string {
828 try {
829 return JSON.parse(`"${s}"`) as string
830 } catch {
831 return s
832 }
833}
834
835export function shortPath(p: string): string {
836 const parts = p.split('/').filter(Boolean)
837 return parts.length <= 2 ? p : `…/${parts.slice(-2).join('/')}`
838}
839
840export function targetOf(head: string): string {
841 const path = PATH_RE.exec(head)
842 if (path?.[1]) return shortPath(unjson(path[1]))
843 const desc = DESC_RE.exec(head)
844 return desc?.[1] ? unjson(desc[1]) : ''
845}
846
847export function isWriter(tool: string): boolean {
848 return WRITERS.has(tool)
849}
850
851function str(e: Record<string, unknown>, key: string): string {
852 const v = e[key]
853 return typeof v === 'string' ? v : ''
854}
855
856function firstLine(s: string): string {
857 const line = s.split('\n', 1)[0] ?? ''
858 return line.length > 80 ? `${line.slice(0, 79)}…` : line
859}
860
861export function workflowName(script: string): string {
862 const m = /\bmeta\s*=\s*\{[\s\S]{0,600}?\bname\s*:\s*(['"`])([^'"`\n]{1,80})\1/.exec(script)
863 return m?.[2] ?? ''
864}
865
866export function toolLabel(e: Record<string, unknown>): string {
867 switch (String(e['tool'])) {
868 case 'Bash':
869 return str(e, 'description') || firstLine(str(e, 'command'))
870 case 'Read':
871 case 'Write':
872 case 'Edit':
873 case 'NotebookEdit':
874 return shortPath(str(e, 'file_path') || str(e, 'notebook_path'))
875 case 'Grep':
876 case 'Glob':
877 return str(e, 'pattern')
878 case 'Agent':
879 return str(e, 'description')
880 case 'Workflow':
881 return workflowName(str(e, 'script')) || str(e, 'name') || shortPath(str(e, 'scriptPath'))
882 case 'WebFetch':
883 return str(e, 'url').replace(/^https?:\/\//, '').slice(0, 60)
884 case 'WebSearch':
885 case 'ToolSearch':
886 return str(e, 'query')
887 case 'Skill':
888 return str(e, 'skill')
889 case 'StructuredOutput':
890 return 'handing back its result'
891 default:
892 return ''
893 }
894}
895
896export function toolLimit(e: Record<string, unknown>): number | null {
897 if (String(e['tool']) !== 'Bash' || e['run_in_background'] === true) return null
898 const timeout = e['timeout']
899 return typeof timeout === 'number' && timeout > 0 ? timeout : BASH_LIMIT_MS
900}
901
902export function clock(ms: number): string {
903 const s = Math.max(0, Math.floor(ms / 1000))
904 const h = Math.floor(s / 3600)
905 const m = Math.floor((s % 3600) / 60)
906 const two = (n: number) => String(n).padStart(2, '0')
907 return h ? `${h}:${two(m)}:${two(s % 60)}` : `${m}:${two(s % 60)}`
908}
909
910export function ago(ms: number): string {
911 const s = Math.max(0, Math.floor(ms / 1000))
912 if (s < 10) return 'now'
913 if (s < 60) return `${s}s`
914 if (s < 3600) return `${Math.floor(s / 60)}m`
915 if (s < 86_400) return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}`
916 return `${Math.floor(s / 86_400)}d`
917}
918
919export function until(ms: number): string {
920 const m = Math.max(0, Math.round(ms / 60_000))
921 if (m < 60) return `${m}m`
922 const h = Math.floor(m / 60)
923 if (h < 24) return `${h}h${String(m % 60).padStart(2, '0')}`
924 return `${Math.floor(h / 24)}d${h % 24}h`
925}
926
927export function tokens(n: number): string {
928 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
929 if (n >= 1_000) return `${Math.round(n / 1_000)}k`
930 return String(n)
931}
932
933export function kb(n: number): string {
934 if (n >= 1_048_576) return `${(n / 1_048_576).toFixed(1)} MB`
935 if (n >= 1_024) return `${(n / 1_024).toFixed(1)} KB`
936 return `${n} B`
937}
938
939const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
940
941export function base64(bytes: Uint8Array): string {
942 let out = ''
943 for (let i = 0; i < bytes.length; i += 3) {
944 const a = bytes[i] ?? 0
945 const b = bytes[i + 1]
946 const c = bytes[i + 2]
947 const n = (a << 16) | ((b ?? 0) << 8) | (c ?? 0)
948 out += B64.charAt((n >> 18) & 63) + B64.charAt((n >> 12) & 63)
949 out += b === undefined ? '=' : B64.charAt((n >> 6) & 63)
950 out += c === undefined ? '=' : B64.charAt(n & 63)
951 }
952 return out
953}
954hooks/view.tsx 259 lines1import type { Cursor, Deck, Prefs, RunRow, ScrubProps } from '../types'
2import type { El } from './art'
3import { banner, hex, paint } from './art'
4import * as D from './deck'
5import { P, compact, rule } from './look'
6import { tiles, gauges, activity, card, agentLine, runSection, throughput, feed, timeShare, legend } from './sections'
7import { flightColumns, flightPlan, flightSpan } from './flight'
8import { burn, headline } from './headline'
9import { heroStatus, heroTiles, laneRowIds, pickHero, sprintViews, viewAt } from './hero'
10import { skyMap } from './sky'
11import { parseLabel } from './sprint'
12
13const LOOSE_WINDOW_MS = 30 * 60_000
14
15export type Actions = {
16 open: (agentId: string) => unknown
17 main: () => unknown
18 toggleDone: () => unknown
19 clearDone: () => unknown
20 toggleEscalate: () => unknown
21 back: () => unknown
22 next: () => unknown
23 live: () => unknown
24}
25
26export type DrawArgs = {
27 deck: Deck
28 prefs: Prefs
29 width: number
30 rows: number
31 surface: string
32 viewing: string | undefined
33 cursor: Cursor
34 scrubber: ((p: ScrubProps) => unknown) | null
35 act: Actions
36}
37
38export function replayRunOf(runs: RunRow[], cursor: Cursor): RunRow | null {
39 const own = cursor ? runs.find(run => run.id === cursor.runId) : undefined
40 if (own) return own
41 return [...runs].filter(run => run.plan !== null).sort((x, y) => y.startedAt - x.startedAt)[0] ?? null
42}
43
44type Axis = { from: number; to: number; cols: number; indent: number }
45
46function scrubSlot(el: El, a: DrawArgs, run: RunRow, width: number, now: number, axis?: Axis) {
47 const { Box } = el
48 const at = a.cursor && a.cursor.runId === run.id ? a.cursor.at : null
49 const p: ScrubProps = axis
50 ? { runId: run.id, cols: axis.cols, from: axis.from, to: axis.to, now, at }
51 : { runId: run.id, cols: width, from: run.startedAt, to: run.endedAt ?? now, now, at }
52 return (
53 <Box key="scrub-slot" flexDirection="column" paddingLeft={axis?.indent ?? 0}>
54 {a.scrubber ? a.scrubber(p) : ''}
55 </Box>
56 )
57}
58
59function stripAxis(span: [number, number], width: number): Axis | undefined {
60 const cols = flightColumns(width)
61 return cols.strip > 0 ? { from: span[0], to: span[1], cols: cols.strip, indent: 2 + cols.idW + cols.titleW + 1 + cols.pipsW } : undefined
62}
63
64export function drawPane(el: El, a: DrawArgs) {
65 const { Box, Text, Button } = el
66 const { deck, prefs, act } = a
67 const now = deck.at || 0
68 const width = Math.max(30, a.width)
69 const terminal = a.surface === 'terminal'
70 const agents = deck.agents
71 const liveAgents = agents.filter(D.isLive)
72 const liveRows = [...(deck.main && D.isLive(deck.main) ? [deck.main] : []), ...liveAgents]
73 const stuck = liveAgents.filter(row => D.displayCode(row, now) === 's').length
74 const done = agents.filter(row => row.endedAt !== null).length
75 const raised = agents.reduce((n, row) => n + row.bumps, 0)
76 const s = D.shares(agents)
77 const nowMin = Math.floor(now / 60_000)
78 const perMin = Math.max(...deck.minutes.filter(([m]) => m >= nowMin - 1).map(([, t]) => t), 0)
79 const replayRun = replayRunOf(deck.runs, a.cursor)
80 const views = sprintViews(deck, a.cursor)
81 const hero = pickHero(views)
82 const heroRows = hero ? agents.filter(row => row.runId === hero.run.id) : []
83 const heroLive = hero && hero.replaying ? viewAt(hero.run, heroRows, null, now) : hero
84 const laneIds = heroLive ? laneRowIds(heroLive) : new Set<string>()
85 const parts = []
86 let used = 0
87
88 const buttons = (
89 <Box key="buttons" flexDirection="row" flexWrap="wrap" gap={1}>
90 {a.viewing ? [<Button key="main" hotkey="m" label="◂ main" onPress={act.main} />] : []}
91 <Button key="done" hotkey="f" label={prefs.showDone ? 'hide done' : 'show done'} onPress={act.toggleDone} />
92 <Button key="clear" hotkey="c" label="clear done" onPress={act.clearDone} />
93 <Button key="raise" hotkey="e" label={`auto-raise ${prefs.autoEscalate ? 'on' : 'off'}`} onPress={act.toggleEscalate} />
94 {replayRun
95 ? [
96 <Button key="back" hotkey="b" label="◂ back" onPress={act.back} />,
97 <Button key="next" hotkey="n" label="fwd ▸" onPress={act.next} />,
98 ]
99 : []}
100 <Button key="live" hotkey="l" label="live" dimColor={!a.cursor} onPress={act.live} />
101 </Box>
102 )
103
104 const status = hero ? heroStatus(hero) : liveAgents.length ? `● ${liveAgents.length} live` : done ? `✓ ${done} done` : '○ standing by'
105 const short = a.rows < 30
106 if (!short) {
107 parts.push(paint(el, banner(width, now, 'MISSION CONTROL', 'agents · effort · time', status, hex(liveAgents.length ? P.green : P.over1)), 'banner', terminal))
108 used += 3
109 }
110 parts.push(buttons)
111 used += 2
112 if (hero) {
113 parts.push(<Text mono color={P.text} wrap="truncate-end">{headline(hero.s, hero.f)}</Text>)
114 used += 1
115 const b = heroLive && heroLive.s.endedAt === null ? burn(heroLive.run, deck.usage, heroLive.f, heroLive.at) : null
116 if (b) {
117 parts.push(<Text mono color={b.level === 'over' ? P.red : b.level === 'warn' ? P.yellow : P.over1} wrap="truncate-end">{b.text}</Text>)
118 used += 1
119 }
120 }
121 const replayDrawn = views.some(v => v.replaying && (prefs.showDone || v.run.endedAt === null))
122 if (a.cursor && replayRun && !hero?.replaying && !replayDrawn) {
123 const end = replayRun.endedAt ?? now
124 parts.push(<Text mono color={P.yellow}>{`REPLAY ${D.clock(a.cursor.at - replayRun.startedAt)} of ${D.clock(end - replayRun.startedAt)} · l goes back to live`}</Text>)
125 used += 1
126 }
127
128 const tileCount = short ? 0 : width >= 96 ? 6 : width >= 80 ? 5 : width >= 64 ? 4 : width >= 48 ? 3 : 0
129 const tileItems = (hero && (hero.replaying || hero.s.stage !== 'deciding') ? heroTiles(hero) : [
130 { label: 'LIVE', value: String(liveAgents.length), color: liveAgents.length ? P.green : P.over0 },
131 { label: 'DONE', value: String(done), color: P.blue },
132 { label: 'TOKENS/MIN', value: compact(perMin), color: P.mauve },
133 { label: 'TIME THINKING', value: `${s?.think ?? 0}%`, color: P.pink },
134 { label: 'STUCK', value: String(stuck), color: stuck ? P.red : P.over0 },
135 { label: 'RAISED', value: String(raised), color: raised ? P.peach : P.over0 },
136 ]).slice(0, tileCount)
137 if (tileItems.length) {
138 parts.push(tiles(el, tileItems, width, terminal))
139 used += 6
140 }
141 const gaugeLines = gauges(el, deck, agents, width, now, terminal)
142 parts.push(
143 <Box key="gauges" flexDirection="column">
144 {gaugeLines}
145 </Box>,
146 )
147 used += gaugeLines.length
148
149 if (hero) {
150 const { s, f } = hero
151 const steadyS = hero.replaying && heroLive ? heroLive.s : undefined
152 const map = skyMap(el, s, f, { width, terminal, steady: steadyS })
153 if (map) {
154 parts.push(map)
155 used += Math.min(8, Math.max(3, (steadyS ?? s).lanes.length)) + 1
156 }
157 const span = hero.replaying && heroLive ? flightSpan(heroLive.s, heroLive.f) : flightSpan(s, f)
158 parts.push(flightPlan(el, s, f, { width, terminal, replay: hero.replaying, span, steady: steadyS }))
159 used += Math.max(s.lanes.length, steadyS?.lanes.length ?? 0) + 5
160 if (a.scrubber) {
161 parts.push(scrubSlot(el, a, hero.run, width, now, stripAxis(span, width)))
162 used += 3
163 }
164 if (s.endedAt === null && !hero.replaying) {
165 const working = s.lanes.filter(lane => lane.live !== null)
166 if (working.length) {
167 parts.push(rule(el, width, 'WORKING', `${working.length} ${working.length === 1 ? 'lane' : 'lanes'}`))
168 used += 1
169 for (const lane of working) {
170 const row = lane.live
171 if (!row) continue
172 const p = parseLabel(row.label)
173 const who = p ? `${p.id} ${p.role}${p.n !== null && p.of !== null ? ` ${p.n}/${p.of}` : ''}` : lane.id
174 const doing = activity(row, D.displayCode(row, now), now)
175 parts.push(
176 <Text mono wrap="truncate-end">
177 <Text mono color={P.sub0}>{` ${who} `}</Text>
178 <Text mono color={doing.color}>{doing.text}</Text>
179 </Text>,
180 )
181 used += 1
182 }
183 }
184 }
185 }
186
187 const cardRows = liveRows.filter(row => !laneIds.has(row.id))
188 const density = cardRows.length <= 3 ? 4 : cardRows.length <= 6 ? 3 : 2
189 parts.push(rule(el, width, 'NOW', liveRows.length ? `${liveRows.length} live${hero?.replaying ? ' now' : ''}${stuck ? ` · ${stuck} stuck` : ''}` : 'nothing running'))
190 used += 1
191 if (!liveRows.length) {
192 parts.push(<Text mono color={P.over0}>{agents.length ? ' All quiet. Finished runs are below.' : ' Agents and workflow runs show up here the moment they start.'}</Text>)
193 used += 1
194 } else if (hero?.replaying) {
195 parts.push(<Text mono color={P.over0}>{' Live agents are hidden while replaying · l goes back to live.'}</Text>)
196 used += 1
197 } else if (!cardRows.length) {
198 parts.push(<Text mono color={P.over0}>{' Every live agent is working a sprint ticket, shown above.'}</Text>)
199 used += 1
200 }
201 const ordered = hero?.replaying ? [] : [...cardRows].sort((x, y) => Number(D.displayCode(y, now) === 's') - Number(D.displayCode(x, now) === 's') || x.startedAt - y.startedAt)
202 for (const row of ordered) {
203 parts.push(card(el, row, now, width, density, a.viewing, act, terminal))
204 used += density - (density >= 4 && !row.recent.length ? 1 : 0)
205 }
206
207 for (const run of [...deck.runs].sort((x, y) => y.startedAt - x.startedAt)) {
208 if (hero && run.id === hero.run.id) continue
209 const mine = agents.filter(row => row.runId === run.id)
210 if (!mine.length) continue
211 if (run.endedAt !== null && !prefs.showDone) {
212 parts.push(<Text mono color={P.over0}>{` ✓ ${run.name} · ${D.clock(run.endedAt - run.startedAt)} · ${mine.length} agents (hidden)`}</Text>)
213 used += 1
214 continue
215 }
216 const view = views.find(v => v.run.id === run.id)
217 if (view) {
218 // Every flight plan keys its Raster 'flight-strips' and a tree may draw that key once, so only the lead sprint's
219 // plan gets one; the others paint the same cells as colored text.
220 // A replayed run keeps the axis of its un-replayed view, so the strip does not re-scale as the cursor moves.
221 const steady = view.replaying ? viewAt(run, mine, null, now) : view
222 const span = steady ? flightSpan(steady.s, steady.f) : flightSpan(view.s, view.f)
223 parts.push(flightPlan(el, view.s, view.f, { width, terminal: false, replay: view.replaying, span, steady: view.replaying && steady ? steady.s : undefined }))
224 used += Math.max(view.s.lanes.length, view.replaying && steady ? steady.s.lanes.length : 0) + 5
225 if (!hero && a.scrubber && replayRun && run.id === replayRun.id) {
226 parts.push(scrubSlot(el, a, run, width, now, stripAxis(span, width)))
227 used += 3
228 }
229 continue
230 }
231 parts.push(runSection(el, run, mine, now, width, terminal))
232 used += 2 + mine.length
233 }
234 const loose = agents.filter(row => row.runId === null && (row.endedAt === null ? D.isLive(row) : prefs.showDone && row.endedAt >= now - LOOSE_WINDOW_MS))
235 if (loose.length) {
236 const from = Math.max(now - LOOSE_WINDOW_MS, Math.min(...loose.map(row => row.startedAt)))
237 parts.push(
238 <Box key="loose" flexDirection="column">
239 {rule(el, width, 'SUBAGENTS', 'last 30 min')}
240 {[...loose].sort((x, y) => x.startedAt - y.startedAt).map(row => agentLine(el, row, from, now, now, width, terminal))}
241 </Box>,
242 )
243 used += 1 + loose.length
244 }
245
246 const thru = throughput(el, deck, now, width, terminal)
247 if (thru) {
248 parts.push(thru)
249 used += 4
250 }
251 const tail = timeShare(el, s, width, terminal)
252 const tailRows = (tail ? 3 : 0) + 2
253 const feedCount = Math.max(4, Math.min(16, a.rows - used - tailRows - 1))
254 parts.push(feed(el, deck.feed, now, width, feedCount))
255 if (tail) parts.push(tail)
256 parts.push(legend(el))
257 return <Box flexDirection="column">{parts}</Box>
258}
259hooks/scrubber.tsx 95 lines1import type { ClientModule, ClientSurface } from 'claude-code'
2import type { ScrubProps } from '../types'
3
4type ScrubState = { lastX: number }
5
6const PAST = '#6c7086'
7const FUTURE = '#45475a'
8const CURSOR = '#f9e2af'
9const LIVE = '#a6e3a1'
10
11function colsOf(p: ScrubProps): number {
12 return Math.max(1, Math.floor(p.cols))
13}
14
15export function xOf(p: ScrubProps, t: number): number {
16 const cols = colsOf(p)
17 const span = p.to - p.from
18 const x = span > 0 ? Math.floor(((t - p.from) / span) * cols) : cols - 1
19 return Math.min(cols - 1, Math.max(0, x))
20}
21
22// The middle of column x, so rounding the time to whole milliseconds never moves it into a neighbour.
23export function timeOf(p: ScrubProps, x: number): number {
24 const cols = colsOf(p)
25 return p.from + ((x + 0.5) * (p.to - p.from)) / cols
26}
27
28function send(surface: ClientSurface<ScrubState>, p: ScrubProps, at: number | null) {
29 surface.post({ runId: p.runId, at })
30}
31
32function finished(p: ScrubProps): boolean {
33 return p.now > p.to
34}
35
36function pointTo(surface: ClientSurface<ScrubState>, p: ScrubProps, rawX: number) {
37 const x = Math.min(colsOf(p) - 1, Math.max(0, Math.round(rawX)))
38 if (x === surface.state?.lastX && x === xOf(p, p.at ?? p.now)) return
39 surface.setState({ lastX: x })
40 if (finished(p)) send(surface, p, x >= colsOf(p) - 1 ? p.to : timeOf(p, x))
41 else send(surface, p, x >= xOf(p, p.now) ? null : timeOf(p, x))
42}
43
44function step(surface: ClientSurface<ScrubState>, p: ScrubProps, dir: -1 | 1) {
45 const by = Math.max(10_000, (p.to - p.from) / 40)
46 const limit = Math.min(p.to, p.now)
47 const at = Math.min(limit, (p.at ?? limit) + dir * by)
48 surface.setState({ lastX: -1 })
49 send(surface, p, dir === 1 && at >= limit && !finished(p) ? null : at)
50}
51
52function runsOf(p: ScrubProps): { text: string; color: string }[] {
53 const cols = colsOf(p)
54 const nowX = xOf(p, p.now)
55 const curX = p.at === null ? nowX : xOf(p, p.at)
56 const runs: { text: string; color: string }[] = []
57 for (let x = 0; x < cols; x++) {
58 const cell =
59 x === curX
60 ? p.at === null
61 ? { text: '●', color: LIVE }
62 : { text: '◆', color: CURSOR }
63 : x < nowX
64 ? { text: '━', color: PAST }
65 : { text: '─', color: FUTURE }
66 const last = runs[runs.length - 1]
67 if (last && last.color === cell.color) last.text += cell.text
68 else runs.push(cell)
69 }
70 return runs
71}
72
73const Scrubber: ClientModule<ScrubProps, ScrubState> = (p, surface) => {
74 const { Box, Text } = surface.elements
75 surface.onPointer(e => {
76 if (e.type === 'down' || (e.type === 'move' && e.button === 'left')) pointTo(surface, p, e.x)
77 })
78 surface.onKey(e => {
79 if (e.key === 'left') step(surface, p, -1)
80 else if (e.key === 'right') step(surface, p, 1)
81 else if (e.key === 'l' || e.key === 'return' || e.key === 'end') {
82 surface.setState({ lastX: -1 })
83 send(surface, p, null)
84 }
85 })
86 return Box({
87 flexDirection: 'row',
88 width: colsOf(p),
89 height: 1,
90 children: runsOf(p).map(r => Text({ mono: true, color: r.color, children: r.text })),
91 })
92}
93
94export default Scrubber
95hooks/look.tsx 100 lines1import type { AgentRow, Code, FeedKind } from '../types'
2import type { El } from './art'
3import { Canvas, hex, paint } from './art'
4import * as D from './deck'
5
6// Catppuccin Mocha, to match the terminal theme.
7export const P = {
8 pink: '#f5c2e7',
9 mauve: '#cba6f7',
10 red: '#f38ba8',
11 peach: '#fab387',
12 yellow: '#f9e2af',
13 green: '#a6e3a1',
14 teal: '#94e2d5',
15 sky: '#89dceb',
16 blue: '#89b4fa',
17 lavender: '#b4befe',
18 text: '#cdd6f4',
19 sub0: '#a6adc8',
20 over1: '#7f849c',
21 over0: '#6c7086',
22 surf2: '#585b70',
23 surf1: '#45475a',
24 surf0: '#313244',
25}
26export const STATE: Record<Code, string> = { q: P.over1, t: P.mauve, w: P.green, x: P.teal, b: P.blue, l: P.yellow, s: P.red, i: P.surf2 }
27export const GLYPH: Record<Code, string> = { q: '·', t: '✻', w: '✎', x: '❯', b: '▶', l: '◷', s: '!', i: '◦' }
28export const WORD: Record<Code, string> = { q: 'waiting', t: 'thinking', w: 'writing', x: 'answering', b: 'running', l: 'long run', s: 'stuck', i: 'idle' }
29export const LEGEND: Code[] = ['t', 'w', 'x', 'b', 'l', 's', 'q', 'i']
30export const SHORT: Record<string, string> = { low: 'low', medium: 'med', high: 'high', xhigh: 'xhi', max: 'max' }
31export const EFFORT_HEX: Record<string, string> = { low: P.green, medium: P.teal, high: P.blue, xhigh: P.mauve, max: P.peach }
32export const FEED_LOOK: Record<FeedKind, [string, string]> = {
33 start: ['▸', P.blue],
34 done: ['✓', P.green],
35 fail: ['✗', P.red],
36 up: ['↑', P.peach],
37 guard: ['⊘', P.yellow],
38 warn: ['!', P.red],
39 run: ['◆', P.lavender],
40}
41
42export function fit(s: string, n: number): string {
43 if (n <= 0) return ''
44 const chars = [...s]
45 return chars.length > n ? `${chars.slice(0, Math.max(0, n - 1)).join('')}…` : s + ' '.repeat(n - chars.length)
46}
47
48export function len(s: string): number {
49 return [...s].length
50}
51
52export function compact(n: number): string {
53 if (n < 1_000) return String(Math.round(n))
54 if (n < 10_000) return `${(n / 1_000).toFixed(1)}k`
55 if (n < 1_000_000) return `${Math.round(n / 1_000)}k`
56 return `${(n / 1_000_000).toFixed(1)}M`
57}
58
59export function tierOf(row: AgentRow): string {
60 const model = D.modelShort(row.model)
61 const effort = row.effort ? SHORT[row.effort] ?? row.effort.slice(0, 4) : ''
62 return `${model}${model && effort ? '·' : ''}${effort}${row.bumps ? '↑' : ''}`
63}
64
65export function effortHex(row: AgentRow): string {
66 return (row.effort && EFFORT_HEX[row.effort]) || P.sub0
67}
68
69export function rule(el: El, width: number, title: string, info = '') {
70 const { Text } = el
71 const tail = info ? ` ${info} ` : ' '
72 const fill = Math.max(0, width - 3 - len(title) - len(tail))
73 return (
74 <Text mono wrap="truncate-end">
75 <Text mono color={P.surf1}>── </Text>
76 <Text mono color={P.lavender} weight="bold">{title}</Text>
77 <Text mono color={P.over0}>{tail}</Text>
78 <Text mono color={P.surf1}>{'─'.repeat(fill)}</Text>
79 </Text>
80 )
81}
82
83export function mark(row: AgentRow, now: number): [string, string] {
84 if (row.outcome === 'failed') return ['✗', P.red]
85 if (row.outcome === 'stopped') return ['■', P.red]
86 if (row.endedAt !== null) return ['✓', P.over0]
87 return D.isLive(row) ? ['●', STATE[D.displayCode(row, now)]] : ['·', P.over0]
88}
89
90// One timeline row: a lower half block per busy cell in its state's color, a thin baseline
91// for idle, nothing before the agent started or after it ended.
92export function strip(el: El, key: string, codes: (Code | null)[], terminal: boolean) {
93 const canvas = new Canvas(Math.max(1, codes.length), 1)
94 codes.forEach((code, x) => {
95 if (code !== null) canvas.put(x, 0, code === 'i' ? 0x2581 : 0x2584, hex(STATE[code]))
96 })
97 return paint(el, canvas, key, terminal)
98}
99
100hooks/sections.tsx 387 lines1import type { AgentRow, Code, Deck, FeedItem, Limit, RunRow } from '../types'
2import type { El } from './art'
3import { along, bigText, bigWidth, hex, meter, paint, spark, stacked } from './art'
4import type { Shares } from './deck'
5import * as D from './deck'
6import { P, STATE, GLYPH, WORD, LEGEND, SHORT, EFFORT_HEX, FEED_LOOK, fit, len, compact, tierOf, effortHex, rule, mark, strip } from './look'
7
8export type CardActions = { open: (agentId: string) => unknown; main: () => unknown }
9
10export function tiles(el: El, items: { label: string; value: string; color: string }[], width: number, terminal: boolean) {
11 const { Box, Text } = el
12 const outer = Math.floor((width - (items.length - 1)) / items.length)
13 const inner = Math.max(4, outer - 2)
14 return (
15 <Box key="tiles" flexDirection="row" gap={1}>
16 {items.map((t, i) => (
17 <Box key={`tile-${i}`} flexDirection="column" alignItems="center" borderStyle="round" borderColor={t.color} width={outer}>
18 {bigWidth(t.value) <= inner
19 ? paint(el, bigText(t.value, hex(t.color)), `big-${i}`, terminal)
20 : <Text mono color={t.color} weight="bold">{fit(t.value, inner)}</Text>}
21 <Text mono color={P.over1}>{fit(t.label, Math.min(inner, len(t.label)))}</Text>
22 </Box>
23 ))}
24 </Box>
25 )
26}
27
28function limitHex(pct: number): string {
29 return pct < 50 ? P.green : pct < 80 ? P.yellow : P.red
30}
31
32function limitGauge(el: El, key: string, label: string, limit: Limit | null, cols: number, now: number, terminal: boolean) {
33 const { Box, Text } = el
34 if (!limit) return null
35 const color = limitHex(limit.pct)
36 const resets = limit.resetsAt > now ? ` · resets ${D.until(limit.resetsAt - now)}` : ''
37 return (
38 <Box key={key} flexDirection="row">
39 <Text mono color={P.over1}>{`${label} `}</Text>
40 {paint(el, meter(cols, limit.pct / 100, hex(color)), `${key}-bar`, terminal)}
41 <Text mono color={color}>{` ${Math.round(limit.pct)}%`}</Text>
42 <Text mono color={P.over0}>{resets}</Text>
43 </Box>
44 )
45}
46
47export function gauges(el: El, deck: Deck, rows: AgentRow[], width: number, now: number, terminal: boolean) {
48 const { Box, Text } = el
49 const out = []
50 const u = deck.usage
51 const bar = Math.max(8, Math.min(24, Math.floor(width * 0.2)))
52 if (u && (u.fiveHour || u.sevenDay)) {
53 const five = limitGauge(el, 'g5', '5h', u.fiveHour, bar, now, terminal)
54 const seven = limitGauge(el, 'g7', '7d', u.sevenDay, bar, now, terminal)
55 if (width >= 76) {
56 out.push(
57 <Box key="limits" flexDirection="row" gap={3}>
58 {five ? [five] : []}
59 {seven ? [seven] : []}
60 </Box>,
61 )
62 } else {
63 if (five) out.push(five)
64 if (seven) out.push(seven)
65 }
66 }
67 const efforts = new Map<string, number>()
68 const models = new Map<string, number>()
69 let raised = 0
70 for (const row of rows) {
71 if (row.effort) efforts.set(row.effort, (efforts.get(row.effort) ?? 0) + 1)
72 const m = D.modelShort(row.model)
73 if (m) models.set(m, (models.get(m) ?? 0) + 1)
74 raised += row.bumps
75 }
76 if (efforts.size) {
77 const order = D.LADDER.filter(level => efforts.has(level))
78 const parts = order.map(level => ({ value: efforts.get(level) ?? 0, color: hex(EFFORT_HEX[level] ?? P.sub0) }))
79 out.push(
80 <Box key="effort" flexDirection="row">
81 <Text mono color={P.over1}>effort </Text>
82 {paint(el, stacked(bar, parts), 'effort-bar', terminal)}
83 <Text mono> </Text>
84 <Text mono wrap="truncate-end">
85 {order.map(level => <Text mono color={EFFORT_HEX[level] ?? P.sub0}>{`${SHORT[level] ?? level} ${efforts.get(level)} `}</Text>)}
86 <Text mono color={P.over0}>{[...models.entries()].map(([m, n]) => `${m} ${n}`).join(' · ')}</Text>
87 {raised ? <Text mono color={P.peach}>{` ↑${raised} raised`}</Text> : ''}
88 </Text>
89 </Box>,
90 )
91 }
92 const g = deck.guards
93 const fired = g.search + g.effort + g.disk > 0
94 const session = [u?.costUsd != null ? `$${u.costUsd.toFixed(2)}` : '', u?.ctxPct != null ? `context ${Math.round(u.ctxPct)}%` : ''].filter(Boolean).join(' · ')
95 if (fired || session) {
96 out.push(
97 <Text mono wrap="truncate-end">
98 {fired ? <Text mono color={P.over1}>guards </Text> : ''}
99 {fired ? <Text mono color={P.yellow}>{`⊘ search ${g.search} · effort ${g.effort} · disk ${g.disk}`}</Text> : ''}
100 {session ? <Text mono color={P.over0}>{`${fired ? ' ' : ''}this chat ${session}`}</Text> : ''}
101 </Text>,
102 )
103 }
104 return out
105}
106
107export function activity(row: AgentRow, code: Code, now: number): { text: string; color: string; limit: number | null; ran: number } {
108 const tool = D.oldestTool(row)
109 if (tool) {
110 const ran = now - tool.since
111 const more = row.tools.length > 1 ? ` +${row.tools.length - 1}` : ''
112 return {
113 text: `${GLYPH[code]} ${tool.tool}${tool.label ? ` · ${tool.label}` : ''}${more}`,
114 color: STATE[code],
115 limit: tool.limitMs,
116 ran,
117 }
118 }
119 const step = row.step ? ` · step ${row.step}` : ''
120 const so = ` · ${D.clock(now - row.since)}`
121 switch (code) {
122 case 'w': {
123 const c = row.compose
124 const verb = c && D.isWriter(c.tool) ? 'writing' : `composing ${c?.tool ?? 'a call'}`
125 return { text: `✎ ${verb}${c?.target ? ` ${c.target}` : ''} · ${D.kb(c?.bytes ?? 0)}`, color: STATE.w, limit: null, ran: 0 }
126 }
127 case 't':
128 return { text: `✻ thinking${step}${so}`, color: STATE.t, limit: null, ran: 0 }
129 case 's':
130 return { text: `! thinking for ${D.clock(now - row.since)} with nothing written yet`, color: STATE.s, limit: null, ran: 0 }
131 case 'x':
132 return { text: `❯ writing its answer${step}`, color: STATE.x, limit: null, ran: 0 }
133 case 'q':
134 return { text: row.step ? `· waiting for the model${step}` : '· starting up', color: STATE.q, limit: null, ran: 0 }
135 default:
136 return { text: row.id === D.MAIN ? '◦ waiting for you' : '◦ idle, waiting on background work or a message', color: STATE.i, limit: null, ran: 0 }
137 }
138}
139
140export function card(el: El, row: AgentRow, now: number, width: number, density: number, viewing: string | undefined, act: CardActions, terminal: boolean) {
141 const { Box, Text, Button } = el
142 const code = D.displayCode(row, now)
143 const tool = D.oldestTool(row)
144 const since = tool ? tool.since : row.since
145 const inView = viewing === row.id || (viewing === undefined && row.id === D.MAIN)
146 const labelW = Math.max(12, Math.min(30, width - 46))
147 const state = `${GLYPH[code]} ${WORD[code]} ${D.clock(now - since)}`
148 const used = 2 + labelW + 1 + 12 + 1 + len(state)
149 const rateText = row.lastRate ? `${row.lastRate} tokens/s` : ''
150 const rate = rateText && used + 1 + len(rateText) <= width ? rateText : ''
151 const press = () => (row.id === D.MAIN ? act.main() : act.open(row.id))
152 const a = activity(row, code, now)
153 const lines = [
154 <Button key={`open-${row.id}`} plain onPress={press}>
155 <Text mono color={STATE[code]}>{inView ? '▶ ' : '▌ '}</Text>
156 <Text mono color={P.text} weight="bold">{fit(row.label, labelW)}</Text>
157 <Text mono color={effortHex(row)}>{` ${fit(tierOf(row), 12)}`}</Text>
158 <Text mono color={STATE[code]}>{` ${state}`}</Text>
159 <Text mono color={P.over0}>{rate ? `${' '.repeat(Math.max(1, width - used - len(rate)))}${rate}` : ''}</Text>
160 </Button>,
161 ]
162 if (density >= 3) {
163 const fill = row.window ? Math.min(1, row.ctx / row.window) : 0
164 const fillHex = fill < 0.6 ? P.teal : fill < 0.85 ? P.yellow : P.red
165 const prefix = ' context '
166 const meterW = Math.max(4, Math.min(10, width - len(prefix) - 24))
167 const detailW = Math.max(0, width - len(prefix) - meterW)
168 const head = ` ${Math.round(fill * 100)}% ${D.tokens(row.ctx)} · step ${row.step} · ${row.toolCalls} commands · `
169 const errs = `${row.errors} errors`
170 const cut = fit(`${head}${errs} · ${D.tokens(row.out)} tokens out`, detailW)
171 const pre = cut.slice(0, head.length)
172 const mid = cut.slice(head.length, head.length + errs.length)
173 const post = cut.slice(head.length + errs.length)
174 lines.push(
175 <Box key={`ctx-${row.id}`} flexDirection="row">
176 <Text mono color={P.over0}>{prefix}</Text>
177 {paint(el, meter(meterW, fill, hex(fillHex)), `ctxbar-${row.id}`, terminal)}
178 <Text mono color={P.over0}>{pre}</Text>
179 <Text mono color={row.errors ? P.red : P.over0}>{mid}</Text>
180 <Text mono color={P.over0}>{post}</Text>
181 </Box>,
182 )
183 }
184 if (a.limit !== null) {
185 const share = a.ran / a.limit
186 lines.push(
187 <Box key={`act-${row.id}`} flexDirection="row">
188 <Text mono color={a.color} wrap="truncate-end">{` ${fit(a.text, Math.max(10, width - 30))} `}</Text>
189 {paint(el, meter(12, share, hex(share < 0.5 ? P.blue : share < 0.85 ? P.yellow : P.red)), `limit-${row.id}`, terminal)}
190 <Text mono color={P.over0}>{` ${D.clock(a.ran)} / ${D.clock(a.limit)}`}</Text>
191 </Box>,
192 )
193 } else {
194 lines.push(<Text mono color={a.color} wrap="truncate-end">{` ${a.text}${tool ? ` · ${D.clock(a.ran)}` : ''}`}</Text>)
195 }
196 if (density >= 4 && row.recent.length) {
197 lines.push(
198 <Text mono wrap="truncate-end">
199 <Text mono color={P.over0}>{' recent '}</Text>
200 {[...row.recent].reverse().map(r => (
201 <Text mono>
202 <Text mono color={r.ok ? P.green : P.red}>{r.ok ? '✓' : '✗'}</Text>
203 <Text mono color={P.over1}>{` ${r.tool}${r.label ? ` ${fit(r.label, 18).trimEnd()}` : ''} ${D.clock(r.ms)} `}</Text>
204 </Text>
205 ))}
206 </Text>,
207 )
208 }
209 return (
210 <Box key={`card-${row.id}`} flexDirection="column">
211 {lines}
212 </Box>
213 )
214}
215
216export function agentLine(el: El, row: AgentRow, from: number, to: number, now: number, width: number, terminal: boolean) {
217 const { Box, Text } = el
218 const labelW = Math.max(10, Math.min(26, Math.floor(width * 0.26)))
219 const stripW = Math.max(0, width - labelW - 23)
220 const [glyph, glyphHex] = mark(row, now)
221 const took = D.clock((row.endedAt ?? now) - row.startedAt)
222 return (
223 <Box key={`row-${row.id}`} flexDirection="row">
224 <Text mono color={glyphHex}>{glyph}</Text>
225 <Text mono color={row.endedAt !== null ? P.sub0 : P.text}>{` ${fit(row.label, labelW)}`}</Text>
226 <Text mono color={effortHex(row)}>{` ${fit(tierOf(row), 11)} `}</Text>
227 {stripW > 0 ? [strip(el, `strip-${row.id}`, D.cells(row, from, to, stripW, now), terminal)] : []}
228 <Text mono color={P.over0}>{` ${took.padStart(7)}`}</Text>
229 </Box>
230 )
231}
232
233export function pipelineLine(el: El, run: RunRow, rows: AgentRow[], width: number) {
234 const { Text } = el
235 const order: string[] = []
236 const byPhase = new Map<string, AgentRow[]>()
237 for (const row of [...rows].sort((a, b) => a.index - b.index || a.startedAt - b.startedAt)) {
238 const phase = run.phases[row.id]
239 if (!phase) continue
240 const list = byPhase.get(phase)
241 if (list) list.push(row)
242 else {
243 byPhase.set(phase, [row])
244 order.push(phase)
245 }
246 }
247 if (!order.length) return null
248 const parts = order.map(phase => {
249 const list = byPhase.get(phase) ?? []
250 const done = list.filter(row => row.endedAt !== null).length
251 const live = list.some(D.isLive)
252 const all = done === list.length
253 return { phase, text: all ? `${phase} ✓` : `${phase} ${done}/${list.length}`, color: all ? P.green : live ? P.blue : P.over0, bold: live }
254 })
255 return (
256 <Text mono wrap="truncate-end">
257 {parts.map((p, i) => (
258 <Text mono>
259 {i ? <Text mono color={P.surf2}>{' ─▶ '}</Text> : <Text mono color={P.surf2}>{' '}</Text>}
260 <Text mono color={p.color} weight={p.bold ? 'bold' : 'regular'}>{p.text}</Text>
261 </Text>
262 ))}
263 </Text>
264 )
265}
266
267export function runSection(el: El, run: RunRow, rows: AgentRow[], now: number, width: number, terminal: boolean) {
268 const { Box } = el
269 const from = run.startedAt
270 const to = run.endedAt ?? now
271 const live = rows.filter(D.isLive).length
272 const done = rows.filter(row => row.outcome === 'done').length
273 const failed = rows.filter(row => row.outcome === 'failed' || row.outcome === 'stopped').length
274 const info = [run.name, D.clock(to - from), live ? `${live} live` : '', done ? `${done} done` : '', failed ? `${failed} failed` : '']
275 .filter(Boolean)
276 .join(' · ')
277 const sorted = [...rows].sort((a, b) => a.index - b.index || a.startedAt - b.startedAt)
278 const body = sorted.map(row => agentLine(el, row, from, to, now, width, terminal))
279 const pipe = pipelineLine(el, run, rows, width)
280 return (
281 <Box key={`run-${run.id}`} flexDirection="column">
282 {rule(el, width, 'RUN', info)}
283 {pipe ? [pipe] : []}
284 {body}
285 </Box>
286 )
287}
288
289export function throughput(el: El, deck: Deck, now: number, width: number, terminal: boolean) {
290 const { Box, Text } = el
291 const nowMin = Math.floor(now / 60_000)
292 const byMin = new Map(deck.minutes.map(([m, t, l]) => [m, [t, l] as const]))
293 const tokensSeries: number[] = []
294 const liveSeries: number[] = []
295 for (let m = nowMin - 29; m <= nowMin; m++) {
296 const slot = byMin.get(m)
297 tokensSeries.push(slot?.[0] ?? 0)
298 liveSeries.push(slot?.[1] ?? 0)
299 }
300 if (!tokensSeries.some(v => v > 0) && !liveSeries.some(v => v > 0)) return null
301 const cols = Math.max(10, width - 29)
302 const peak = Math.max(...tokensSeries)
303 const current = Math.max(tokensSeries[tokensSeries.length - 1] ?? 0, tokensSeries[tokensSeries.length - 2] ?? 0)
304 const stops = [hex(P.blue), hex(P.mauve), hex(P.pink)]
305 return (
306 <Box key="throughput" flexDirection="column">
307 {rule(el, width, 'THROUGHPUT', 'last 30 min')}
308 <Box flexDirection="row">
309 <Text mono color={P.over1}>{'tokens/min '}</Text>
310 {paint(el, spark(cols, 2, tokensSeries, (v, max) => along(stops, v / max)), 'spark-out', terminal)}
311 <Box flexDirection="column">
312 <Text mono color={P.mauve}>{` ${compact(current)}/min`}</Text>
313 <Text mono color={P.over0}>{` peak ${compact(peak)}`}</Text>
314 </Box>
315 </Box>
316 <Box flexDirection="row">
317 <Text mono color={P.over1}>{'live '}</Text>
318 {paint(el, spark(cols, 1, liveSeries, () => hex(P.teal)), 'spark-live', terminal)}
319 <Text mono color={P.teal}>{` ${liveSeries[liveSeries.length - 1] ?? 0} now · peak ${Math.max(...liveSeries)}`}</Text>
320 </Box>
321 </Box>
322 )
323}
324
325export function feed(el: El, items: FeedItem[], now: number, width: number, count: number) {
326 const { Box, Text } = el
327 const shown = items.slice(-count).reverse()
328 return (
329 <Box key="feed" flexDirection="column">
330 {rule(el, width, 'FEED', shown.length ? `${items.length} events` : 'nothing yet')}
331 {shown.map(item => {
332 const [glyph, color] = FEED_LOOK[item.kind]
333 return (
334 <Text mono wrap="truncate-end">
335 <Text mono color={P.over0}>{`${D.ago(now - item.at).padStart(5)} `}</Text>
336 <Text mono color={color}>{`${glyph} `}</Text>
337 <Text mono color={item.kind === 'warn' || item.kind === 'fail' ? color : P.sub0}>{item.text}</Text>
338 </Text>
339 )
340 })}
341 </Box>
342 )
343}
344
345export function timeShare(el: El, s: Shares | null, width: number, terminal: boolean) {
346 const { Box, Text } = el
347 if (!s) return null
348 const parts: [string, number, Code][] = [
349 ['think', s.think, 't'],
350 ['write', s.write, 'w'],
351 ['text', s.text, 'x'],
352 ['tools', s.tools, 'b'],
353 ['stuck', s.stuck, 's'],
354 ['wait', s.wait, 'q'],
355 ]
356 return (
357 <Box key="time" flexDirection="column">
358 {rule(el, width, 'TIME', 'where it went')}
359 {paint(el, stacked(width, parts.map(([, v, code]) => ({ value: v, color: hex(STATE[code]) }))), 'timebar', terminal)}
360 <Text mono wrap="truncate-end">
361 {parts
362 .filter(([, v]) => v > 0)
363 .map(([label, v, code]) => (
364 <Text mono>
365 <Text mono color={STATE[code]}>{'■ '}</Text>
366 <Text mono color={P.sub0}>{`${label} ${v}% `}</Text>
367 </Text>
368 ))}
369 </Text>
370 </Box>
371 )
372}
373
374export function legend(el: El) {
375 const { Text } = el
376 return (
377 <Text mono wrap="truncate-end">
378 {LEGEND.map(code => (
379 <Text mono>
380 <Text mono color={STATE[code]}>{code === 'i' ? '▁' : '▄'}</Text>
381 <Text mono color={P.over0}>{`${WORD[code]} `}</Text>
382 </Text>
383 ))}
384 </Text>
385 )
386}
387hooks/flight.tsx 356 lines1import type { AgentRow, Attempt, Code, Forecast, Lane, Sprint } from '../types'
2import type { El } from './art'
3import { Canvas, hex, paint } from './art'
4import * as D from './deck'
5import { fit, len, P, rule, STATE } from './look'
6
7type Seg = [text: string, color: string, bold?: boolean]
8
9const ID_W = 5
10const PIPS = '▁▂▄▆█'
11const DONE = new Set(['passed', 'failed', 'skipped'])
12
13export type FlightColumns = { idW: number; titleW: number; pipsW: number; rightW: number; strip: number }
14
15// Where each part of a flight-plan row goes: two for the glyph and its space, then id, title,
16// tier pips, the strip, and the time column. The parts add up to the width from 40 columns.
17export function flightColumns(width: number): FlightColumns {
18 let titleW = width >= 100 ? Math.min(40, 24 + Math.floor((width - 100) / 2)) : width >= 84 ? 18 : width >= 70 ? 14 : 10
19 let pipsW = width >= 70 ? 4 : 0
20 const rightW = width >= 100 ? 16 : 10
21 const room = () => width - 2 - ID_W - (titleW + 1) - pipsW - (rightW + 1)
22 let strip = room()
23 if (strip < 8) {
24 pipsW = 0
25 titleW = 8
26 strip = Math.max(0, room())
27 }
28 return { idW: ID_W, titleW, pipsW, rightW, strip }
29}
30
31// The time axis: the strip starts at the first ticket, so the width goes to the work and not to
32// planning (the back button and the left arrow reach planning before it; labels stay relative to the
33// run's start). Until a ticket has started it starts at the run's start. It ends at the finish while
34// the run is over, at the build-end estimate while deciding or building, otherwise at the finish
35// estimate, and never less than a minute after its start.
36export function flightSpan(s: Sprint, f: Forecast): [number, number] {
37 let from = Infinity
38 for (const lane of s.lanes) if (lane.startedAt !== null && lane.startedAt < from) from = lane.startedAt
39 if (from === Infinity) from = s.startedAt
40 const building = s.stage === 'deciding' || s.stage === 'building'
41 const to = s.endedAt ?? Math.max(building ? f.buildEnd ?? s.at : f.eta ?? s.at, s.at)
42 return [from, Math.max(to, from + 60_000)]
43}
44
45function nowCol(s: Sprint, from: number, to: number, cols: number): number | null {
46 if (s.endedAt !== null || cols <= 0) return null
47 const x = Math.floor(((s.at - from) / Math.max(1, to - from)) * cols)
48 if (x < 0) return null
49 if (x >= cols) return s.at <= to ? cols - 1 : null
50 return x
51}
52
53function glyphOf(code: Code | null, fg: number | null, canvas: Canvas, x: number, y: number, review: boolean) {
54 if (code === null) return
55 canvas.put(x, y, review ? 0x2580 : code === 'i' ? 0x2581 : 0x2584, fg ?? hex(STATE[code]))
56}
57
58function history(canvas: Canvas, y: number, row: AgentRow | null, s: Sprint, from: number, to: number, review: boolean, verdict: Attempt['verdict']) {
59 if (!row) return
60 const cols = canvas.cols
61 const span = Math.max(1, to - from)
62 const codes = D.cells(row, from, to, cols, s.at)
63 const fixed = review && verdict ? hex(verdict.pass ? P.green : P.red) : null
64 for (let c = 0; c < cols; c++) {
65 if (from + ((c + 1) * span) / cols > s.at) break
66 glyphOf(codes[c] ?? null, fixed, canvas, c, y, review)
67 }
68}
69
70// One row per ticket: what each try did (builds low, reviews high), what is projected ahead of
71// now, and a band at now while the sprint is live.
72export function flightCanvas(s: Sprint, f: Forecast, from: number, to: number, cols: number): Canvas {
73 const canvas = new Canvas(cols, s.lanes.length)
74 const span = Math.max(1, to - from)
75 s.lanes.forEach((lane, y) => {
76 for (const a of lane.attempts) history(canvas, y, a.build, s, from, to, false, null)
77 for (const a of lane.attempts) history(canvas, y, a.review, s, from, to, true, a.verdict)
78 const p = f.projections[lane.id]
79 if (p && !DONE.has(lane.status)) {
80 const fg = hex(p.critical ? P.peach : P.surf2)
81 for (let c = 0; c < cols; c++) {
82 const t0 = from + (c * span) / cols
83 const t1 = from + ((c + 1) * span) / cols
84 if (t0 >= s.at && t1 > p.start && t0 < p.finish) canvas.put(c, y, 0x2591, fg)
85 }
86 }
87 })
88 const now = nowCol(s, from, to, cols)
89 if (now !== null) {
90 const bg = hex(P.surf1)
91 for (let y = 0; y < s.lanes.length; y++) canvas.words[(y * cols + now) * 3 + 2] = bg
92 }
93 return canvas
94}
95
96function line(el: El, segs: Seg[], width: number) {
97 const { Text } = el
98 const out: Seg[] = []
99 let left = Math.max(0, width)
100 for (const [text, color, bold] of segs) {
101 if (left <= 0) break
102 const chars = [...text]
103 const keep = chars.length > left ? chars.slice(0, left).join('') : text
104 left -= Math.min(chars.length, left)
105 out.push([keep, color, bold])
106 }
107 return (
108 <Text mono wrap="truncate-end">
109 {out.map(([text, color, bold]) => (bold ? <Text mono color={color} weight="bold">{text}</Text> : <Text mono color={color}>{text}</Text>))}
110 </Text>
111 )
112}
113
114function glyphFor(lane: Lane): [string, string] {
115 switch (lane.status) {
116 case 'passed': return ['✓', P.green]
117 case 'failed': return ['✗', P.red]
118 case 'building': return ['●', P.blue]
119 case 'reviewing': return ['●', P.mauve]
120 case 'ready': return ['◌', P.text]
121 case 'skipped': return ['–', P.surf2]
122 default: return ['○', P.over0]
123 }
124}
125
126function pipsFor(lane: Lane, pipsW: number): Seg[] {
127 if (pipsW <= 0) return []
128 const live = lane.status === 'building' || lane.status === 'reviewing'
129 const last = lane.attempts.length - 1
130 const shown = lane.attempts.slice(-Math.min(3, pipsW))
131 const segs: Seg[] = shown.map((a, i) => {
132 const rung = a.rung < 0 ? 0 : Math.min(4, a.rung)
133 const color = a.verdict ? (a.verdict.pass ? P.green : P.red) : live && lane.attempts.length - shown.length + i === last ? P.blue : P.over0
134 return [PIPS.charAt(a.rung < 0 ? 0 : rung), color]
135 })
136 segs.push([' '.repeat(pipsW - shown.length), P.over0])
137 return segs
138}
139
140function attemptStart(lane: Lane, at: number): number {
141 const a = lane.attempts[lane.attempts.length - 1]
142 const starts = [a?.build?.startedAt, a?.review?.startedAt].filter((t): t is number => typeof t === 'number')
143 if (starts.length) return Math.min(...starts)
144 return lane.live?.startedAt ?? lane.startedAt ?? at
145}
146
147function rightOf(lane: Lane, s: Sprint, f: Forecast, rightW: number): [string, string] {
148 const p = f.projections[lane.id]
149 let text: string
150 let color = P.sub0
151 switch (lane.status) {
152 case 'passed':
153 text = `✓ ${D.clock((lane.endedAt ?? s.at) - (lane.startedAt ?? s.at))}`
154 color = P.green
155 break
156 case 'failed': {
157 const full = `✗ ${lane.attempts.length}/${lane.maxAttempts} tries`
158 text = len(full) <= rightW - (lane.tokens > 0 ? len(D.tokens(lane.tokens)) + 5 : 0) ? full : `✗ ${lane.attempts.length}/${lane.maxAttempts}`
159 color = P.red
160 break
161 }
162 case 'building':
163 case 'reviewing': {
164 const took = Math.max(0, s.at - attemptStart(lane, s.at))
165 const left = p ? ` ~${Math.max(1, Math.round((p.finish - s.at) / 60_000))}m` : ''
166 text = `${D.clock(took)}${left}`
167 const budget = lane.estimateMin * 60_000 * f.factor
168 color = took > 2 * budget ? P.red : took > budget ? P.yellow : lane.status === 'building' ? P.blue : P.mauve
169 break
170 }
171 case 'ready':
172 text = 'next'
173 color = P.text
174 break
175 case 'skipped':
176 text = 'skipped'
177 color = P.surf2
178 break
179 default:
180 text = `after ${p?.after ?? lane.dependsOn[0] ?? '?'}`
181 color = P.over0
182 }
183 if (lane.tokens > 0) {
184 const withTokens = `${D.tokens(lane.tokens)} out ${text}`
185 if (len(withTokens) <= rightW) text = withTokens
186 }
187 return [fit(text, rightW), color]
188}
189
190function deciderLine(el: El, s: Sprint, width: number) {
191 const segs: Seg[] = []
192 const fixPlans = s.deciders.filter(r => r.label.startsWith('fix plan ('))
193 const firstRound = fixPlans.length - Math.min(s.round, fixPlans.length)
194 for (const row of s.deciders) {
195 if (segs.length) segs.push([' · ', P.surf2])
196 let name = row.label.startsWith('plan critic') ? 'critic' : row.label.split(' ')[0] ?? row.label
197 if (row.label.startsWith('fix plan (')) {
198 const at = fixPlans.indexOf(row)
199 name = at < firstRound ? 'triage' : `fix round ${at - firstRound + 1} plan`
200 }
201 const over = row.endedAt !== null && row.endedAt <= s.at
202 segs.push([`${name} `, P.over0])
203 if (over) segs.push([`${D.clock((row.endedAt ?? s.at) - row.startedAt)} `, P.over0], row.outcome === 'done' || row.outcome === null ? ['✓', P.green] : ['✗', P.red])
204 else segs.push(['●', P.blue], [` ${D.clock(Math.max(0, s.at - row.startedAt))}`, P.over0])
205 }
206 return line(el, segs, width)
207}
208
209function closerLine(el: El, s: Sprint, width: number) {
210 const said: Seg[] = s.round > 0 && s.previous ? [[` · ${s.round === 1 ? 'the first round' : `fix round ${s.round - 1}`} said ${s.previous.verdict}`, P.over0]] : []
211 if (s.closers.length === 0) return line(el, [['integrate and final review next', P.over0], ...said], width)
212 const segs: Seg[] = []
213 if (s.final) segs.push(s.final.verdict === 'ship' ? ['✓ ship', P.green, true] : [`✗ ${s.final.verdict}`, P.red, true], [' · ', P.surf2])
214 const finals = s.closers.filter(r => r.label.startsWith('final review'))
215 const parts: [string, AgentRow[]][] = [
216 ['integrate', s.closers.filter(r => r.label.startsWith('integrate')).slice(-1)],
217 ['final review', finals],
218 ]
219 let first = true
220 for (const [name, rows] of parts) {
221 if (!first) segs.push([' · ', P.surf2])
222 first = false
223 if (rows.length === 0) {
224 segs.push([`${name} next`, P.over0])
225 continue
226 }
227 const isOver = (r: AgentRow) => r.endedAt !== null && r.endedAt <= s.at
228 const live = rows.some(r => !isOver(r))
229 const took = Math.max(...rows.map(r => Math.max(0, (isOver(r) ? r.endedAt ?? s.at : s.at) - r.startedAt)))
230 segs.push([`${rows.length > 1 ? `${name} ×${rows.length}` : name} ${D.clock(took)}`, P.over0])
231 if (live) segs.push([' ●', P.blue])
232 else if (name === 'final review' && s.final) continue
233 else {
234 const bad = rows.some(r => r.outcome === 'failed' || r.outcome === 'stopped')
235 segs.push([bad ? ' ✗' : ' ✓', bad ? P.red : P.green])
236 }
237 }
238 return line(el, [...segs, ...said], width)
239}
240
241function axisLine(el: El, s: Sprint, span: [number, number], width: number, cols: FlightColumns, replay: boolean) {
242 const offset = width - cols.strip - (cols.rightW + 1)
243 const startL = D.clock(span[0] - s.startedAt)
244 const endL = D.clock(span[1] - s.startedAt)
245 const cells = Array<string>(cols.strip).fill(' ')
246 const put = (x: number, text: string) => [...text].forEach((ch, i) => { if (x + i >= 0 && x + i < cells.length) cells[x + i] = ch })
247 const endX = cols.strip - len(endL)
248 const both = endX >= len(startL) + 1
249 put(0, startL)
250 if (both) put(endX, endL)
251 else put(Math.max(0, endX), endL)
252 const nx = nowCol(s, span[0], span[1], cols.strip)
253 let px = -1
254 if (nx !== null) {
255 const w = replay ? 1 : 3
256 const x = replay ? nx : Math.min(Math.max(0, nx - 1), cols.strip - 3)
257 if (x >= len(startL) + 1 && x + w <= endX - 1) px = x
258 }
259 const mark = replay ? '◆' : 'now'
260 if (px >= 0) put(px, mark)
261 const text = cells.join('')
262 const segs: Seg[] = [[' '.repeat(Math.max(0, offset)), P.over0]]
263 if (px >= 0) segs.push([text.slice(0, px), P.over0], replay ? [mark, P.yellow, true] : [mark, P.lavender, true], [text.slice(px + mark.length), P.over0])
264 else segs.push([text, P.over0])
265 return line(el, segs, width)
266}
267
268function legendLine(el: El, width: number) {
269 return line(
270 el,
271 [
272 ['▄', P.blue], [' build ', P.over0],
273 ['▀', P.mauve], [' review ', P.over0],
274 ['░', P.surf2], [' ahead ', P.over0],
275 ['░', P.peach], [' critical ', P.over0],
276 ['▁▄█', P.green], [' tier per try', P.over0],
277 ],
278 width,
279 )
280}
281
282export function flightPlan(el: El, s: Sprint, f: Forecast, o: { width: number; terminal: boolean; replay: boolean; span?: [number, number]; steady?: Sprint }) {
283 const { Box } = el
284 const width = o.width
285 const cols = flightColumns(width)
286 const span = o.span ?? flightSpan(s, f)
287 const passed = s.lanes.filter(l => l.status === 'passed').length
288 const ended = s.endedAt
289 const agoText = ended !== null ? D.ago(s.at - ended) : ''
290 const base = o.replay
291 ? `${D.clock(s.at - s.startedAt)} of ${D.clock(span[1] - s.startedAt)}`
292 : ended !== null
293 ? `${passed} of ${s.lanes.length} passed · ${agoText === 'now' ? 'finished just now' : `finished ${agoText} ago`} · took ${D.clock(ended - s.startedAt)}`
294 : s.stage === 'done'
295 ? `${passed} of ${s.lanes.length} passed`
296 : f.eta !== null
297 ? `${passed} of ${s.lanes.length} passed · about ${Math.max(1, Math.round((f.eta - s.at) / 60_000))} min left`
298 : 'planning'
299 const info = s.round > 0 ? `fix round ${s.round} · ${base}` : base
300 const children: unknown[] = [rule(el, width, o.replay ? 'REPLAY' : 'FLIGHT PLAN', info)]
301 const steady = o.steady
302 const rowsWanted = Math.max(s.lanes.length, steady?.lanes.length ?? 0)
303 if (s.deciders.length) children.push(deciderLine(el, s, width))
304 else if (steady?.deciders.length) children.push(line(el, [[' ', P.over0]], width))
305 if (s.lanes.length === 0) {
306 children.push(line(el, [['tickets appear here once the plan is ready', P.over0]], width))
307 if (rowsWanted > 0) {
308 for (let i = 1; i < rowsWanted; i++) children.push(line(el, [[' ', P.over0]], width))
309 if (cols.strip > 0) children.push(line(el, [[' ', P.over0]], width))
310 }
311 } else {
312 const left = s.lanes.map(lane => {
313 const [glyph, color] = glyphFor(lane)
314 const critical = f.projections[lane.id]?.critical === true || f.criticalPath.includes(lane.id)
315 return line(
316 el,
317 [
318 [glyph, color], [' ', P.over0],
319 [fit(lane.id.slice(0, cols.idW - 1), cols.idW - 1), critical ? P.peach : P.sub0, critical], [' ', P.over0],
320 [fit(lane.title, cols.titleW), lane.status === 'waiting' || lane.status === 'skipped' ? P.over1 : P.text], [' ', P.over0],
321 ...pipsFor(lane, cols.pipsW),
322 ],
323 2 + cols.idW + cols.titleW + 1 + cols.pipsW,
324 )
325 })
326 const right = s.lanes.map(lane => {
327 const [text, color] = rightOf(lane, s, f, cols.rightW)
328 return line(el, [[' ', P.over0], [text, color]], cols.rightW + 1)
329 })
330 for (let i = s.lanes.length; i < rowsWanted; i++) {
331 left.push(line(el, [[' ', P.over0]], 2 + cols.idW + cols.titleW + 1 + cols.pipsW))
332 right.push(line(el, [[' ', P.over0]], cols.rightW + 1))
333 }
334 const parts: unknown[] = [<Box key="flight-left" flexDirection="column">{left}</Box>]
335 if (cols.strip > 0) {
336 let canvas = flightCanvas(s, f, span[0], span[1], cols.strip)
337 if (canvas.rows < rowsWanted) {
338 const tall = new Canvas(cols.strip, rowsWanted)
339 tall.words.set(canvas.words.subarray(0, cols.strip * canvas.rows * 3))
340 canvas = tall
341 }
342 parts.push(paint(el, canvas, 'flight-strips', o.terminal))
343 }
344 parts.push(<Box key="flight-right" flexDirection="column">{right}</Box>)
345 children.push(<Box key="flight-rows" flexDirection="row">{parts}</Box>)
346 if (cols.strip > 0) children.push(axisLine(el, s, span, width, cols, o.replay))
347 }
348 children.push(closerLine(el, s, width))
349 if (width >= 70) children.push(legendLine(el, width))
350 return (
351 <Box key={`flight-${s.runId}`} flexDirection="column">
352 {children}
353 </Box>
354 )
355}
356hooks/headline.ts 131 lines1import type { AgentRow, Deck, Forecast, Lane, RunRow, Sprint } from '../types'
2import * as D from './deck'
3import { parseLabel } from './sprint'
4
5const SEP = ' · '
6
7function chain(ids: string[]): string {
8 const shown = ids.length > 4 ? [...ids.slice(0, 2), '…', ...ids.slice(-1)] : ids
9 return shown.join(' → ')
10}
11
12function minutesLeft(s: Sprint, f: Forecast): string[] {
13 if (f.eta === null) return []
14 return [`about ${Math.max(1, Math.round((f.eta - s.at) / 60000))} min left`]
15}
16
17// How long an agent has worked as of the moment shown: in a replay, up to the cursor, never past it.
18function worked(s: Sprint, row: AgentRow | undefined): number {
19 if (!row) return Math.max(0, s.at - s.startedAt)
20 return Math.max(0, Math.min(row.endedAt ?? s.at, s.at) - row.startedAt)
21}
22
23function count(lanes: Lane[], status: Lane['status']): number {
24 return lanes.filter(l => l.status === status).length
25}
26
27function waitingOn(s: Sprint, f: Forecast, passed: number): string | null {
28 const entry = f.longPole === null ? undefined : f.waiting.find(w => w.on === f.longPole)
29 if (!entry) return null
30 const lane = s.lanes.find(l => l.id === entry.on)
31 const last = lane?.attempts[lane.attempts.length - 1]
32 const since = last ? (last.build?.startedAt ?? last.review?.startedAt ?? null) : null
33 const tier = (lane?.live ? parseLabel(lane.live.label)?.tier : undefined) ?? last?.tier
34 const inside = tier ? ` (${tier}${since === null ? '' : `, ${D.clock(Math.max(0, s.at - since))} in`})` : ''
35 const parts = [`Waiting on ${entry.on}${inside}`]
36 if (entry.blocked.length) parts.push(`${entry.blocked.length} ${entry.blocked.length === 1 ? 'ticket' : 'tickets'} behind it`)
37 parts.push(`${passed} of ${s.lanes.length} passed`)
38 parts.push(...minutesLeft(s, f))
39 if (lane?.title) parts.push(lane.title)
40 return parts.join(SEP)
41}
42
43export function headline(s: Sprint, f: Forecast): string {
44 const text = headlineBody(s, f)
45 const stage = s.stage === 'building' || s.stage === 'integrating' || s.stage === 'reviewing'
46 return stage && s.round > 0 ? `Fix round ${s.round}${SEP}${text}` : text
47}
48
49function headlineBody(s: Sprint, f: Forecast): string {
50 const passed = count(s.lanes, 'passed')
51 const total = s.lanes.length
52 switch (s.stage) {
53 case 'deciding': {
54 const newest = s.deciders.reduce<AgentRow | undefined>((a, r) => (!a || r.startedAt > a.startedAt ? r : a), undefined)
55 if (newest && newest.label.startsWith('fix plan (')) {
56 const parts = [`Planning fixes: the triage agent has worked ${D.clock(worked(s, newest))}`]
57 if (total > 0) parts.push(`${passed} of ${total} passed`)
58 return parts.join(SEP)
59 }
60 if (newest && newest.label.startsWith('plan critic (')) {
61 const parts = [`Checking the plan: the critic has worked ${D.clock(worked(s, newest))}`]
62 if (total > 0) parts.push(`the draft has ${total} ${total === 1 ? 'ticket' : 'tickets'}`)
63 return parts.join(SEP)
64 }
65 if (s.draft) {
66 const critic = s.deciders.find(r => r.label.startsWith('plan critic ('))
67 return [`Checking the plan: the critic has worked ${D.clock(worked(s, critic))}`, `the draft has ${total} ${total === 1 ? 'ticket' : 'tickets'}`].join(SEP)
68 }
69 const planner = s.deciders.find(r => r.label.startsWith('plan ('))
70 return `Planning: the planner has worked ${D.clock(worked(s, planner))}`
71 }
72 case 'building': {
73 const pole = waitingOn(s, f, passed)
74 if (pole) return pole
75 const working = count(s.lanes, 'building') + count(s.lanes, 'reviewing')
76 const parts = [`${passed} of ${total} passed`, `${working} working`]
77 if (f.criticalPath.length) parts.push(`longest chain ${chain(f.criticalPath)}`)
78 return parts.concat(minutesLeft(s, f)).join(SEP)
79 }
80 case 'integrating': {
81 const failed = count(s.lanes, 'failed')
82 const settled = `Tickets settled: ${passed} passed${failed ? `, ${failed} failed` : ''}`
83 const closer = s.closers.find(r => r.label.startsWith('integrate ('))
84 return [settled, `integrating ${D.clock(worked(s, closer))}`].concat(minutesLeft(s, f)).join(SEP)
85 }
86 case 'reviewing': {
87 const reviewers = s.closers.filter(r => r.label.startsWith('final review ('))
88 const since = reviewers.length ? Math.min(...reviewers.map(r => r.startedAt)) : s.startedAt
89 const live = reviewers.filter(r => r.endedAt === null || r.endedAt > s.at).length
90 const lead = live > 1 ? `${live} final reviewers running` : 'Final review running'
91 return [`${lead} ${D.clock(Math.max(0, s.at - since))}`].concat(minutesLeft(s, f)).join(SEP)
92 }
93 default: {
94 const took = D.clock(Math.max(0, (s.endedAt ?? f.eta ?? s.at) - s.startedAt))
95 const parts = s.final ? [`Final review: ${s.final.verdict}`, `finished in ${took}`] : [`Finished in ${took}`]
96 parts.push(`${passed} of ${total} passed`)
97 const lanes = f.criticalPath.map(id => s.lanes.find(l => l.id === id)).filter((l): l is Lane => !!l)
98 const first = lanes[0]
99 const last = lanes[lanes.length - 1]
100 if (first && last) {
101 const from = first.startedAt
102 const to = last.endedAt
103 parts.push(`longest chain ${chain(f.criticalPath)}${from === null || to === null ? '' : ` took ${D.clock(to - from)}`}`)
104 }
105 if (f.factor !== 1) parts.push(`tickets took ${f.factor.toFixed(1)}× their estimates on average`)
106 return parts.join(SEP)
107 }
108 }
109}
110
111export function burn(run: RunRow, usage: Deck['usage'], f: Forecast, at: number): { text: string; level: 'ok' | 'warn' | 'over' } | null {
112 const base = run.limitBase
113 const cur = usage?.fiveHour
114 if (!base || !cur) return null
115 const elapsed = at - run.startedAt
116 if (elapsed < 60000) return null
117 if (Math.abs(cur.resetsAt - base.resetsAt) > 2 * 60000) return { text: 'Your 5-hour limit window reset during this sprint', level: 'ok' }
118 const text = `5-hour limit ${Math.round(base.pct)}% → ${Math.round(cur.pct)}% since this sprint started`
119 const tail = ` · ${Math.round(base.pct)}% → ${Math.round(cur.pct)}% so far`
120 const rise = cur.pct - base.pct
121 if (f.eta !== null && f.eta > at && rise > 0) {
122 const proj = cur.pct + (rise * (f.eta - at)) / elapsed
123 if (proj >= 100) {
124 const mins = Math.max(1, Math.round((((100 - cur.pct) / rise) * elapsed) / 60000))
125 return { text: `5-hour limit runs out in about ${mins} min, before the sprint ends${tail}`, level: 'over' }
126 }
127 return { text: `5-hour limit about ${Math.round(proj)}% by the end${tail}`, level: proj >= 80 ? 'warn' : 'ok' }
128 }
129 return { text, level: 'ok' }
130}
131hooks/hero.ts 74 lines1import type { AgentRow, Cursor, Deck, Forecast, RunRow, Sprint } from '../types'
2import { forecast } from './forecast'
3import { compact, P } from './look'
4import { sprintOf } from './sprint'
5
6export type SprintView = { run: RunRow; s: Sprint; f: Forecast; at: number; replaying: boolean; now?: number }
7
8export function viewAt(run: RunRow, rows: readonly AgentRow[], cursor: Cursor, now: number): SprintView | null {
9 const replaying = cursor !== null && cursor.runId === run.id
10 const at = replaying && cursor ? Math.min(Math.max(cursor.at, run.startedAt), run.endedAt ?? now) : now
11 const s = sprintOf(run, rows, at, now)
12 if (!s) return null
13 return { run, s, f: forecast(s, at), at, replaying, now }
14}
15
16export function sprintViews(deck: Deck, cursor: Cursor): SprintView[] {
17 const now = deck.at
18 const views: SprintView[] = []
19 const runs = [...deck.runs].sort((a, b) => b.startedAt - a.startedAt)
20 for (const run of runs) {
21 const v = viewAt(run, deck.agents.filter(row => row.runId === run.id), cursor, now)
22 if (v) views.push(v)
23 }
24 return views
25}
26
27export const HERO_RECENT_MS = 5 * 60_000
28
29export function pickHero(views: SprintView[]): SprintView | null {
30 // The lead is judged by the run's own end and the deck's time, never by the replay cursor.
31 const recent = views.find(v => v.run.endedAt !== null && (v.now ?? v.at) - v.run.endedAt <= HERO_RECENT_MS)
32 const lead = views.find(v => v.run.endedAt === null) ?? recent ?? null
33 return lead ? (views.find(v => v.replaying) ?? lead) : null
34}
35
36export function minutesLeft(v: SprintView): number | null {
37 if (v.f.eta === null || v.s.stage === 'done') return null
38 return Math.max(1, Math.round((v.f.eta - v.at) / 60000))
39}
40
41export function heroTiles(v: SprintView): { label: string; value: string; color: string }[] {
42 const lanes = v.s.lanes
43 const count = (...states: string[]) => lanes.filter(l => states.includes(l.status)).length
44 const retries = lanes.reduce((n, l) => n + Math.max(0, l.attempts.length - 1), 0)
45 const left = minutesLeft(v)
46 return [
47 { label: `PASSED OF ${lanes.length}`, value: String(count('passed')), color: P.green },
48 { label: 'MIN LEFT', value: left === null ? '-' : String(left), color: P.peach },
49 { label: 'WORKING', value: String(count('building', 'reviewing')), color: P.blue },
50 { label: 'WAITING', value: String(count('waiting')), color: P.over1 },
51 { label: 'RETRIES', value: String(retries), color: retries > 0 ? P.yellow : P.over0 },
52 { label: 'TOKENS OUT', value: compact(v.s.tokens), color: P.mauve },
53 ]
54}
55
56export function heroStatus(v: SprintView): string {
57 if (v.s.stage === 'done') return 'sprint done'
58 const left = minutesLeft(v)
59 if (left === null) return 'planning'
60 const passed = v.s.lanes.filter(l => l.status === 'passed').length
61 return `${passed} of ${v.s.lanes.length} passed · ${left}m left`
62}
63
64export function laneRowIds(v: SprintView): Set<string> {
65 const ids = new Set<string>()
66 for (const lane of v.s.lanes) {
67 for (const a of lane.attempts) {
68 if (a.build) ids.add(a.build.id)
69 if (a.review) ids.add(a.review.id)
70 }
71 }
72 return ids
73}
74hooks/sky.tsx 231 lines1import type { Forecast, Lane, LaneStatus, Sprint } from '../types'
2import type { El } from './art'
3import { Canvas, hex, mix, paint } from './art'
4import { P, rule } from './look'
5
6type Pos = { x: number; y: number }
7
8type Plan = {
9 depth: Map<string, number>
10 deps: Map<string, string[]>
11 layers: Lane[][]
12 widths: number[]
13 maxNodeW: number
14 free: number
15}
16
17const DOTS = [
18 [0x01, 0x08],
19 [0x02, 0x10],
20 [0x04, 0x20],
21 [0x40, 0x80],
22]
23
24const STATUS_COLOR: Record<LaneStatus, string> = {
25 passed: P.green,
26 failed: P.red,
27 building: P.blue,
28 reviewing: P.mauve,
29 ready: P.text,
30 waiting: P.over0,
31 skipped: P.surf2,
32}
33
34function shown(id: string): string {
35 let out = ''
36 for (const ch of id.slice(0, 5)) {
37 const c = ch.codePointAt(0) ?? 0
38 out += c > 0x20 && c < 0x7f ? ch : '?'
39 }
40 return out
41}
42
43function nodeW(id: string): number {
44 return 1 + shown(id).length
45}
46
47function clamp(n: number, lo: number, hi: number): number {
48 return Math.max(lo, Math.min(hi, n))
49}
50
51function plan(s: Sprint, cols: number, rows: number): Plan {
52 const byId = new Map(s.lanes.map(l => [l.id, l]))
53 const depth = new Map<string, number>()
54 const state = new Map<string, 1 | 2>()
55 const visit = (id: string): number => {
56 const seen = state.get(id)
57 if (seen === 2) return depth.get(id) ?? 0
58 if (seen === 1) return -1
59 state.set(id, 1)
60 let d = 0
61 for (const dep of byId.get(id)?.dependsOn ?? []) {
62 if (dep === id || !byId.has(dep)) continue
63 const dd = visit(dep)
64 if (dd >= 0) d = Math.max(d, dd + 1)
65 }
66 state.set(id, 2)
67 depth.set(id, d)
68 return d
69 }
70 for (const l of s.lanes) visit(l.id)
71 const deps = new Map<string, string[]>()
72 for (const l of s.lanes) {
73 const mine = depth.get(l.id) ?? 0
74 deps.set(
75 l.id,
76 [...new Set(l.dependsOn)].filter(d => d !== l.id && byId.has(d) && (depth.get(d) ?? 0) < mine),
77 )
78 }
79 const layers: Lane[][] = []
80 for (const l of s.lanes) {
81 const d = depth.get(l.id) ?? 0
82 while (layers.length <= d) layers.push([])
83 layers[d]!.push(l)
84 }
85 const maxNodeW = s.lanes.reduce((m, l) => Math.max(m, nodeW(l.id)), 1)
86 const r = Math.max(1, rows)
87 const widths = layers.map(layer => Math.ceil(layer.length / r) * (maxNodeW + 1) - 1)
88 const free = cols - 6 - widths.reduce((a, b) => a + b, 0)
89 return { depth, deps, layers, widths, maxNodeW, free }
90}
91
92function mapRows(s: Sprint): number {
93 return clamp(Math.max(0, ...plan(s, 0, 1).layers.map(l => l.length)), 3, 8)
94}
95
96export function skyLayout(s: Sprint, cols: number, rows: number): Map<string, Pos> {
97 const p = plan(s, cols, rows)
98 const r = Math.max(1, rows)
99 const out = new Map<string, Pos>()
100 const L = p.layers.length
101 const gap = L > 1 ? Math.max(0, Math.floor(p.free / (L - 1))) : 0
102 let x = 0
103 p.layers.forEach((layer, i) => {
104 const order = layer.map((l, k) => {
105 const ys = (p.deps.get(l.id) ?? []).map(d => out.get(d)?.y ?? 0)
106 return { l, k, mean: ys.length ? ys.reduce((a, b) => a + b, 0) / ys.length : -1 }
107 })
108 if (i > 0) order.sort((a, b) => a.mean - b.mean || a.k - b.k)
109 const sub = Math.max(1, Math.ceil(layer.length / r))
110 for (let c = 0; c < sub; c++) {
111 const col = order.filter((_, k) => k % sub === c)
112 col.forEach(({ l }, j) => {
113 out.set(l.id, { x: x + c * (p.maxNodeW + 1), y: Math.floor(((j + 0.5) * r) / col.length) })
114 })
115 }
116 x += (p.widths[i] ?? 0) + gap
117 })
118 out.set('ship', { x: cols - 5, y: Math.floor(rows / 2) })
119 return out
120}
121
122export function skyFits(s: Sprint, width: number): boolean {
123 if (s.lanes.length < 2 || width < 56) return false
124 const p = plan(s, width, mapRows(s))
125 return p.free >= 2 * (p.layers.length - 1)
126}
127
128function shipColor(s: Sprint): string {
129 if (s.final && s.final.verdict !== 'ship') return P.red
130 if (s.final?.verdict === 'ship' && s.stage === 'done') return P.green
131 if (s.stage === 'integrating' || s.stage === 'reviewing') return P.blue
132 return P.surf2
133}
134
135export function skyCanvas(s: Sprint, f: Forecast, cols: number, rows: number): Canvas {
136 const canvas = new Canvas(cols, rows)
137 const pos = skyLayout(s, cols, rows)
138 const p = plan(s, cols, rows)
139 const critical = new Set(f.criticalPath)
140 for (const [id, pr] of Object.entries(f.projections)) if (pr.critical) critical.add(id)
141 const status = new Map(s.lanes.map(l => [l.id, l.status]))
142
143 const node = new Uint8Array(cols * rows)
144 const claim = (x: number, y: number, w: number) => {
145 for (let i = 0; i < w; i++) if (x + i >= 0 && x + i < cols && y >= 0 && y < rows) node[y * cols + x + i] = 1
146 }
147 for (const l of s.lanes) {
148 const at = pos.get(l.id)
149 if (at) claim(at.x, at.y, nodeW(l.id))
150 }
151 const ship = pos.get('ship')!
152 claim(ship.x, ship.y, 5)
153
154 const bits = new Uint8Array(cols * rows)
155 const rank = new Uint8Array(cols * rows)
156 const tone = [0, hex(P.surf2), mix(hex(P.green), 0x1e1e2e, 0.4), hex(P.peach)]
157 const edge = (from: string, to: string, level: number) => {
158 const a = pos.get(from)
159 const b = pos.get(to)
160 if (!a || !b) return
161 const x0 = 2 * (a.x + nodeW(from))
162 const y0 = 4 * a.y + 2
163 const x3 = 2 * b.x - 1
164 const y3 = 4 * b.y + 2
165 const h = (x3 - x0) / 2
166 const n = 2 * (Math.abs(x3 - x0) + Math.abs(y3 - y0)) + 1
167 for (let i = 0; i < n; i++) {
168 const t = n > 1 ? i / (n - 1) : 0
169 const u = 1 - t
170 const X = Math.round(u * u * u * x0 + 3 * u * u * t * (x0 + h) + 3 * u * t * t * (x3 - h) + t * t * t * x3)
171 const Y = Math.round(u * u * u * y0 + 3 * u * u * t * y0 + 3 * u * t * t * y3 + t * t * t * y3)
172 if (X < 0 || Y < 0) continue
173 const cx = Math.floor(X / 2)
174 const cy = Math.floor(Y / 4)
175 if (cx >= cols || cy >= rows) continue
176 const c = cy * cols + cx
177 if (node[c]) continue
178 bits[c] = (bits[c] ?? 0) | (DOTS[Y % 4]![X % 2] ?? 0)
179 if (level > (rank[c] ?? 0)) rank[c] = level
180 }
181 }
182 const levelOf = (from: string, crit: boolean) => (crit ? 3 : status.get(from) === 'passed' ? 2 : 1)
183 const fed = new Set<string>()
184 for (const l of s.lanes) {
185 for (const d of p.deps.get(l.id) ?? []) {
186 fed.add(d)
187 edge(d, l.id, levelOf(d, critical.has(d) && critical.has(l.id)))
188 }
189 }
190 for (const l of s.lanes) if (!fed.has(l.id)) edge(l.id, 'ship', levelOf(l.id, critical.has(l.id)))
191
192 for (let c = 0; c < cols * rows; c++) {
193 const b = bits[c] ?? 0
194 if (b) canvas.put(c % cols, Math.floor(c / cols), 0x2800 + b, tone[rank[c] ?? 1] ?? tone[1]!)
195 }
196
197 const odd = Math.floor(s.at / 1000) % 2 === 1
198 for (const l of s.lanes) {
199 const at = pos.get(l.id)
200 if (!at) continue
201 const glyph = l.id === f.longPole && odd ? '▓' : '█'
202 canvas.put(at.x, at.y, glyph, hex(STATUS_COLOR[l.status] ?? P.over0))
203 canvas.text(at.x + 1, at.y, shown(l.id), hex(critical.has(l.id) ? P.peach : P.sub0))
204 }
205 canvas.text(ship.x, ship.y, '█ship', hex(shipColor(s)))
206 return canvas
207}
208
209function chain(path: string[]): string {
210 if (path.length <= 4) return path.join(' → ')
211 return `${path[0]} → … → ${path[path.length - 1]}`
212}
213
214export function skyMap(el: El, s: Sprint, f: Forecast, o: { width: number; terminal: boolean; steady?: Sprint }) {
215 if (!skyFits(o.steady ?? s, o.width)) return null
216 const { Box, Text } = el
217 const rows = mapRows(o.steady ?? s)
218 const n = s.lanes.length
219 const info = s.draft
220 ? 'draft · the critic is checking it'
221 : `${n} ticket${n === 1 ? '' : 's'}${f.criticalPath.length ? ` · longest chain ${chain(f.criticalPath)}` : ''}`
222 return (
223 <Box key={`sky-${s.runId}`} flexDirection="column">
224 {rule(el, o.width, 'SPRINT MAP', info)}
225 {skyFits(s, o.width)
226 ? paint(el, skyCanvas(s, f, o.width, rows), 'sky-map', o.terminal)
227 : Array.from({ length: rows }, () => <Text mono> </Text>)}
228 </Box>
229 )
230}
231hooks/sprint.ts 307 lines1import type { AgentRow, Attempt, FinalVerdict, Lane, LaneStatus, PlanTicket, RunRow, Sprint, SprintStage } from '../types'
2
3export const RUNG_OF: Record<string, number> = { 'haiku·low': 0, 'sonnet·medium': 1, 'sonnet·high': 2, 'top·high': 3, 'top·max': 4 }
4export const KIND_MINUTES: Record<string, number> = { mechanical: 3, routine: 4, judgment: 6, hard: 9 }
5
6const TICKET = /^(\S+) (build|review)(?: (\d+)\/(\d+))? \(([^)]+)\)$/
7const DEFAULT_ATTEMPTS = 3
8const DEFAULT_MINUTES = 6
9
10export type Parsed = { id: string; role: 'build' | 'review'; n: number | null; of: number | null; tier: string }
11
12export function isDecider(label: string): boolean {
13 return label.startsWith('plan (') || label.startsWith('plan critic (') || label.startsWith('fix plan (')
14}
15
16const isFixPlan = (label: string): boolean => label.startsWith('fix plan (')
17
18export function isCloser(label: string): boolean {
19 return label.startsWith('integrate (') || label.startsWith('final review (')
20}
21
22export function parseLabel(label: string): Parsed | null {
23 if (isDecider(label) || isCloser(label)) return null
24 const m = TICKET.exec(label)
25 if (!m) return null
26 return {
27 id: m[1] as string,
28 role: m[2] as 'build' | 'review',
29 n: m[3] === undefined ? null : Number(m[3]),
30 of: m[4] === undefined ? null : Number(m[4]),
31 tier: m[5] as string,
32 }
33}
34
35export function liveAt(row: AgentRow, at: number): boolean {
36 return row.startedAt <= at && (row.endedAt === null || row.endedAt > at)
37}
38
39const endedBy = (row: AgentRow, at: number): boolean => row.endedAt !== null && row.endedAt <= at
40
41// Output tokens as of `at`: all of them once the row has ended, a straight-line share before its end. A row still
42// running has written `out` by `now`, so a cursor before now gets the share of the time before the cursor.
43function tokensAt(row: AgentRow, at: number, now: number): number {
44 if (row.endedAt === null) {
45 if (at >= now) return row.out
46 return Math.round((row.out * Math.max(0, at - row.startedAt)) / Math.max(1, now - row.startedAt))
47 }
48 if (row.endedAt <= at) return row.out
49 const span = row.endedAt - row.startedAt
50 return span > 0 ? Math.round((row.out * Math.max(0, at - row.startedAt)) / span) : row.out
51}
52
53type Entry = { row: AgentRow; p: Parsed }
54
55type Ticket = { id: string; title: string; kind: string; dependsOn: string[]; estimateMin: number | null; round: number }
56
57function lastOf(rows: AgentRow[], prefix: string): AgentRow | null {
58 let found: AgentRow | null = null
59 for (const row of rows) if (row.label.startsWith(prefix)) found = row
60 return found
61}
62
63function attemptsOf(entries: Entry[], run: RunRow, at: number): Attempt[] {
64 const attempts: Attempt[] = []
65 let order = 0
66 for (const { row, p } of entries) {
67 if (p.role !== 'build') continue
68 order += 1
69 attempts.push({ n: p.n ?? order, tier: p.tier, rung: RUNG_OF[p.tier] ?? -1, build: row, review: null, verdict: null })
70 }
71 for (const { row, p } of entries) {
72 if (p.role !== 'review') continue
73 let target: Attempt | undefined
74 if (p.n !== null) target = attempts.find(a => a.n === p.n)
75 else {
76 for (const a of attempts) if (a.build && a.build.startedAt <= row.startedAt) target = a
77 }
78 if (!target) {
79 target = { n: p.n ?? attempts.length + 1, tier: p.tier, rung: RUNG_OF[p.tier] ?? -1, build: null, review: null, verdict: null }
80 attempts.push(target)
81 }
82 target.review = row
83 target.verdict = endedBy(row, at) ? run.verdicts[row.id] ?? null : null
84 }
85 return attempts.sort((a, b) => a.n - b.n)
86}
87
88// The verdict the shown round's own final review gave: none when it gave none (it failed, or the run was
89// stopped first). A deck saved before verdicts were kept per review falls back to the run's last verdict.
90function finalOf(run: RunRow, review: AgentRow | null): FinalVerdict | null {
91 const finals = run.finals ?? {}
92 if (!Object.keys(finals).length) return review ? run.final : null
93 return review ? finals[review.id] ?? null : null
94}
95
96const VERDICT_RANK: Record<string, number> = { ship: 0, 'fix first': 1, block: 2 }
97
98// Several final reviewers in one round count as one review: the worst verdict any of them gave, block over fix first
99// over ship, with the summary of that worst one. A reviewer that gave no verdict does not count.
100function mergedFinal(run: RunRow, reviews: AgentRow[]): FinalVerdict | null {
101 let worst: FinalVerdict | null = null
102 for (const review of reviews) {
103 const v = finalOf(run, review)
104 if (v && (worst === null || (VERDICT_RANK[v.verdict] ?? 0) > (VERDICT_RANK[worst.verdict] ?? 0))) worst = v
105 }
106 return worst
107}
108
109export function sprintOf(run: RunRow, rows: readonly AgentRow[], at: number, now: number = at): Sprint | null {
110 const all = rows.filter(row => row.runId === run.id)
111 const byStart = (a: AgentRow, b: AgentRow) => a.index - b.index || a.startedAt - b.startedAt
112 const mine = all.filter(row => row.startedAt <= at).sort(byStart)
113
114 const deciders = mine.filter(row => isDecider(row.label))
115
116 // Rounds: a fix plan that starts after a closer has started opens the next round.
117 const chrono = [...all].sort((a, b) => a.startedAt - b.startedAt || a.index - b.index)
118 let firstCloser: number | null = null
119 const openers: AgentRow[] = []
120 for (const row of chrono) {
121 if (isCloser(row.label) && firstCloser === null) firstCloser = row.startedAt
122 else if (isFixPlan(row.label) && firstCloser !== null && firstCloser < row.startedAt) openers.push(row)
123 }
124 const roundOf = (row: AgentRow): number => openers.filter(o => o.startedAt <= row.startedAt).length
125 const round = openers.filter(o => o.startedAt <= at).length
126 const opener = round > 0 ? (openers[round - 1] as AgentRow) : null
127 const allClosers = mine.filter(row => isCloser(row.label))
128 const closersIn = (r: number): AgentRow[] => allClosers.filter(row => roundOf(row) === r)
129 const closers = closersIn(round)
130 const laneRows: Entry[] = []
131 for (const row of mine) {
132 const p = parseLabel(row.label)
133 if (p) laneRows.push({ row, p })
134 }
135
136 const labelIds: string[] = []
137 for (const { p } of laneRows) if (!labelIds.includes(p.id)) labelIds.push(p.id)
138 const everyLabelId = new Set<string>()
139 for (const row of all) {
140 const p = parseLabel(row.label)
141 if (p) everyLabelId.add(p.id)
142 }
143 if (run.plan === null && !all.some(row => isDecider(row.label)) && everyLabelId.size < 2) return null
144
145 const producerPrefix = run.planFrom === 'critic' ? 'plan critic (' : 'plan ('
146 const producer = lastOf([...all].sort(byStart), producerPrefix)
147 const known = run.plan !== null && (producer === null || endedBy(producer, at))
148 const plannerTickets: PlanTicket[] | null = run.draft ?? (run.planFrom === 'planner' && run.plan !== null ? run.plan.filter(t => !t.from) : null)
149 const draft = plannerTickets !== null && all.some(row => row.label.startsWith('plan critic (') && liveAt(row, at))
150 const base = known || draft
151 const rowById = new Map(all.map(row => [row.id, row]))
152 const shown = (t: PlanTicket): boolean => {
153 if (!t.from) return base
154 const from = rowById.get(t.from)
155 return !from || endedBy(from, at)
156 }
157 const roundOfTicket = (t: PlanTicket): number => {
158 if (!t.from) return 0
159 const from = rowById.get(t.from)
160 return from ? roundOf(from) : openers.length
161 }
162 const plan: PlanTicket[] = draft ? (plannerTickets as PlanTicket[]) : run.plan === null ? [] : run.plan.filter(shown)
163 const kept = new Set(plan.filter(t => !t.dropped).map(t => t.id))
164 const droppedIds = new Set(plan.filter(t => t.dropped && !kept.has(t.id)).map(t => t.id))
165
166 const tickets: Ticket[] = []
167 const seen = new Set<string>(droppedIds)
168 for (const t of plan) {
169 if (t.dropped || seen.has(t.id)) continue
170 seen.add(t.id)
171 tickets.push({ id: t.id, title: t.title, kind: t.kind, dependsOn: t.dependsOn, estimateMin: t.estimateMin, round: roundOfTicket(t) })
172 }
173 for (const id of labelIds) {
174 if (seen.has(id)) continue
175 seen.add(id)
176 const first = laneRows.find(e => e.p.id === id) as Entry
177 tickets.push({ id, title: id, kind: 'judgment', dependsOn: [], estimateMin: null, round: roundOf(first.row) })
178 }
179
180 const runEnded = run.endedAt !== null && run.endedAt <= at
181 const closerStarted = (r: number): boolean => closersIn(r).length > 0
182 const ids = new Set(tickets.map(t => t.id))
183
184 const entriesOf = new Map<string, Entry[]>()
185 for (const e of laneRows) {
186 const list = entriesOf.get(e.p.id)
187 if (list) list.push(e)
188 else entriesOf.set(e.p.id, [e])
189 }
190
191 type Built = { t: Ticket; entries: Entry[]; attempts: Attempt[]; maxAttempts: number; own: LaneStatus | null; deps: string[] }
192 const built = new Map<string, Built>()
193 for (const t of tickets) {
194 const entries = entriesOf.get(t.id) ?? []
195 const attempts = attemptsOf(entries, run, at)
196 const maxAttempts = entries.reduce((m, e) => (e.p.of !== null && e.p.of > m ? e.p.of : m), 0) || DEFAULT_ATTEMPTS
197 const deps = t.dependsOn.filter(d => d !== t.id && ids.has(d))
198 let own: LaneStatus | null = null
199 if (attempts.some(a => a.verdict?.pass)) own = 'passed'
200 else if (entries.length > 0) {
201 let latest: Entry | null = null
202 for (const e of entries) if (!latest || e.row.startedAt >= latest.row.startedAt) latest = e
203 const last = attempts[attempts.length - 1]
204 const review = last?.review ?? null
205 const spent =
206 attempts.length >= maxAttempts &&
207 review !== null &&
208 endedBy(review, at) &&
209 (last?.verdict ? !last.verdict.pass : review.outcome !== 'done')
210 if (latest && liveAt(latest.row, at)) own = latest.p.role === 'build' ? 'building' : 'reviewing'
211 else if (spent || runEnded || closerStarted(t.round)) own = 'failed'
212 else own = 'building'
213 }
214 built.set(t.id, { t, entries, attempts, maxAttempts, own, deps })
215 }
216
217 const resolved = new Map<string, LaneStatus>()
218 const visiting = new Set<string>()
219 const statusOf = (id: string): LaneStatus => {
220 const done = resolved.get(id)
221 if (done) return done
222 const b = built.get(id)
223 if (!b) return 'waiting'
224 if (b.own) {
225 resolved.set(id, b.own)
226 return b.own
227 }
228 if (visiting.has(id)) return 'waiting'
229 visiting.add(id)
230 const deps = b.deps.map(statusOf)
231 visiting.delete(id)
232 let status: LaneStatus
233 const lost = b.t.dependsOn.some(d => droppedIds.has(d))
234 if (lost || deps.some(s => s === 'failed' || s === 'skipped') || closerStarted(b.t.round) || runEnded) status = 'skipped'
235 else if (deps.every(s => s === 'passed')) status = 'ready'
236 else status = 'waiting'
237 if (visiting.size === 0) resolved.set(id, status)
238 return status
239 }
240
241 const lanes: Lane[] = tickets.map(t => {
242 const b = built.get(t.id) as Built
243 const status = statusOf(t.id)
244 const laneRowList = b.entries.map(e => e.row)
245 const firstBuild = b.attempts.find(a => a.build)?.build ?? null
246 const first = laneRowList[0] ?? null
247 let endedAt: number | null = null
248 if (status === 'passed') {
249 const passing = b.attempts.find(a => a.verdict?.pass)
250 endedAt = passing?.review?.endedAt ?? null
251 } else if (status === 'failed') {
252 for (const row of laneRowList) if (endedBy(row, at) && (endedAt === null || (row.endedAt as number) > endedAt)) endedAt = row.endedAt
253 }
254 let live: AgentRow | null = null
255 for (const row of laneRowList) if (liveAt(row, at) && (!live || row.startedAt >= live.startedAt)) live = row
256 return {
257 id: t.id,
258 title: t.title,
259 kind: t.kind,
260 dependsOn: t.dependsOn,
261 estimateMin: t.estimateMin ?? KIND_MINUTES[t.kind] ?? DEFAULT_MINUTES,
262 status,
263 attempts: b.attempts,
264 maxAttempts: b.maxAttempts,
265 startedAt: firstBuild ? firstBuild.startedAt : first ? first.startedAt : null,
266 endedAt,
267 tokens: laneRowList.reduce((sum, row) => sum + tokensAt(row, at, now), 0),
268 live,
269 }
270 })
271
272 const integrate = lastOf(closers, 'integrate (')
273 const finalReviews = closers.filter(row => row.label.startsWith('final review ('))
274 let stage: SprintStage
275 if (runEnded || (finalReviews.length > 0 && finalReviews.every(row => endedBy(row, at)))) stage = 'done'
276 else if (finalReviews.some(row => liveAt(row, at)) || (integrate !== null && endedBy(integrate, at) && finalReviews.length === 0)) stage = 'reviewing'
277 else if (integrate !== null && liveAt(integrate, at)) stage = 'integrating'
278 else if (draft || lanes.length === 0) stage = 'deciding'
279 else if (opener !== null && (liveAt(opener, at) || !tickets.some((t, i) => t.round === round && lanes[i]?.startedAt != null))) stage = 'deciding'
280 else stage = 'building'
281
282 const final: FinalVerdict | null = stage === 'done' ? mergedFinal(run, finalReviews) : null
283 // The previous round's merged verdict. A deck saved before verdicts were kept per review has only the run's last
284 // verdict, which belongs to the previous round only while no later round has a final review of its own.
285 const previousReviews = round > 0 ? closersIn(round - 1).filter(row => row.label.startsWith('final review (')) : []
286 const laterReview = all.some(row => row.label.startsWith('final review (') && roundOf(row) >= round)
287 const previous: FinalVerdict | null = round === 0 || previousReviews.length === 0
288 ? null
289 : Object.keys(run.finals ?? {}).length || !laterReview ? mergedFinal(run, previousReviews) : null
290 return {
291 runId: run.id,
292 name: run.name,
293 startedAt: run.startedAt,
294 endedAt: runEnded ? run.endedAt : null,
295 at,
296 stage,
297 round,
298 draft,
299 tokens: mine.reduce((sum, row) => sum + tokensAt(row, at, now), 0),
300 previous,
301 lanes,
302 deciders,
303 closers,
304 final,
305 }
306}
307