SLOPSHOPPER

mission-control

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

newpaneguardcommandtoastprocess
v0.1.0Unlicenseupdated 2026-10-23kevin-huff/claude-code-mission-control
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mission-control
│ ┃ Mission Control ✕ › fix the failing auth test and add an audit log call │ ┃ ⠁ │ ┃ ⠄ MISSION CONTROL ⠠ ○ s ⏺ Read(src/auth.ts) │ ┃ · agents · effort · time ⎿ Read 6 lines │ ┃ [ hide done ] [ clear done ] [ auto-raise on ⏺ Update(src/auth.ts) │ ┃ ╭────────────────╮ ╭────────────────╮ ╭───── ⎿ Added 2 lines, removed 1 line │ ┃ │ █▀█ │ │ █▀█ │ │ ⏺ Bash(bun test) │ ┃ │ █ █ │ │ █ █ │ │ ⎿ 3 pass, 1 fail │ ┃ │ ▀▀▀ │ │ ▀▀▀ │ │ │ ┃ │ LIVE │ │ DONE │ │ TO ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╰────────────────╯ ╰────────────────╯ ╰───── │ ┃ ── NOW nothing running ────────────────────… ✻ Worked for 42s · done 4:20 PM │ ┃ Agents and workflow runs show up here the │ ┃ moment they start. › /mission-control │ ┃ ── FEED nothing yet ───────────────────────… ⎿ mission-control: Mission Control is open beside the chat. │ ┃ ▄thinking ▄writing ▄answering ▄running … │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Mission Control
⠁ ⠂⠁ ⠄ MISSION CONTROL ⠠ ○ standing by · agents · effort · time + [ hide done ] [ clear done ] [ auto-raise on ] [ live ] ╭────────────────╮ ╭────────────────╮ ╭────────────────╮ │ █▀█ │ │ █▀█ │ │ █▀█ │ │ █ █ │ │ █ █ │ │ █ █ │ │ ▀▀▀ │ │ ▀▀▀ │ │ ▀▀▀ │ │ LIVE │ │ DONE │ │ TOKENS/MIN │ ╰────────────────╯ ╰────────────────╯ ╰────────────────╯ ── NOW nothing running ───────────────────────────────── Agents and workflow runs show up here the moment they start. ── FEED nothing yet ──────────────────────────────────── ▄thinking ▄writing ▄answering ▄running ▄long run ▄stuc…
README

Mission Control

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.

Mission Control docked beside a Claude Code session after a long sprint: live tiles, usage, the effort and model mix, the feed and where the time went

Install

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.

What the pane shows

  • NOW: a card for each live agent with its model, effort, current step and running time. The heading counts any that look stuck.
  • SUBAGENTS: ordinary subagents from the last 30 minutes, one line each.
  • RUN and WORKING: each workflow run, and the lanes it has in progress.
  • THROUGHPUT and TIME: tokens per minute over the last 30 minutes, and a bar of where the time went.
  • FEED: events as they happen. Agents starting, commands failing, tickets passing or failing review, plans landing, final verdicts.

While a workflow sprint runs, and for five minutes after it ends, the top of the pane follows it:

  • A headline in plain words, for example Waiting on F3 (sonnet·high, 4:10 in) · 2 tickets behind it · 5 of 9 passed · about 12 min left.
  • Tiles for tickets passed, minutes left, working, waiting, retries and tokens out.
  • SPRINT MAP: the tickets, their dependencies and the longest chain.
  • FLIGHT PLAN: one row per ticket, with its builds and reviews drawn on a time axis that runs out to the forecast finish.
  • Your 5-hour usage limit, now and where it will be when the sprint ends (see below).
  • A scrubber under the flight plan. Click or drag to replay any moment of a running or finished sprint. The layout stays put while you scrub.

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.

Keys

KeyWhat it does
b / nStep back or forward through a sprint
lBack to live
fShow or hide finished sprints
cClear finished sprints
eAuto-raise on or off
mLeave 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.

The 5-hour limit line

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.

The sprint workflow

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.

Development

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.

License

Unlicense (public domain). See LICENSE.

Source 14 files
hooks/register.tsx 417 lines
1import { 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}
417
hooks/art.tsx 241 lines
1import { 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}
241
hooks/deck.ts 954 lines
1import 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}
954
hooks/view.tsx 259 lines
1import 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}
259
hooks/scrubber.tsx 95 lines
1import 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
95
hooks/look.tsx 100 lines
1import 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
100
hooks/sections.tsx 387 lines
1import 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}
387
hooks/flight.tsx 356 lines
1import 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}
356
hooks/headline.ts 131 lines
1import 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}
131
hooks/hero.ts 74 lines
1import 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}
74
hooks/sky.tsx 231 lines
1import 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}
231
hooks/sprint.ts 307 lines
1import 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