SLOPSHOPPER

agent-flow

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…

newpaneguardcommandstatusagents
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-flow
│ ┃ agents ✕ › fix the failing auth test and add an audit log call │ ┃ Session · turn 1 │ ┃ context 97.4k/200k 49% ● agent-flow: agent-flow loaded: /agent-flow opens the pane │ ┃ ████████████████████░░░░░░░░░░░░░░░░░░░░ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Agents (0 running · 0 done) ⏺ Update(src/auth.ts) │ ┃ ● main 97.4k 9⚒ ⎿ Added 2 lines, removed 1 line │ ┃ no subagents yet ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ [ refresh ] [ clear ] [ close ] │ ┃ ◐ running ● done ✗ failed ○ stopped · ctx ⚒ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ tools │ ✻ Worked for 42s · done 4:20 PM │ │ › /agent-flow │ ⎿ agent-flow: 0 agents so far · click an agent, or Tab and Enter · │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ agent-flow: agents: 0 running · 0 done · ctx 49%

Draws

Pane · agents
Session · turn 1 context 97.4k/200k 49% ████████████████████░░░░░░░░░░░░░░░░░░░░ Agents (0 running · 0 done) ● main 97.4k 9⚒ no subagents yet [ refresh ] [ clear ] [ close ] ◐ running ● done ✗ failed ○ stopped · ctx ⚒ tools
README

agent-flow

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.

What it shows

  • The general context. The main loop's window (tokens used of the window, %, a bar) and the prompt of the current turn. Select the 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.
  • Each subagent's context. Select an agent row to see:
  • ↓ 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.
  • The flow between them. The tree nests agents by the loop that spawned them, in spawn order. A row's right edge shows the agent's current context size and its tool calls.

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.

Where it draws

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

How it works

It only observes: every hook passes its event on unchanged.

EventWhat it records
turn.starta new main-loop turn and its prompt
agent.spawna subagent: parent loop, type, description, prompt, fork, background, model
turn.stepeach model request of any loop: its usage, so that loop's context size
tool.calleach tool call, by the loop it ran in
turn.completea loop's end: its answer, duration, and how it ended
session.measurethe 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>.

Options

  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.

Install

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.

Tests

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

Source 2 files
hooks/agent-flow.tsx 435 lines
1/**
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}
435
hooks/flow.ts 347 lines
1// 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