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, and /agent-flow on|off to disable the whole mod (so meus-mods can control it too) — off also closes the pane and stops recording the flow.
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 — unless the mod was turned off with /agent-flow off, which stops recording until /agent-flow on. 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 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+.
Vendored from davila7/claude-code-templates (cli-tool/components/mods/ui/agent-flow), MIT licensed. Added the isEnabled toggle above so it fits the on/off convention of every other mod in this repo.
hooks/register.tsx 475 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 · `/agent-flow on|off` disables the mod
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 *
25 * Vendored from davila7/claude-code-templates (cli-tool/components/mods/ui/agent-flow),
26 * MIT licensed. Added: an isEnabled toggle (/agent-flow on|off) so meus-mods can control it.
27 */
28import { atom, read, update } from 'claude-code'
29import type { ContextCategory, Register } from 'claude-code'
30import {
31 MAIN,
32 STATUS_MARK,
33 bar,
34 completed,
35 counts,
36 createFlow,
37 estimateTokens,
38 excerpt,
39 fit,
40 fmtDuration,
41 fmtTokens,
42 mainTurn,
43 maxAgents,
44 paneColumns,
45 rows,
46 spawned,
47 stepped,
48 synced,
49 toolLabel,
50 toolRan,
51 trimmed,
52} from './flow.ts'
53import type { Flow, FlowNode, Status } from './flow.ts'
54
55const PANE = 'agent-flow'
56const COMMAND = 'agent-flow'
57const KEY = 'ag:'
58
59const isEnabledRef = atom({ plugin: 'agent-flow', key: 'isEnabled' } as const, true)
60
61let flow: Flow = createFlow()
62let isOpen = false
63let selected: string | undefined
64let breakdown: ContextCategory[] | undefined
65// kept in sync with isEnabledRef at session.start and on every /agent-flow on|off,
66// so the frequent observer hooks below can check it without an async $.state read
67let isEnabled = true
68
69const STATUS_COLOR: Record<Status, string> = { running: 'yellow', done: 'green', failed: 'red', aborted: 'gray' }
70
71function statusText(): string | undefined {
72 if (!isOpen) return undefined
73 const c = counts(flow)
74 const parts = [`${c.running} running`, `${c.done} done`]
75 if (c.failed) parts.push(`${c.failed} failed`)
76 const ctx = flow.percent !== undefined ? ` · ctx ${flow.percent}%` : ''
77 return `agents: ${parts.join(' · ')}${ctx}`
78}
79
80/** Tokens handed down to a node's children and handed back up by them. */
81function exchanged(node: FlowNode): { down: number; up: number; kids: number } {
82 let down = 0
83 let up = 0
84 let kids = 0
85 for (const n of flow.nodes.values()) {
86 if (n.parentId !== node.id) continue
87 kids += 1
88 down += n.fork ? (node.contextTokens ?? 0) : estimateTokens(n.prompt)
89 up += estimateTokens(n.answer)
90 }
91 return { down, up, kids }
92}
93
94export const register: Register = (on, options) => {
95 const columns = paneColumns(options.columns)
96 const keep = maxAgents(options.maxAgents)
97 const openOnStart = options.openOnStart === true
98
99 on('session.start', async ($, e, next) => {
100 const saved = await $.store.get('isEnabled').catch(() => undefined)
101 await update($, isEnabledRef, () => (typeof saved === 'boolean' ? saved : isEnabledRef.initial))
102 const r = await next(e)
103 flow = createFlow(Date.now())
104 selected = undefined
105 breakdown = undefined
106 isEnabled = await read($, isEnabledRef)
107 await $.command
108 .register({
109 name: COMMAND,
110 description: 'Live tree of the session’s agents and the context passed between them (clear|stop|on|off)',
111 argumentHint: '[clear|stop|on|off]',
112 immediate: true,
113 })
114 .catch(err => $.ui.log(`agent-flow: /${COMMAND} not registered: ${err}`))
115 $.ui.log(`agent-flow loaded: /${COMMAND} opens the pane`, { to: 'debug' })
116 if (openOnStart && isEnabled) {
117 isOpen = true
118 await $.ui.open({ id: PANE, title: 'agents', columns }).catch(err => {
119 isOpen = false
120 $.ui.log(`agent-flow: pane not opened: ${err}`)
121 })
122 $.ui.status(statusText())
123 }
124 return r
125 })
126
127 on('command.run', { command: COMMAND }, async ($, e) => {
128 const arg = e.args.trim().toLowerCase()
129
130 if (arg === 'off') {
131 isEnabled = false
132 await update($, isEnabledRef, () => false)
133 await $.store.set('isEnabled', false) // $.state is per session; the store survives a restart
134 if (isOpen) {
135 await $.ui.close({ id: PANE }).catch(() => undefined)
136 isOpen = false
137 $.ui.status(undefined)
138 }
139 return { text: 'agent-flow desativado.' }
140 }
141 // on also opens the pane: it is never drawn unasked, so a bare state flip showed nothing
142 if (arg === 'on') {
143 isEnabled = true
144 await update($, isEnabledRef, () => true)
145 await $.store.set('isEnabled', true) // $.state is per session; the store survives a restart
146 }
147 if (!isEnabled) {
148 return { text: 'agent-flow esta desativado. Uso: /agent-flow on' }
149 }
150
151 if (arg === 'stop' || arg === 'close') {
152 await $.ui.close({ id: PANE }).catch(() => undefined)
153 isOpen = false
154 $.ui.status(undefined)
155 return { text: 'agent-flow closed' }
156 }
157 if (arg === 'clear') {
158 trimmed(flow, counts(flow).running)
159 selected = undefined
160 if (isOpen) {
161 $.ui.status(statusText())
162 $.ui.invalidate('ui.render')
163 }
164 return { text: 'agent-flow: finished agents cleared' }
165 }
166 synced(flow, await $.agent.list().catch(() => []))
167 try {
168 await $.ui.open({ id: PANE, title: 'agents', focus: true, columns })
169 } catch (err) {
170 isOpen = false
171 return { text: `agent-flow: pane not opened: ${err}` }
172 }
173 isOpen = true
174 $.ui.status(statusText())
175 $.ui.invalidate('ui.render')
176 const c = counts(flow)
177 const hint = e.presentation?.isFullscreen
178 ? 'click an agent, or Tab and Enter'
179 : 'drawn above the prompt; /tui fullscreen docks it beside the transcript'
180 const prefix = arg === 'on' ? 'agent-flow ativado. ' : ''
181 return { text: `${prefix}${c.running + c.done + c.failed} agents so far · ${hint} · /${COMMAND} stop closes` }
182 })
183
184 on('turn.start', async ($, e, next) => {
185 if (!isEnabled) return next(e)
186 mainTurn(flow, e.text, Date.now())
187 if (isOpen) {
188 $.ui.status(statusText())
189 $.ui.invalidate('ui.render')
190 }
191 return next(e)
192 })
193
194 // the context handed down: the Agent call's prompt, known before the subagent runs
195 on('agent.spawn', async ($, e, next) => {
196 const r = await next(e)
197 if (isEnabled && r.agentId) {
198 spawned(
199 flow,
200 {
201 agentId: r.agentId,
202 parentAgentId: e.parentAgentId,
203 description: e.description,
204 subagentType: e.subagentType,
205 prompt: e.prompt,
206 fork: e.fork,
207 background: e.background,
208 model: r.model,
209 },
210 Date.now(),
211 )
212 trimmed(flow, keep)
213 if (isOpen) {
214 $.ui.status(statusText())
215 $.ui.invalidate('ui.render')
216 }
217 }
218 return r
219 })
220
221 // each model request: how full that loop's window is now
222 on('turn.step', async function* ($, e, next) {
223 const r = yield* next(e)
224 if (!isEnabled) return r
225 stepped(flow, e.agentId, r.usage, Date.now())
226 if (isOpen) {
227 $.ui.status(statusText())
228 $.ui.invalidate('ui.render')
229 }
230 return r
231 })
232
233 on('tool.call', async ($, e, next) => {
234 if (!isEnabled) return next(e)
235 const use = { tool: e.tool, label: toolLabel(e.tool, e as unknown as Record<string, unknown>) }
236 toolRan(flow, e.agentId, use, Date.now())
237 if (isOpen) {
238 $.ui.status(statusText())
239 $.ui.invalidate('ui.render')
240 }
241 const r = await next(e)
242 if (r.deny !== undefined || r.isError) {
243 Object.assign(use, { isError: true })
244 if (isOpen) {
245 $.ui.status(statusText())
246 $.ui.invalidate('ui.render')
247 }
248 }
249 return r
250 })
251
252 // the context handed back up: a subagent's final answer
253 on('turn.complete', async ($, e, next) => {
254 const r = await next(e)
255 if (!isEnabled) return r
256 completed(
257 flow,
258 e.agentId,
259 { answer: e.answer, reason: e.reason, durationMs: e.durationMs, model: e.usage?.model },
260 Date.now(),
261 )
262 if (!e.agentId) synced(flow, await $.agent.list().catch(() => []))
263 if (isOpen) {
264 $.ui.status(statusText())
265 $.ui.invalidate('ui.render')
266 }
267 return r
268 })
269
270 on('session.measure', async ($, e, next) => {
271 const r = await next(e)
272 if (!isEnabled) return r
273 flow.window = e.context.window
274 flow.percent = e.context.percent
275 const main = flow.nodes.get(MAIN)
276 if (main && e.context.tokens !== undefined) main.contextTokens = e.context.tokens
277 if (isOpen) {
278 $.ui.status(statusText())
279 $.ui.invalidate('ui.render')
280 }
281 return r
282 })
283
284 on('ui.close', async ($, e, next) => {
285 if (e.id !== PANE) return next(e)
286 const r = await next(e)
287 isOpen = false
288 $.ui.status(undefined)
289 return r
290 })
291
292 on('ui.press', async ($, e, next) => {
293 if (e.plugin !== $.plugin.name || e.requestId !== PANE) return next(e)
294 const r = await next(e)
295 const key = e.element
296 if (key === 'close') {
297 await $.ui.close({ id: PANE }).catch(() => undefined)
298 return r
299 }
300 if (key === 'clear') {
301 trimmed(flow, counts(flow).running)
302 if (selected && !flow.nodes.has(selected)) selected = undefined
303 } else if (key === 'refresh') {
304 synced(flow, await $.agent.list().catch(() => []))
305 } else if (key.startsWith(KEY)) {
306 const id = key.slice(KEY.length)
307 selected = selected === id ? undefined : id
308 }
309 if (selected === MAIN && (key === 'refresh' || key === `${KEY}${MAIN}`)) {
310 const usage = await $.session.usage({ breakdown: 'summary', columns }).catch(() => undefined)
311 breakdown = usage?.context.breakdown?.categories
312 }
313 if (isOpen) {
314 $.ui.status(statusText())
315 $.ui.invalidate('ui.render')
316 }
317 return r
318 })
319
320 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
321 if (e.requestId !== PANE) return next(e)
322 const { Box, Text, Button } = $.ui.resolve(e)
323 const width = Math.max(24, e.props.bodyColumns - 1)
324 const noop = () => {}
325 const main = flow.nodes.get(MAIN)!
326 const tree = rows(flow)
327 const c = counts(flow)
328 const chosen = selected ? flow.nodes.get(selected) : undefined
329
330 const agentRow = (node: FlowNode, prefix: string) => {
331 const ctx = node.contextTokens !== undefined ? fmtTokens(node.contextTokens) : ''
332 const tools = node.toolCount ? `${node.toolCount}⚒` : ''
333 const right = [ctx, tools].filter(Boolean).join(' ')
334 const room = width - prefix.length - 2 - (right ? right.length + 1 : 0)
335 const name = node.id === MAIN ? 'main' : `${node.type}${node.fork ? ' (fork)' : ''} ${node.description}`
336 return (
337 <Box key={`row:${node.id}`} flexDirection="row">
338 {prefix ? <Text dimColor>{prefix}</Text> : null}
339 <Text color={STATUS_COLOR[node.status]}>{`${STATUS_MARK[node.status]} `}</Text>
340 <Button
341 key={`${KEY}${node.id}`}
342 plain
343 dimColor={selected !== node.id && (node.unlisted || node.status !== 'running')}
344 label={fit(name, Math.max(4, room))}
345 onPress={noop}
346 />
347 {right ? <Text color="magenta">{` ${right}`}</Text> : null}
348 </Box>
349 )
350 }
351
352 const header = (
353 <Box key="head" flexDirection="column">
354 <Text bold>{fit(`Session · turn ${flow.turn}`, width)}</Text>
355 <Text>
356 <Text color="cyan">{'context '}</Text>
357 {`${fmtTokens(main.contextTokens)}${flow.window ? `/${fmtTokens(flow.window)}` : ''}`}
358 {flow.percent !== undefined ? <Text dimColor>{` ${flow.percent}%`}</Text> : null}
359 </Text>
360 {flow.percent !== undefined ? (
361 <Text color={flow.percent > 80 ? 'red' : flow.percent > 60 ? 'yellow' : 'green'}>
362 {bar(flow.percent, Math.min(width, 40))}
363 </Text>
364 ) : null}
365 {main.prompt ? <Text dimColor wrap="truncate-end">{`› ${main.prompt.replace(/\s+/g, ' ')}`}</Text> : null}
366 </Box>
367 )
368
369 const detail = chosen ? (
370 <Box key="detail" flexDirection="column" marginTop={1} borderStyle="round" borderDimColor paddingX={1}>
371 {chosen.id === MAIN ? mainDetail(chosen) : agentDetail(chosen)}
372 </Box>
373 ) : null
374
375 function mainDetail(node: FlowNode) {
376 const w = width - 4
377 const x = exchanged(node)
378 const used = (breakdown ?? []).filter(cat => cat.kind === 'used' && cat.tokens > 0)
379 used.sort((a, b) => b.tokens - a.tokens)
380 return [
381 <Text key="t" bold>{fit(`main loop · ${node.steps} requests · ${node.model ?? ''}`, w)}</Text>,
382 <Text key="x" dimColor>
383 {fit(`${x.kids} subagents · ↓ ${fmtTokens(x.down)} handed down · ↑ ${fmtTokens(x.up)} back`, w)}
384 </Text>,
385 <Text key="ch" color="cyan" bold>{'context by category'}</Text>,
386 ...(used.length
387 ? used.slice(0, 8).map((cat, i) => (
388 <Text key={`cat:${i}`}>
389 {fit(cat.name, w - 8).padEnd(Math.max(1, w - 8))}
390 <Text color="magenta">{fmtTokens(cat.tokens).padStart(7)}</Text>
391 </Text>
392 ))
393 : [<Text key="nb" dimColor>{'press refresh to read the breakdown'}</Text>]),
394 ]
395 }
396
397 function agentDetail(node: FlowNode) {
398 const w = width - 4
399 const parent = node.parentId === MAIN ? 'main' : flow.nodes.get(node.parentId ?? '')?.type ?? 'parent'
400 const parentCtx = flow.nodes.get(node.parentId ?? MAIN)?.contextTokens
401 const meta = [node.model, node.status, fmtDuration(node.durationMs), node.background ? 'background' : '']
402 const out = [
403 <Text key="t" bold>{fit(`${node.type} ${node.description}`, w)}</Text>,
404 <Text key="m" dimColor>{fit(meta.filter(Boolean).join(' · '), w)}</Text>,
405 <Text key="in" color="cyan" bold>
406 {fit(
407 node.fork
408 ? `↓ in from ${parent}: its whole context (~${fmtTokens(parentCtx)}) + ${fmtTokens(estimateTokens(node.prompt))}`
409 : node.unlisted
410 ? '↓ in: not seen (a loop no Agent call announced)'
411 : `↓ in from ${parent}: prompt ${fmtTokens(estimateTokens(node.prompt))} tok`,
412 w,
413 )}
414 </Text>,
415 ...excerpt(node.prompt, 4, w - 2).map((l, i) => <Text key={`p:${i}`} dimColor>{` ${l}`}</Text>),
416 <Text key="w" color="yellow" bold>
417 {fit(
418 `⚙ ${node.steps} requests · ctx ${fmtTokens(node.contextTokens)} (peak ${fmtTokens(node.peakContext)}) · out ${fmtTokens(node.outputTokens)}`,
419 w,
420 )}
421 </Text>,
422 ...node.tools.slice(-5).map((u, i) => (
423 <Text key={`u:${i}`} color={u.isError ? 'red' : undefined} dimColor={!u.isError}>
424 {fit(` ${u.tool} ${u.label}`, w)}
425 </Text>
426 )),
427 node.toolCount > 5 ? <Text key="more" dimColor>{` … ${node.toolCount} tool calls in all`}</Text> : null,
428 <Text key="o" color="green" bold>
429 {fit(
430 node.answer !== undefined
431 ? `↑ out to ${parent}: answer ${fmtTokens(estimateTokens(node.answer))} tok`
432 : `↑ out to ${parent}: still working`,
433 w,
434 )}
435 </Text>,
436 ...(node.answer ? excerpt(node.answer, 4, w - 2) : []).map((l, i) => (
437 <Text key={`a:${i}`} dimColor>{` ${l}`}</Text>
438 )),
439 ]
440 const kids = exchanged(node)
441 if (kids.kids) {
442 out.push(
443 <Text key="k" dimColor>
444 {fit(`${kids.kids} subagents · ↓ ${fmtTokens(kids.down)} · ↑ ${fmtTokens(kids.up)}`, w)}
445 </Text>,
446 )
447 }
448 return out
449 }
450
451 return (
452 <Box flexDirection="column">
453 {header}
454 <Box key="ag-head" marginTop={1}>
455 <Text bold color="cyan">
456 {fit(`Agents (${c.running} running · ${c.done} done${c.failed ? ` · ${c.failed} failed` : ''})`, width)}
457 </Text>
458 </Box>
459 {agentRow(main, '')}
460 {tree.map(r => agentRow(r.node, r.prefix))}
461 {tree.length === 0 ? <Text dimColor>{fit(' no subagents yet', width)}</Text> : null}
462 {detail}
463 <Box key="foot" marginTop={1} flexDirection="column">
464 <Box key="toolbar" flexDirection="row" columnGap={1}>
465 <Button key="refresh" label="refresh" hotkey="r" onPress={noop} />
466 <Button key="clear" label="clear" onPress={noop} />
467 <Button key="close" label="close" onPress={noop} />
468 </Box>
469 <Text dimColor>{fit('◐ running ● done ✗ failed ○ stopped · ctx ⚒ tools', width)}</Text>
470 </Box>
471 </Box>
472 )
473 })
474}
475hooks/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}
347types/index.d.ts 12 lines1export type AgentFlowState = {
2 isEnabled: boolean
3}
4
5declare module 'claude-code' {
6 interface PluginState {
7 'agent-flow': {
8 isEnabled: boolean
9 }
10 }
11}
12