SLOPSHOPPER

subagents-plus

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

newpanebandrowsguardcommand
v0.2.3MITupdated 2026-10-14algonormative/subagents-plus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · subagents-plus
│ ┃ Agents ✕ › fix the failing auth test and add an audit log call │ ┃ ● 0 live · ✓ 0 done · 0 tools · 0 tok │ ┃ ⏺ Read(src/auth.ts) │ ┃ [ Agents ] [ Timeline ] ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ No subagents yet. They appear here as they ⏺ Bash(bun test) │ ┃ spawn. ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /subagents │ ⎿ subagents-plus: Agents pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agents
● 0 live · ✓ 0 done · 0 tools · 0 tok [ Agents ] [ Timeline ] No subagents yet. They appear here as they spawn.
README

subagents-plus

ci release license Claude Code plugin Claude Code surfaces

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 paneTimeline
<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 rowBand 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 agentsThe 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">

  • Agents tree. Every subagent nested under its parent, and workflow agents grouped under their workflow. Each row shows:
  • status, with the name gently pulsing while the agent runs
  • type and model
  • elapsed time, tool calls and tokens
  • a live line saying what the agent is doing right now: the tool it is in and for how long, or the tail of what it is writing or thinking
  • Expand a row for:
  • its token breakdown, with cache reads counted apart
  • its tool mix and recent calls
  • its final answer
  • its transcript, read in the pane (a workflow agent's too, from its run's folder)
  • Workflow runs. A run of the Workflow tool is a row of its own: name, status, agents done of total, elapsed time and tokens, with its agents grouped under their phases (from the script's 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.
  • Timeline. Swimlanes of tool calls (colored by kind) and model time per agent. The agent buttons under it open an agent in the tree.
  • Agent-row cards (desktop app). Each Agent call in the transcript gets a live card: status, model, a strip of its tool calls, and counts. The engine's own row stays underneath.
  • Band above the prompt. While subagents run it shows each agent with its elapsed time and a two-minute activity sparkline. It closes 10 seconds after the last one finishes.

Nested subagents are supported to any depth. The engine allows 3 levels by default; raise it with CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH.

Install

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.

Use

  • /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.
  • Tabs: press 1 for Agents and 2 for Timeline.
  • In the tree: click a name to expand it, and click ▾ to fold an agent's children.
  • Inside an expanded agent:
  • Activity and Transcript switch what it shows.
  • Show in conversation scrolls the transcript to the agent's Agent row. In the desktop app, clicking that row opens the app's own subagent view. A mod can't switch the main transcript itself.
  • Other commands:
  • /subagents tree and /subagents timeline open the pane on that tab.
  • /subagents clear forgets finished agents.
  • /subagents close closes the pane.

Options

Set them with /plugin configure subagents-plus@algonormative. They are stored under pluginConfigs in ~/.claude/settings.json.

OptionDefaultWhat it does
autoOpentrueOpen the pane when the first subagent spawns, where it docks beside the transcript
inlineCardsdesktopLive cards on Agent rows: desktop, all (terminal too) or off
bandtrueThe band above the prompt while subagents run
streamTapsubagentsTime each model request for subagents (subagents), every loop (all), or not at all (off). The tap also feeds the live "writing / thinking" line
animatetruePulse running agents
diagnosticsfalseRegister a read-only status tool the model can call to report what the mod sees and draws. A development aid

How it works

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:

  • Lanes: one lane per agent.
  • Spans: one span per tool call.
  • Steps: one step per model request.

$.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.

Develop

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.

Re-record the demo

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.

License

MIT

Source 13 files
hooks/register.tsx 744 lines
1import { 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}
744
hooks/events.ts 98 lines
1// 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}
98
hooks/format.ts 77 lines
1// 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, '&amp;')
43    .replace(/</g, '&lt;')
44    .replace(/>/g, '&gt;')
45    .replace(/"/g, '&quot;')
46    .replace(/'/g, '&apos;')
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}
77
hooks/kinds.ts 129 lines
1// 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}
129
hooks/layout.ts 43 lines
1// 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}
43
hooks/model.ts 685 lines
1// 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}
685
hooks/raster.ts 117 lines
1// 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}
117
hooks/svg-mini.ts 80 lines
1// 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}
80
hooks/svg-timeline.ts 334 lines
1// 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}
334
hooks/workflow.ts 128 lines
1// 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}
128
hooks/views.tsx 732 lines
1// 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}
732
hooks/palette.ts 112 lines
1// 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