A side pane that draws the session's agent flow live: the main loop's context window, every subagent it spawns as a tree, and for each one the context handed…

A side pane that draws the session's agents as they run: the main loop and its context window at the top, then every subagent it spawns as a tree, and for each one the context handed down to it, what it did with that context, and the answer it handed back up.
Session · turn 3
context 45.2k/200k 22%
████████░░░░░░░░░░░░░░░░░░░░░░
› refactor the auth module and add tests
Agents (1 running · 2 done)
● main 45.2k 12⚒
├─● Explore map auth module 12.0k 6⚒
│ └─◐ general-purpose check tests 8.4k 3⚒
└─● Plan design the refactor 9.1k 2⚒
╭──────────────────────────────────────────────╮
│ Explore map auth module │
│ claude-haiku-4-5 · done · 4.2s │
│ ↓ in from main: prompt 312 tok │
│ Map the auth module. List every file… │
│ ⚙ 5 requests · ctx 12.0k (peak 14.1k) · out 2│
│ Read src/auth.ts │
│ Grep refreshToken │
│ ↑ out to main: answer 820 tok │
│ auth lives in src/auth.ts and src/sess… │
╰──────────────────────────────────────────────╯
[ refresh ] [ clear ] [ close ]
Run /agent-flow to open it, /agent-flow clear to forget finished agents, /agent-flow stop to close it.
main row to see the window broken down by category, as /context counts it, plus how much the main loop handed down to its subagents and got back from them.↓ in: the context handed down. For a normal subagent this is its prompt, the only thing it starts with, and its size in tokens. For a fork, it is the parent's whole context (its size at the moment) plus the directive.⚙: what the agent did with it. The requests it made, how full its own window is now and at its peak, the output tokens, and its last tool calls.↑ out: the context handed back up, which is its final answer (the only part of its work the parent sees) and its size.Markers: ◐ running, ● done, ✗ failed, ○ stopped. Token counts for prompts and answers are estimates (4 characters per token); context sizes come from the API's own usage figures for each request.
In the fullscreen layout the engine docks the pane beside the transcript, floor to ceiling. That layout needs a terminal at least 110 columns wide; turn it on with /tui fullscreen or CLAUDE_CODE_NO_FLICKER=1. On the classic layout the same pane sits above the prompt instead. Mouse clicks land in the fullscreen layout. Everywhere else, focus the pane (ctrl+x tab), move with Tab and press with Enter.
The flow is recorded from session start, whether the pane is open or not, so opening it late still shows every agent so far. While it is open, the status line reads agents: N running · N done · ctx N%.
It only observes: every hook passes its event on unchanged.
| Event | What it records |
|---|---|
turn.start | a new main-loop turn and its prompt |
agent.spawn | a subagent: parent loop, type, description, prompt, fork, background, model |
turn.step | each model request of any loop: its usage, so that loop's context size |
tool.call | each tool call, by the loop it ran in |
turn.complete | a loop's end: its answer, duration, and how it ended |
session.measure | the main window and its % |
It reads $.agent.list() to fold in status changes it did not see (a background agent killed) and $.session.usage({ breakdown: 'summary' }) when you select main. A loop that does work without an Agent call announcing it (a workflow's agent, an engine fork) is still drawn, as loop <id>.
columns: number width asked for the docked pane, 32-120 (default 52)
maxAgents: number subagents kept, oldest finished dropped first (default 60)
openOnStart: boolean open the pane when a session starts (default false)
Declared in .claude-plugin/plugin.json (userConfig). Set them in user settings (~/.claude/settings.json, never project settings), with --settings <file> or in managed settings, under the plugin's full id:
{ "pluginConfigs": { "agent-flow@skills-dir": { "options": { "columns": 64 } } } }
With --plugin-dir the id is plain agent-flow.
npx claude-code-templates@latest --mod ui/agent-flow
claude
It is written to .claude/skills/agent-flow/, which Claude Code auto-loads as agent-flow@skills-dir in a trusted project. For one session: claude --plugin-dir .claude/skills/agent-flow.
If /agent-flow is missing from the typeahead, the mod did not load: run claude --debug and look for hooks module agent-flow@… loaded in ~/.claude/debug/latest.
claude plugin test .claude/skills/agent-flow
The tests drive the flow model directly, then raise turn.start, agent.spawn, turn.step, tool.call and turn.complete through the engine, mount the pane on the terminal surface and press its rows.
Requirements. Mods are on by default in Claude Code 2.1.287+. Written and tested on 2.1.282 against the 2.1.278 declarations. Typed against Anthropic's declarations: https://github.com/anthropics/claude-code/tree/main/mods
hooks/agent-flow.tsx 435 lines1/**
2 * agent-flow — Claude Mod
3 *
4 * A side pane that draws the session's agents as they run: the main loop and
5 * its context window on top, then every subagent as a tree in spawn order.
6 * Select an agent (click, or Tab and Enter) to see the context it was handed
7 * (its prompt, or the parent's whole context for a fork), what it did with it
8 * (steps, tool calls, how full its own window got) and the answer it handed
9 * back up. Select the main loop for the /context breakdown of the session.
10 *
11 * `/agent-flow` opens it · `/agent-flow clear` forgets finished agents ·
12 * `/agent-flow stop` closes it
13 *
14 * It only observes: every hook passes its event on unchanged, and the only
15 * engine reads are `$.agent.list()` and `$.session.usage()`. The flow is
16 * recorded from session start whether the pane is open or not.
17 *
18 * Needs Claude Code >= 2.1.287.
19 *
20 * Options (pluginConfigs["agent-flow@skills-dir"].options):
21 * columns: number width asked for the docked pane (default 52)
22 * maxAgents: number subagents kept, oldest finished dropped first (default 60)
23 * openOnStart: boolean open the pane when the session starts (default false)
24 */
25import type { ContextCategory, Register } from 'claude-code'
26import {
27 MAIN,
28 STATUS_MARK,
29 bar,
30 completed,
31 counts,
32 createFlow,
33 estimateTokens,
34 excerpt,
35 fit,
36 fmtDuration,
37 fmtTokens,
38 mainTurn,
39 maxAgents,
40 paneColumns,
41 rows,
42 spawned,
43 stepped,
44 synced,
45 toolLabel,
46 toolRan,
47 trimmed,
48} from './flow.ts'
49import type { Flow, FlowNode, Status } from './flow.ts'
50
51const PANE = 'agent-flow'
52const COMMAND = 'agent-flow'
53const KEY = 'ag:'
54
55let flow: Flow = createFlow()
56let isOpen = false
57let selected: string | undefined
58let breakdown: ContextCategory[] | undefined
59
60const STATUS_COLOR: Record<Status, string> = { running: 'yellow', done: 'green', failed: 'red', aborted: 'gray' }
61
62function statusText(): string | undefined {
63 if (!isOpen) return undefined
64 const c = counts(flow)
65 const parts = [`${c.running} running`, `${c.done} done`]
66 if (c.failed) parts.push(`${c.failed} failed`)
67 const ctx = flow.percent !== undefined ? ` · ctx ${flow.percent}%` : ''
68 return `agents: ${parts.join(' · ')}${ctx}`
69}
70
71/** Tokens handed down to a node's children and handed back up by them. */
72function exchanged(node: FlowNode): { down: number; up: number; kids: number } {
73 let down = 0
74 let up = 0
75 let kids = 0
76 for (const n of flow.nodes.values()) {
77 if (n.parentId !== node.id) continue
78 kids += 1
79 down += n.fork ? (node.contextTokens ?? 0) : estimateTokens(n.prompt)
80 up += estimateTokens(n.answer)
81 }
82 return { down, up, kids }
83}
84
85export const register: Register = (on, options) => {
86 const columns = paneColumns(options.columns)
87 const keep = maxAgents(options.maxAgents)
88 const openOnStart = options.openOnStart === true
89
90 on('session.start', async ($, e, next) => {
91 const r = await next(e)
92 flow = createFlow(Date.now())
93 selected = undefined
94 breakdown = undefined
95 await $.command
96 .register({
97 name: COMMAND,
98 description: 'Live tree of the session’s agents and the context passed between them (clear|stop)',
99 argumentHint: '[clear|stop]',
100 immediate: true,
101 })
102 .catch(err => $.ui.log(`agent-flow: /${COMMAND} not registered: ${err}`))
103 $.ui.log(`agent-flow loaded: /${COMMAND} opens the pane`, { to: 'debug' })
104 if (openOnStart) {
105 isOpen = true
106 await $.ui.open({ id: PANE, title: 'agents', columns }).catch(err => {
107 isOpen = false
108 $.ui.log(`agent-flow: pane not opened: ${err}`)
109 })
110 $.ui.status(statusText())
111 }
112 return r
113 })
114
115 on('command.run', { command: COMMAND }, async ($, e) => {
116 const arg = e.args.trim().toLowerCase()
117 if (arg === 'stop' || arg === 'close') {
118 await $.ui.close({ id: PANE }).catch(() => undefined)
119 isOpen = false
120 $.ui.status(undefined)
121 return { text: 'agent-flow closed' }
122 }
123 if (arg === 'clear') {
124 trimmed(flow, counts(flow).running)
125 selected = undefined
126 if (isOpen) {
127 $.ui.status(statusText())
128 $.ui.invalidate('ui.render')
129 }
130 return { text: 'agent-flow: finished agents cleared' }
131 }
132 synced(flow, await $.agent.list().catch(() => []))
133 try {
134 await $.ui.open({ id: PANE, title: 'agents', focus: true, columns })
135 } catch (err) {
136 isOpen = false
137 return { text: `agent-flow: pane not opened: ${err}` }
138 }
139 isOpen = true
140 $.ui.status(statusText())
141 $.ui.invalidate('ui.render')
142 const c = counts(flow)
143 const hint = e.presentation.isFullscreen
144 ? 'click an agent, or Tab and Enter'
145 : 'drawn above the prompt; /tui fullscreen docks it beside the transcript'
146 return { text: `${c.running + c.done + c.failed} agents so far · ${hint} · /${COMMAND} stop closes` }
147 })
148
149 on('turn.start', async ($, e, next) => {
150 mainTurn(flow, e.text, Date.now())
151 if (isOpen) {
152 $.ui.status(statusText())
153 $.ui.invalidate('ui.render')
154 }
155 return next(e)
156 })
157
158 // the context handed down: the Agent call's prompt, known before the subagent runs
159 on('agent.spawn', async ($, e, next) => {
160 const r = await next(e)
161 if (r.agentId) {
162 spawned(
163 flow,
164 {
165 agentId: r.agentId,
166 parentAgentId: e.parentAgentId,
167 description: e.description,
168 subagentType: e.subagentType,
169 prompt: e.prompt,
170 fork: e.fork,
171 background: e.background,
172 model: r.model,
173 },
174 Date.now(),
175 )
176 trimmed(flow, keep)
177 if (isOpen) {
178 $.ui.status(statusText())
179 $.ui.invalidate('ui.render')
180 }
181 }
182 return r
183 })
184
185 // each model request: how full that loop's window is now
186 on('turn.step', async function* ($, e, next) {
187 const r = yield* next(e)
188 stepped(flow, e.agentId, r.usage, Date.now())
189 if (isOpen) {
190 $.ui.status(statusText())
191 $.ui.invalidate('ui.render')
192 }
193 return r
194 })
195
196 on('tool.call', async ($, e, next) => {
197 const use = { tool: e.tool, label: toolLabel(e.tool, e as unknown as Record<string, unknown>) }
198 toolRan(flow, e.agentId, use, Date.now())
199 if (isOpen) {
200 $.ui.status(statusText())
201 $.ui.invalidate('ui.render')
202 }
203 const r = await next(e)
204 if (r.deny !== undefined || r.isError) {
205 Object.assign(use, { isError: true })
206 if (isOpen) {
207 $.ui.status(statusText())
208 $.ui.invalidate('ui.render')
209 }
210 }
211 return r
212 })
213
214 // the context handed back up: a subagent's final answer
215 on('turn.complete', async ($, e, next) => {
216 const r = await next(e)
217 completed(
218 flow,
219 e.agentId,
220 { answer: e.answer, reason: e.reason, durationMs: e.durationMs, model: e.usage?.model },
221 Date.now(),
222 )
223 if (!e.agentId) synced(flow, await $.agent.list().catch(() => []))
224 if (isOpen) {
225 $.ui.status(statusText())
226 $.ui.invalidate('ui.render')
227 }
228 return r
229 })
230
231 on('session.measure', async ($, e, next) => {
232 const r = await next(e)
233 flow.window = e.context.window
234 flow.percent = e.context.percent
235 const main = flow.nodes.get(MAIN)
236 if (main && e.context.tokens !== undefined) main.contextTokens = e.context.tokens
237 if (isOpen) {
238 $.ui.status(statusText())
239 $.ui.invalidate('ui.render')
240 }
241 return r
242 })
243
244 on('ui.close', async ($, e, next) => {
245 if (e.id !== PANE) return next(e)
246 const r = await next(e)
247 isOpen = false
248 $.ui.status(undefined)
249 return r
250 })
251
252 on('ui.press', async ($, e, next) => {
253 if (e.plugin !== $.plugin.name || e.requestId !== PANE) return next(e)
254 const r = await next(e)
255 const key = e.element
256 if (key === 'close') {
257 await $.ui.close({ id: PANE }).catch(() => undefined)
258 return r
259 }
260 if (key === 'clear') {
261 trimmed(flow, counts(flow).running)
262 if (selected && !flow.nodes.has(selected)) selected = undefined
263 } else if (key === 'refresh') {
264 synced(flow, await $.agent.list().catch(() => []))
265 } else if (key.startsWith(KEY)) {
266 const id = key.slice(KEY.length)
267 selected = selected === id ? undefined : id
268 }
269 if (selected === MAIN && (key === 'refresh' || key === `${KEY}${MAIN}`)) {
270 const usage = await $.session.usage({ breakdown: 'summary', columns }).catch(() => undefined)
271 breakdown = usage?.context.breakdown?.categories
272 }
273 if (isOpen) {
274 $.ui.status(statusText())
275 $.ui.invalidate('ui.render')
276 }
277 return r
278 })
279
280 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
281 if (e.requestId !== PANE) return next(e)
282 const { Box, Text, Button } = $.ui.resolve(e)
283 const width = Math.max(24, e.props.bodyColumns - 1)
284 const noop = () => {}
285 const main = flow.nodes.get(MAIN)!
286 const tree = rows(flow)
287 const c = counts(flow)
288 const chosen = selected ? flow.nodes.get(selected) : undefined
289
290 const agentRow = (node: FlowNode, prefix: string) => {
291 const ctx = node.contextTokens !== undefined ? fmtTokens(node.contextTokens) : ''
292 const tools = node.toolCount ? `${node.toolCount}⚒` : ''
293 const right = [ctx, tools].filter(Boolean).join(' ')
294 const room = width - prefix.length - 2 - (right ? right.length + 1 : 0)
295 const name = node.id === MAIN ? 'main' : `${node.type}${node.fork ? ' (fork)' : ''} ${node.description}`
296 return (
297 <Box key={`row:${node.id}`} flexDirection="row">
298 {prefix ? <Text dimColor>{prefix}</Text> : null}
299 <Text color={STATUS_COLOR[node.status]}>{`${STATUS_MARK[node.status]} `}</Text>
300 <Button
301 key={`${KEY}${node.id}`}
302 plain
303 dimColor={selected !== node.id && (node.unlisted || node.status !== 'running')}
304 label={fit(name, Math.max(4, room))}
305 onPress={noop}
306 />
307 {right ? <Text color="magenta">{` ${right}`}</Text> : null}
308 </Box>
309 )
310 }
311
312 const header = (
313 <Box key="head" flexDirection="column">
314 <Text bold>{fit(`Session · turn ${flow.turn}`, width)}</Text>
315 <Text>
316 <Text color="cyan">{'context '}</Text>
317 {`${fmtTokens(main.contextTokens)}${flow.window ? `/${fmtTokens(flow.window)}` : ''}`}
318 {flow.percent !== undefined ? <Text dimColor>{` ${flow.percent}%`}</Text> : null}
319 </Text>
320 {flow.percent !== undefined ? (
321 <Text color={flow.percent > 80 ? 'red' : flow.percent > 60 ? 'yellow' : 'green'}>
322 {bar(flow.percent, Math.min(width, 40))}
323 </Text>
324 ) : null}
325 {main.prompt ? <Text dimColor wrap="truncate-end">{`› ${main.prompt.replace(/\s+/g, ' ')}`}</Text> : null}
326 </Box>
327 )
328
329 const detail = chosen ? (
330 <Box key="detail" flexDirection="column" marginTop={1} borderStyle="round" borderDimColor paddingX={1}>
331 {chosen.id === MAIN ? mainDetail(chosen) : agentDetail(chosen)}
332 </Box>
333 ) : null
334
335 function mainDetail(node: FlowNode) {
336 const w = width - 4
337 const x = exchanged(node)
338 const used = (breakdown ?? []).filter(cat => cat.kind === 'used' && cat.tokens > 0)
339 used.sort((a, b) => b.tokens - a.tokens)
340 return [
341 <Text key="t" bold>{fit(`main loop · ${node.steps} requests · ${node.model ?? ''}`, w)}</Text>,
342 <Text key="x" dimColor>
343 {fit(`${x.kids} subagents · ↓ ${fmtTokens(x.down)} handed down · ↑ ${fmtTokens(x.up)} back`, w)}
344 </Text>,
345 <Text key="ch" color="cyan" bold>{'context by category'}</Text>,
346 ...(used.length
347 ? used.slice(0, 8).map((cat, i) => (
348 <Text key={`cat:${i}`}>
349 {fit(cat.name, w - 8).padEnd(Math.max(1, w - 8))}
350 <Text color="magenta">{fmtTokens(cat.tokens).padStart(7)}</Text>
351 </Text>
352 ))
353 : [<Text key="nb" dimColor>{'press refresh to read the breakdown'}</Text>]),
354 ]
355 }
356
357 function agentDetail(node: FlowNode) {
358 const w = width - 4
359 const parent = node.parentId === MAIN ? 'main' : flow.nodes.get(node.parentId ?? '')?.type ?? 'parent'
360 const parentCtx = flow.nodes.get(node.parentId ?? MAIN)?.contextTokens
361 const meta = [node.model, node.status, fmtDuration(node.durationMs), node.background ? 'background' : '']
362 const out = [
363 <Text key="t" bold>{fit(`${node.type} ${node.description}`, w)}</Text>,
364 <Text key="m" dimColor>{fit(meta.filter(Boolean).join(' · '), w)}</Text>,
365 <Text key="in" color="cyan" bold>
366 {fit(
367 node.fork
368 ? `↓ in from ${parent}: its whole context (~${fmtTokens(parentCtx)}) + ${fmtTokens(estimateTokens(node.prompt))}`
369 : node.unlisted
370 ? '↓ in: not seen (a loop no Agent call announced)'
371 : `↓ in from ${parent}: prompt ${fmtTokens(estimateTokens(node.prompt))} tok`,
372 w,
373 )}
374 </Text>,
375 ...excerpt(node.prompt, 4, w - 2).map((l, i) => <Text key={`p:${i}`} dimColor>{` ${l}`}</Text>),
376 <Text key="w" color="yellow" bold>
377 {fit(
378 `⚙ ${node.steps} requests · ctx ${fmtTokens(node.contextTokens)} (peak ${fmtTokens(node.peakContext)}) · out ${fmtTokens(node.outputTokens)}`,
379 w,
380 )}
381 </Text>,
382 ...node.tools.slice(-5).map((u, i) => (
383 <Text key={`u:${i}`} color={u.isError ? 'red' : undefined} dimColor={!u.isError}>
384 {fit(` ${u.tool} ${u.label}`, w)}
385 </Text>
386 )),
387 node.toolCount > 5 ? <Text key="more" dimColor>{` … ${node.toolCount} tool calls in all`}</Text> : null,
388 <Text key="o" color="green" bold>
389 {fit(
390 node.answer !== undefined
391 ? `↑ out to ${parent}: answer ${fmtTokens(estimateTokens(node.answer))} tok`
392 : `↑ out to ${parent}: still working`,
393 w,
394 )}
395 </Text>,
396 ...(node.answer ? excerpt(node.answer, 4, w - 2) : []).map((l, i) => (
397 <Text key={`a:${i}`} dimColor>{` ${l}`}</Text>
398 )),
399 ]
400 const kids = exchanged(node)
401 if (kids.kids) {
402 out.push(
403 <Text key="k" dimColor>
404 {fit(`${kids.kids} subagents · ↓ ${fmtTokens(kids.down)} · ↑ ${fmtTokens(kids.up)}`, w)}
405 </Text>,
406 )
407 }
408 return out
409 }
410
411 return (
412 <Box flexDirection="column">
413 {header}
414 <Box key="ag-head" marginTop={1}>
415 <Text bold color="cyan">
416 {fit(`Agents (${c.running} running · ${c.done} done${c.failed ? ` · ${c.failed} failed` : ''})`, width)}
417 </Text>
418 </Box>
419 {agentRow(main, '')}
420 {tree.map(r => agentRow(r.node, r.prefix))}
421 {tree.length === 0 ? <Text dimColor>{fit(' no subagents yet', width)}</Text> : null}
422 {detail}
423 <Box key="foot" marginTop={1} flexDirection="column">
424 <Box key="toolbar" flexDirection="row" columnGap={1}>
425 <Button key="refresh" label="refresh" hotkey="r" onPress={noop} />
426 <Button key="clear" label="clear" onPress={noop} />
427 <Button key="close" label="close" onPress={noop} />
428 </Box>
429 <Text dimColor>{fit('◐ running ● done ✗ failed ○ stopped · ctx ⚒ tools', width)}</Text>
430 </Box>
431 </Box>
432 )
433 })
434}
435hooks/flow.ts 347 lines1// The agent flow as plain data: the main loop, the subagents it spawned, and
2// what context crossed between them. No engine calls here, so the tests drive
3// it directly and the pane only reads it.
4
5export const MAIN = 'main'
6
7export type Status = 'running' | 'done' | 'failed' | 'aborted'
8
9export type Usage = {
10 input_tokens: number
11 output_tokens: number
12 cache_read_input_tokens: number
13 cache_creation_input_tokens: number
14}
15
16export type ToolUse = { tool: string; label: string; isError?: boolean }
17
18export type FlowNode = {
19 /** the loop's agent id; `main` for the main loop */
20 id: string
21 /** the loop that spawned it; undefined on main */
22 parentId?: string
23 /** agent type (`Explore`, `general-purpose`, `fork`, ...) */
24 type: string
25 /** the Agent call's short description */
26 description: string
27 model?: string
28 status: Status
29 /** inherits the parent's whole context instead of starting from a prompt */
30 fork: boolean
31 background: boolean
32 /** context handed down: the prompt it was given */
33 prompt: string
34 /** context handed back up: its final answer */
35 answer?: string
36 /** input tokens of its latest request: how full its window is now */
37 contextTokens?: number
38 peakContext?: number
39 outputTokens: number
40 steps: number
41 toolCount: number
42 /** the most recent tool calls, newest last */
43 tools: ToolUse[]
44 startedAt: number
45 durationMs?: number
46 /** the main-loop turn it was spawned in (1-based) */
47 turn: number
48 /** a loop the flow saw work but no spawn for (a workflow's agent, an engine fork) */
49 unlisted?: boolean
50}
51
52export type Flow = {
53 nodes: Map<string, FlowNode>
54 /** spawn order, main first */
55 order: string[]
56 turn: number
57 /** the main loop's window, from session.measure */
58 window?: number
59 percent?: number
60}
61
62export const KEEP_TOOLS = 8
63
64export function createFlow(now = 0): Flow {
65 const main: FlowNode = {
66 id: MAIN,
67 type: 'main',
68 description: 'main loop',
69 status: 'done',
70 fork: false,
71 background: false,
72 prompt: '',
73 outputTokens: 0,
74 steps: 0,
75 toolCount: 0,
76 tools: [],
77 startedAt: now,
78 turn: 0,
79 }
80 return { nodes: new Map([[MAIN, main]]), order: [MAIN], turn: 0 }
81}
82
83export const loopId = (agentId: string | undefined) => agentId ?? MAIN
84
85/** Rough tokens of a text: 4 characters each, as the engine's own estimates count. */
86export function estimateTokens(text: string | undefined): number {
87 return text ? Math.ceil(text.length / 4) : 0
88}
89
90/** What a request was answered over: uncached, cache-written and cache-read input together. */
91export function contextOf(usage: Usage): number {
92 return usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
93}
94
95export function mainTurn(flow: Flow, text: string, now: number): void {
96 const main = flow.nodes.get(MAIN)!
97 flow.turn += 1
98 main.turn = flow.turn
99 main.status = 'running'
100 main.startedAt = now
101 main.durationMs = undefined
102 // a continuation carries no typed prompt: keep the one before
103 if (text) main.prompt = text
104}
105
106export type SpawnFacts = {
107 agentId: string
108 parentAgentId?: string
109 description: string
110 subagentType: string
111 prompt: string
112 fork: boolean
113 background: boolean
114 model?: string
115}
116
117export function spawned(flow: Flow, s: SpawnFacts, now: number): FlowNode {
118 const parentId = s.parentAgentId && flow.nodes.has(s.parentAgentId) ? s.parentAgentId : MAIN
119 const known = flow.nodes.get(s.agentId)
120 const node: FlowNode = {
121 ...(known ?? {
122 outputTokens: 0,
123 steps: 0,
124 toolCount: 0,
125 tools: [],
126 startedAt: now,
127 }),
128 id: s.agentId,
129 parentId,
130 type: s.subagentType,
131 description: s.description,
132 model: s.model ?? known?.model,
133 status: known?.status ?? 'running',
134 fork: s.fork,
135 background: s.background,
136 prompt: s.prompt,
137 turn: flow.turn,
138 unlisted: false,
139 }
140 flow.nodes.set(s.agentId, node)
141 if (!known) flow.order.push(s.agentId)
142 return node
143}
144
145/** The node an event's loop maps to, made on the spot for a loop no spawn announced. */
146export function nodeFor(flow: Flow, agentId: string | undefined, now: number): FlowNode {
147 const id = loopId(agentId)
148 const known = flow.nodes.get(id)
149 if (known) return known
150 const node: FlowNode = {
151 id,
152 parentId: MAIN,
153 type: 'loop',
154 description: `loop ${id.slice(0, 8)}`,
155 status: 'running',
156 fork: false,
157 background: false,
158 prompt: '',
159 outputTokens: 0,
160 steps: 0,
161 toolCount: 0,
162 tools: [],
163 startedAt: now,
164 turn: flow.turn,
165 unlisted: true,
166 }
167 flow.nodes.set(id, node)
168 flow.order.push(id)
169 return node
170}
171
172export function stepped(flow: Flow, agentId: string | undefined, usage: Usage | null, now: number): void {
173 const node = nodeFor(flow, agentId, now)
174 node.steps += 1
175 if (node.id !== MAIN && node.status !== 'running') node.status = 'running'
176 if (!usage) return
177 const ctx = contextOf(usage)
178 node.contextTokens = ctx
179 node.peakContext = Math.max(node.peakContext ?? 0, ctx)
180 node.outputTokens += usage.output_tokens
181}
182
183export function toolRan(flow: Flow, agentId: string | undefined, use: ToolUse, now: number): void {
184 const node = nodeFor(flow, agentId, now)
185 node.toolCount += 1
186 node.tools.push(use)
187 if (node.tools.length > KEEP_TOOLS) node.tools.splice(0, node.tools.length - KEEP_TOOLS)
188}
189
190export type CompleteFacts = {
191 answer: string
192 reason: 'answer' | 'aborted' | 'refusal' | 'error'
193 durationMs: number
194 model?: string
195}
196
197export function completed(flow: Flow, agentId: string | undefined, c: CompleteFacts, now: number): void {
198 const node = nodeFor(flow, agentId, now)
199 node.status = c.reason === 'answer' ? 'done' : c.reason === 'aborted' ? 'aborted' : 'failed'
200 node.answer = c.answer
201 node.durationMs = c.durationMs
202 if (c.model) node.model = c.model
203}
204
205/** Folds `$.agent.list()` statuses in: a background agent killed outside our view, a name. */
206export function synced(flow: Flow, list: readonly { id: string; status: string; type: string; description: string }[]): void {
207 for (const info of list) {
208 const node = flow.nodes.get(info.id)
209 if (!node) continue
210 if (node.unlisted) {
211 node.unlisted = false
212 node.type = info.type
213 node.description = info.description
214 }
215 if (node.status === 'running') {
216 if (info.status === 'completed') node.status = 'done'
217 else if (info.status === 'failed') node.status = 'failed'
218 else if (info.status === 'killed') node.status = 'aborted'
219 }
220 }
221}
222
223/** Drops the oldest finished subagents past `max`, and their subtrees with them. */
224export function trimmed(flow: Flow, max: number): void {
225 const subs = flow.order.filter(id => id !== MAIN)
226 let over = subs.length - max
227 for (const id of subs) {
228 if (over <= 0) break
229 if (!flow.nodes.has(id)) continue
230 const branch = subtree(flow, id)
231 if (branch.some(x => flow.nodes.get(x)?.status === 'running')) continue
232 for (const gone of branch) {
233 flow.nodes.delete(gone)
234 flow.order = flow.order.filter(x => x !== gone)
235 over -= 1
236 }
237 }
238}
239
240export function children(flow: Flow, id: string): FlowNode[] {
241 return flow.order.map(x => flow.nodes.get(x)!).filter(n => n && n.parentId === id)
242}
243
244export function subtree(flow: Flow, id: string): string[] {
245 return [id, ...children(flow, id).flatMap(c => subtree(flow, c.id))]
246}
247
248export type Row = { node: FlowNode; depth: number; prefix: string }
249
250/** The tree in spawn order, depth first, with its box-drawing prefix. */
251export function rows(flow: Flow): Row[] {
252 const out: Row[] = []
253 const walk = (id: string, depth: number, lead: string) => {
254 const kids = children(flow, id)
255 kids.forEach((kid, i) => {
256 const last = i === kids.length - 1
257 out.push({ node: kid, depth, prefix: `${lead}${last ? '└─' : '├─'}` })
258 walk(kid.id, depth + 1, `${lead}${last ? ' ' : '│ '}`)
259 })
260 }
261 walk(MAIN, 1, '')
262 return out
263}
264
265export function counts(flow: Flow): { running: number; done: number; failed: number } {
266 let running = 0
267 let done = 0
268 let failed = 0
269 for (const n of flow.nodes.values()) {
270 if (n.id === MAIN) continue
271 if (n.status === 'running') running += 1
272 else if (n.status === 'done') done += 1
273 else failed += 1
274 }
275 return { running, done, failed }
276}
277
278const str = (v: unknown) => (typeof v === 'string' ? v : '')
279
280/** One line saying what a tool call touched: the command, the path, the pattern. */
281export function toolLabel(tool: string, input: Record<string, unknown>): string {
282 const pick =
283 str(input.description) && tool === 'Agent'
284 ? str(input.description)
285 : str(input.command) ||
286 str(input.file_path) ||
287 str(input.path) ||
288 str(input.pattern) ||
289 str(input.url) ||
290 str(input.query) ||
291 str(input.prompt) ||
292 str(input.description)
293 return pick.replace(/\s+/g, ' ').trim()
294}
295
296export function fmtTokens(n: number | undefined): string {
297 if (n === undefined) return '–'
298 if (n < 1000) return String(n)
299 if (n < 100_000) return `${(n / 1000).toFixed(1)}k`
300 if (n < 1_000_000) return `${Math.round(n / 1000)}k`
301 return `${(n / 1_000_000).toFixed(1)}M`
302}
303
304export function fmtDuration(ms: number | undefined): string {
305 if (ms === undefined) return ''
306 if (ms < 1000) return `${ms}ms`
307 const s = ms / 1000
308 if (s < 60) return `${s.toFixed(1)}s`
309 return `${Math.floor(s / 60)}m${String(Math.round(s % 60)).padStart(2, '0')}s`
310}
311
312/** `████░░░░` for a percentage, `width` cells. */
313export function bar(percent: number | undefined, width: number): string {
314 const w = Math.max(1, Math.floor(width))
315 const p = Math.min(100, Math.max(0, percent ?? 0))
316 const full = Math.round((p / 100) * w)
317 return '█'.repeat(full) + '░'.repeat(w - full)
318}
319
320/** Cuts `text` to `width` cells with an ellipsis; never below 1. */
321export function fit(text: string, width: number): string {
322 const w = Math.max(1, Math.floor(width))
323 const chars = Array.from(text)
324 if (chars.length <= w) return text
325 return chars.slice(0, Math.max(0, w - 1)).join('') + '…'
326}
327
328/** The first `lines` non-empty lines of `text`, each fitted to `width`; `…` when cut. */
329export function excerpt(text: string, lines: number, width: number): string[] {
330 const all = text.split('\n').map(l => l.trimEnd()).filter(l => l.trim() !== '')
331 const shown = all.slice(0, lines).map(l => fit(l, width))
332 if (all.length > lines) shown.push(fit(`… ${all.length - lines} more lines`, width))
333 return shown
334}
335
336export const STATUS_MARK: Record<Status, string> = { running: '◐', done: '●', failed: '✗', aborted: '○' }
337
338export function paneColumns(value: unknown): number {
339 const n = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 52
340 return Math.min(120, Math.max(32, n))
341}
342
343export function maxAgents(value: unknown): number {
344 const n = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 60
345 return Math.min(500, Math.max(1, n))
346}
347