See your subagents and workflows: a /subagents pane with an expandable tree of every subagent and workflow run (live activity, phases, tokens, transcripts) and…

See what your subagents are doing. A Claude Code mod that adds a /subagents pane: a live, expandable tree of every subagent in the session, nested under the agent that spawned it. It also adds a card on each Agent tool row and a band above the prompt while agents run.
| Transcript in the pane | Timeline |
|---|---|
| <img src="docs/agent-transcript.png" alt="An expanded agent showing its transcript: the task it was given and each tool call" width="380"> | <img src="docs/timeline.png" alt="Timeline: swimlanes per agent with tool calls colored by kind" width="380"> |
| Card on an Agent row | Band above the prompt |
|---|---|
| <img src="docs/agent-card.png" alt="A finished Agent tool row as a card: type, task, model, a strip of its tool calls, counts and tokens" width="380"> | <img src="docs/band.png" alt="The band above the prompt listing seven running agents with elapsed times and a sparkline" width="380"> |
| A workflow run: its phases and agents | The run on the timeline |
|---|---|
| <img src="docs/workflow-tree.png" alt="A workflow run in the tree: the run row with status and agent count, its Scan phase done 3 of 3 and its Summarize phase running" width="380"> | <img src="docs/workflow-timeline.png" alt="The timeline with the workflow run drawn as a band split into its phases above its agents' lanes" width="380"> |
In a terminal the Workflow row becomes a live card, and the run sits in the docked pane with its phases:
<img src="docs/workflow-terminal.png" alt="Terminal: a workflow run's card in the transcript showing both phases done, and the docked Agents pane with the run, its Scan and Summarize phases and their three agents" width="760">
<sub>Screenshots from the Claude desktop app, light theme. The pane, band and cards also draw in a terminal session.</sub>
In a terminal session the pane docks beside the transcript (demo/demo.tape records this with VHS):
<img src="demo/terminal.gif" alt="Terminal demo: two subagents each spawning a nested one, shown live in the docked Agents pane and the band above the prompt" width="760">
meta.phases), each phase with its own progress. On the timeline a run is a band split into its phases. In a terminal its Workflow tool row becomes a live card (the desktop app draws its own Workflow card). Remote runs link to their cloud session.Nested subagents are supported to any depth. The engine allows 3 levels by default; raise it with CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH.
Add the marketplace once, then install from it:
/plugin marketplace add algonormative/claude-plugins
/plugin install subagents-plus@algonormative
Or install it from this repository alone:
/plugin install subagents-plus --marketplace algonormative/subagents-plus
Type these at the prompt of a terminal claude session (answer y to add the marketplace, then pick a scope). A mod installed at the user scope also loads in the desktop app's Code tab. Outside a session the same works as claude plugin marketplace add … and claude plugin install ….
Requires a Claude Code build with function-hook mods (2.1.295 or newer). The mod API is early access and may change between releases.
/subagents opens the pane. It also opens on its own when the first subagent of a session spawns, where the pane can dock beside the transcript.1 for Agents and 2 for Timeline.▾ to fold an agent's children./subagents tree and /subagents timeline open the pane on that tab./subagents clear forgets finished agents./subagents close closes the pane.Set them with /plugin configure subagents-plus@algonormative. They are stored under pluginConfigs in ~/.claude/settings.json.
| Option | Default | What it does |
|---|---|---|
autoOpen | true | Open the pane when the first subagent spawns, where it docks beside the transcript |
inlineCards | desktop | Live cards on Agent rows: desktop, all (terminal too) or off |
band | true | The band above the prompt while subagents run |
streamTap | subagents | Time each model request for subagents (subagents), every loop (all), or not at all (off). The tap also feeds the live "writing / thinking" line |
animate | true | Pulse running agents |
diagnostics | false | Register a read-only status tool the model can call to report what the mod sees and draws. A development aid |
Hooks on agent.spawn, tool.call, turn.start, turn.step and turn.complete push small events into a buffer. Every 400 ms a pure reducer (hooks/model.ts) folds them into session state:
$.agent.list() is checked every 2 seconds, so an agent whose end was missed is still closed. Nothing is sent anywhere and nothing calls a model.
Tokens are counted once per turn, from turn.complete; per-request figures stand in only while a turn is running. Cache reads are reported apart from fresh tokens, because summed over many requests they would dwarf everything else.
claude --plugin-dir . # run a session with the mod loaded from this folder
claude plugin validate . # what the engine will load, hook and refuse
claude plugin test . # the *.test.ts(x) files (no network, no model calls)
On first load the engine writes its API types to .claude-plugin/types/ (git-ignored), so tsc -p . type-checks the mod after that.
Releases: bump version in .claude-plugin/plugin.json, add a CHANGELOG.md section, then tag v<version>. GitHub Actions validates, tests and publishes the release. Installs update through /plugin update (or the marketplace's auto-update) once the version changes.
demo/demo.tape records demo/terminal.gif with VHS in your own logged-in claude. To keep your other plugins out of the recording, give it a settings file that turns every installed plugin off except this one:
python3 - > /tmp/subagents-plus-demo-settings.json <<'EOF'
import json, os
s = json.load(open(os.path.expanduser("~/.claude/settings.json")))
on = {n: n.split("@")[0] == "subagents-plus" for n in s.get("enabledPlugins", {})}
out = {"enabledPlugins": on, "theme": "dark", "statusLine": {"type": "command", "command": "true"}}
print(json.dumps(out))
EOF
DEMO_DIR="$(demo/setup.sh)" DEMO_SETTINGS=/tmp/subagents-plus-demo-settings.json vhs demo/demo.tape
The settings file only switches plugins for the recording; your own settings.json is untouched. Look through the GIF before you publish it: anything your session prints (paths, task titles) ends up in it.
MIT
hooks/register.tsx 744 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Lane, Session, UiState } from '../types'
5import type { Ev } from './events'
6import { MAIN, runKey, usageOf } from './events'
7import { clip, sanitize, tildeHome } from './format'
8import { classify, summarize } from './kinds'
9import { axisEnd, pxWidth, SVG_ELEMENT_MAX } from './layout'
10import { emptySession, isLive, planBatch, reconcileEvents, reduceLane, reduceSession } from './model'
11import { timelineCells, sparkCells } from './raster'
12import { cardStripSvg, foldActivity, sparklineSvg } from './svg-mini'
13import { buildTimelineSvg } from './svg-timeline'
14import { parseNotification, parsePhases, parseTranscript } from './workflow'
15import type { Actions, Kit, Picture, TranscriptRow } from './views'
16import { bandTree, cardTree, paneTree, runCardTree, runSummary, timelineRows } from './views'
17
18// subagents-plus: hooks capture what subagents and the main loop do into a buffer; a
19// 400 ms loop folds it into $.state (model.ts); the pane, the band and the
20// Agent-row cards draw from that state. Nothing here calls a model.
21
22const PANE = 'subagents'
23const STATUS_TOOL = 'mcp__subagents-plus__status'
24const LOOP_MS = 400
25const TICK_MS = 1000
26const RECONCILE_MS = 2000
27const COST_MS = 10_000
28const IDLE_STOP_MS = 10_000
29const BAND_GRACE_MS = 10_000
30const TEN_MIN = 600_000
31
32const DEFAULT_UI: UiState = { tab: 'tree', window: 'all', userClosed: false }
33
34const sessionRef = atom({ plugin: 'subagents-plus', key: 'session' } as const, null)
35const uiRef = atom({ plugin: 'subagents-plus', key: 'ui' } as const, DEFAULT_UI)
36const tickRef = atom({ plugin: 'subagents-plus', key: 'tick' } as const, 0)
37const lanesRef = { plugin: 'subagents-plus', key: 'lanes' } as const
38const toolUseRef = { plugin: 'subagents-plus', key: 'byToolUse' } as const
39
40// Module variables hold only what may die with a reload: the unflushed buffer
41// (reconcile repairs it), timers, memos and diagnostics.
42let epoch = -1
43let buffer: Ev[] = []
44let loop: Timer | undefined
45let flushing = false
46let ticking = false
47let lastTick = 0
48let lastReconcile = 0
49let lastCost = 0
50let lastFullscreen: boolean | undefined
51const axisEnds = new Map<string, number>()
52/** The newest streamed text per lane, folded into the next flush as one `say` event each. */
53const saying = new Map<string, { kind: 'text' | 'thinking' | 'tool'; text: string; at: number }>()
54const memo = new Map<string, Picture>()
55const builds = new Map<string, { shape: string; at: number; built: number }>()
56/** How often each workflow agent's meta.json has been looked for. */
57const phaseTries = new Map<string, number>()
58const REBUILD_MS = 8_000
59const diag = {
60 loadedAt: 0,
61 flushes: 0,
62 events: 0,
63 dropped: 0,
64 renders: {} as Record<string, Record<string, unknown>>,
65 autoOpen: undefined as unknown,
66 steps: { main: 0, sub: 0 },
67 errors: [] as string[],
68}
69
70type Options = { autoOpen: boolean; inlineCards: 'desktop' | 'all' | 'off'; band: boolean; streamTap: 'subagents' | 'all' | 'off'; animate: boolean; diagnostics: boolean }
71
72function note(where: string, err: unknown) {
73 const text = String(err instanceof Error ? err.message : err).replace(/\b(toolu|agent|wf)[_-][\w-]+/g, '<id>')
74 diag.errors.push(`${where}: ${text.slice(0, 160)}`)
75 if (diag.errors.length > 20) diag.errors.shift()
76}
77
78function push(ev: Ev) {
79 buffer.push(ev)
80 diag.events += 1
81 // A runaway burst between flushes keeps its newest.
82 if (buffer.length > 5000) buffer.splice(0, buffer.length - 5000)
83}
84
85// ---- the loop ------------------------------------------------------------------
86
87function ensureLoop($: EngineInterface, o: Options) {
88 if (loop) return
89 loop = $.clock.every(LOOP_MS, () => {
90 if (ticking) return
91 ticking = true
92 void loopTick($, o).catch(err => note('loop', err)).finally(() => { ticking = false })
93 }) as unknown as Timer
94}
95
96async function loopTick($: EngineInterface, o: Options) {
97 const now = await $.clock.now()
98 await flush($, o, now)
99 const session = await read($, sessionRef)
100 if (!session) return
101 const busy = session.live > 0 || (session.digests[MAIN]?.status === 'running' && session.firstSpawnAt !== undefined)
102 if (session.live > 0 && now - lastTick >= TICK_MS) {
103 lastTick = now
104 await update($, tickRef, () => now)
105 }
106 if ((busy || now - session.lastEventAt < IDLE_STOP_MS) && now - lastReconcile >= RECONCILE_MS) {
107 lastReconcile = now
108 await reconcile($, session, now)
109 }
110 if (session.live > 0 && now - lastCost >= COST_MS) {
111 lastCost = now
112 try {
113 const usage = await $.session.usage()
114 const usd = (usage as { cost?: { usd?: number } }).cost?.usd
115 if (typeof usd === 'number') await update($, sessionRef, s => (s ? { ...s, costUsd: usd } : s))
116 } catch (err) { note('usage', err) }
117 }
118 if (!busy && buffer.length === 0 && now - session.lastEventAt > IDLE_STOP_MS) {
119 loop?.cancel()
120 loop = undefined
121 // One last tick so elapsed times settle on their final values.
122 await update($, tickRef, () => now)
123 }
124}
125
126async function reconcile($: EngineInterface, session: Session, now: number) {
127 try {
128 const list = await $.agent.list()
129 for (const ev of reconcileEvents(session, list, now)) push(ev)
130 } catch (err) { note('reconcile', err) }
131 await learnPhases($, session, now)
132}
133
134/** Each workflow agent's phase, from the meta.json its run writes beside its transcript. */
135async function learnPhases($: EngineInterface, session: Session, now: number) {
136 for (const d of Object.values(session.digests)) {
137 if (!d.run || d.phase) continue
138 const dir = session.workflows[d.run]?.transcriptDir
139 const tries = phaseTries.get(d.key) ?? 0
140 if (!dir || tries >= 10) continue
141 phaseTries.set(d.key, tries + 1)
142 try {
143 const raw = await $.fs.read(`${dir}/agent-${d.key}.meta.json`)
144 const phase = (JSON.parse(String(raw)) as { workflowPhase?: unknown }).workflowPhase
145 if (typeof phase === 'string' && phase) push({ t: 'lane-phase', epoch, at: now, lane: d.key, phase: clip(sanitize(phase), 40) })
146 } catch {
147 // Not written yet, or not a local run: try again on a later pass.
148 }
149 }
150}
151
152async function readPhases($: EngineInterface, key: string, scriptPath: string) {
153 try {
154 const phases = parsePhases(String(await $.fs.read(scriptPath)))
155 if (phases.length) push({ t: 'run-update', epoch, at: await $.clock.now(), lane: MAIN, key, patch: { phases } })
156 } catch (err) { note('phases', err) }
157}
158
159/** The text of a row's content, whatever its shape. */
160function textOf(content: unknown): string {
161 if (typeof content === 'string') return content
162 if (!Array.isArray(content)) return ''
163 return content.map(b => (b && typeof b === 'object' && typeof (b as { text?: unknown }).text === 'string' ? (b as { text: string }).text : '')).join('\n')
164}
165
166async function flush($: EngineInterface, o: Options, now: number) {
167 if (flushing || (buffer.length === 0 && saying.size === 0)) return
168 flushing = true
169 try {
170 for (const [lane, s] of saying) push({ t: 'say', epoch, at: s.at, lane, kind: s.kind, text: s.text })
171 saying.clear()
172 let session = await read($, sessionRef)
173 if (!session) {
174 session = emptySession(epoch < 0 ? 0 : epoch, now)
175 epoch = session.epoch
176 }
177 // Events captured before session.start read the epoch belong to the current one.
178 const batch = buffer.splice(0).map(ev => (ev.epoch < 0 ? { ...ev, epoch: session!.epoch } : ev))
179 const plan = planBatch(session, batch)
180 diag.dropped += plan.dropped
181 const changed: Lane[] = []
182 for (const [key, evs] of plan.byLane) {
183 const color = plan.newLanes.get(key) ?? session.digests[key]?.color ?? 0
184 const prev = (await read($, { ...lanesRef, id: key })) ?? null
185 const next = reduceLane(prev, evs, { key, epoch: session.epoch, color, now })
186 if (epoch >= 0 && epoch !== session.epoch) return
187 await update($, { ...lanesRef, id: key }, () => next)
188 changed.push(next)
189 }
190 for (const [id, keys] of plan.toolUse) {
191 await update($, { ...toolUseRef, id: id }, prev => [...new Set([...(prev ?? []), ...keys])])
192 }
193 const reduced = reduceSession(session, changed, plan, now)
194 const starts = batch.filter((ev): ev is Extract<Ev, { t: 'tool-start' }> => ev.t === 'tool-start' && ev.epoch === session!.epoch)
195 const next: Session = { ...reduced.session, activity: foldActivity(session.activity ?? [], starts, now) }
196 for (const key of reduced.evict) await update($, { ...lanesRef, id: key }, () => null)
197 // A /clear that landed mid-flush wins: never write an older epoch back.
198 await update($, sessionRef, cur => (cur && cur.epoch !== session!.epoch ? cur : next))
199 diag.flushes += 1
200 if (next.firstSpawnAt !== undefined) await maybeAutoOpen($, o, next)
201 } finally {
202 flushing = false
203 }
204}
205
206async function maybeAutoOpen($: EngineInterface, o: Options, session: Session) {
207 if (!o.autoOpen) return
208 const ui = await read($, uiRef)
209 if (ui.userClosed || ui.autoOpenedEpoch === session.epoch) return
210 // Only where a pane would sit beside the transcript; unknown counts as yes
211 // (the engine itself still holds an unasked pane back below 144 columns).
212 if (lastFullscreen === false) return
213 await update($, uiRef, u => ({ ...u, autoOpenedEpoch: session.epoch }))
214 try {
215 diag.autoOpen = await $.ui.open({ id: PANE, title: 'Agents', columns: 100 })
216 } catch (err) { note('autoOpen', err) }
217}
218
219async function startSession($: EngineInterface, o: Options) {
220 diag.loadedAt = await $.clock.now()
221 const session = await read($, sessionRef)
222 if (session) epoch = session.epoch
223 else {
224 epoch = 0
225 await update($, sessionRef, () => emptySession(0, diag.loadedAt))
226 }
227 try {
228 await $.command.register({
229 name: 'subagents',
230 description: 'Agents pane: subagents and session activity, drawn',
231 argumentHint: '[open|close|clear|tree|timeline]',
232 immediate: true,
233 })
234 } catch (err) { note('command', err) }
235 if (o.diagnostics) {
236 try {
237 await $.tool.register({
238 name: 'status',
239 description: 'subagents-plus development aid: what the subagents-plus mod sees and draws (render surfaces, widths, Svg sizes and detail levels, lane and token summaries). Read-only; takes no input.',
240 inputSchema: { type: 'object', properties: {} },
241 })
242 } catch (err) { note('tool', err) }
243 }
244 // Digests written before cache reads were counted apart are recomputed once from their lanes.
245 if (session && Object.values(session.digests).some(d => d.cached === undefined)) {
246 try {
247 const lanes: Lane[] = []
248 for (const key of Object.keys(session.digests)) {
249 const lane = await read($, { ...lanesRef, id: key })
250 if (lane) lanes.push(lane)
251 }
252 const redone = reduceSession(session, lanes, planBatch(session, []), diag.loadedAt).session
253 await update($, sessionRef, cur => (cur && cur.epoch === redone.epoch ? { ...redone, activity: cur.activity, costUsd: cur.costUsd } : cur))
254 } catch (err) { note('recount', err) }
255 }
256 // A reload lost the timers and anything unflushed: catch up from the engine's list.
257 if (session) {
258 await reconcile($, session, diag.loadedAt)
259 ensureLoop($, o)
260 }
261}
262
263async function clearSession($: EngineInterface) {
264 const old = await read($, sessionRef)
265 buffer = []
266 epoch = (old?.epoch ?? 0) + 1
267 const now = await $.clock.now()
268 await update($, sessionRef, () => emptySession(epoch, now))
269 await update($, uiRef, (u): UiState => ({ ...u, expanded: [], folded: [], transcriptOf: undefined, autoOpenedEpoch: undefined, fullAnswer: undefined }))
270 axisEnds.clear()
271 memo.clear()
272 builds.clear()
273 for (const key of Object.keys(old?.digests ?? {})) await update($, { ...lanesRef, id: key }, () => null)
274}
275
276async function statusText($: EngineInterface): Promise<string> {
277 const session = await read($, sessionRef)
278 const summary = session
279 ? {
280 epoch: session.epoch,
281 lanes: Object.values(session.digests).map(d => ({ label: d.label, type: d.type, status: d.status, depth: d.depth, tools: d.tools, tokens: d.tokens, hidden: d.hidden ?? false, workflow: d.run ? (session.workflows[d.run]?.name ?? 'unnamed') : undefined })),
282 totals: session.totals, live: session.live, done: session.done, failed: session.failed, archived: session.archived,
283 }
284 : null
285 return JSON.stringify({ ...diag, buffered: buffer.length, loop: loop !== undefined, session: summary }, null, 1)
286}
287
288// ---- drawing helpers ----------------------------------------------------------------
289
290async function lanesFor($: EngineInterface, session: Session, keys: readonly string[]): Promise<Map<string, Lane>> {
291 const out = new Map<string, Lane>()
292 for (const key of keys) {
293 if (!session.digests[key] || session.digests[key]!.hidden) continue
294 const lane = await read($, { ...lanesRef, id: key })
295 if (lane) out.set(key, lane)
296 }
297 return out
298}
299
300function actionsFor($: EngineInterface): Actions {
301 return {
302 tab: tab => void update($, uiRef, u => ({ ...u, tab })),
303 expand: key => void update($, uiRef, (u): UiState => ({ ...u, expanded: toggled(u.expanded, key), fullAnswer: undefined })),
304 fold: key => void update($, uiRef, (u): UiState => ({ ...u, folded: toggled(u.folded, key) })),
305 transcript: key => void update($, uiRef, (u): UiState => ({ ...u, transcriptOf: key })),
306 toggleWindow: () => void update($, uiRef, (u): UiState => ({ ...u, window: u.window === '10m' ? 'all' : '10m' })),
307 toggleAnswer: () => void update($, uiRef, u => ({ ...u, fullAnswer: !u.fullAnswer })),
308 jump: key => void jumpTo($, key),
309 open: key => void openPane($, key),
310 openRun: key => void openRun($, key),
311 }
312}
313
314/** The last read of a workflow agent's transcript file, kept two seconds so a redraw doesn't re-read it. */
315let fileTranscript: { agentId: string; at: number; rows: TranscriptRow[] } | undefined
316
317/** A workflow agent's transcript, read from `agent-<id>.jsonl` in its run's folder. */
318async function workflowTranscript($: EngineInterface, agentId: string): Promise<TranscriptRow[] | 'unreadable'> {
319 const session = await read($, sessionRef)
320 const d = session?.digests[agentId]
321 const dir = d?.run ? session?.workflows[d.run]?.transcriptDir : undefined
322 if (!d || !dir) return 'unreadable'
323 const now = await $.clock.now()
324 if (fileTranscript?.agentId === agentId && now - fileTranscript.at < 2000) return fileTranscript.rows
325 try {
326 const rows = parseTranscript(String(await $.fs.read(`${dir}/agent-${agentId}.jsonl`)), (tool, input) => summarize(tool, input))
327 .slice(-60)
328 .map(m => ({ ...m, text: tildeHome(m.text) }))
329 fileTranscript = { agentId, at: now, rows }
330 return rows
331 } catch (err) {
332 note('transcript', err)
333 // A starting agent's file may not be written yet.
334 return isLive(d.status) ? [] : 'unreadable'
335 }
336}
337
338async function transcriptOf($: EngineInterface, agentId: string): Promise<TranscriptRow[] | 'unreadable'> {
339 try {
340 const found = await $.session.messages({ agentId })
341 if (!Array.isArray(found) || !found.length) {
342 const session = await read($, sessionRef)
343 if (session?.digests[agentId]?.run) return await workflowTranscript($, agentId)
344 return Array.isArray(found) ? [] : 'unreadable'
345 }
346 return found.slice(-60).map(m => ({
347 role: m.role,
348 text: tildeHome(m.text),
349 tools: m.toolUses.map(t => ({ tool: String(t.tool), summary: summarize(String(t.tool), t.input), ...((t as { isError?: boolean }).isError ? { isError: true } : {}) })),
350 }))
351 } catch (err) {
352 note('transcript', err)
353 return 'unreadable'
354 }
355}
356
357async function openRun($: EngineInterface, key: string) {
358 await update($, uiRef, (u): UiState => ({ ...u, userClosed: false, tab: 'tree', folded: (u.folded ?? []).filter(f => f !== `run:${key}`) }))
359 try {
360 await $.ui.open({ id: PANE, title: 'Agents', columns: 100 })
361 } catch (err) { note('open', err) }
362}
363
364async function jumpTo($: EngineInterface, key: string) {
365 const lane = await read($, { ...lanesRef, id: key })
366 const id = lane?.meta?.toolUseId
367 if (!id) return
368 try {
369 await $.ui.scroll({ to: { requestId: id } })
370 } catch (err) { note('jump', err) }
371}
372
373async function openPane($: EngineInterface, key?: string) {
374 // Opening an agent unfolds its ancestors, so its row is on screen.
375 const session = key ? await read($, sessionRef) : null
376 const ancestors = new Set<string>()
377 for (let k = key ? session?.digests[key]?.parent : undefined, hops = 0; k && hops < 64; k = session?.digests[k]?.parent, hops++) ancestors.add(k)
378 await update($, uiRef, (u): UiState => ({
379 ...u,
380 userClosed: false,
381 tab: key ? 'tree' : u.tab,
382 ...(key ? {
383 expanded: (u.expanded ?? []).includes(key) ? u.expanded : [...(u.expanded ?? []), key],
384 folded: (u.folded ?? []).filter(f => !ancestors.has(f)),
385 } : {}),
386 }))
387 try {
388 await $.ui.open({ id: PANE, title: 'Agents', columns: 100 })
389 } catch (err) { note('open', err) }
390}
391
392function toggled(list: readonly string[] | undefined, key: string): string[] {
393 const now = list ?? []
394 return now.includes(key) ? now.filter(k => k !== key) : [...now, key].slice(-20)
395}
396
397function memoized(key: string, make: () => Picture): Picture {
398 const hit = memo.get(key)
399 if (hit) return hit
400 const p = make()
401 memo.set(key, p)
402 if (memo.size > 64) memo.delete(memo.keys().next().value as string)
403 return p
404}
405
406// ---- register --------------------------------------------------------------------
407
408export const register: Register = (on, options) => {
409 const o: Options = {
410 autoOpen: options.autoOpen !== false,
411 inlineCards: options.inlineCards === 'all' || options.inlineCards === 'off' ? options.inlineCards : 'desktop',
412 band: options.band !== false,
413 streamTap: options.streamTap === 'all' || options.streamTap === 'off' ? options.streamTap : 'subagents',
414 animate: options.animate !== false,
415 diagnostics: options.diagnostics === true,
416 }
417
418 on('session.start', { isInteractive: [true, false] }, async ($, e, next) => {
419 await startSession($, o)
420 return next(e)
421 }).catch(($, e, next) => next(e))
422
423 on('session.end', { reason: 'clear' }, async ($, e, next) => {
424 await clearSession($)
425 return next(e)
426 }).catch(($, e, next) => next(e))
427
428 on('command.run', { command: 'subagents' }, async ($, e) => {
429 const verb = e.args.trim().toLowerCase()
430 if (verb === 'close') {
431 await $.ui.close({ id: PANE })
432 return { text: 'Agents pane closed.' }
433 }
434 if (verb === 'clear') {
435 await clearSession($)
436 return { text: 'Cleared the Agents pane; agents still running reappear as they act.' }
437 }
438 if (verb === 'tree' || verb === 'timeline') {
439 await update($, uiRef, (u): UiState => ({ ...u, tab: verb }))
440 }
441 await update($, uiRef, u => ({ ...u, userClosed: false }))
442 const opened = await $.ui.open({ id: PANE, title: 'Agents', columns: 100 })
443 ensureLoop($, o)
444 return { text: opened.isPlaced ? 'Agents pane opened.' : `The Agents pane waits for room (${opened.reason ?? 'not placed'}).` }
445 }).catch(() => ({ text: 'subagents-plus: that failed, try again.' }))
446
447 if (o.diagnostics) {
448 // Cast: the laid tool table predates this mod's own tool.
449 on('tool.call', { tool: STATUS_TOOL } as unknown as { tool: 'Read' }, async $ => ({ result: await statusText($) }) as never)
450 .catch(() => ({ deny: 'subagents-plus status failed' }))
451 }
452
453 on('ui.close', { id: PANE }, async ($, e, next) => {
454 const out = await next(e)
455 if (e.origin.kind === 'person') await update($, uiRef, u => ({ ...u, userClosed: true }))
456 return out
457 }).catch(($, e, next) => next(e))
458
459 // ---- capture ------------------------------------------------------------------
460
461 on('agent.spawn', async ($, e, next) => {
462 const at = await $.clock.now()
463 const r = await next(e)
464 if (r.agentId) {
465 push({
466 t: 'spawned', epoch, at, lane: r.agentId,
467 meta: {
468 description: e.description,
469 type: e.subagentType,
470 ...(e.name ? { name: e.name } : {}),
471 model: r.model,
472 ...(e.isTeammate ? { isTeammate: true as const } : {}),
473 ...(e.fork ? { isFork: true as const } : {}),
474 background: e.background,
475 parent: e.parentAgentId ?? MAIN,
476 ...(e.workflow ? { run: e.workflow.runId, runIndex: e.workflow.agentIndex } : {}),
477 ...(e.tool_use_id && !e.workflow ? { toolUseId: e.tool_use_id } : {}),
478 },
479 })
480 ensureLoop($, o)
481 }
482 return r
483 }).catch(($, e, next) => next(e))
484
485 on('tool.call', async ($, e, next) => {
486 if (String(e.tool) === STATUS_TOOL) return next(e)
487 const lane = e.agentId ?? MAIN
488 const tool = String(e.tool)
489 const kind = classify(tool, e)
490 const summary = summarize(tool, e)
491 const startedAt = await $.clock.now()
492 push({ t: 'tool-start', epoch, at: startedAt, lane, id: e.tool_use_id, tool, kind, summary })
493 ensureLoop($, o)
494 const r = await next(e)
495 const at = await $.clock.now()
496 const result = (r as { result?: unknown }).result as Record<string, unknown> | undefined
497 const ev: Extract<Ev, { t: 'tool-end' }> = {
498 t: 'tool-end', epoch, at, lane, id: e.tool_use_id, tool, kind, summary, startedAt,
499 isError: (r as { isError?: boolean }).isError === true || (r as { deny?: string }).deny !== undefined,
500 }
501 if (result && typeof result === 'object') {
502 if (tool === 'Agent' || tool === 'Task') {
503 const child = typeof result.agentId === 'string' ? result.agentId : typeof result.agent_id === 'string' ? result.agent_id : undefined
504 if (child) ev.child = child
505 const status = String(result.status ?? '')
506 ev.launch = status === 'async_launched' ? 'async' : status === 'remote_launched' ? 'remote' : status === 'teammate_spawned' ? 'teammate' : status === 'completed' ? 'completed' : undefined
507 if (typeof result.totalTokens === 'number') {
508 ev.childTotals = { tokens: result.totalTokens, tools: Number(result.totalToolUseCount ?? 0), durationMs: Number(result.totalDurationMs ?? 0) }
509 }
510 } else if (tool === 'Workflow') {
511 const str = (k: string) => (typeof result[k] === 'string' && result[k] ? (result[k] as string) : undefined)
512 const remote = result.status === 'remote_launched'
513 const sessionUrl = str('sessionUrl')
514 ev.workflow = {
515 runId: str('runId'),
516 taskId: str('taskId'),
517 ...(str('workflowName') ? { name: clip(sanitize(str('workflowName')!), 40) } : {}),
518 transcriptDir: str('transcriptDir'),
519 scriptPath: str('scriptPath'),
520 // Only an https link to a cloud session is ever drawn.
521 ...(sessionUrl && /^https:\/\//.test(sessionUrl) ? { sessionUrl } : {}),
522 ...(remote ? { remote: true } : {}),
523 }
524 }
525 }
526 push(ev)
527 // A run's phases are in its script; read them once, off the call's own path.
528 const key = ev.workflow ? runKey(ev.workflow) : undefined
529 if (key && ev.workflow?.scriptPath) void readPhases($, key, ev.workflow.scriptPath)
530 return r
531 }).catch(($, e, next) => next(e))
532
533 // The end of a background run arrives as a task notification row.
534 on('session.append', async ($, e, next) => {
535 const out = await next(e)
536 if (e.origin.kind === 'task-notification') {
537 const end = parseNotification(textOf(e.message.content))
538 if (end) {
539 push({
540 t: 'run-update', epoch, at: await $.clock.now(), lane: MAIN, taskId: end.taskId,
541 patch: { status: end.status, endedAt: await $.clock.now(), agents: end.agents, tokens: end.tokens, durationMs: end.durationMs },
542 })
543 ensureLoop($, o)
544 }
545 }
546 return out
547 }).catch(($, e, next) => next(e))
548
549 on('turn.start', async ($, e, next) => {
550 push({ t: 'turn-start', epoch, at: await $.clock.now(), lane: MAIN, turnId: e.turnId })
551 ensureLoop($, o)
552 return next(e)
553 }).catch(($, e, next) => next(e))
554
555 on('turn.complete', async ($, e, next) => {
556 push({
557 t: 'turn-complete', epoch, at: await $.clock.now(), lane: e.agentId ?? MAIN, turnId: e.turnId,
558 reason: e.reason, durationMs: e.durationMs, usage: usageOf(e.usage), answer: clip(e.answer ?? '', 4000),
559 })
560 ensureLoop($, o)
561 return next(e)
562 }).catch(($, e, next) => next(e))
563
564 if (o.streamTap !== 'off') {
565 on('turn.step', async function* ($, e, next) {
566 if (e.agentId === undefined) diag.steps.main += 1
567 else diag.steps.sub += 1
568 if (o.streamTap === 'subagents' && e.agentId === undefined) return yield* next(e)
569 const lane = e.agentId ?? MAIN
570 push({ t: 'step-start', epoch, at: await $.clock.now(), lane, turnId: e.turnId, index: e.index, model: e.model })
571 ensureLoop($, o)
572 // Forward every chunk unchanged; keep the tail of what the model says for the live line.
573 const stream = next(e)
574 let r
575 for (;;) {
576 const step = await stream.next()
577 if (step.done) { r = step.value; break }
578 const c = step.value
579 if (c.kind === 'text' || c.kind === 'thinking') {
580 const prev = saying.get(lane)
581 const text = (prev && prev.kind === c.kind ? prev.text : '') + c.text
582 saying.set(lane, { kind: c.kind, text: tildeHome(text.slice(-240)), at: Date.now() })
583 } else if (c.kind === 'tool') {
584 saying.set(lane, { kind: 'tool', text: c.name, at: Date.now() })
585 }
586 yield c
587 }
588 push({ t: 'step-end', epoch, at: await $.clock.now(), lane, turnId: e.turnId, index: e.index, usage: r.usage ? usageOf(r.usage) : undefined })
589 return r
590 })
591 }
592
593 // ---- drawing ------------------------------------------------------------------------
594
595 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
596 const els = $.ui.resolve(e)
597 const kit: Kit = {
598 surface: e.surface, Box: els.Box, Text: els.Text, Button: els.Button, Markdown: els.Markdown, Link: els.Link,
599 ...(e.surface !== 'terminal' && 'Svg' in els ? { Svg: els.Svg } : {}),
600 ...(e.surface === 'terminal' && 'Raster' in els ? { Raster: els.Raster } : {}),
601 }
602 if (e.viewport?.isFullscreen !== undefined) lastFullscreen = e.viewport.isFullscreen
603 const session = await read($, sessionRef)
604 const ui = await read($, uiRef)
605 if (!session) return <kit.Text dimColor>Waiting for the session to start…</kit.Text>
606 // The pane holds no interactive frame any more, so a 1 Hz redraw (glyphs
607 // turning, names breathing, elapsed times) costs no flash or lost click.
608 const tick = await read($, tickRef)
609 const now = Math.max(Date.now(), tick, session.lastEventAt)
610 const viewKey = e.props.view.agentId
611 const columns = Math.max(20, e.props.bodyColumns)
612 const lanes = await lanesFor($, session, session.order)
613 const t0Session = Math.min(session.startedAt, ...[...lanes.values()].map(l => l.firstAt))
614 const t0 = ui.window === '10m' ? Math.max(t0Session, now - TEN_MIN) : t0Session
615 // Svg rebuilt when the data changes or every 15 s; between, SMIL moves it.
616 // The picture is rebuilt when the cast changes (who, nesting, status,
617 // selection, width) at once, and for new tool calls at most every 8 s;
618 // SMIL carries live bars and heads in between.
619 const cast = session.order.map(k => `${k}:${session.digests[k]?.status}${session.digests[k]?.quiet ? 'q' : ''}`).join(',')
620 const shape = `${session.epoch}|${cast}|${viewKey ?? ''}|${columns}|${o.animate}|${ui.tab}|${ui.window}`
621 const prevBuild = builds.get('pane')
622 const fresh = !prevBuild || prevBuild.shape !== shape || (session.lastEventAt > prevBuild.at && now - prevBuild.built >= REBUILD_MS)
623 if (fresh) builds.set('pane', { shape, at: session.lastEventAt, built: now })
624 const dataKey = `${shape}|${builds.get('pane')!.built}`
625
626 let picture: Picture | undefined
627 if (ui.tab === 'timeline') {
628 const axisKey = `${ui.window}|${session.epoch}`
629 const t1 = axisEnd(t0, now, axisEnds.get(axisKey))
630 axisEnds.set(axisKey, t1)
631 picture = memoized(`t|${dataKey}|${ui.window}|${t1}`, () => {
632 const rows = timelineRows(session, lanes, ui, viewKey, t0)
633 if (!kit.Svg) {
634 const laneRows = rows.filter(r => r.t === 'lane')
635 const cols = Math.max(10, Math.min(512, columns - 24))
636 const packed = timelineCells(laneRows.map(r => ({ color: r.color, status: r.status, live: isLive(r.status) && !r.quiet, start: r.start, end: r.end, spans: r.spans, steps: r.steps })), { columns: cols, t0, t1, now })
637 return {
638 kind: 'raster', key: 'timeline', ...packed,
639 labels: laneRows.map(r => ({ text: `${' '.repeat(r.depth)}${r.label}`, color: r.color < 0 ? 'claude' : undefined, dim: !isLive(r.status) })),
640 }
641 }
642 const width = pxWidth(columns, 2)
643 const b = buildTimelineSvg(rows, { width, now, t0, t1, animate: false }, SVG_ELEMENT_MAX)
644 diag.renders.timeline = { chars: b.source.length, level: b.level, rows: rows.length }
645 // A still image: an interactive frame swallows the pane's scroll and clicks.
646 return { kind: 'svg', source: b.source, alt: b.alt, width, height: b.height, interactive: false }
647 })
648 }
649 const sparkline: Picture | undefined = session.activity && session.activity.length
650 ? kit.Svg
651 ? { kind: 'svg', source: sparklineSvg(session.activity, Math.floor(now / 5000) * 5000, 160, 16), alt: 'Tool calls over the last two minutes', width: 160, height: 16, interactive: false }
652 : { kind: 'raster', key: 'spark', ...sparkCells(session.activity, now, 24) }
653 : undefined
654 diag.renders.Pane = { surface: e.surface, columns: e.props.bodyColumns, placement: e.props.placement, isFullscreen: e.viewport?.isFullscreen, tab: ui.tab }
655 const showing = ui.transcriptOf && (ui.expanded ?? []).includes(ui.transcriptOf) ? ui.transcriptOf : undefined
656 const transcript = showing ? await transcriptOf($, showing) : undefined
657 return paneTree(kit, { session, ui, lanes, now, columns, viewKey, picture, sparkline, transcript }, actionsFor($))
658 })
659
660 if (o.band) {
661 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
662 if (e.viewport?.isFullscreen !== undefined) lastFullscreen = e.viewport.isFullscreen
663 if (e.props.hasSurvey) return next(e)
664 const session = await read($, sessionRef)
665 const tick = await read($, tickRef)
666 if (!session || session.firstSpawnAt === undefined) return next(e)
667 const now = Math.max(tick, session.lastEventAt)
668 const grace = session.live === 0
669 const lastEnd = Math.max(0, ...Object.values(session.digests).map(d => d.endedAt ?? 0))
670 if (grace && now - lastEnd > BAND_GRACE_MS) return next(e)
671 const els = $.ui.resolve(e)
672 const kit: Kit = {
673 surface: e.surface, Box: els.Box, Text: els.Text, Button: els.Button, Markdown: els.Markdown, Link: els.Link,
674 ...(e.surface !== 'terminal' && 'Svg' in els ? { Svg: els.Svg } : {}),
675 ...(e.surface === 'terminal' && 'Raster' in els ? { Raster: els.Raster } : {}),
676 }
677 const spark: Picture | undefined = !grace && session.activity?.length
678 ? kit.Svg
679 ? { kind: 'svg', source: sparklineSvg(session.activity, Math.floor(now / 5000) * 5000, 120, 14), alt: 'Tool calls over the last two minutes', width: 120, height: 14, interactive: false }
680 : { kind: 'raster', key: 'band-spark', ...sparkCells(session.activity, now, 16) }
681 : undefined
682 diag.renders.AbovePrompt = { surface: e.surface, columns: e.props.bodyColumns, live: session.live }
683 const mine = bandTree(kit, session, now, e.props.view.agentId, grace, spark, actionsFor($))
684 // Another plugin's band (beads-band) still draws beneath ours.
685 const beneath = await next(e)
686 return <kit.Box flexDirection="column">{mine}{beneath}</kit.Box>
687 })
688 }
689
690 if (o.inlineCards !== 'off') {
691 // The desktop draws its own Workflow card and never asks for this row; the
692 // terminal draws a plain row, so the card is drawn wherever it is asked for.
693 on('ui.render', { component: 'ToolUse', props: { tool: 'Workflow' } }, async ($, e, next) => {
694 const session = await read($, sessionRef)
695 const key = session ? Object.keys(session.workflows).find(k => session.workflows[k]!.toolUseId === e.props.tool_use_id) : undefined
696 if (!key && !e.props.isRunning) return next(e)
697 const els = $.ui.resolve(e)
698 const kit: Kit = {
699 surface: e.surface, Box: els.Box, Text: els.Text, Button: els.Button, Markdown: els.Markdown, Link: els.Link,
700 ...(e.surface !== 'terminal' && 'Svg' in els ? { Svg: els.Svg } : {}),
701 }
702 const run = session && key ? runSummary(session, key, Date.now()) : undefined
703 const input = e.props.input as { name?: unknown } | undefined
704 const beneath = await next(e)
705 return runCardTree(kit, run, { name: typeof input?.name === 'string' ? clip(sanitize(input.name), 48) : undefined }, Math.max(Date.now(), session?.lastEventAt ?? 0), actionsFor($), beneath)
706 })
707 }
708
709 if (o.inlineCards !== 'off') {
710 on('ui.render', { component: 'ToolUse', props: { tool: 'Agent' } }, async ($, e, next) => {
711 if (e.viewport?.isFullscreen !== undefined) lastFullscreen = e.viewport.isFullscreen
712 if (o.inlineCards === 'desktop' && e.surface === 'terminal') return next(e)
713 const keys = (await read($, { ...toolUseRef, id: e.props.tool_use_id })) ?? []
714 const session = await read($, sessionRef)
715 const key = keys[0]
716 const d = key && session ? session.digests[key] : undefined
717 if (!d && !e.props.isRunning) return next(e)
718 const lane = key ? ((await read($, { ...lanesRef, id: key })) ?? undefined) : undefined
719 const onScreen = e.props.onScreen !== null
720 const live = d ? isLive(d.status) && !d.quiet : true
721 // A card redraws with its lane, not every second (its strip is a picture).
722 const now = Math.max(Date.now(), lane?.lastAt ?? 0)
723 const els = $.ui.resolve(e)
724 const kit: Kit = {
725 surface: e.surface, Box: els.Box, Text: els.Text, Button: els.Button, Markdown: els.Markdown, Link: els.Link,
726 ...(e.surface !== 'terminal' && 'Svg' in els ? { Svg: els.Svg } : {}),
727 }
728 const width = pxWidth(Math.min(e.viewport?.columns ?? 80, 110), 8)
729 const strip: Picture | undefined = kit.Svg && lane && onScreen && lane.spans.length
730 ? { kind: 'svg', source: cardStripSvg(lane.spans, { width, t0: lane.spawnedAt ?? lane.firstAt, now: lane.lastAt, end: lane.endedAt, color: lane.color, live }), alt: `${lane.tools} tool calls`, width, height: 14, interactive: false }
731 : undefined
732 const children = session && key ? Object.values(session.digests).filter(c => c.parent === key && !c.hidden).length : 0
733 const input = e.props.input as { description?: unknown; subagent_type?: unknown } | undefined
734 diag.renders.ToolUse = { surface: e.surface, known: d !== undefined, onScreen: e.props.onScreen === undefined ? 'absent' : onScreen }
735 const beneath = await next(e)
736 return cardTree(
737 kit, d, lane,
738 { description: typeof input?.description === 'string' ? input.description : undefined, subagentType: typeof input?.subagent_type === 'string' ? input.subagent_type : undefined },
739 now, strip, children, actionsFor($), beneath,
740 )
741 })
742 }
743}
744hooks/events.ts 98 lines1// The events the hooks capture and the reducer (model.ts) folds into lanes.
2// Pure: no `$`. Every event carries the epoch it was captured in (a /clear
3// bumps it, and the reducer drops events of an older one) and its own time.
4
5import type { Kind, LaneMeta, LaneStatus, RunPatch, Usage } from '../types'
6
7type Base = { epoch: number; at: number }
8
9/** The main loop's lane key. Every other lane is keyed by its agent id. */
10export const MAIN = 'main'
11
12export type Ev =
13 /** A subagent started: agent.spawn's input and what next(e) resolved. */
14 | (Base & { t: 'spawned'; lane: string; meta: LaneMeta })
15 /** A tool call began on a lane (tool.call before next). */
16 | (Base & { t: 'tool-start'; lane: string; id: string; tool: string; kind: Kind; summary: string })
17 /**
18 * A tool call ended. It repeats start's fields and its own `startedAt`, so a
19 * span can be built from the end alone when the start was lost or late.
20 */
21 | (Base & {
22 t: 'tool-end'
23 lane: string
24 id: string
25 tool: string
26 kind: Kind
27 summary: string
28 startedAt: number
29 isError: boolean
30 isInterrupted?: boolean
31 /** An Agent call's started agent (from its result's agentId). */
32 child?: string
33 /** A foreground Agent call's totals: the fallback until its turn.complete lands. */
34 childTotals?: { tokens: number; tools: number; durationMs: number }
35 /** How an Agent call launched: completed in the foreground, sent to the background, remote, a teammate. */
36 launch?: 'completed' | 'async' | 'remote' | 'teammate'
37 /**
38 * A Workflow call's run: its agents group under it. Keyed by runId; a
39 * remote run has none and is keyed by its task id.
40 */
41 workflow?: { runId?: string; taskId?: string; name?: string; transcriptDir?: string; scriptPath?: string; sessionUrl?: string; remote?: boolean }
42 })
43 /**
44 * What the parent's Agent call reported about the child it started, routed to
45 * the child's lane by planBatch: a foreground run's totals (the token fallback)
46 * and how it launched (`remote` lanes never report a loop here).
47 */
48 | (Base & { t: 'child-report'; lane: string; tokens?: number; launch?: 'completed' | 'async' | 'remote' | 'teammate' })
49 /** What a lane's model is streaming right now: the tail of its text or thinking, or the tool it is calling. */
50 | (Base & { t: 'say'; lane: string; kind: 'text' | 'thinking' | 'tool'; text: string })
51 /** News about a workflow run, by run key or by the task id its notification names. */
52 | (Base & { t: 'run-update'; lane: string; key?: string; taskId?: string; patch: RunPatch })
53 /** A workflow agent's phase, read from the run's agent meta.json. */
54 | (Base & { t: 'lane-phase'; lane: string; phase: string })
55 /** The main loop began a turn (turn.start fires for main only). */
56 | (Base & { t: 'turn-start'; lane: string; turnId: string })
57 /** A model request began on a lane (turn.step before next). */
58 | (Base & { t: 'step-start'; lane: string; turnId: string; index: number; model: string })
59 /** A model request finished (turn.step after next); usage is that request's. */
60 | (Base & { t: 'step-end'; lane: string; turnId: string; index: number; usage?: Usage })
61 /** A turn ended. For a subagent, one run of its loop. Usage is the turn's sum; absent on interrupt or error. */
62 | (Base & {
63 t: 'turn-complete'
64 lane: string
65 turnId: string
66 reason: 'answer' | 'aborted' | 'refusal' | 'error'
67 durationMs: number
68 usage?: Usage
69 answer: string
70 })
71 /**
72 * What $.agent.list() said of a lane on a reconcile pass, as a synthetic event:
73 * its status, and the meta of a lane the list names but no spawn described.
74 */
75 | (Base & { t: 'listed'; lane: string; status: LaneStatus; adopt?: LaneMeta })
76 /** Reconcile found a once-listed lane missing from the list (it may have ended unseen). */
77 | (Base & { t: 'unlisted'; lane: string })
78
79export type EvType = Ev['t']
80
81/** The token counts of an API usage record, any of its fields missing as 0. */
82export function usageOf(u: unknown): Usage | undefined {
83 if (u === null || typeof u !== 'object') return undefined
84 const r = u as Record<string, unknown>
85 const n = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0)
86 return {
87 input: n(r.input_tokens),
88 output: n(r.output_tokens),
89 cacheRead: n(r.cache_read_input_tokens),
90 cacheWrite: n(r.cache_creation_input_tokens),
91 }
92}
93
94/** The run key a Workflow result files under: its runId, else (remote) its task id. */
95export function runKey(w: { runId?: string; taskId?: string }): string | undefined {
96 return w.runId ?? (w.taskId ? `task:${w.taskId}` : undefined)
97}
98hooks/format.ts 77 lines1// Pure text helpers: nothing a person reads is built without passing here.
2// Ids never reach the screen; label() is the one place a lane gets a name.
3
4import type { LaneMeta } from '../types'
5
6// C0 and C1 controls, plus the bidi overrides and isolates that can reorder text.
7const UNSAFE = /[\u0000-\u001f\u007f-\u009f--]/g
8
9/** Strip control and bidi characters, collapse whitespace, trim. */
10export function sanitize(s: string): string {
11 return String(s ?? '').replace(/\s+/g, ' ').replace(UNSAFE, '').replace(/\s+/g, ' ').trim()
12}
13
14const SECRET_WORDS = '(?:key|token|secret|password|auth)'
15const FLAG_SECRET = new RegExp(`(--[A-Za-z0-9-]*${SECRET_WORDS}[A-Za-z0-9-]*)(?:\\s+|=)(\\S+)`, 'gi')
16const ASSIGN_SECRET = new RegExp(`\\b([A-Za-z0-9_]*${SECRET_WORDS}[A-Za-z0-9_]*)=("[^"]*"|'[^']*'|\\S*)`, 'gi')
17const LONG_RUN = /[A-Za-z0-9_\-+/=]{24,}/g
18
19/** Replace likely secrets (named assignments, flags, long opaque runs) with an ellipsis. */
20export function scrubSecrets(s: string): string {
21 return String(s ?? '')
22 .replace(FLAG_SECRET, '$1=…')
23 .replace(ASSIGN_SECRET, '$1=…')
24 .replace(LONG_RUN, (run) => (/[A-Za-z]/.test(run) && /[0-9]/.test(run) ? '…' : run))
25}
26
27const HOME = /(?:\/Users|\/home)\/[^/\s'"]+(?=\/|\s|$)|C:\\Users\\[^\\\s'"]+/g
28
29/** Home directories written as `~`: shorter, and a summary never shows whose machine it ran on. */
30export function tildeHome(s: string): string {
31 return String(s ?? '').replace(HOME, '~')
32}
33
34/** At most n characters, a clipped string ending in '…'. */
35export function clip(s: string, n: number): string {
36 if (n <= 0) return ''
37 return s.length <= n ? s : s.slice(0, n - 1) + '…'
38}
39
40export function xmlEscape(s: string): string {
41 return String(s ?? '')
42 .replace(/&/g, '&')
43 .replace(/</g, '<')
44 .replace(/>/g, '>')
45 .replace(/"/g, '"')
46 .replace(/'/g, ''')
47}
48
49export function fmtDuration(ms: number): string {
50 if (!Number.isFinite(ms) || ms < 0) return '0s'
51 if (ms < 10_000) return `${(ms / 1000).toFixed(1)}s`
52 if (ms < 60_000) return `${Math.floor(ms / 1000)}s`
53 const total = Math.floor(ms / 1000)
54 if (ms < 3_600_000) return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`
55 const m = Math.floor((total % 3600) / 60)
56 return `${Math.floor(total / 3600)}h ${String(m).padStart(2, '0')}m`
57}
58
59export function fmtTokens(n: number): string {
60 if (!Number.isFinite(n) || n < 0) return '0'
61 if (n < 1000) return String(Math.round(n))
62 const k = Math.round(n / 100) / 10
63 if (n < 1e6 && k < 1000) return `${String(k)}k`
64 return `${String(Math.round(n / 1e5) / 10)}M`
65}
66
67/** A lane's display name; ids (the key, a run id) never appear in it. */
68export function label(meta: LaneMeta | undefined, key: string): string {
69 if (key === 'main') return 'main'
70 const pick = [meta?.name, meta?.description, meta?.type ? `${meta.type} agent` : undefined]
71 for (const c of pick) {
72 const t = clip(sanitize(c ?? ''), 48)
73 if (t && !(key.length >= 4 && t.includes(key))) return t
74 }
75 return 'agent'
76}
77hooks/kinds.ts 129 lines1// What a tool call is (for color and counting) and a one-line, safe summary of it.
2
3import type { Kind } from '../types'
4import { clip, sanitize, scrubSecrets, tildeHome } from './format'
5
6export const KIND_ORDER: Kind[] = ['read', 'search', 'edit', 'write', 'bash', 'web', 'mcp', 'agent', 'other']
7
8export const KIND_LABEL: Record<Kind, string> = {
9 read: 'Read',
10 search: 'Search',
11 edit: 'Edit',
12 write: 'Write',
13 bash: 'Shell',
14 web: 'Web',
15 mcp: 'MCP',
16 agent: 'Agents',
17 other: 'Other',
18}
19
20const BASH_TOOLS = ['Bash', 'BashOutput', 'KillShell', 'Monitor', 'PowerShell']
21const AGENT_TOOLS = ['Agent', 'Task', 'Workflow', 'SendMessage', 'TaskStop', 'TaskOutput']
22const SEARCH_CMDS = ['rg', 'grep', 'find', 'fd', 'ls', 'tree']
23const READ_CMDS = ['cat', 'head', 'tail', 'less', 'bat', 'wc']
24
25function rec(input: unknown): Record<string, unknown> {
26 return input !== null && typeof input === 'object' ? (input as Record<string, unknown>) : {}
27}
28
29function str(v: unknown): string {
30 return typeof v === 'string' ? v : ''
31}
32
33/** The command's own first word, after any `cd x &&` and `VAR=value` prefixes. */
34function bashRest(command: string): string {
35 let c = command.trim()
36 for (let i = 0; i < 8; i++) {
37 const next = c
38 .replace(/^cd\s+\S+\s*(?:&&|;)\s*/, '')
39 .replace(/^[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S*)\s+/, '')
40 if (next === c) break
41 c = next
42 }
43 return c
44}
45
46export function classify(tool: string, input: unknown): Kind {
47 const t = String(tool)
48 if (t === 'Read' || t === 'NotebookRead') return 'read'
49 if (t === 'Grep' || t === 'Glob' || t === 'LSP') return 'search'
50 if (t === 'Edit' || t === 'MultiEdit' || t === 'NotebookEdit') return 'edit'
51 if (t === 'Write') return 'write'
52 if (t === 'Bash') {
53 const rest = bashRest(str(rec(input).command))
54 const word = (rest.split(/\s+/)[0] ?? '').split('/').pop() ?? ''
55 if (SEARCH_CMDS.includes(word)) return 'search'
56 if (READ_CMDS.includes(word) || (word === 'sed' && /^sed\s+(?:-\S+\s+)*-[A-Za-z]*n/.test(rest))) return 'read'
57 return 'bash'
58 }
59 if (BASH_TOOLS.includes(t)) return 'bash'
60 if (t === 'WebFetch' || t === 'WebSearch') return 'web'
61 if (t.startsWith('mcp__')) return /browser|chrome/i.test(t) ? 'web' : 'mcp'
62 if (AGENT_TOOLS.includes(t)) return 'agent'
63 return 'other'
64}
65
66function tail(path: string): string {
67 const parts = path.split(/[\\/]/).filter(Boolean)
68 return parts.slice(-2).join('/')
69}
70
71function base(path: string): string {
72 return path.split(/[\\/]/).filter(Boolean).pop() ?? ''
73}
74
75function host(url: string): string {
76 const m = /^[a-z][a-z0-9+.-]*:\/\/(?:[^/@?#]*@)?([^/:?#]+)/i.exec(url.trim())
77 return m?.[1] ?? url
78}
79
80function raw(tool: string, input: unknown): string {
81 const i = rec(input)
82 switch (tool) {
83 case 'Read':
84 case 'Edit':
85 case 'MultiEdit':
86 case 'Write':
87 case 'NotebookEdit':
88 case 'NotebookRead':
89 return tail(str(i.file_path) || str(i.notebook_path))
90 case 'Grep': {
91 const p = str(i.pattern)
92 const where = base(str(i.path))
93 return p ? `"${p}"${where ? ` in ${where}` : ''}` : ''
94 }
95 case 'Glob':
96 return str(i.pattern)
97 case 'Bash':
98 return str(i.command).trim().split('\n')[0] ?? ''
99 case 'WebFetch':
100 return host(str(i.url))
101 case 'WebSearch':
102 return str(i.query)
103 case 'Agent':
104 case 'Task':
105 return str(i.description)
106 case 'Workflow':
107 return str(i.name) || str(i.description) || str(i.workflow)
108 case 'SendMessage':
109 // A teammate's name reads well; an agent id does not, so ids are left out.
110 return /^[A-Za-z][\w-]{0,31}$/.test(str(i.to)) && !/[0-9a-f]{6}/i.test(str(i.to)) ? `to ${str(i.to)}` : 'message'
111 case 'Skill':
112 return str(i.skill) || str(i.name)
113 }
114 if (tool.startsWith('mcp__')) {
115 const [server, ...rest] = tool.slice(5).split('__')
116 return rest.length ? `${server} · ${rest.join('__')}` : (server ?? '')
117 }
118 return ''
119}
120
121/** Short, single-line, secret-free, at most 60 characters; never throws. */
122export function summarize(tool: string, input: unknown): string {
123 try {
124 return clip(scrubSecrets(tildeHome(sanitize(raw(String(tool), input)))), 60)
125 } catch {
126 return ''
127 }
128}
129hooks/layout.ts 43 lines1// Sizes and limits every drawing shares. Pure: no `$`.
2
3/** CSS pixels per character column on the desktop: a Svg sized from bodyColumns fits its pane. */
4export const PX_PER_COLUMN = 8
5/** One Svg's source is refused past 131,072 characters; stay well under (canvas measured 120k as safe). */
6export const SVG_ELEMENT_MAX = 120_000
7/** All the Svg one pane draws together. */
8export const SVG_PAGE_BUDGET = 240_000
9/** Inline cards and the band are small, static pictures. */
10export const SVG_MINI_MAX = 4_000
11
12/** Pixel width for a site `columns` wide, within sane bounds. */
13export function pxWidth(columns: number, minus = 0): number {
14 return Math.max(200, Math.min(1600, Math.round((columns - minus) * PX_PER_COLUMN)))
15}
16
17/**
18 * The time axis' right end: now plus a quarter of the elapsed span (30 s at
19 * least), kept while it still holds now, so a tick that adds nothing redraws
20 * the identical picture.
21 */
22export function axisEnd(t0: number, now: number, previous: number | undefined): number {
23 if (previous !== undefined && previous > now && previous - t0 < (now - t0) * 2 + 60_000) return previous
24 return now + Math.max(30_000, Math.round((now - t0) * 0.25))
25}
26
27const STEPS_S = [1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600, 7200, 14_400]
28
29/** A round tick interval (ms) giving at most `maxTicks` ticks over `spanMs`. */
30export function niceStep(spanMs: number, maxTicks: number): number {
31 for (const s of STEPS_S) if (spanMs / (s * 1000) <= Math.max(1, maxTicks)) return s * 1000
32 return STEPS_S[STEPS_S.length - 1]! * 1000
33}
34
35/** An axis label for an offset from the start: `0s`, `45s`, `1:30`, `1h`, `1h30`. */
36export function axisLabel(ms: number): string {
37 const s = Math.round(ms / 1000)
38 if (s < 60) return `${s}s`
39 if (s < 3600) return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
40 const m = Math.floor((s % 3600) / 60)
41 return m === 0 ? `${Math.floor(s / 3600)}h` : `${Math.floor(s / 3600)}h${String(m).padStart(2, '0')}`
42}
43hooks/model.ts 685 lines1// The reducer: events in, lanes and a session summary out. Pure: inputs are
2// never mutated, nothing here touches `$`. register.tsx owns the plumbing
3// (planBatch -> reduceLane per lane -> reduceSession) and the state families.
4
5import type { Digest, Kind, Lane, LaneMeta, LaneStatus, LaneTokens, Rollup, Session, Span, Usage, RunPatch } from '../types'
6import type { Ev } from './events'
7import { runKey } from './events'
8import { clip, label } from './format'
9
10export const CAPS = {
11 spans: 300,
12 rollupChunk: 10,
13 rollups: 200,
14 steps: 200,
15 lanes: 150,
16 answer: 4000,
17 turnsDone: 64,
18 lostAfterMs: 30_000,
19 quietAfterMs: 600_000,
20 hiddenGcMs: 60_000,
21}
22
23const PALETTE = 7
24
25export function isLive(status: LaneStatus): boolean {
26 return status === 'running' || status === 'waiting' || status === 'pending'
27}
28
29export function isTerminal(status: LaneStatus): boolean {
30 return status === 'completed' || status === 'failed' || status === 'killed' || status === 'lost'
31}
32
33// ---- tokens -------------------------------------------------------------
34
35const zeroUsage = (): Usage => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 })
36
37export function usageTotal(u: Usage): number {
38 return u.input + u.output + u.cacheRead + u.cacheWrite
39}
40
41export function addUsage(a: Usage, b: Usage): Usage {
42 return {
43 input: a.input + b.input,
44 output: a.output + b.output,
45 cacheRead: a.cacheRead + b.cacheRead,
46 cacheWrite: a.cacheWrite + b.cacheWrite,
47 }
48}
49
50/**
51 * Tokens the lane paid for fresh (input, output, cache writes): committed
52 * turns plus requests of turns still running; the foreground fallback only
53 * when nothing is recorded. Cache reads are counted apart (cachedTokens):
54 * summed over every request they dwarf the rest.
55 */
56export function totalTokens(t: LaneTokens): number {
57 let sum = usageTotal(t.committed) - t.committed.cacheRead
58 let any = usageTotal(t.committed) > 0
59 for (const u of Object.values(t.provisional)) {
60 sum += usageTotal(u) - u.cacheRead
61 any ||= usageTotal(u) > 0
62 }
63 return any ? sum : (t.fallback ?? 0)
64}
65
66/** Input tokens the prompt cache served, over the same requests as totalTokens. */
67export function cachedTokens(t: LaneTokens): number {
68 let sum = t.committed.cacheRead
69 for (const u of Object.values(t.provisional)) sum += u.cacheRead
70 return sum
71}
72
73// ---- constructors -------------------------------------------------------
74
75export function emptySession(epoch: number, now: number): Session {
76 return {
77 v: 1,
78 epoch,
79 startedAt: now,
80 order: [],
81 digests: {},
82 workflows: {},
83 colorNext: 0,
84 live: 0,
85 done: 0,
86 failed: 0,
87 archived: { lanes: 0, tokens: 0, tools: 0, errors: 0 },
88 totals: { tokens: 0, tools: 0, errors: 0 },
89 lastEventAt: now,
90 }
91}
92
93export function emptyLane(key: string, epoch: number, color: number, at: number): Lane {
94 return {
95 v: 1,
96 key,
97 epoch,
98 color,
99 status: key === 'main' ? 'idle' : 'pending',
100 firstAt: at,
101 lastAt: at,
102 runs: [],
103 spans: [],
104 rollups: [],
105 steps: [],
106 counts: {},
107 errors: 0,
108 tools: 0,
109 tokens: { committed: zeroUsage(), provisional: {}, turnsDone: [] },
110 }
111}
112
113// ---- planning -----------------------------------------------------------
114
115export type Plan = {
116 byLane: Map<string, Ev[]>
117 /** Lanes the session has no digest for yet, with the color each gets. */
118 newLanes: Map<string, number>
119 colorNext: number
120 /** tool_use id -> the lane keys it started. */
121 toolUse: Map<string, string[]>
122 /** Run key -> what this batch learned about the run. */
123 workflows: Record<string, RunPatch>
124 dropped: number
125}
126
127const RUN_ENDED: ReadonlySet<string> = new Set(['completed', 'failed', 'killed'])
128
129/** Merges a patch into a run record: fields left out keep their value; an ended run never goes back to running. */
130export function mergeRun<T extends RunPatch>(prev: T, patch: RunPatch): T {
131 const out: RunPatch = { ...prev }
132 for (const [k, v] of Object.entries(patch) as [keyof RunPatch, unknown][]) {
133 if (v === undefined) continue
134 if (k === 'status' && prev.status && RUN_ENDED.has(prev.status) && v === 'running') continue
135 if (k === 'startedAt' && prev.startedAt !== undefined) continue
136 ;(out as Record<string, unknown>)[k] = v
137 }
138 return out as T
139}
140
141export function planBatch(session: Session, batch: Ev[]): Plan {
142 const plan: Plan = {
143 byLane: new Map(),
144 newLanes: new Map(),
145 colorNext: session.colorNext,
146 toolUse: new Map(),
147 workflows: {},
148 dropped: 0,
149 }
150 const kept: { ev: Ev; i: number }[] = []
151 batch.forEach((ev, i) => {
152 if (ev.epoch === session.epoch) kept.push({ ev, i })
153 else plan.dropped++
154 })
155 kept.sort((a, b) => a.ev.at - b.ev.at || a.i - b.i)
156
157 const lane = (key: string): Ev[] => {
158 let list = plan.byLane.get(key)
159 if (!list) plan.byLane.set(key, (list = []))
160 if (!session.digests[key] && !plan.newLanes.has(key)) {
161 plan.newLanes.set(key, key === 'main' ? -1 : plan.colorNext++ % PALETTE)
162 }
163 return list
164 }
165 const link = (id: string, key: string) => {
166 const keys = plan.toolUse.get(id) ?? []
167 if (!keys.includes(key)) keys.push(key)
168 plan.toolUse.set(id, keys)
169 }
170
171 const patchRun = (key: string, patch: RunPatch) => {
172 plan.workflows[key] = mergeRun(plan.workflows[key] ?? {}, patch)
173 }
174 // A notification names its run by task id: the run may be in the session or in this batch.
175 const keyOfTask = (taskId: string): string | undefined => {
176 for (const [k, w] of Object.entries(plan.workflows)) if (w.taskId === taskId) return k
177 for (const [k, w] of Object.entries(session.workflows)) if (w.taskId === taskId) return k
178 return undefined
179 }
180
181 for (const { ev } of kept) {
182 if (ev.t === 'run-update') {
183 const key = ev.key ?? (ev.taskId ? keyOfTask(ev.taskId) : undefined)
184 if (key) patchRun(key, ev.patch)
185 continue
186 }
187 lane(ev.lane).push(ev)
188 if (ev.t === 'spawned' && ev.meta.toolUseId) link(ev.meta.toolUseId, ev.lane)
189 if (ev.t === 'tool-end') {
190 if (ev.child) {
191 link(ev.id, ev.child)
192 lane(ev.child).push({
193 t: 'child-report',
194 epoch: ev.epoch,
195 at: ev.at,
196 lane: ev.child,
197 tokens: ev.childTotals?.tokens,
198 launch: ev.launch,
199 })
200 }
201 if (ev.workflow) {
202 const w = ev.workflow
203 const key = runKey(w)
204 if (key) {
205 patchRun(key, {
206 ...(w.name ? { name: w.name } : {}),
207 ...(w.taskId ? { taskId: w.taskId } : {}),
208 ...(w.transcriptDir ? { transcriptDir: w.transcriptDir } : {}),
209 ...(w.scriptPath ? { scriptPath: w.scriptPath } : {}),
210 ...(w.sessionUrl ? { sessionUrl: w.sessionUrl } : {}),
211 toolUseId: ev.id,
212 startedAt: ev.startedAt,
213 status: w.remote ? 'remote' : 'running',
214 })
215 }
216 }
217 }
218 }
219 return plan
220}
221
222// ---- one lane -----------------------------------------------------------
223
224const hasOpenRun = (l: Lane) => l.runs.some((r) => r.end === undefined)
225
226function lastRunEnd(l: Lane): number | undefined {
227 let end: number | undefined
228 for (const r of l.runs) if (r.end !== undefined && (end === undefined || r.end > end)) end = r.end
229 return end
230}
231
232/** Close everything still open at `at`; spans are marked only when the turn was cut short. */
233function closeAll(l: Lane, at: number, interrupted = false, turnId?: string) {
234 for (const s of l.spans) {
235 if (s.end !== undefined) continue
236 s.end = Math.max(at, s.start)
237 if (interrupted) s.isInterrupted = true
238 }
239 for (const s of l.steps) if (s.end === undefined && (turnId === undefined || s.turnId === turnId)) s.end = Math.max(at, s.start)
240 for (const r of l.runs) if (r.end === undefined) r.end = Math.max(at, r.start)
241}
242
243/** An activity event: wake a dormant lane, make sure a live one has an open run. */
244function touch(l: Lane, at: number, force = false) {
245 if (isTerminal(l.status) || l.status === 'idle') {
246 const since = l.endedAt ?? lastRunEnd(l)
247 if (force || since === undefined || at > since) {
248 l.status = 'running'
249 delete l.endedAt
250 }
251 } else if (l.status === 'pending') {
252 l.status = 'running'
253 }
254 delete l.quiet
255 delete l.missingSince
256 if (!isTerminal(l.status) && l.status !== 'idle' && !hasOpenRun(l)) l.runs.push({ start: at })
257}
258
259function count(l: Lane, kind: Kind, isError: boolean) {
260 l.counts[kind] = (l.counts[kind] ?? 0) + 1
261 l.tools++
262 if (isError) l.errors++
263}
264
265function findSpan(l: Lane, id: string): Span | undefined {
266 for (let i = l.spans.length - 1; i >= 0; i--) if (l.spans[i]!.id === id) return l.spans[i]
267 return undefined
268}
269
270function insertSpan(l: Lane, span: Span) {
271 let i = l.spans.length
272 while (i > 0 && l.spans[i - 1]!.start > span.start) i--
273 l.spans.splice(i, 0, span)
274}
275
276function mergeRollups(a: Rollup, b: Rollup): Rollup {
277 const counts: Rollup['counts'] = { ...a.counts }
278 for (const [k, n] of Object.entries(b.counts)) counts[k as Kind] = (counts[k as Kind] ?? 0) + (n ?? 0)
279 return { start: Math.min(a.start, b.start), end: Math.max(a.end, b.end), counts, errors: a.errors + b.errors }
280}
281
282function applyCaps(l: Lane) {
283 while (l.spans.length > CAPS.spans) {
284 const take: number[] = []
285 for (let i = 0; i < l.spans.length && take.length < CAPS.rollupChunk; i++) {
286 if (l.spans[i]!.end !== undefined) take.push(i)
287 }
288 if (!take.length) break
289 const chunk = take.map((i) => l.spans[i]!)
290 const roll: Rollup = { start: Infinity, end: -Infinity, counts: {}, errors: 0 }
291 for (const s of chunk) {
292 roll.start = Math.min(roll.start, s.start)
293 roll.end = Math.max(roll.end, s.end ?? s.start)
294 roll.counts[s.kind] = (roll.counts[s.kind] ?? 0) + 1
295 if (s.isError) roll.errors++
296 }
297 const gone = new Set(take)
298 l.spans = l.spans.filter((_, i) => !gone.has(i))
299 l.rollups.push(roll)
300 }
301 while (l.rollups.length > CAPS.rollups) {
302 const pairs = Math.min(l.rollups.length - CAPS.rollups, Math.floor(l.rollups.length / 2))
303 const merged: Rollup[] = []
304 for (let i = 0; i < pairs; i++) merged.push(mergeRollups(l.rollups[2 * i]!, l.rollups[2 * i + 1]!))
305 l.rollups = merged.concat(l.rollups.slice(pairs * 2))
306 }
307 if (l.steps.length > CAPS.steps) {
308 let excess = l.steps.length - CAPS.steps
309 l.steps = l.steps.filter((s) => {
310 if (excess > 0 && s.end !== undefined) {
311 excess--
312 return false
313 }
314 return true
315 })
316 }
317}
318
319function setList(l: Lane, status: Exclude<LaneStatus, 'lost' | 'remote'>) {
320 if (status === 'completed' || status === 'failed' || status === 'killed') {
321 if (!isTerminal(l.status) || l.status === 'lost') {
322 l.status = status
323 l.endedAt = l.lastAt
324 delete l.quiet
325 closeAll(l, l.endedAt)
326 }
327 return
328 }
329 // The list lags: it never reopens a finished lane or demotes a running one to pending.
330 // ...except `lost`, our own guess, which the list may contradict.
331 if ((isTerminal(l.status) && l.status !== 'lost') || l.status === 'remote') return
332 if (l.status === 'lost') delete l.endedAt
333 if (status === 'pending' && l.status !== 'pending') return
334 l.status = status
335}
336
337function apply(l: Lane, ev: Ev, now: number) {
338 switch (ev.t) {
339 case 'spawned': {
340 const meta: Record<string, unknown> = { ...(l.meta ?? {}) }
341 for (const [k, v] of Object.entries(ev.meta)) if (v !== undefined) meta[k] = v
342 l.meta = meta as LaneMeta
343 l.spawnedAt = ev.at
344 touch(l, ev.at)
345 if (l.meta.run) delete l.missingSince
346 return
347 }
348 case 'tool-start': {
349 touch(l, ev.at)
350 if (findSpan(l, ev.id)) return
351 insertSpan(l, { id: ev.id, kind: ev.kind, tool: ev.tool, summary: ev.summary, start: ev.at })
352 count(l, ev.kind, false)
353 return
354 }
355 case 'tool-end': {
356 touch(l, ev.at)
357 let span = findSpan(l, ev.id)
358 if (!span) {
359 span = { id: ev.id, kind: ev.kind, tool: ev.tool, summary: ev.summary, start: Math.min(ev.startedAt, ev.at) }
360 insertSpan(l, span)
361 count(l, ev.kind, false)
362 }
363 span.end = Math.max(ev.at, span.start)
364 if (ev.isError && !span.isError) {
365 span.isError = true
366 l.errors++
367 }
368 if (ev.isInterrupted) span.isInterrupted = true
369 if (ev.child) span.child = ev.child
370 return
371 }
372 case 'child-report': {
373 if (ev.tokens !== undefined) l.tokens.fallback = ev.tokens
374 if (ev.launch === 'remote' && !isTerminal(l.status)) l.status = 'remote'
375 return
376 }
377 case 'step-start': {
378 touch(l, ev.at)
379 if (!l.steps.some((s) => s.turnId === ev.turnId && s.index === ev.index)) {
380 l.steps.push({ turnId: ev.turnId, index: ev.index, start: ev.at })
381 }
382 return
383 }
384 case 'step-end': {
385 const step = l.steps.find((s) => s.turnId === ev.turnId && s.index === ev.index)
386 if (step && step.end === undefined) step.end = Math.max(ev.at, step.start)
387 if (ev.usage && !l.tokens.turnsDone.includes(ev.turnId)) {
388 l.tokens.provisional[`${ev.turnId}:${ev.index}`] = ev.usage
389 }
390 return
391 }
392 case 'turn-start': {
393 touch(l, ev.at, true)
394 l.status = 'running'
395 return
396 }
397 case 'lane-phase': {
398 l.phase = ev.phase
399 return
400 }
401 case 'run-update':
402 return
403 case 'say': {
404 // Display only: never reopens a finished lane.
405 if (isTerminal(l.status)) return
406 l.live = { kind: ev.kind, text: ev.text, at: ev.at }
407 return
408 }
409 case 'turn-complete': {
410 if (l.tokens.turnsDone.includes(ev.turnId)) return
411 delete l.live
412 const prefix = `${ev.turnId}:`
413 let sum = zeroUsage()
414 for (const [k, u] of Object.entries(l.tokens.provisional)) {
415 if (!k.startsWith(prefix)) continue
416 sum = addUsage(sum, u)
417 delete l.tokens.provisional[k]
418 }
419 l.tokens.committed = addUsage(l.tokens.committed, ev.usage ?? sum)
420 l.tokens.turnsDone = [...l.tokens.turnsDone, ev.turnId].slice(-CAPS.turnsDone)
421 closeAll(l, ev.at, ev.reason === 'aborted' || ev.reason === 'error', ev.turnId)
422 if (ev.answer) l.answer = clip(ev.answer, CAPS.answer)
423 if (l.key === 'main' || l.meta?.isTeammate) {
424 l.status = 'idle'
425 } else {
426 l.status = ev.reason === 'answer' ? 'completed' : ev.reason === 'aborted' ? 'killed' : 'failed'
427 l.endedAt = ev.at
428 }
429 return
430 }
431 case 'listed': {
432 l.listed = true
433 delete l.missingSince
434 if (ev.adopt && !l.meta) l.meta = ev.adopt
435 if (ev.status !== 'lost' && ev.status !== 'remote') setList(l, ev.status)
436 return
437 }
438 case 'unlisted': {
439 if (l.meta?.run || l.meta?.isFork) {
440 if (!isTerminal(l.status) && now - l.lastAt > CAPS.quietAfterMs) l.quiet = true
441 return
442 }
443 l.missingSince = l.missingSince ?? ev.at
444 if (!isTerminal(l.status) && l.status !== 'remote' && ev.at - l.missingSince >= CAPS.lostAfterMs) {
445 l.status = 'lost'
446 l.endedAt = l.lastAt
447 closeAll(l, l.lastAt)
448 }
449 return
450 }
451 }
452}
453
454export function reduceLane(
455 lane: Lane | null,
456 events: Ev[],
457 ctx: { key: string; epoch: number; color: number; now: number },
458): Lane {
459 const first = events.length ? Math.min(...events.map((e) => e.at)) : ctx.now
460 const l: Lane = lane ? (JSON.parse(JSON.stringify(lane)) as Lane) : emptyLane(ctx.key, ctx.epoch, ctx.color, first)
461 for (const ev of events) {
462 l.firstAt = Math.min(l.firstAt, ev.at)
463 if (ev.t !== 'listed' && ev.t !== 'unlisted') l.lastAt = Math.max(l.lastAt, ev.at)
464 apply(l, ev, ctx.now)
465 applyCaps(l)
466 }
467 return l
468}
469
470// ---- digest, order, session ----------------------------------------------
471
472export function digestOf(lane: Lane, depth: number): Digest {
473 const meta = lane.meta
474 const main = lane.key === 'main'
475 let last: Span | undefined
476 for (const s of lane.spans) {
477 const better = !last || (s.end === undefined) > (last.end === undefined) || ((s.end === undefined) === (last.end === undefined) && s.start >= last.start)
478 if (better) last = s
479 }
480 const d: Digest = {
481 key: lane.key,
482 label: label(meta, lane.key),
483 type: main ? 'main' : (meta?.type ?? ''),
484 status: lane.status,
485 color: lane.color,
486 depth,
487 startedAt: lane.spawnedAt ?? lane.firstAt,
488 lastAt: lane.lastAt,
489 tokens: totalTokens(lane.tokens),
490 cached: cachedTokens(lane.tokens),
491 tools: lane.tools,
492 errors: lane.errors,
493 mix: { ...lane.counts },
494 }
495 if (meta?.model) d.model = meta.model
496 if (!main && meta?.parent) d.parent = meta.parent
497 if (lane.phase) d.phase = lane.phase
498 if (meta?.run) d.run = meta.run
499 if (lane.endedAt !== undefined) d.endedAt = lane.endedAt
500 if (last) d.last = { kind: last.kind, summary: last.summary, at: last.start }
501 if (lane.quiet) d.quiet = true
502 if (!main && !meta) d.hidden = true
503 return d
504}
505
506export function laneOrder(digests: Record<string, Digest>): { order: string[]; depth: Record<string, number> } {
507 const visible = Object.values(digests).filter((d) => !d.hidden)
508 const has = new Set(visible.map((d) => d.key))
509 const hasMain = has.has('main')
510 const kids = new Map<string, Digest[]>()
511 const roots: Digest[] = []
512 for (const d of visible) {
513 if (d.key === 'main') continue
514 const p = d.parent && d.parent !== d.key && has.has(d.parent) ? d.parent : hasMain ? 'main' : undefined
515 if (p === undefined) roots.push(d)
516 else kids.set(p, [...(kids.get(p) ?? []), d])
517 }
518 const byStart = (a: Digest, b: Digest) => a.startedAt - b.startedAt || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)
519
520 // Siblings of one workflow run stay together, placed where the run's first agent starts.
521 const arrange = (list: Digest[]): Digest[] => {
522 const runs = new Map<string, Digest[]>()
523 const items: { at: number; key: string; members: Digest[] }[] = []
524 for (const d of [...list].sort(byStart)) {
525 if (!d.run) {
526 items.push({ at: d.startedAt, key: d.key, members: [d] })
527 continue
528 }
529 let group = runs.get(d.run)
530 if (!group) {
531 runs.set(d.run, (group = []))
532 items.push({ at: d.startedAt, key: d.key, members: group })
533 }
534 group.push(d)
535 }
536 items.sort((a, b) => a.at - b.at || (a.key < b.key ? -1 : 1))
537 return items.flatMap((i) => i.members)
538 }
539
540 const order: string[] = []
541 const depth: Record<string, number> = {}
542 const seen = new Set<string>()
543 const walk = (d: Digest, level: number) => {
544 if (seen.has(d.key)) return
545 seen.add(d.key)
546 order.push(d.key)
547 depth[d.key] = level
548 for (const c of arrange(kids.get(d.key) ?? [])) walk(c, level + 1)
549 }
550 const main = digests['main']
551 if (main && hasMain) walk(main, 0)
552 for (const r of arrange(roots)) walk(r, 0)
553 // Parent cycles are unreachable from any root: show them at the top level instead of losing them.
554 for (const d of [...visible].sort(byStart)) walk(d, hasMain ? 1 : 0)
555 for (const d of Object.values(digests)) if (!(d.key in depth)) depth[d.key] = 0
556 return { order, depth }
557}
558
559export function reduceSession(
560 session: Session,
561 changed: Lane[],
562 plan: Plan,
563 now: number,
564): { session: Session; evict: string[] } {
565 let digests: Record<string, Digest> = { ...session.digests }
566 for (const l of changed) digests[l.key] = digestOf(l, digests[l.key]?.depth ?? 0)
567
568 const workflows = { ...session.workflows }
569 const addRun = (key: string, patch: RunPatch = {}) => {
570 const prev = workflows[key] ?? { at: now, ordinal: Object.keys(workflows).length + 1 }
571 workflows[key] = mergeRun(prev, patch)
572 }
573 for (const [key, patch] of Object.entries(plan.workflows)) addRun(key, patch)
574 for (const l of changed) if (l.meta?.run) addRun(l.meta.run)
575
576 const archived = { ...session.archived }
577 const evict: string[] = []
578 const drop = (key: string) => {
579 const { [key]: _gone, ...rest } = digests
580 digests = rest
581 evict.push(key)
582 }
583 for (const d of Object.values(digests)) {
584 if (d.hidden && (d.status === 'lost' || now - (d.lastAt ?? d.startedAt) > CAPS.hiddenGcMs)) drop(d.key)
585 }
586 while (Object.keys(digests).length > CAPS.lanes) {
587 let oldest: Digest | undefined
588 for (const d of Object.values(digests)) {
589 if (d.key === 'main' || !isTerminal(d.status)) continue
590 if (!oldest || (d.endedAt ?? d.lastAt ?? 0) < (oldest.endedAt ?? oldest.lastAt ?? 0)) oldest = d
591 }
592 if (!oldest) break
593 archived.lanes++
594 archived.tokens += oldest.tokens
595 archived.cached = (archived.cached ?? 0) + (oldest.cached ?? 0)
596 archived.tools += oldest.tools
597 archived.errors += oldest.errors
598 drop(oldest.key)
599 }
600
601 const { order, depth } = laneOrder(digests)
602 let live = 0
603 let done = 0
604 let failed = 0
605 const totals = { tokens: archived.tokens, cached: archived.cached ?? 0, tools: archived.tools, errors: archived.errors }
606 const next: Record<string, Digest> = {}
607 for (const [key, d] of Object.entries(digests)) {
608 next[key] = d.depth === (depth[key] ?? 0) ? d : { ...d, depth: depth[key] ?? 0 }
609 totals.tokens += d.tokens
610 totals.cached += d.cached ?? 0
611 totals.tools += d.tools
612 totals.errors += d.errors
613 if (key === 'main' || d.hidden) continue
614 if (isLive(d.status) && !d.quiet) live++
615 else if (d.status === 'completed') done++
616 else if (d.status === 'failed' || d.status === 'killed' || d.status === 'lost') failed++
617 }
618
619 let lastEventAt = session.lastEventAt
620 let firstSpawnAt = session.firstSpawnAt
621 for (const l of changed) {
622 lastEventAt = Math.max(lastEventAt, l.lastAt)
623 if (l.spawnedAt !== undefined) firstSpawnAt = Math.min(firstSpawnAt ?? l.spawnedAt, l.spawnedAt)
624 }
625
626 const out: Session = {
627 ...session,
628 order,
629 digests: next,
630 workflows,
631 colorNext: plan.colorNext,
632 live,
633 done,
634 failed,
635 archived,
636 totals,
637 lastEventAt,
638 }
639 if (firstSpawnAt !== undefined) out.firstSpawnAt = firstSpawnAt
640 return { session: out, evict }
641}
642
643// ---- reconcile -----------------------------------------------------------
644
645function mapStatus(s: string): LaneStatus {
646 const x = s.toLowerCase()
647 if (x.startsWith('complet') || x === 'done' || x === 'success') return 'completed'
648 if (x.startsWith('fail') || x === 'error') return 'failed'
649 if (x.startsWith('kill') || x.startsWith('cancel') || x.startsWith('stop')) return 'killed'
650 if (x.startsWith('wait') || x.startsWith('block')) return 'waiting'
651 if (x.startsWith('pend') || x.startsWith('queue')) return 'pending'
652 if (x === 'idle') return 'idle'
653 return 'running'
654}
655
656/** Synthetic events for one $.agent.list() pass; the reducer decides what they mean. */
657export function reconcileEvents(
658 session: Session,
659 list: { id: string; status: string; description: string; type: string; parentId?: string; name?: string }[],
660 now: number,
661): Ev[] {
662 const base = { epoch: session.epoch, at: now }
663 const out: Ev[] = []
664 const seen = new Set<string>()
665 for (const item of list) {
666 seen.add(item.id)
667 const known = session.digests[item.id]
668 const ev: Ev = { ...base, t: 'listed', lane: item.id, status: mapStatus(item.status) }
669 if (!known || known.hidden) {
670 ev.adopt = {
671 description: item.description,
672 type: item.type,
673 ...(item.name ? { name: item.name } : {}),
674 parent: item.parentId ?? 'main',
675 }
676 }
677 out.push(ev)
678 }
679 for (const d of Object.values(session.digests)) {
680 if (d.key === 'main' || seen.has(d.key) || isTerminal(d.status) || d.status === 'remote') continue
681 out.push({ ...base, t: 'unlisted', lane: d.key })
682 }
683 return out
684}
685hooks/raster.ts 117 lines1// The terminal's pictures: a Raster per drawing, cells packed as the engine
2// takes them (base64 of little-endian u32 triplets [codePoint, fg, bg]).
3// Pure: no `$`.
4
5import type { Kind, LaneStatus, Span } from '../types'
6import { identity, KIND_SWATCH } from './palette'
7import type { Activity } from './svg-mini'
8import { ACTIVITY_BUCKET_MS, ACTIVITY_WINDOW_MS } from './svg-mini'
9
10const DEFAULT = 0x01000000
11
12export type Cell = [codePoint: number, fg: number, bg: number]
13
14function hex(c: string): number {
15 return parseInt(c.slice(1), 16) & 0xffffff
16}
17
18const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
19
20function base64(bytes: Uint8Array): string {
21 let out = ''
22 for (let i = 0; i < bytes.length; i += 3) {
23 const a = bytes[i]!
24 const b = bytes[i + 1]
25 const c = bytes[i + 2]
26 out += B64[a >> 2]! + B64[((a & 3) << 4) | ((b ?? 0) >> 4)]!
27 out += b === undefined ? '=' : B64[((b & 15) << 2) | ((c ?? 0) >> 6)]!
28 out += c === undefined ? '=' : B64[c & 63]!
29 }
30 return out
31}
32
33/** Packs a grid (row-major, every row `columns` long) as Raster `cells`. */
34export function packCells(grid: readonly Cell[], columns: number, rows: number): string {
35 const words = new Uint32Array(columns * rows * 3)
36 for (let i = 0; i < columns * rows; i++) {
37 const cell = grid[i] ?? [0x20, DEFAULT, DEFAULT]
38 words[i * 3] = cell[0]
39 words[i * 3 + 1] = cell[1]
40 words[i * 3 + 2] = cell[2]
41 }
42 return base64(new Uint8Array(words.buffer))
43}
44
45export type RasterLane = {
46 color: number
47 status: LaneStatus
48 live: boolean
49 start: number
50 end?: number
51 spans: readonly Span[]
52 steps: readonly { start: number; end?: number }[]
53}
54
55/**
56 * One row per lane, one cell per time bucket: `█` in the dominant tool kind's
57 * color where calls ran, `░` in the agent's color for model time, `·` alive and
58 * idle, `▶` for a live head.
59 */
60export function timelineCells(lanes: readonly RasterLane[], o: { columns: number; t0: number; t1: number; now: number }): { cells: string; columns: number; rows: number } {
61 const columns = Math.max(1, Math.min(512, o.columns))
62 const rows = Math.max(1, Math.min(256, lanes.length))
63 const span = Math.max(1000, o.t1 - o.t0)
64 const col = (t: number) => Math.max(0, Math.min(columns - 1, Math.floor(((t - o.t0) / span) * columns)))
65 const grid: Cell[] = new Array(columns * rows)
66 lanes.slice(0, rows).forEach((lane, r) => {
67 const id = hex(identity(lane.color).dark)
68 const kinds: (Partial<Record<Kind, number>> | undefined)[] = new Array(columns)
69 const model: boolean[] = new Array(columns).fill(false)
70 for (const s of lane.spans) {
71 for (let c = col(s.start); c <= col(s.end ?? o.now); c++) {
72 const k = (kinds[c] ??= {})
73 k[s.kind] = (k[s.kind] ?? 0) + 1
74 }
75 }
76 for (const s of lane.steps) for (let c = col(s.start); c <= col(s.end ?? o.now); c++) model[c] = true
77 const a = col(lane.start)
78 const b = col(lane.end ?? o.now)
79 for (let c = 0; c < columns; c++) {
80 let cell: Cell = [0x20, DEFAULT, DEFAULT]
81 const k = kinds[c]
82 if (k) {
83 let best: Kind = 'other'
84 let n = -1
85 for (const [kk, v] of Object.entries(k) as [Kind, number][]) if (v > n) { best = kk; n = v }
86 cell = [0x2588, hex(KIND_SWATCH[best].dark), DEFAULT]
87 } else if (model[c]) cell = [0x2591, id, DEFAULT]
88 else if (c >= a && c <= b) cell = [0x00b7, id, DEFAULT]
89 grid[r * columns + c] = cell
90 }
91 if (lane.live) grid[r * columns + col(o.now)] = [0x25b6, id, DEFAULT]
92 })
93 return { cells: packCells(grid, columns, rows), columns, rows }
94}
95
96/** One row of ▁▂▃▄▅▆▇█ for tool calls per 5 s over two minutes. */
97export function sparkCells(activity: Activity, now: number, columns = 24): { cells: string; columns: number; rows: number } {
98 const n = Math.max(1, Math.min(512, columns))
99 const start = Math.floor(now / ACTIVITY_BUCKET_MS) * ACTIVITY_BUCKET_MS - (Math.round(ACTIVITY_WINDOW_MS / ACTIVITY_BUCKET_MS) - 1) * ACTIVITY_BUCKET_MS
100 const per = Math.round(ACTIVITY_WINDOW_MS / ACTIVITY_BUCKET_MS) / n
101 const totals = new Array(n).fill(0)
102 const tops: (Kind | undefined)[] = new Array(n)
103 for (const b of activity) {
104 const i = Math.floor((b.at - start) / ACTIVITY_BUCKET_MS / per)
105 if (i < 0 || i >= n) continue
106 for (const [k, v] of Object.entries(b.counts) as [Kind, number][]) {
107 totals[i] += v
108 if (!tops[i] || (b.counts[tops[i]!] ?? 0) < v) tops[i] = k
109 }
110 }
111 const max = Math.max(1, ...totals)
112 const grid: Cell[] = totals.map((t, i) =>
113 t === 0 ? [0x2581, hex('#5a5045'), DEFAULT] : [0x2581 + Math.min(7, Math.round((t / max) * 7)), hex(KIND_SWATCH[tops[i] ?? 'other'].dark), DEFAULT],
114 )
115 return { cells: packCells(grid, n, 1), columns: n, rows: 1 }
116}
117hooks/svg-mini.ts 80 lines1// Small static pictures: the tick strip on an inline Agent card and the
2// activity sparkline in the band and the stats strip. No animation (they are
3// redrawn often and drawn as plain images). Pure: no `$`.
4
5import type { Kind, Span } from '../types'
6import { KIND_ORDER } from './kinds'
7import { SVG_MINI_MAX } from './layout'
8import { idClass, MINI_STYLE } from './palette'
9
10/** Tool calls per time bucket, newest last: the band's and the stats strip's sparkline. */
11export type Activity = { at: number; counts: Partial<Record<Kind, number>> }[]
12
13export const ACTIVITY_BUCKET_MS = 5_000
14export const ACTIVITY_WINDOW_MS = 120_000
15
16/** Adds tool starts to the activity buckets and drops buckets older than the window. */
17export function foldActivity(prev: Activity, starts: readonly { at: number; kind: Kind }[], now: number): Activity {
18 const byAt = new Map(prev.map(b => [b.at, { at: b.at, counts: { ...b.counts } }]))
19 for (const s of starts) {
20 const at = Math.floor(s.at / ACTIVITY_BUCKET_MS) * ACTIVITY_BUCKET_MS
21 const b = byAt.get(at) ?? { at, counts: {} }
22 b.counts[s.kind] = (b.counts[s.kind] ?? 0) + 1
23 byAt.set(at, b)
24 }
25 const floor = now - ACTIVITY_WINDOW_MS - ACTIVITY_BUCKET_MS
26 return [...byAt.values()].filter(b => b.at >= floor).sort((a, b) => a.at - b.at)
27}
28
29/** Stacked bars of tool calls per 5 s over the last two minutes, colored by kind. */
30export function sparklineSvg(activity: Activity, now: number, width: number, height = 18): string {
31 const w = Math.max(60, Math.round(width))
32 const n = Math.round(ACTIVITY_WINDOW_MS / ACTIVITY_BUCKET_MS)
33 const bw = w / n
34 const start = Math.floor(now / ACTIVITY_BUCKET_MS) * ACTIVITY_BUCKET_MS - (n - 1) * ACTIVITY_BUCKET_MS
35 let max = 1
36 for (const b of activity) max = Math.max(max, Object.values(b.counts).reduce((s, v) => s + (v ?? 0), 0))
37 const out: string[] = [`<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${height}" viewBox="0 0 ${w} ${height}"><style>${MINI_STYLE}</style>`]
38 out.push(`<rect x="0" y="${height - 1}" width="${w}" height="1" class="trk" fill-opacity=".35"/>`)
39 for (const b of activity) {
40 const i = Math.round((b.at - start) / ACTIVITY_BUCKET_MS)
41 if (i < 0 || i >= n) continue
42 let y = height - 1
43 for (const k of KIND_ORDER) {
44 const v = b.counts[k] ?? 0
45 if (!v) continue
46 const h = Math.max(1, Math.round(((height - 2) * v) / max))
47 y -= h
48 out.push(`<rect x="${Math.round(i * bw)}" y="${y}" width="${Math.max(1, Math.round(bw) - 1)}" height="${h}" class="k-${k}"/>`)
49 }
50 }
51 out.push('</svg>')
52 const s = out.join('')
53 return s.length <= SVG_MINI_MAX ? s : out.slice(0, 2).join('') + '</svg>'
54}
55
56/** A lane's recent tool calls as ticks on its alive track, for an inline card. */
57export function cardStripSvg(spans: readonly Span[], o: { width: number; t0: number; now: number; end?: number; color: number; live: boolean }): string {
58 const w = Math.max(80, Math.round(o.width))
59 const h = 14
60 const id = idClass(o.color)
61 const t1 = Math.max((o.end ?? o.now) + 1000, o.t0 + 10_000)
62 const x = (t: number) => Math.round(((Math.max(o.t0, Math.min(t1, t)) - o.t0) / (t1 - o.t0)) * (w - 8))
63 const head = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><style>${MINI_STYLE}</style>` +
64 `<rect x="0" y="2" width="${Math.max(2, x(o.end ?? o.now))}" height="10" rx="3" class="fi${id}" fill-opacity=".18"/>`
65 const tail = (o.live ? `<circle cx="${x(o.now)}" cy="7" r="3.5" class="fi${id}"/>` : '') + '</svg>'
66 const ticks: string[] = []
67 let size = head.length + tail.length
68 // Newest first until the budget is spent, then drawn oldest first.
69 for (let i = spans.length - 1; i >= 0; i--) {
70 const s = spans[i]!
71 const sx = x(s.start)
72 const t = `<rect x="${sx}" y="4" width="${Math.max(2, x(s.end ?? o.now) - sx)}" height="6" class="k-${s.kind}"/>` +
73 (s.isError ? `<rect x="${sx}" y="2" width="2" height="2" class="err"/>` : '')
74 if (size + t.length > SVG_MINI_MAX) break
75 size += t.length
76 ticks.push(t)
77 }
78 return head + ticks.reverse().join('') + tail
79}
80hooks/svg-timeline.ts 334 lines1// The swimlane timeline: one lane per agent under its parent, tool calls as
2// ticks colored by kind, model requests shaded in the agent's color, live
3// heads pulsing. A string builder that measures itself and sheds detail,
4// level by level, until it fits the Svg limit. Pure: no `$`.
5
6import type { Kind, LaneStatus, Rollup, Span } from '../types'
7import { fmtDuration, xmlEscape } from './format'
8import { axisLabel, niceStep, SVG_ELEMENT_MAX } from './layout'
9import { idClass, shimmerDef, STYLE } from './palette'
10
11export type TimelineLaneRow = {
12 t: 'lane'
13 key: string
14 label: string
15 depth: number
16 color: number
17 status: LaneStatus
18 isMain: boolean
19 quiet?: boolean
20 start: number
21 end?: number
22 spawnedAt?: number
23 parentKey?: string
24 runs: readonly { start: number; end?: number }[]
25 steps: readonly { start: number; end?: number }[]
26 spans: readonly Span[]
27 rollups: readonly Rollup[]
28 emphasis?: 'selected' | 'view'
29 /** Recency rank for the lane cap: lower is kept first (main, live, emphasized, recent). */
30 keep: number
31}
32export type TimelineGroupRow = {
33 t: 'group'
34 label: string
35 depth: number
36 /** A workflow run's span and phases: drawn as a band over its agents' lanes. */
37 band?: { start: number; end?: number; live: boolean; ended?: 'ok' | 'error'; phases: { title: string; start?: number; end?: number }[] }
38}
39export type TimelineRow = TimelineLaneRow | TimelineGroupRow
40
41export type TimelineOpts = {
42 width: number
43 now: number
44 t0: number
45 t1: number
46 animate: boolean
47 /** Lanes left out before drawing (past the drawn-lane cap). */
48 moreCount?: number
49}
50
51export type Built = { source: string; level: number; alt: string; height: number }
52
53const AXIS_H = 20
54const ROW_H = 20
55const GROUP_H = 20
56const BAR_H = 14
57const TICK_H = 8
58const RECENT_TITLES = 40
59const LIVE: ReadonlySet<LaneStatus> = new Set(['running', 'waiting', 'pending'])
60/** Seconds of SMIL growth a live bar gets between redraws. */
61const GROW_S = 60
62
63type Level = { titles: 'all' | 'recent' | 'none'; ticks: 'each' | 'merged' | 'buckets'; maxLanes: number; summary?: true }
64const LEVELS: readonly Level[] = [
65 { titles: 'all', ticks: 'each', maxLanes: Infinity },
66 { titles: 'recent', ticks: 'each', maxLanes: Infinity },
67 { titles: 'none', ticks: 'merged', maxLanes: Infinity },
68 { titles: 'none', ticks: 'buckets', maxLanes: Infinity },
69 { titles: 'none', ticks: 'buckets', maxLanes: 40 },
70 { titles: 'none', ticks: 'buckets', maxLanes: 15 },
71 { titles: 'none', ticks: 'buckets', maxLanes: 0, summary: true },
72]
73
74const r = (n: number) => Math.round(n)
75
76function isLive(row: TimelineLaneRow): boolean {
77 return LIVE.has(row.status) && !row.quiet
78}
79
80const GLYPH: Partial<Record<LaneStatus, [string, string]>> = {
81 completed: ['✓', 'ok'],
82 failed: ['✗', 'err'],
83 killed: ['■', 'mut'],
84 lost: ['?', 'mut'],
85 remote: ['↗', 'mut'],
86 idle: ['◦', 'mut'],
87}
88
89function dominant(counts: Partial<Record<Kind, number>>): Kind {
90 let best: Kind = 'other'
91 let n = -1
92 for (const [k, v] of Object.entries(counts) as [Kind, number][]) if (v > n) { best = k; n = v }
93 return best
94}
95
96/** Which lanes a level keeps: groups survive when one of their lanes does. */
97function pickRows(rows: readonly TimelineRow[], maxLanes: number): { rows: TimelineRow[]; dropped: number } {
98 const lanes = rows.filter((x): x is TimelineLaneRow => x.t === 'lane')
99 if (lanes.length <= maxLanes) return { rows: [...rows], dropped: 0 }
100 const keep = new Set([...lanes].sort((a, b) => a.keep - b.keep).slice(0, maxLanes).map(l => l.key))
101 const out: TimelineRow[] = []
102 let pendingGroup: TimelineGroupRow | undefined
103 for (const row of rows) {
104 if (row.t === 'group') { pendingGroup = row; continue }
105 if (!keep.has(row.key)) continue
106 if (pendingGroup) { out.push(pendingGroup); pendingGroup = undefined }
107 out.push(row)
108 }
109 return { rows: out, dropped: lanes.length - keep.size }
110}
111
112export function timelineAlt(rows: readonly TimelineRow[]): string {
113 const lanes = rows.filter((x): x is TimelineLaneRow => x.t === 'lane' && !x.isMain)
114 const live = lanes.filter(isLive).length
115 const done = lanes.filter(l => l.status === 'completed').length
116 const tools = rows.reduce((n, x) => n + (x.t === 'lane' ? x.spans.length : 0), 0)
117 return `Timeline of ${lanes.length} subagent${lanes.length === 1 ? '' : 's'}: ${live} running, ${done} done, ${tools} recent tool calls`
118}
119
120function buildAt(all: readonly TimelineRow[], o: TimelineOpts, level: Level): { source: string; height: number } {
121 const width = r(Math.max(200, o.width))
122 const t0 = o.t0
123 const t1 = Math.max(o.t1, t0 + 1000)
124 const labelW = r(Math.max(130, Math.min(300, width * 0.34)))
125 const x0 = labelW + 8
126 const x1 = width - 26
127 const span = t1 - t0
128 const pxPerMs = (x1 - x0) / span
129 const x = (t: number) => r(x0 + Math.max(0, Math.min(span, t - t0)) * pxPerMs)
130 const growTo = (w: number, from: number) => Math.max(w, Math.min(w + pxPerMs * GROW_S * 1000, x1 - from))
131
132 const { rows, dropped } = level.summary ? { rows: [] as TimelineRow[], dropped: 0 } : pickRows(all, level.maxLanes)
133 const more = (o.moreCount ?? 0) + dropped + (level.summary ? all.filter(x => x.t === 'lane').length : 0)
134
135 // Row positions first: spawn links need the parent's row.
136 const yMid = new Map<string, number>()
137 let y = AXIS_H
138 const placed: { row: TimelineRow; top: number }[] = []
139 for (const row of rows) {
140 placed.push({ row, top: y })
141 if (row.t === 'lane') yMid.set(row.key, y + ROW_H / 2)
142 y += row.t === 'lane' ? ROW_H : GROUP_H
143 }
144 const height = y + (more > 0 ? 18 : 6)
145
146 const out: string[] = []
147 out.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`)
148 out.push(`<style>${STYLE}.lab{font-size:11px}.sm{font-size:10px}.g{opacity:.55}</style>`)
149
150 // Axis: round ticks, faint grid, the now line.
151 const step = niceStep(span, Math.floor((x1 - x0) / 70))
152 const grid: string[] = []
153 for (let t = 0; t <= span; t += step) {
154 const gx = x(t0 + t)
155 out.push(`<text x="${gx}" y="12" class="ax sm" text-anchor="middle">${axisLabel(t)}</text>`)
156 grid.push(`M${gx} ${AXIS_H - 4}V${height - 4}`)
157 }
158 if (grid.length) out.push(`<path d="${grid.join('')}" class="sax" stroke-opacity=".12" fill="none"/>`)
159 const nx = x(o.now)
160 out.push(`<line x1="${nx}" y1="${AXIS_H - 6}" x2="${nx}" y2="${height - 4}" class="sax" stroke-opacity=".45" stroke-dasharray="3 3">${
161 o.animate && o.now < t1 ? `<animate attributeName="x1" from="${nx}" to="${r(growTo(0, nx) + nx)}" dur="${GROW_S}s" fill="freeze"/><animate attributeName="x2" from="${nx}" to="${r(growTo(0, nx) + nx)}" dur="${GROW_S}s" fill="freeze"/>` : ''
162 }</line>`)
163
164 let shimmers = 0
165 for (const { row, top } of placed) {
166 if (row.t === 'group') {
167 const ix = 4 + row.depth * 12
168 const room = Math.max(4, Math.floor((labelW - ix - 6) / 6.1))
169 const name = row.label.length > room ? `${row.label.slice(0, room - 1)}…` : row.label
170 out.push(`<text x="${ix}" y="${top + 14}" class="fg sm" font-weight="600">◇ ${xmlEscape(name)}</text>`)
171 const b = row.band
172 if (!b) {
173 out.push(`<line x1="${x0}" y1="${top + 10}" x2="${x1}" y2="${top + 10}" class="sax" stroke-opacity=".18"/>`)
174 continue
175 }
176 // The run as a band, its phases as segments, each titled where it fits.
177 const by = top + 5
178 const bh = 11
179 const rx0 = x(b.start)
180 const rw = Math.max(2, x(b.end ?? o.now) - rx0)
181 out.push(`<rect x="${rx0}" y="${by}" width="${rw}" height="${bh}" rx="3" class="trk" fill-opacity=".18"/>`)
182 b.phases.forEach((p, pi) => {
183 if (p.start === undefined) return
184 const px = x(p.start)
185 const pw = Math.max(2, x(p.end ?? b.end ?? o.now) - px)
186 out.push(`<rect x="${px}" y="${by}" width="${pw}" height="${bh}" rx="2" class="fi${pi % 7}" fill-opacity="${p.end === undefined ? '.55' : '.35'}">${level.titles !== 'none' ? `<title>${xmlEscape(p.title)}</title>` : ''}</rect>`)
187 const fits = Math.floor((pw - 6) / 5.6)
188 if (fits >= 3) out.push(`<text x="${px + 4}" y="${by + 8.5}" class="fg" font-size="8.5">${xmlEscape(p.title.length > fits ? `${p.title.slice(0, fits - 1)}…` : p.title)}</text>`)
189 })
190 if (b.live) out.push(`<circle cx="${x(o.now)}" cy="${by + bh / 2}" r="3" class="fim"/>`)
191 else if (b.ended) out.push(`<text x="${x1 + 8}" y="${top + 14}" class="${b.ended === 'ok' ? 'ok' : 'err'} lab">${b.ended === 'ok' ? '✓' : '✗'}</text>`)
192 continue
193 }
194 const mid = top + ROW_H / 2
195 const id = idClass(row.color)
196 const live = isLive(row)
197 const ix = 4 + row.depth * 12
198
199 if (row.emphasis) out.push(`<rect x="0" y="${top}" width="${width}" height="${ROW_H}" class="fi${id}" fill-opacity="${row.emphasis === 'selected' ? '.10' : '.06'}"/>`)
200
201 // Label column: tree rail, identity dot, the name.
202 if (row.depth > 0) out.push(`<path d="M${ix - 7} ${mid - 10}V${mid}H${ix - 1}" class="sax" stroke-opacity=".5" fill="none"/>`)
203 out.push(`<circle cx="${ix + 4}" cy="${mid}" r="3.5" class="fi${id}"/>`)
204 const room = Math.max(4, Math.floor((labelW - ix - 14) / 6.1))
205 const name = row.label.length > room ? `${row.label.slice(0, room - 1)}…` : row.label
206 if (live && o.animate && !row.isMain && level.titles !== 'none') {
207 const gid = `sh${shimmers++}`
208 out.push(`<defs>${shimmerDef(gid, row.color, ix + 12, name.length * 6.4, true)}</defs>`)
209 out.push(`<text x="${ix + 12}" y="${mid + 4}" class="lab" fill="url(#${gid})">${xmlEscape(name)}</text>`)
210 } else {
211 out.push(`<text x="${ix + 12}" y="${mid + 4}" class="fg lab"${row.isMain ? ' font-weight="600"' : ''}${row.quiet ? ' opacity=".55"' : ''}>${xmlEscape(name)}</text>`)
212 }
213
214 // Spawn link from the parent's row.
215 const py = row.parentKey !== undefined ? yMid.get(row.parentKey) : undefined
216 if (py !== undefined && row.spawnedAt !== undefined) {
217 const sx = x(row.spawnedAt)
218 out.push(`<path d="M${sx} ${py}V${mid}" class="si${id}" stroke-opacity=".5" stroke-dasharray="2 2" fill="none"/>`)
219 }
220
221 // Alive track (main: faint whole session, its turns brighter).
222 const end = row.end ?? o.now
223 const bx = x(row.start)
224 const bw = Math.max(2, x(end) - bx)
225 const barTop = mid - BAR_H / 2
226 const grow = live && o.animate && row.end === undefined
227 const growAnim = (w: number, from: number) => grow ? `<animate attributeName="width" from="${w}" to="${r(growTo(w, from))}" dur="${GROW_S}s" fill="freeze"/>` : ''
228 out.push(`<rect x="${bx}" y="${barTop}" width="${bw}" height="${BAR_H}" rx="3" class="fi${id}" fill-opacity="${row.isMain ? '.06' : '.16'}">${row.isMain ? '' : growAnim(bw, bx)}</rect>`)
229 for (const run of row.isMain || row.runs.length > 1 ? row.runs : []) {
230 const rx0 = x(run.start)
231 const rw = Math.max(2, x(run.end ?? o.now) - rx0)
232 out.push(`<rect x="${rx0}" y="${barTop}" width="${rw}" height="${BAR_H}" rx="3" class="fi${id}" fill-opacity=".16">${run.end === undefined ? growAnim(rw, rx0) : ''}</rect>`)
233 }
234
235 // Model requests: brighter shading in the agent's color.
236 if (row.steps.length) {
237 const d: string[] = []
238 for (const s of row.steps) {
239 const sx = x(s.start)
240 const sw = Math.max(1, x(s.end ?? o.now) - sx)
241 d.push(`M${sx} ${barTop}h${sw}v${BAR_H}h${-sw}z`)
242 }
243 out.push(`<path d="${d.join('')}" class="fi${id}" fill-opacity=".38"/>`)
244 }
245
246 // Tool calls.
247 const ty = mid - TICK_H / 2
248 for (const ru of row.rollups) {
249 const rx0 = x(ru.start)
250 const rw = Math.max(2, x(ru.end) - rx0)
251 const n = Object.values(ru.counts).reduce((a, b) => a + (b ?? 0), 0)
252 const title = level.titles === 'none' ? '' : `<title>${n} earlier calls</title>`
253 out.push(`<rect x="${rx0}" y="${ty}" width="${rw}" height="${TICK_H}" class="k-${dominant(ru.counts)}" fill-opacity=".5">${title}</rect>`)
254 }
255 if (level.ticks === 'each') {
256 const titled = level.titles === 'all' ? 0 : Math.max(0, row.spans.length - RECENT_TITLES)
257 row.spans.forEach((s, i) => {
258 const sx = x(s.start)
259 const open = s.end === undefined
260 const sw = Math.max(2, x(s.end ?? o.now) - sx)
261 const anim = open && grow ? `<animate attributeName="width" from="${sw}" to="${r(growTo(sw, sx))}" dur="${GROW_S}s" fill="freeze"/>` : ''
262 const tip = level.titles !== 'none' && i >= titled
263 ? `<title>${xmlEscape(s.summary ? `${s.tool} · ${s.summary}` : s.tool)} · ${open ? 'running' : fmtDuration((s.end ?? o.now) - s.start)}${s.isError ? ' · error' : ''}</title>`
264 : ''
265 out.push(`<rect x="${sx}" y="${ty}" width="${sw}" height="${TICK_H}" class="k-${s.kind}"${open ? ' fill-opacity=".75"' : ''}>${tip}${anim}</rect>`)
266 if (s.isError) out.push(`<rect x="${sx}" y="${ty - 2}" width="${sw}" height="2" class="err"/>`)
267 })
268 } else if (level.ticks === 'merged') {
269 const byKind = new Map<Kind, string[]>()
270 const errs: string[] = []
271 for (const s of row.spans) {
272 const sx = x(s.start)
273 const sw = Math.max(2, x(s.end ?? o.now) - sx)
274 const list = byKind.get(s.kind) ?? []
275 list.push(`M${sx} ${ty}h${sw}v${TICK_H}h${-sw}z`)
276 byKind.set(s.kind, list)
277 if (s.isError) errs.push(`M${sx} ${ty - 2}h${sw}v2h${-sw}z`)
278 }
279 for (const [k, d] of byKind) out.push(`<path d="${d.join('')}" class="k-${k}"/>`)
280 if (errs.length) out.push(`<path d="${errs.join('')}" class="err"/>`)
281 } else {
282 // Buckets: each a few pixels, the dominant kind's color, denser is brighter.
283 const bucketPx = Math.max(3, Math.round((x1 - x0) / Math.min(240, Math.max(30, Math.floor((x1 - x0) / 3)))))
284 const buckets = new Map<number, Partial<Record<Kind, number>>>()
285 for (const s of row.spans) {
286 const b = Math.floor((x(s.start) - x0) / bucketPx)
287 const c = buckets.get(b) ?? {}
288 c[s.kind] = (c[s.kind] ?? 0) + 1
289 buckets.set(b, c)
290 }
291 let max = 1
292 for (const c of buckets.values()) max = Math.max(max, Object.values(c).reduce((a, v) => a + (v ?? 0), 0))
293 for (const [b, c] of buckets) {
294 const n = Object.values(c).reduce((a, v) => a + (v ?? 0), 0)
295 out.push(`<rect x="${x0 + b * bucketPx}" y="${ty}" width="${bucketPx - 1}" height="${TICK_H}" class="k-${dominant(c)}" fill-opacity="${(0.35 + 0.65 * (n / max)).toFixed(2)}"/>`)
296 }
297 }
298
299 // Live head, or how it ended.
300 if (live) {
301 const hx = x(o.now)
302 const pulse = o.animate
303 ? `<animate attributeName="r" values="3;6;3" dur="1.4s" repeatCount="indefinite"/><animate attributeName="fill-opacity" values="1;.45;1" dur="1.4s" repeatCount="indefinite"/>` +
304 (grow ? `<animate attributeName="cx" from="${hx}" to="${r(hx + growTo(0, hx))}" dur="${GROW_S}s" fill="freeze"/>` : '')
305 : ''
306 out.push(`<circle cx="${hx}" cy="${mid}" r="3.5" class="fi${id}">${pulse}</circle>`)
307 } else {
308 const g = row.quiet ? (['⋯', 'mut'] as [string, string]) : GLYPH[row.status]
309 if (g) out.push(`<text x="${x1 + 8}" y="${mid + 4}" class="${g[1]} lab">${g[0]}</text>`)
310 }
311 }
312
313 if (more > 0) {
314 out.push(`<text x="4" y="${height - 5}" class="ax sm">${level.summary ? `${more} agents: too much activity to draw here; the Feed tab lists them` : `+${more} more agent${more === 1 ? '' : 's'} not drawn`}</text>`)
315 }
316 out.push('</svg>')
317 return { source: out.join(''), height }
318}
319
320/** Builds at the richest level that fits `max` characters. */
321export function buildTimelineSvg(rows: readonly TimelineRow[], o: TimelineOpts, max = SVG_ELEMENT_MAX): Built {
322 // Start where a rough count says the first fit lies, then step down by measuring.
323 const ticks = rows.reduce((n, x) => n + (x.t === 'lane' ? x.spans.length + x.steps.length + x.rollups.length : 0), 0)
324 let start = 0
325 if (ticks * 110 > max) start = 1
326 if (ticks * 40 > max) start = 2
327 for (let i = start; i < LEVELS.length; i++) {
328 const built = buildAt(rows, o, LEVELS[i]!)
329 if (built.source.length <= max) return { ...built, level: i, alt: timelineAlt(rows) }
330 }
331 const last = buildAt(rows, o, LEVELS[LEVELS.length - 1]!)
332 return { ...last, level: LEVELS.length - 1, alt: timelineAlt(rows) }
333}
334hooks/workflow.ts 128 lines1// Reading what a Workflow run leaves behind: the phase titles its script
2// declares, and the notification the engine posts when the run ends.
3// Pure: no `$`.
4
5import type { RunStatus } from '../types'
6import { clip, sanitize } from './format'
7
8/**
9 * The phase titles a workflow script's `meta.phases` declares, in order. The
10 * meta block is a pure literal by the Workflow tool's rule, so the titles are
11 * read straight from its text; anything unreadable gives [].
12 */
13export function parsePhases(script: string): string[] {
14 const start = script.indexOf('phases')
15 if (start < 0) return []
16 const open = script.indexOf('[', start)
17 if (open < 0) return []
18 let depth = 0
19 let end = -1
20 for (let i = open; i < script.length; i++) {
21 const c = script[i]
22 if (c === '[') depth++
23 else if (c === ']' && --depth === 0) { end = i; break }
24 }
25 if (end < 0) return []
26 const out: string[] = []
27 for (const m of script.slice(open, end).matchAll(/title\s*:\s*(['"`])((?:\\.|(?!\1).)*)\1/g)) {
28 const title = clip(sanitize(m[2]!.replace(/\\(.)/g, '$1')), 40)
29 if (title && !out.includes(title)) out.push(title)
30 if (out.length >= 24) break
31 }
32 return out
33}
34
35export type RunEnd = { taskId: string; toolUseId?: string; status: RunStatus; agents?: number; tokens?: number; durationMs?: number }
36
37const tag = (text: string, name: string): string | undefined => {
38 const m = text.match(new RegExp(`<${name}>([^<]*)</${name}>`))
39 return m ? m[1]!.trim() : undefined
40}
41
42const num = (s: string | undefined): number | undefined => {
43 const n = s === undefined ? NaN : Number(s)
44 return Number.isFinite(n) && n >= 0 ? n : undefined
45}
46
47/**
48 * A background task's end-of-run notification, if `text` holds one: its task
49 * id, the tool_use id of the call that started it, how it ended and, for a
50 * workflow, its usage. Undefined for any other text.
51 */
52export function parseNotification(text: string): RunEnd | undefined {
53 const i = text.indexOf('<task-notification>')
54 if (i < 0) return undefined
55 const body = text.slice(i, text.indexOf('</task-notification>', i) + 1 || undefined)
56 const taskId = tag(body, 'task-id')
57 const raw = tag(body, 'status')
58 if (!taskId || !raw) return undefined
59 const status: RunStatus = raw === 'completed' ? 'completed' : raw === 'killed' || raw === 'stopped' ? 'killed' : 'failed'
60 return {
61 taskId,
62 toolUseId: tag(body, 'tool-use-id'),
63 status,
64 agents: num(tag(body, 'agent_count')),
65 tokens: num(tag(body, 'subagent_tokens')),
66 durationMs: num(tag(body, 'duration_ms')),
67 }
68}
69
70export type TranscriptTool = { tool: string; summary: string; isError?: boolean }
71export type TranscriptMessage = { role: 'user' | 'assistant'; text: string; tools: TranscriptTool[] }
72
73const COMPUTED = '[Workflow harness — computed task]'
74const RELAYED = '[Workflow harness — user request]'
75
76/**
77 * A workflow agent's transcript file (`agent-<id>.jsonl`, one record per line)
78 * as messages: its task (the computed task text, the harness wrapper taken
79 * off), then each assistant reply with the tool calls it made, consecutive
80 * replies merged. The relayed user request and tool results are left out; a
81 * failed tool result marks its call. `summarize` names a call (kinds.ts).
82 */
83export function parseTranscript(jsonl: string, summarize: (tool: string, input: unknown) => string): TranscriptMessage[] {
84 const out: TranscriptMessage[] = []
85 const byId = new Map<string, TranscriptTool>()
86 for (const line of jsonl.split('\n')) {
87 if (!line.trim()) continue
88 let rec: { type?: string; isMeta?: boolean; message?: { role?: string; content?: unknown } }
89 try { rec = JSON.parse(line) } catch { continue }
90 if (rec.isMeta || (rec.type !== 'user' && rec.type !== 'assistant')) continue
91 const content = rec.message?.content
92 if (rec.type === 'user') {
93 if (typeof content === 'string') {
94 if (content.startsWith(RELAYED)) continue
95 let text = content
96 if (content.startsWith(COMPUTED)) {
97 const at = content.indexOf('follows:\n')
98 text = at < 0 ? '' : content.slice(at + 'follows:\n'.length).replace(/^ {2}/gm, '')
99 }
100 if (text.trim()) out.push({ role: 'user', text: text.trim(), tools: [] })
101 continue
102 }
103 for (const b of Array.isArray(content) ? content : []) {
104 if (b?.type === 'tool_result' && b.is_error) {
105 const t = byId.get(String(b.tool_use_id))
106 if (t) t.isError = true
107 }
108 }
109 continue
110 }
111 const blocks = Array.isArray(content) ? content : typeof content === 'string' ? [{ type: 'text', text: content }] : []
112 let last = out[out.length - 1]
113 if (!last || last.role !== 'assistant') {
114 last = { role: 'assistant', text: '', tools: [] }
115 out.push(last)
116 }
117 for (const b of blocks) {
118 if (b?.type === 'text' && typeof b.text === 'string' && b.text.trim()) last.text = last.text ? `${last.text}\n\n${b.text}` : b.text
119 if (b?.type === 'tool_use') {
120 const t: TranscriptTool = { tool: String(b.name ?? 'tool'), summary: summarize(String(b.name ?? ''), b.input) }
121 last.tools.push(t)
122 if (b.id) byId.set(String(b.id), t)
123 }
124 }
125 }
126 return out.filter(m => m.role === 'user' || m.text || m.tools.length)
127}
128hooks/views.tsx 732 lines1// What the pane, the band and the inline card draw, built from state that
2// register.tsx has read. Pure: no `$`; presses arrive as closures (Actions).
3
4import type { Elements, RenderElement, RenderSurface } from 'claude-code'
5
6import type { Digest, Kind, Lane, LaneStatus, RunStatus, Session, Span, UiState, Usage } from '../types'
7import { clip, fmtDuration, fmtTokens } from './format'
8import { KIND_LABEL, KIND_ORDER } from './kinds'
9import { cachedTokens, isLive, isTerminal, totalTokens } from './model'
10import { KIND_SWATCH, STATUS_SWATCH, textColor } from './palette'
11import { cardStripSvg } from './svg-mini'
12import type { TimelineRow } from './svg-timeline'
13
14export type Kit = {
15 surface: RenderSurface
16 Box: Elements['desktop']['Box']
17 Text: Elements['desktop']['Text']
18 Button: Elements['desktop']['Button']
19 Markdown: Elements['desktop']['Markdown']
20 Link: Elements['desktop']['Link']
21 Svg?: Elements['desktop']['Svg']
22 Raster?: Elements['terminal']['Raster']
23}
24
25export type Actions = {
26 tab: (tab: UiState['tab']) => void
27 /** Show or hide an agent's detail under its row. */
28 expand: (key: string) => void
29 /** Show or hide an agent's children. */
30 fold: (key: string) => void
31 /** Show this agent's transcript in its detail, or its activity again (undefined). */
32 transcript: (key: string | undefined) => void
33 toggleWindow: () => void
34 toggleAnswer: () => void
35 jump?: (key: string) => void
36 open?: (key?: string) => void
37 /** Open the pane on a workflow run, unfolded. */
38 openRun?: (key: string) => void
39}
40
41/** A drawn picture: Svg source on a remote surface, Raster cells on the terminal. */
42export type Picture =
43 | { kind: 'svg'; source: string; alt: string; width: number; height: number; interactive: boolean }
44 | { kind: 'raster'; key: string; cells: string; columns: number; rows: number; labels?: { text: string; color?: string; dim?: boolean }[] }
45 | { kind: 'text'; lines: { text: string; color?: string; dim?: boolean }[] }
46
47// ---- rows for the pictures ----------------------------------------------------
48
49const KEEP_LIVE = 1
50
51/** The visible lanes in display order, with workflow headings, ready for the timeline. */
52export function timelineRows(session: Session, lanes: ReadonlyMap<string, Lane>, ui: UiState, viewKey: string | undefined, t0: number): TimelineRow[] {
53 const rows: TimelineRow[] = []
54 let run: string | undefined
55 let recency = 0
56 const byRecency = [...session.order].sort((a, b) => (session.digests[b]?.lastAt ?? 0) - (session.digests[a]?.lastAt ?? 0))
57 const rank = new Map(byRecency.map((k, i) => [k, i]))
58 for (const key of session.order) {
59 const d = session.digests[key]
60 const lane = lanes.get(key)
61 if (!d || d.hidden || !lane) continue
62 if (d.run !== run) {
63 run = d.run
64 if (run !== undefined) {
65 const r = runSummary(session, run, Date.now())
66 rows.push({
67 t: 'group', label: r.name, depth: Math.max(0, d.depth),
68 band: {
69 start: r.startedAt, ...(r.endedAt !== undefined ? { end: r.endedAt } : {}),
70 live: r.status === 'running' && r.live > 0,
71 ...(r.status === 'completed' ? { ended: 'ok' as const } : r.status === 'failed' || r.status === 'killed' ? { ended: 'error' as const } : {}),
72 phases: r.phases.map(p => ({ title: p.title, ...(p.start !== undefined ? { start: p.start } : {}), ...(p.end !== undefined ? { end: p.end } : {}) })),
73 },
74 })
75 }
76 }
77 const isMain = key === 'main'
78 const emphasis = viewKey === key ? 'view' : undefined
79 const live = isLive(d.status) && !d.quiet
80 recency = rank.get(key) ?? 999
81 const spans = lane.spans.filter(s => (s.end ?? Infinity) >= t0)
82 rows.push({
83 t: 'lane',
84 key,
85 label: d.label,
86 depth: d.depth,
87 color: d.color,
88 status: d.status,
89 isMain,
90 quiet: d.quiet,
91 start: lane.spawnedAt ?? lane.firstAt,
92 end: isTerminal(d.status) || d.status === 'remote' ? (lane.endedAt ?? lane.lastAt) : undefined,
93 spawnedAt: lane.spawnedAt,
94 parentKey: lane.meta?.parent,
95 runs: lane.runs,
96 steps: lane.steps.filter(s => (s.end ?? Infinity) >= t0),
97 spans,
98 rollups: lane.rollups.filter(r => r.end >= t0),
99 emphasis,
100 keep: isMain ? 0 : live ? KEEP_LIVE : emphasis ? 2 : 3 + recency,
101 })
102 }
103 return rows
104}
105
106// ---- numbers -------------------------------------------------------------------
107
108export function laneUsage(lane: Lane): Usage {
109 const u: Usage = { ...lane.tokens.committed }
110 for (const p of Object.values(lane.tokens.provisional)) {
111 u.input += p.input
112 u.output += p.output
113 u.cacheRead += p.cacheRead
114 u.cacheWrite += p.cacheWrite
115 }
116 return u
117}
118
119export function elapsed(d: Pick<Digest, 'startedAt' | 'endedAt' | 'status'>, now: number): number {
120 return Math.max(0, (d.endedAt ?? now) - d.startedAt)
121}
122
123function subagents(session: Session): Digest[] {
124 return session.order.map(k => session.digests[k]).filter((d): d is Digest => !!d && !d.hidden && d.key !== 'main')
125}
126
127export function statsLine(session: Session, now: number): string {
128 const subs = subagents(session)
129 const live = subs.filter(d => isLive(d.status) && !d.quiet).length
130 const done = subs.filter(d => d.status === 'completed').length
131 const failed = subs.filter(d => d.status === 'failed' || d.status === 'killed' || d.status === 'lost').length
132 const parts = [`● ${live} live`, `✓ ${done} done`]
133 if (failed) parts.push(`✗ ${failed}`)
134 parts.push(`${session.totals.tools} tool${session.totals.tools === 1 ? '' : 's'}`, `${fmtTokens(session.totals.tokens)} tok`)
135 if (session.totals.cached) parts.push(`${fmtTokens(session.totals.cached)} cached`)
136 if (session.firstSpawnAt !== undefined) {
137 const wall = Math.max(1, now - session.firstSpawnAt)
138 parts.push(fmtDuration(wall))
139 const agentTime = subs.reduce((s, d) => s + elapsed(d, now), 0)
140 if (agentTime > 0) parts.push(`${(agentTime / wall).toFixed(1)}× parallel`)
141 }
142 if (session.costUsd !== undefined) parts.push(`$${session.costUsd.toFixed(2)}`)
143 return parts.join(' · ')
144}
145
146const STATUS_WORD: Record<LaneStatus, string> = {
147 pending: 'starting', running: 'running', waiting: 'waiting', idle: 'idle', completed: 'done',
148 failed: 'failed', killed: 'stopped', lost: 'lost track', remote: 'remote',
149}
150
151function statusGlyph(d: Pick<Digest, 'status' | 'quiet'>): string {
152 if (d.quiet) return '⋯'
153 switch (d.status) {
154 case 'completed': return '✓'
155 case 'failed': return '✗'
156 case 'killed': return '■'
157 case 'lost': return '?'
158 case 'remote': return '↗'
159 case 'idle': return '◦'
160 default: return '●'
161 }
162}
163
164const SPIN = ['◐', '◓', '◑', '◒']
165
166/** A live agent's glyph turns once a second; an ended one shows how it ended. */
167function liveGlyph(d: Pick<Digest, 'status' | 'quiet'>, now: number): string {
168 return isLive(d.status) && !d.quiet ? SPIN[Math.floor(now / 1000) % SPIN.length]! : statusGlyph(d)
169}
170
171function statusColor(d: Pick<Digest, 'status'>): string | undefined {
172 if (d.status === 'completed') return STATUS_SWATCH.ok.dark
173 if (d.status === 'failed') return STATUS_SWATCH.error.dark
174 return undefined
175}
176
177// ---- pane ------------------------------------------------------------------------
178
179export type PaneInput = {
180 session: Session
181 ui: UiState
182 lanes: ReadonlyMap<string, Lane>
183 now: number
184 columns: number
185 viewKey?: string
186 /** The timeline picture, when that tab is open. */
187 picture?: Picture
188 sparkline?: Picture
189 /** The transcript of the agent whose detail shows it. */
190 transcript?: TranscriptRow[] | 'unreadable'
191}
192
193/** One message of an agent's transcript, reduced to what the pane draws. */
194export type TranscriptRow = { role: 'user' | 'assistant'; text: string; tools: { tool: string; summary: string; isError?: boolean }[] }
195
196function pictureEl(k: Kit, p: Picture): RenderElement {
197 if (p.kind === 'svg' && k.Svg) {
198 return <k.Svg source={p.source} alt={p.alt} width={p.width} height={p.height} {...(p.interactive ? { isInteractive: true } : {})} />
199 }
200 if (p.kind === 'raster' && k.Raster) {
201 const raster = <k.Raster key={p.key} cells={p.cells} columns={p.columns} rows={p.rows} />
202 if (!p.labels) return raster
203 return (
204 <k.Box flexDirection="row">
205 <k.Box flexDirection="column" width={22} flexShrink={0}>
206 {p.labels.map(l => <k.Text color={l.color} dimColor={l.dim} wrap="truncate-end">{l.text}</k.Text>)}
207 </k.Box>
208 {raster}
209 </k.Box>
210 )
211 }
212 const lines = p.kind === 'text' ? p.lines : [{ text: p.kind === 'svg' ? p.alt : 'nothing to draw here', dim: true }]
213 return <k.Box flexDirection="column">{lines.map(l => <k.Text color={l.color} dimColor={l.dim}>{l.text}</k.Text>)}</k.Box>
214}
215
216function legend(k: Kit): RenderElement {
217 return (
218 <k.Box flexDirection="row" flexWrap="wrap" columnGap={2}>
219 {KIND_ORDER.filter(kind => kind !== 'other').map(kind => (
220 <k.Text><k.Text color={KIND_SWATCH[kind].dark}>■</k.Text><k.Text dimColor> {KIND_LABEL[kind]}</k.Text></k.Text>
221 ))}
222 </k.Box>
223 )
224}
225
226function toolName(tool: string): string {
227 return tool.startsWith('mcp__') ? 'MCP' : tool
228}
229
230function spanLine(k: Kit, s: Span, t0: number): RenderElement {
231 const dur = s.end === undefined ? 'running' : fmtDuration(s.end - s.start)
232 return (
233 <k.Text wrap="truncate-end">
234 <k.Text dimColor>+{fmtDuration(Math.max(0, s.start - t0)).padStart(5)} </k.Text>
235 <k.Text color={KIND_SWATCH[s.kind].dark}>▮</k.Text> {toolName(s.tool)}
236 {s.summary ? <k.Text dimColor> {s.summary}</k.Text> : null}
237 <k.Text dimColor> {dur}</k.Text>
238 {s.isError ? <k.Text color={STATUS_SWATCH.error.dark}> ✗</k.Text> : null}
239 </k.Text>
240 )
241}
242
243function mixLine(k: Kit, counts: Partial<Record<Kind, number>>): RenderElement {
244 return (
245 <k.Text wrap="wrap">
246 {KIND_ORDER.filter(kind => (counts[kind] ?? 0) > 0).map(kind => (
247 <k.Text><k.Text color={KIND_SWATCH[kind].dark}>■</k.Text> {KIND_LABEL[kind]} {counts[kind]} </k.Text>
248 ))}
249 </k.Text>
250 )
251}
252
253const LIVE_VERB = { text: 'writing', thinking: 'thinking', tool: 'calling' } as const
254
255/** What a running agent is doing this moment: its open tool call, or the tail of what it streams. */
256function liveLine(k: Kit, lane: Lane, now: number): RenderElement | null {
257 const open = [...lane.spans].reverse().find(s => s.end === undefined)
258 if (open) {
259 return (
260 <k.Text wrap="truncate-end">
261 <k.Text color={KIND_SWATCH[open.kind].dark}>▶</k.Text> {toolName(open.tool)}
262 {open.summary ? <k.Text dimColor> {open.summary}</k.Text> : null}<k.Text dimColor> · {fmtDuration(now - open.start)}</k.Text>
263 </k.Text>
264 )
265 }
266 if (!lane.live) return null
267 const tail = lane.live.text.replace(/\s+/g, ' ').trim()
268 return (
269 <k.Text wrap="truncate-start" dimColor={lane.live.kind === 'thinking'} italic={lane.live.kind === 'thinking'}>
270 <k.Text color={textColor(lane.color, k.surface)}>{LIVE_VERB[lane.live.kind]}</k.Text> {tail}
271 </k.Text>
272 )
273}
274
275function transcriptView(k: Kit, rows: TranscriptRow[] | 'unreadable' | undefined): RenderElement {
276 if (rows === undefined) return <k.Text dimColor>Reading the transcript…</k.Text>
277 if (rows === 'unreadable') return <k.Text dimColor>This agent's transcript can't be read from here (remote agents keep theirs in their cloud session).</k.Text>
278 if (rows.length === 0) return <k.Text dimColor>Nothing in its transcript yet.</k.Text>
279 const shown = rows.slice(-14)
280 return (
281 <k.Box flexDirection="column">
282 {rows.length > shown.length ? <k.Text dimColor>… {rows.length - shown.length} earlier messages</k.Text> : null}
283 {shown.map((m, i) => (
284 <k.Box flexDirection="column" marginTop={i ? 1 : 0}>
285 {m.role === 'user'
286 ? (m.text ? <k.Text dimColor wrap="wrap">{i === 0 && rows.length === shown.length ? 'Task: ' : '› '}{clip(m.text, 600)}</k.Text> : null)
287 : (m.text ? <k.Markdown text={clip(m.text, 1500)} /> : null)}
288 {m.tools.map(t => (
289 <k.Text wrap="truncate-end"><k.Text dimColor> ⎿ </k.Text>{toolName(t.tool)}{t.summary ? <k.Text dimColor> {t.summary}</k.Text> : null}{t.isError ? <k.Text color={STATUS_SWATCH.error.dark}> ✗</k.Text> : null}</k.Text>
290 ))}
291 </k.Box>
292 ))}
293 </k.Box>
294 )
295}
296
297// ---- the tree ----------------------------------------------------------------------
298
299export type RunSummary = {
300 key: string
301 name: string
302 status: RunStatus
303 agents: number
304 done: number
305 failed: number
306 live: number
307 tokens: number
308 startedAt: number
309 endedAt?: number
310 phases: { title: string; total: number; done: number; live: number; start?: number; end?: number }[]
311 sessionUrl?: string
312}
313
314const ENDED_RUN: ReadonlySet<RunStatus> = new Set(['completed', 'failed', 'killed'])
315
316/** One run's standing, from its record and its agents' digests. */
317export function runSummary(session: Session, key: string, now: number = session.lastEventAt): RunSummary {
318 const wf = session.workflows[key]
319 const ds = Object.values(session.digests).filter(d => d.run === key && !d.hidden)
320 const ended = (d: Digest) => isTerminal(d.status)
321 const titles = [...(wf?.phases ?? [])]
322 for (const d of ds) if (d.phase && !titles.includes(d.phase)) titles.push(d.phase)
323 const phases = titles.map(title => {
324 const of = ds.filter(d => d.phase === title)
325 const starts = of.map(d => d.startedAt)
326 const ends = of.map(d => d.endedAt).filter((x): x is number => x !== undefined)
327 return {
328 title, total: of.length, done: of.filter(ended).length, live: of.filter(d => isLive(d.status) && !d.quiet).length,
329 ...(starts.length ? { start: Math.min(...starts) } : {}),
330 ...(of.length && ends.length === of.length ? { end: Math.max(...ends) } : {}),
331 }
332 })
333 const starts = ds.map(d => d.startedAt)
334 const lastEnd = ds.length && ds.every(ended) ? Math.max(...ds.map(d => d.endedAt ?? d.startedAt)) : undefined
335 // A missed notification (a reload mid-run) must not leave a run spinning:
336 // every agent ended and a minute of quiet counts as done.
337 const settled = wf?.status === 'running' && lastEnd !== undefined && now - lastEnd > 60_000
338 const status: RunStatus = settled ? (ds.some(d => d.status === 'failed') ? 'failed' : 'completed') : (wf?.status ?? 'running')
339 return {
340 key,
341 name: wf?.name || `Workflow ${wf?.ordinal ?? ''}`.trim(),
342 status,
343 agents: Math.max(ds.length, wf?.agents ?? 0),
344 done: ds.filter(d => d.status === 'completed').length,
345 failed: ds.filter(d => d.status === 'failed' || d.status === 'killed' || d.status === 'lost').length,
346 live: ds.filter(d => isLive(d.status) && !d.quiet).length,
347 tokens: ds.reduce((n, d) => n + d.tokens, 0),
348 startedAt: wf?.startedAt ?? (starts.length ? Math.min(...starts) : wf?.at ?? 0),
349 ...(ENDED_RUN.has(status) ? { endedAt: wf?.endedAt ?? lastEnd } : {}),
350 phases,
351 ...(wf?.sessionUrl ? { sessionUrl: wf.sessionUrl } : {}),
352 }
353}
354
355type TreeRow =
356 | { t: 'run'; run: RunSummary; n: number; rail: string; childRail: string; children: number }
357 | { t: 'phase'; title: string; total: number; done: number; live: number; rail: string }
358 | { t: 'agent'; d: Digest; n: number; rail: string; childRail: string; children: number }
359
360type Item = { depth: number; d?: Digest; run?: string; phase?: { title: string; total: number; done: number; live: number } }
361
362/**
363 * Rows in display order with box-drawing rails (`├─`, `└─`, `│`), children of a
364 * folded row left out. A workflow run is a row of its own, its agents under
365 * their phases. `n` is an arrival ordinal: what a row's Buttons are keyed by.
366 */
367export function treeRows(session: Session, folded: readonly string[]): TreeRow[] {
368 const items: Item[] = []
369 const seenRuns = new Set<string>()
370 const order = session.order.map(k => session.digests[k]).filter((d): d is Digest => !!d && !d.hidden)
371 for (let i = 0; i < order.length; i++) {
372 const d = order[i]!
373 if (!d.run) {
374 items.push({ d, depth: d.depth })
375 continue
376 }
377 // A run's agents are contiguous among their siblings: take the block.
378 const block: Digest[] = []
379 while (i < order.length && order[i]!.run === d.run && order[i]!.depth === d.depth) block.push(order[i++]!)
380 i--
381 const run = runSummary(session, d.run)
382 seenRuns.add(d.run)
383 items.push({ run: d.run, depth: Math.max(1, d.depth) })
384 const titles = run.phases.map(p => p.title)
385 const rank = (x: Digest) => (x.phase ? titles.indexOf(x.phase) : titles.length)
386 block.sort((a, b) => rank(a) - rank(b) || a.startedAt - b.startedAt)
387 let current: string | undefined
388 for (const x of block) {
389 const base = Math.max(1, d.depth) + 1
390 if (titles.length && x.phase !== current) {
391 current = x.phase
392 const p = run.phases.find(q => q.title === x.phase)
393 if (p) items.push({ phase: p, depth: base })
394 }
395 items.push({ d: x, depth: titles.length && x.phase ? base + 1 : base })
396 }
397 }
398 // Runs with no agent here yet, or none at all (remote): a row each, at the end.
399 for (const [key, wf] of Object.entries(session.workflows).sort((a, b) => a[1].at - b[1].at)) {
400 if (!seenRuns.has(key) && wf.toolUseId) items.push({ run: key, depth: 1 })
401 }
402
403 const out: TreeRow[] = []
404 const fold = new Set(folded)
405 // Keys by arrival, not position: a new agent above never shifts a Button being clicked.
406 const ordinal = new Map(Object.keys(session.digests).map((k, i) => [k, i + 1]))
407 const runOrdinal = new Map(Object.entries(session.workflows).map(([k, w]) => [k, w.ordinal]))
408 let hideBelow = Infinity
409 items.forEach((it, i) => {
410 if (it.depth > hideBelow) return
411 hideBelow = Infinity
412 // Rails: for each ancestor level, a bar while that level has more rows to come.
413 const more = (level: number) => {
414 for (let j = i + 1; j < items.length; j++) {
415 if (items[j]!.depth < level) return false
416 if (items[j]!.depth === level) return true
417 }
418 return false
419 }
420 let rail = ''
421 for (let l = 1; l < it.depth; l++) rail += more(l) ? '│ ' : ' '
422 if (it.depth > 0) rail += more(it.depth) ? '├─' : '└─'
423 let childRail = ''
424 for (let l = 1; l <= it.depth; l++) childRail += more(l) ? '│ ' : ' '
425 let children = 0
426 for (let j = i + 1; j < items.length && items[j]!.depth > it.depth; j++) if (items[j]!.d) children++
427 if (it.phase) {
428 out.push({ t: 'phase', ...it.phase, rail })
429 return
430 }
431 if (it.run !== undefined) {
432 out.push({ t: 'run', run: runSummary(session, it.run, Date.now()), n: runOrdinal.get(it.run) ?? out.length + 1, rail, childRail, children })
433 if (fold.has(`run:${it.run}`) && children > 0) hideBelow = it.depth
434 return
435 }
436 const d = it.d!
437 out.push({ t: 'agent', d, n: ordinal.get(d.key) ?? out.length + 1, rail, childRail, children })
438 if (fold.has(d.key) && children > 0) hideBelow = it.depth
439 })
440 return out
441}
442
443const RUN_WORD: Record<RunStatus, string> = { running: 'running', completed: 'done', failed: 'failed', killed: 'stopped', remote: 'remote' }
444
445function runGlyph(r: RunSummary, now: number): string {
446 if (r.status === 'running') return SPIN[Math.floor(now / 1000) % SPIN.length]!
447 return r.status === 'completed' ? '✓' : r.status === 'remote' ? '↗' : r.status === 'killed' ? '■' : '✗'
448}
449
450function runRow(k: Kit, x: PaneInput, row: Extract<TreeRow, { t: 'run' }>, a: Actions): RenderElement {
451 const r = row.run
452 const isFolded = (x.ui.folded ?? []).includes(`run:${r.key}`)
453 const color = r.status === 'completed' ? STATUS_SWATCH.ok.dark : r.status === 'failed' ? STATUS_SWATCH.error.dark : undefined
454 const meta = [
455 `workflow · ${RUN_WORD[r.status]}`,
456 r.agents ? `${r.done}/${r.agents} agents${r.failed ? ` · ${r.failed} failed` : ''}` : '',
457 r.startedAt ? fmtDuration((r.endedAt ?? x.now) - r.startedAt) : '',
458 r.tokens ? `${fmtTokens(r.tokens)} tok` : '',
459 ].filter(Boolean).join(' · ')
460 return (
461 <k.Box flexDirection="row" columnGap={1}>
462 <k.Box flexDirection="row" flexShrink={0}>
463 {row.rail ? <k.Text dimColor>{row.rail}</k.Text> : null}
464 {row.children > 0
465 ? <k.Button key={`foldrun-${row.n}`} plain dimColor label={isFolded ? `▸${row.children}` : '▾'} onPress={() => a.fold(`run:${r.key}`)} />
466 : <k.Text dimColor>{row.rail ? '─' : ' '}</k.Text>}
467 </k.Box>
468 <k.Box flexShrink={0}><k.Text wrap="truncate-end"><k.Text color={color}>{runGlyph(r, x.now)}</k.Text> <k.Text bold>◇ {clip(r.name, 40)}</k.Text></k.Text></k.Box>
469 <k.Text dimColor wrap="truncate-end">{meta}</k.Text>
470 {r.sessionUrl ? <k.Link href={r.sessionUrl} label="open session" /> : null}
471 </k.Box>
472 )
473}
474
475function phaseRow(k: Kit, row: Extract<TreeRow, { t: 'phase' }>): RenderElement {
476 const done = row.total > 0 && row.done === row.total
477 return (
478 <k.Box flexDirection="row" columnGap={1}>
479 <k.Text dimColor>{row.rail}</k.Text>
480 <k.Text dimColor={!row.live}>{row.title}</k.Text>
481 <k.Text dimColor>{row.done}/{row.total}{row.live ? ` · ${row.live} running` : ''}</k.Text>
482 {done ? <k.Text color={STATUS_SWATCH.ok.dark}>✓</k.Text> : null}
483 </k.Box>
484 )
485}
486
487/** A running agent's name breathes between its color and the page's text, once a second. */
488function nameColor(d: Digest, surface: RenderSurface, now: number): string | undefined {
489 if (!isLive(d.status) || d.quiet) return d.key === 'main' ? textColor(-1, surface) : undefined
490 return Math.floor(now / 1000) % 2 === 0 ? textColor(d.color, surface) : undefined
491}
492
493function agentRow(k: Kit, x: PaneInput, row: Extract<TreeRow, { t: 'agent' }>, a: Actions): RenderElement {
494 const { d, n } = row
495 const lane = x.lanes.get(d.key)
496 const live = isLive(d.status) && !d.quiet
497 const isOpen = (x.ui.expanded ?? []).includes(d.key)
498 const isFolded = (x.ui.folded ?? []).includes(d.key)
499 const viewed = x.viewKey === d.key
500 const meta: string[] = []
501 if (d.key !== 'main') meta.push(d.model ? `${d.type} · ${shortModel(d.model)}` : d.type)
502 if (d.key !== 'main' || live) meta.push(`${STATUS_WORD[d.status]}${d.quiet ? ' (quiet)' : ''}`)
503 if (d.key !== 'main') meta.push(fmtDuration(elapsed(d, x.now)))
504 meta.push(`${d.tools} tool${d.tools === 1 ? '' : 's'}`, `${fmtTokens(d.tokens)} tok`)
505 const stripWidth = 112
506 const strip = k.Svg && lane && d.key !== 'main' && lane.spans.length > 0 && x.columns >= 90
507 ? cardStripSvg(lane.spans, { width: stripWidth, t0: lane.spawnedAt ?? lane.firstAt, now: lane.lastAt, end: lane.endedAt, color: lane.color, live })
508 : undefined
509 const pad = row.childRail + ' '
510 return (
511 <k.Box flexDirection="column">
512 <k.Box flexDirection="row" columnGap={1}>
513 <k.Box flexDirection="row" flexShrink={0}>
514 {row.rail ? <k.Text dimColor>{row.rail}</k.Text> : null}
515 {row.children > 0
516 ? <k.Button key={`fold-${n}`} plain dimColor label={isFolded ? `▸${row.children}` : '▾'} onPress={() => a.fold(d.key)} />
517 : <k.Text dimColor>{row.rail ? '─' : ' '}</k.Text>}
518 </k.Box>
519 <k.Button key={`row-${n}`} plain onPress={() => a.expand(d.key)}>
520 <k.Text color={textColor(d.color, k.surface)}>{liveGlyph(d, x.now)}</k.Text> <k.Text bold={live || d.key === 'main' || isOpen} color={nameColor(d, k.surface, x.now)} underline={viewed}>{clip(d.label, 48)}</k.Text>
521 </k.Button>
522 <k.Text dimColor wrap="truncate-end">{meta.join(' · ')}{d.errors ? ` · ${d.errors} errors` : ''}</k.Text>
523 {strip && k.Svg ? <k.Box flexShrink={0}><k.Svg source={strip} alt={`${d.tools} tool calls`} width={stripWidth} height={14} /></k.Box> : null}
524 </k.Box>
525 {(() => {
526 // Nothing to say yet draws nothing: no row of bare rails.
527 const line = live && lane ? liveLine(k, lane, x.now) : null
528 return line ? <k.Box flexDirection="row"><k.Text dimColor>{pad}</k.Text>{line}</k.Box> : null
529 })()}
530 {isOpen && lane ? (
531 <k.Box flexDirection="row">
532 <k.Text dimColor>{pad}</k.Text>
533 {expandedBlock(k, x, d, lane, n, a)}
534 </k.Box>
535 ) : null}
536 </k.Box>
537 )
538}
539
540function shortModel(model: string): string {
541 return model.replace(/^claude-/, '').replace(/-\d{8}$/, '')
542}
543
544function expandedBlock(k: Kit, x: PaneInput, d: Digest, lane: Lane, n: number, a: Actions): RenderElement {
545 const u = laneUsage(lane)
546 const parent = d.parent ? x.session.digests[d.parent] : undefined
547 const showTranscript = x.ui.transcriptOf === d.key && d.key !== 'main'
548 const answer = lane.answer ?? ''
549 const recent = lane.spans.slice(-8)
550 return (
551 <k.Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1} flexGrow={1}>
552 <k.Text dimColor wrap="wrap">
553 {fmtTokens(totalTokens(lane.tokens))} tokens
554 {u.input + u.output > 0 ? ` (in ${fmtTokens(u.input)} · out ${fmtTokens(u.output)}${u.cacheWrite ? ` · cache write ${fmtTokens(u.cacheWrite)}` : ''})` : ''}
555 {u.cacheRead ? ` · ${fmtTokens(u.cacheRead)} cached` : ''}
556 {parent && d.key !== 'main' ? ` · from ${parent.label}` : ''}
557 </k.Text>
558 {lane.tools > 0 ? mixLine(k, lane.counts) : null}
559 {d.key !== 'main' ? (
560 <k.Box flexDirection="row" columnGap={1}>
561 <k.Button key={`act-${n}`} label="Activity" variant={showTranscript ? undefined : 'primary'} onPress={() => a.transcript(undefined)} />
562 <k.Button key={`tx-${n}`} label="Transcript" variant={showTranscript ? 'primary' : undefined} onPress={() => a.transcript(d.key)} />
563 {a.jump && lane.meta?.toolUseId ? <k.Button key={`jump-${n}`} label="Show in conversation" dimColor onPress={() => a.jump?.(d.key)} /> : null}
564 </k.Box>
565 ) : null}
566 {showTranscript ? transcriptView(k, x.transcript) : null}
567 {!showTranscript && recent.length > 0 ? <k.Text dimColor>Recent calls{lane.tools > recent.length ? ` (last ${recent.length} of ${lane.tools})` : ''}</k.Text> : null}
568 {!showTranscript ? recent.map(s => spanLine(k, s, lane.spawnedAt ?? lane.firstAt)) : null}
569 {!showTranscript && answer ? <k.Markdown text={x.ui.fullAnswer ? answer : clip(answer, 700)} /> : null}
570 {!showTranscript && answer.length > 700 ? <k.Box flexDirection="row"><k.Button key={`answer-${n}`} label={x.ui.fullAnswer ? 'Less' : 'Whole answer'} dimColor onPress={a.toggleAnswer} /></k.Box> : null}
571 </k.Box>
572 )
573}
574
575function tree(k: Kit, x: PaneInput, a: Actions): RenderElement {
576 const rows = treeRows(x.session, x.ui.folded ?? [])
577 return (
578 <k.Box flexDirection="column">
579 {rows.map(row => row.t === 'run' ? runRow(k, x, row, a) : row.t === 'phase' ? phaseRow(k, row) : agentRow(k, x, row, a))}
580 </k.Box>
581 )
582}
583
584/** The timeline is a picture and takes no clicks: its agents, as buttons that open them in the tree. */
585function timelinePicks(k: Kit, x: PaneInput, a: Actions): RenderElement {
586 const ordinal = new Map(Object.keys(x.session.digests).map((key, i) => [key, i + 1]))
587 return (
588 <k.Box flexDirection="row" flexWrap="wrap" columnGap={2}>
589 <k.Text dimColor>Open:</k.Text>
590 {subagents(x.session).map(d => (
591 <k.Button key={`tl-${ordinal.get(d.key)}`} plain onPress={() => a.open?.(d.key)}>
592 <k.Text color={textColor(d.color, k.surface)}>{liveGlyph(d, x.now)}</k.Text> {clip(d.label, 32)}
593 </k.Button>
594 ))}
595 </k.Box>
596 )
597}
598
599export function paneTree(k: Kit, x: PaneInput, a: Actions): RenderElement {
600 const tabs: [UiState['tab'], string, string][] = [['tree', 'Agents', '1'], ['timeline', 'Timeline', '2']]
601 const tab = x.ui.tab === 'timeline' ? 'timeline' : 'tree'
602 const hasAgents = subagents(x.session).length > 0
603 return (
604 <k.Box flexDirection="column" gap={1}>
605 <k.Box flexDirection="row" columnGap={2} flexWrap="wrap">
606 <k.Text wrap="truncate-end">{statsLine(x.session, x.now)}</k.Text>
607 {x.sparkline ? pictureEl(k, x.sparkline) : null}
608 </k.Box>
609 <k.Box flexDirection="row" columnGap={1}>
610 {tabs.map(([id, label, key]) => (
611 <k.Button key={`tab-${id}`} label={label} hotkey={key} variant={tab === id ? 'primary' : undefined} onPress={() => a.tab(id)} />
612 ))}
613 {tab === 'timeline' ? <k.Button key="window" label={x.ui.window === '10m' ? 'Last 10 min' : 'Whole session'} hotkey="w" dimColor onPress={a.toggleWindow} /> : null}
614 </k.Box>
615 {tab === 'tree' ? tree(k, x, a) : null}
616 {tab === 'timeline' && x.picture ? pictureEl(k, x.picture) : null}
617 {tab === 'timeline' && hasAgents ? legend(k) : null}
618 {tab === 'timeline' && hasAgents ? timelinePicks(k, x, a) : null}
619 {!hasAgents ? <k.Text dimColor>No subagents yet. They appear here as they spawn.</k.Text> : null}
620 </k.Box>
621 )
622}
623
624// ---- band --------------------------------------------------------------------
625
626export function bandTree(k: Kit, session: Session, now: number, viewKey: string | undefined, grace: boolean, spark: Picture | undefined, a: Actions): RenderElement {
627 const subs = subagents(session)
628 const live = subs.filter(d => isLive(d.status) && !d.quiet)
629 if (grace || live.length === 0) {
630 const recent = subs.filter(d => d.endedAt !== undefined && now - d.endedAt < 15_000)
631 const span = recent.length ? Math.max(...recent.map(d => (d.endedAt ?? now) - d.startedAt)) : 0
632 return (
633 <k.Box flexDirection="row" columnGap={1}>
634 <k.Text><k.Text color={STATUS_SWATCH.ok.dark}>✓</k.Text> {recent.length} agent{recent.length === 1 ? '' : 's'} done in {fmtDuration(span)} <k.Text dimColor>· {plural(recent.reduce((s, d) => s + d.tools, 0), 'tool')}</k.Text></k.Text>
635 {a.open ? <k.Button key="band-open" label="Agents" hotkey="o" plain dimColor onPress={() => a.open?.()} /> : null}
636 </k.Box>
637 )
638 }
639 return (
640 <k.Box flexDirection="row" columnGap={1} flexWrap="wrap">
641 <k.Text bold>◉ {live.length} agent{live.length === 1 ? '' : 's'}</k.Text>
642 {live.slice(0, 6).map((d, i) => (
643 <k.Button key={`band-${i + 1}`} plain onPress={() => a.open?.(d.key)}>
644 <k.Text color={textColor(d.color, k.surface)}>●</k.Text> <k.Text bold={viewKey === d.key}>{clip(d.label, 22)}</k.Text> <k.Text dimColor>{fmtDuration(elapsed(d, now))}</k.Text>
645 </k.Button>
646 ))}
647 {live.length > 6 ? <k.Text dimColor>+{live.length - 6}</k.Text> : null}
648 {spark ? pictureEl(k, spark) : null}
649 {a.open ? <k.Button key="band-open" label="Agents" hotkey="o" plain dimColor onPress={() => a.open?.()} /> : null}
650 </k.Box>
651 )
652}
653
654// ---- inline Agent card --------------------------------------------------------
655
656export function cardTree(k: Kit, d: Digest | undefined, lane: Lane | undefined, input: { description?: string; subagentType?: string }, now: number, strip: Picture | undefined, children: number, a: Actions, below?: RenderElement): RenderElement {
657 const live = d ? isLive(d.status) && !d.quiet : true
658 const label = d?.label ?? (input.description ? clip(input.description, 48) : 'agent')
659 const type = d?.type ?? input.subagentType ?? 'agent'
660 const color = d?.color ?? 0
661 return (
662 <k.Box flexDirection="column">
663 <k.Box flexDirection="column" borderStyle="round" borderColor={textColor(color, k.surface)} paddingX={1}>
664 <k.Box flexDirection="row" justifyContent="space-between">
665 <k.Text wrap="truncate-end">
666 <k.Text color={textColor(color, k.surface)}>{d ? statusGlyph(d) : '●'}</k.Text> <k.Text bold>{type}</k.Text> <k.Text>{label}</k.Text>
667 {d?.model ? <k.Text dimColor> {d.model}</k.Text> : null}
668 </k.Text>
669 <k.Text color={d ? statusColor(d) : undefined}>{d ? `${STATUS_WORD[d.status]} ${fmtDuration(elapsed(d, now))}` : 'starting…'}</k.Text>
670 </k.Box>
671 {strip ? pictureEl(k, strip) : null}
672 {lane ? (
673 <k.Text dimColor wrap="truncate-end">
674 {plural(lane.tools, 'tool')}
675 {KIND_ORDER.filter(kind => (lane.counts[kind] ?? 0) > 0).slice(0, 4).map(kind => ` · ${lane.counts[kind]} ${KIND_LABEL[kind].toLowerCase()}`).join('')}
676 {' · '}{fmtTokens(totalTokens(lane.tokens))} tok{cachedTokens(lane.tokens) ? ` + ${fmtTokens(cachedTokens(lane.tokens))} cached` : ''}{children ? ` · ${children} nested` : ''}{lane.errors ? ` · ${lane.errors} errors` : ''}
677 </k.Text>
678 ) : null}
679 {live && d?.last ? <k.Text dimColor wrap="truncate-end">↳ {d.last.summary || KIND_LABEL[d.last.kind]}</k.Text> : null}
680 {a.open && d ? (
681 <k.Box flexDirection="row"><k.Button key="card-details" label="Details" plain dimColor onPress={() => a.open?.(d.key)} /></k.Box>
682 ) : null}
683 </k.Box>
684 {below ?? null}
685 </k.Box>
686 )
687}
688
689// ---- inline Workflow card -----------------------------------------------------
690
691/** A Workflow tool row as a live card: the run, its phases, its agents. */
692export function runCardTree(k: Kit, r: RunSummary | undefined, input: { name?: string }, now: number, a: Actions, below?: RenderElement): RenderElement {
693 const name = r?.name ?? input.name ?? 'workflow'
694 const color = r?.status === 'completed' ? STATUS_SWATCH.ok.dark : r?.status === 'failed' ? STATUS_SWATCH.error.dark : undefined
695 return (
696 <k.Box flexDirection="column">
697 <k.Box flexDirection="column" borderStyle="round" borderColor={textColor(-1, k.surface)} paddingX={1}>
698 <k.Box flexDirection="row" justifyContent="space-between">
699 <k.Text wrap="truncate-end"><k.Text color={color}>{r ? runGlyph(r, now) : '●'}</k.Text> <k.Text bold>◇ {clip(name, 48)}</k.Text> <k.Text dimColor>workflow</k.Text></k.Text>
700 <k.Text color={color}>{r ? `${RUN_WORD[r.status]} ${r.startedAt ? fmtDuration((r.endedAt ?? now) - r.startedAt) : ''}` : 'starting…'}</k.Text>
701 </k.Box>
702 {r && r.phases.length ? (
703 <k.Text wrap="wrap">
704 {r.phases.map((p, i) => (
705 <k.Text>
706 {i ? <k.Text dimColor> → </k.Text> : null}
707 <k.Text dimColor={!p.live && p.done < p.total}>{p.title}</k.Text>
708 <k.Text dimColor> {p.done}/{p.total}</k.Text>
709 {p.total > 0 && p.done === p.total ? <k.Text color={STATUS_SWATCH.ok.dark}> ✓</k.Text> : p.live ? <k.Text dimColor> ·{p.live} running</k.Text> : null}
710 </k.Text>
711 ))}
712 </k.Text>
713 ) : null}
714 {r ? (
715 <k.Text dimColor wrap="truncate-end">
716 {r.agents} agents{r.done ? ` · ${r.done} done` : ''}{r.live ? ` · ${r.live} running` : ''}{r.failed ? ` · ${r.failed} failed` : ''}{r.tokens ? ` · ${fmtTokens(r.tokens)} tok` : ''}
717 </k.Text>
718 ) : null}
719 <k.Box flexDirection="row" columnGap={1}>
720 {r?.sessionUrl ? <k.Link href={r.sessionUrl} label="open session" /> : null}
721 {a.openRun && r ? <k.Button key="run-details" label="Details" plain dimColor onPress={() => a.openRun?.(r.key)} /> : null}
722 </k.Box>
723 </k.Box>
724 {below ?? null}
725 </k.Box>
726 )
727}
728
729function plural(n: number, word: string): string {
730 return `${n} ${word}${n === 1 ? '' : 's'}`
731}
732hooks/palette.ts 112 lines1// Colors: agent identity (one per lane, cycling) and tool kind. Taken from the
2// Endless Bounty theme, whose *_FOR_SUBAGENTS_ONLY keys are the engine's own
3// subagent colors, dark values first and light ones for a light page.
4// Pure: no `$`.
5
6import type { Kind } from '../types'
7
8type Swatch = { dark: string; light: string; /** A theme key Text can name on the terminal, when the theme has one. */ key?: string }
9
10/** Main loop: the theme's claude orange. */
11export const MAIN_SWATCH: Swatch = { dark: '#e07b39', light: '#d05010', key: 'claude' }
12
13/** Subagent identities, in assignment order. Red is left out: it means failure here. */
14export const IDENTITY: readonly Swatch[] = [
15 { dark: '#7090b0', light: '#2050a0', key: 'blue_FOR_SUBAGENTS_ONLY' },
16 { dark: '#8cb369', light: '#2d7818', key: 'green_FOR_SUBAGENTS_ONLY' },
17 { dark: '#e0a458', light: '#b07020', key: 'yellow_FOR_SUBAGENTS_ONLY' },
18 { dark: '#9b6b9e', light: '#7030a0', key: 'purple_FOR_SUBAGENTS_ONLY' },
19 { dark: '#609888', light: '#108868', key: 'cyan_FOR_SUBAGENTS_ONLY' },
20 { dark: '#d87070', light: '#d83030', key: 'pink_FOR_SUBAGENTS_ONLY' },
21 { dark: '#b89a6a', light: '#8a6a30' },
22]
23
24export const KIND_SWATCH: Record<Kind, Swatch> = {
25 read: { dark: '#7090b0', light: '#2050a0' },
26 search: { dark: '#609888', light: '#108868' },
27 edit: { dark: '#e0a458', light: '#b07020' },
28 write: { dark: '#e07b39', light: '#d05010' },
29 bash: { dark: '#9b6b9e', light: '#7030a0' },
30 web: { dark: '#8cb369', light: '#2d7818' },
31 mcp: { dark: '#d87070', light: '#d83030' },
32 agent: { dark: '#dcc8a8', light: '#6a5a40' },
33 other: { dark: '#7d8471', light: '#788068' },
34}
35
36export const STATUS_SWATCH = {
37 ok: { dark: '#8cb369', light: '#2d7818' },
38 error: { dark: '#c75d5d', light: '#c03030' },
39 muted: { dark: '#7d8471', light: '#788068' },
40 fg: { dark: '#dcc8a8', light: '#3a3225' },
41 axis: { dark: '#8c8478', light: '#7a7060' },
42 track: { dark: '#7d8471', light: '#788068' },
43}
44
45export function identity(color: number): Swatch {
46 return color < 0 ? MAIN_SWATCH : IDENTITY[color % IDENTITY.length] ?? IDENTITY[0]!
47}
48
49/** What a desktop Text names for a lane: its dark hex (the desktop app keeps its own contrast). */
50export function textColor(color: number, surface: string): string {
51 const s = identity(color)
52 return surface === 'terminal' && s.key ? s.key : s.dark
53}
54
55const KINDS = Object.keys(KIND_SWATCH) as Kind[]
56
57/**
58 * The stylesheet every drawing shares: identity classes `fi0`..`fi6`/`fim`
59 * (fill) and `si*` (stroke), kind classes `k-read`..., and the text classes.
60 * Dark values by default; a light page swaps them.
61 */
62function sheet(scheme: 'dark' | 'light'): string {
63 const out: string[] = []
64 const pick = (s: Swatch) => (scheme === 'dark' ? s.dark : s.light)
65 IDENTITY.forEach((s, i) => out.push(`.fi${i}{fill:${pick(s)}}.si${i}{stroke:${pick(s)}}`))
66 out.push(`.fim{fill:${pick(MAIN_SWATCH)}}.sim{stroke:${pick(MAIN_SWATCH)}}`)
67 IDENTITY.forEach((s, i) => out.push(`.st${i}{stop-color:${pick(s)}}`))
68 out.push(`.stm{stop-color:${pick(MAIN_SWATCH)}}.st-fg{stop-color:${pick(STATUS_SWATCH.fg)}}`)
69 for (const k of KINDS) out.push(`.k-${k}{fill:${pick(KIND_SWATCH[k])}}.sk-${k}{stroke:${pick(KIND_SWATCH[k])}}`)
70 out.push(
71 `.fg{fill:${pick(STATUS_SWATCH.fg)}}.ax{fill:${pick(STATUS_SWATCH.axis)}}.sax{stroke:${pick(STATUS_SWATCH.axis)}}`,
72 `.ok{fill:${pick(STATUS_SWATCH.ok)}}.err{fill:${pick(STATUS_SWATCH.error)}}.serr{stroke:${pick(STATUS_SWATCH.error)}}`,
73 `.mut{fill:${pick(STATUS_SWATCH.muted)}}.trk{fill:${pick(STATUS_SWATCH.track)}}.strk{stroke:${pick(STATUS_SWATCH.track)}}`,
74 )
75 return out.join('')
76}
77
78export const STYLE =
79 `text{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text","Segoe UI",sans-serif}` +
80 sheet('dark') +
81 `@media (prefers-color-scheme: light){${sheet('light')}}`
82
83/**
84 * A gradient that sweeps the agent's color across text drawn in the page's
85 * foreground: a live agent's name shimmers. `width` is the text's extent.
86 */
87export function shimmerDef(id: string, color: number, x: number, width: number, animate: boolean): string {
88 const w = Math.max(40, Math.round(width))
89 const sweep = animate
90 ? `<animateTransform attributeName="gradientTransform" type="translate" from="${-w} 0" to="${w} 0" dur="2.4s" repeatCount="indefinite"/>`
91 : ''
92 return `<linearGradient id="${id}" gradientUnits="userSpaceOnUse" x1="${x}" y1="0" x2="${x + w}" y2="0">` +
93 `<stop offset="0" class="st-fg"/><stop offset=".4" class="st-fg"/><stop offset=".5" class="st${idClass(color)}"/>` +
94 `<stop offset=".6" class="st-fg"/><stop offset="1" class="st-fg"/>${sweep}</linearGradient>`
95}
96
97/** Fill classes alone (identity, kind, err, trk): the small pictures' stylesheet. */
98function fills(scheme: 'dark' | 'light'): string {
99 const pick = (s: Swatch) => (scheme === 'dark' ? s.dark : s.light)
100 return IDENTITY.map((s, i) => `.fi${i}{fill:${pick(s)}}`).join('') +
101 `.fim{fill:${pick(MAIN_SWATCH)}}` +
102 KINDS.map(k => `.k-${k}{fill:${pick(KIND_SWATCH[k])}}`).join('') +
103 `.err{fill:${pick(STATUS_SWATCH.error)}}.trk{fill:${pick(STATUS_SWATCH.track)}}`
104}
105
106export const MINI_STYLE = fills('dark') + `@media (prefers-color-scheme: light){${fills('light')}}`
107
108/** The class suffix of a lane's identity: `0`..`6`, or `m` for main. */
109export function idClass(color: number): string {
110 return color < 0 ? 'm' : String(color % IDENTITY.length)
111}
112