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
v0.1.0MITupdated 2026-10-19Covre81/claude-code-mods/agent-flow
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, 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.

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

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

Source 3 files
hooks/register.tsx 475 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 · `/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}
475
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
types/index.d.ts 12 lines
1export type AgentFlowState = {
2  isEnabled: boolean
3}
4
5declare module 'claude-code' {
6  interface PluginState {
7    'agent-flow': {
8      isEnabled: boolean
9    }
10  }
11}
12