The agent flow pane: /flow opens a live tree of the session's subagents and teammates beside the transcript, fed by engine events, with a text fallback where…

Note: I'm still a student, so I may not be able to respond to issues or ship updates right away. I'll keep maintaining this plugin in my spare time as best I can.
The agent flow pane as a plugin: /flow opens a live tree of the session's subagents and in-process teammates beside the transcript, and closes it again. Each row is one agent: status, type, name, description, elapsed time, what it is doing right now (a tool call and how long it has run, or a wait for the person's approval), its call count, and its tokens once it finished. A [+] on a row expands its details: model, prompt excerpt, recent tool calls, tokens. Agents waiting for approval are highlighted; a tool call over 30 seconds and a running agent silent for two minutes are marked too. A wait for approval stays shown until the approved tool call ends, since no engine event carries the person's answer.
The tree comes from engine events (agent.spawn, tool.call, turn.complete, the classic permission events) and is reconciled with $.agent.list() every two seconds while anything runs. Loops the engine never listed (a Workflow tool's agents, the engine's own forks) appear under a collapsed "unlisted loops" group so they never vanish silently.
Where the surface cannot draw a pane (a -p run, the VS Code extension as of 2.1.270) /flow prints the same tree as text; /flow text always prints it. When the surface seats the pane inline above the prompt (narrow terminals) it shows only the rows that need attention. The first spawn of a session opens the pane by itself on a terminal of 144 columns or more (110 when you kept it open before), unless you closed it.
Hooks modules are early access and load only where function hooks are enabled; see mods/README.md.
| event | what the hook does |
|---|---|
session.start | Binds the engine, registers /flow (stands down when another /flow is listed), reads the open preference, reconciles once. |
ui.render of PromptHint | Reads the terminal's width for the auto-open decision. |
ui.render of Pane | Draws the pane: header, root, tree, unlisted group, last event; the inline summary when seated above the prompt. |
command.run of flow | Toggles the pane, printing the text tree where no surface draws it; text prints it outright. |
command.run of clear, resume | Forgets the tree; the pane's state is kept. |
ui.close | Forgets an open pane the person closed, and remembers not to auto-open again. |
agent.spawn | Adds the new agent under its parent; opens the pane on the session's first spawn. |
tool.call | Marks the loop busy in the tool, then counts the call and its duration. |
turn.start, turn.complete | The root's busy state; a subagent's end status, duration and tokens. |
classic.PermissionRequest, classic.Notification | Marks the loop waiting for approval. |
$agent.list, clock.after, clock.every, clock.now, clock.sleep, command.register, store.get, store.set, ui.close, ui.invalidate, ui.log, ui.open, ui.resolve, ui.status.
From the standalone repository (Charlie0113-T/claude-agent-flow):
claude plugin marketplace add Charlie0113-T/claude-agent-flow claude plugin install agent-flow@claude-agent-flow
or, for one session from a checkout:
claude --plugin-dir /path/to/agent-flow
then /flow, and ask Claude to use the Agent tool. In the VS Code extension /flow prints the tree as text; the extension has its own agent map since 2.1.269, this mod is the terminal's counterpart.
cd mods/agent-flow && bun test # unit tests over the pure model and views bunx tsc -p mods/tsconfig.json # types, with the rest of the mods claude plugin validate mods/agent-flow # the engine's static checks mods/agent-flow/scripts/smoke.sh # interactive smoke test, costs API calls
tests/register.kit.ts is written for claude plugin test; rename it to register.test.ts once that command ships.
The mod is developed as mods/agent-flow in the fork Charlie0113-T/ARRS-claude-code, next to the built-in mods, and mirrored to the standalone repository with scripts/sync-standalone.sh (a git subtree push of this folder). vendor/claude-code.d.ts is a copy of the engine's declarations so the standalone checkout typechecks on its own; the sync script refreshes it from mods/types, and /plugin-types writes a current one into .claude/types in any Claude Code session.
Apache License 2.0, see LICENSE. Copyright 2026 Charles Tao.
hooks/register.ts 517 lines1import type { On, Timer } from 'claude-code'
2
3import type { Host } from './host'
4import Limits from './limits'
5import Model from './model'
6import Names from './names'
7import PaneToggle from './pane-toggle'
8import Views from './views'
9
10const messageOf = (error: unknown): string => (error instanceof Error ? error.message : String(error))
11
12/**
13 * Runs an engine call that must not fail the caller: its rejection or throw
14 * answers `fallback`. Engine calls are awaited, never chained with .catch,
15 * because the live engine's answers are awaitable but not Promises.
16 */
17async function quietly<T>(work: () => PromiseLike<T> | T, fallback: T): Promise<T> {
18 try {
19 return await work()
20 } catch {
21 return fallback
22 }
23}
24
25/**
26 * Registers the agent flow pane: `/flow` once the command is granted, the
27 * pane's drawing, the reducers behind every agent event, the reconcile and
28 * tick timers while agents run, the auto-open on the first spawn, the reset
29 * on `/clear` and `/resume`, and the text fallback where no surface draws
30 * the pane.
31 *
32 * `session.start` binds the host every later hook reads through; until it
33 * has (registration refused for any reason, said once over `$.ui.log`)
34 * every hook passes its event on untouched.
35 *
36 * @param on the engine's registrar
37 */
38export function register(on: On) {
39 let host: Host | null = null
40 let state: Model.FlowState = Model.initialState(0)
41 let storedOpen: unknown = undefined
42 let lastStatus: string | undefined = undefined
43 let redrawTimer: Timer | null = null
44 const timers = new Map<'reconcile' | 'tick', Timer>()
45
46 function isAnythingRunning(): boolean {
47 return Model.countsOf(state).running > 0
48 }
49
50 function redraw(): void {
51 const engine = host
52
53 if (engine === null || redrawTimer !== null) {
54 return
55 }
56
57 redrawTimer = engine.after(Limits.INVALIDATE_DEBOUNCE_MS, () => {
58 redrawTimer = null
59 engine.invalidate()
60 })
61 }
62
63 function syncTimers(): void {
64 const engine = host
65
66 if (engine === null) {
67 return
68 }
69
70 const isWanted = isAnythingRunning()
71
72 if (isWanted && !timers.has('reconcile')) {
73 timers.set(
74 'reconcile',
75 engine.every(Limits.RECONCILE_MS, () => {
76 void reconcileNow()
77 }),
78 )
79 timers.set(
80 'tick',
81 engine.every(Limits.TICK_MS, () => {
82 if (state.pane.isBelievedOpen) {
83 engine.invalidate()
84 }
85 }),
86 )
87 }
88
89 if (!isWanted && timers.size > 0) {
90 for (const timer of timers.values()) {
91 timer.cancel()
92 }
93
94 timers.clear()
95 }
96 }
97
98 function syncStatus(): void {
99 const engine = host
100
101 if (engine === null) {
102 return
103 }
104
105 const waiting = Model.countsOf(state).waiting
106 const text =
107 !state.pane.isBelievedOpen && waiting > 0
108 ? `${waiting} agent${waiting === 1 ? '' : 's'} waiting for approval`
109 : undefined
110
111 if (text !== lastStatus) {
112 lastStatus = text
113 engine.status(text)
114 }
115 }
116
117 function apply(reduce: (current: Model.FlowState) => Model.FlowState): void {
118 state = reduce(state)
119 syncTimers()
120 syncStatus()
121 redraw()
122 }
123
124 function noteFailure(kind: string, error: unknown, at: number): void {
125 state = Model.withEvent(state, { at, kind, text: `failed: ${messageOf(error)}` })
126 }
127
128 async function reconcileNow(): Promise<void> {
129 const engine = host
130
131 if (engine === null) {
132 return
133 }
134
135 try {
136 const listed = await engine.listAgents()
137 const now = await engine.now()
138
139 apply(current => Model.reconcile(current, listed, now))
140 } catch (error) {
141 noteFailure('agent.list', error, await quietly(() => engine.now(), 0))
142 }
143 }
144
145 async function probeDrawn(engine: Host): Promise<boolean> {
146 state = Model.withPane(state, { wasDrawnSinceProbe: false })
147 engine.invalidate()
148 await engine.sleep(Limits.OPEN_PROBE_MS)
149
150 return state.pane.wasDrawnSinceProbe
151 }
152
153 async function openPane(engine: Host, isFocused: boolean): Promise<boolean> {
154 await engine.openPane({
155 id: Names.PANE_ID,
156 title: Names.PANE_TITLE,
157 ...(isFocused ? { focus: true as const } : {}),
158 })
159 state = Model.withPane(state, { isBelievedOpen: true })
160
161 const isDrawn = await probeDrawn(engine)
162
163 if (!isDrawn) {
164 await quietly(() => engine.closePane({ id: Names.PANE_ID }), undefined)
165 state = Model.withPane(state, { isBelievedOpen: false })
166 }
167
168 syncStatus()
169
170 return isDrawn
171 }
172
173 async function closePane(engine: Host): Promise<void> {
174 await engine.closePane({ id: Names.PANE_ID })
175 state = Model.withPane(state, { isBelievedOpen: false })
176 syncStatus()
177 }
178
179 function toggleExpanded(id: string): void {
180 const expanded = new Set(state.pane.expanded)
181
182 if (expanded.has(id)) {
183 expanded.delete(id)
184 } else {
185 expanded.add(id)
186 }
187
188 state = Model.withPane(state, { expanded })
189 redraw()
190 }
191
192 function textTreeOf(now: number): string {
193 return Views.textView(Model.rowsOf(state, now, state.pane.expanded))
194 }
195
196 async function maybeAutoOpen(engine: Host): Promise<void> {
197 const isWanted = PaneToggle.shouldAutoOpen({
198 startedSurface: state.startedSurface,
199 hasAutoOpened: state.pane.hasAutoOpened,
200 closedByPerson: state.pane.closedByPerson,
201 storedOpen,
202 columns: state.pane.columns,
203 })
204
205 if (!isWanted || state.pane.isBelievedOpen) {
206 return
207 }
208
209 state = Model.withPane(state, { hasAutoOpened: true })
210 await openPane(engine, false)
211 }
212
213 on('session.start', async ($, e, next) => {
214 const engine: Host = {
215 now: () => $.clock.now(),
216 after: (ms, fn) => $.clock.after(ms, fn),
217 every: (ms, fn) => $.clock.every(ms, fn),
218 sleep: ms => $.clock.sleep(ms),
219 listAgents: () => $.agent.list(),
220 storeGet: key => $.store.get(key),
221 storeSet: (key, value) => $.store.set(key, value),
222 invalidate: () => $.ui.invalidate('ui.render'),
223 status: text => $.ui.status(text),
224 uiLog: text => $.ui.log(text),
225 openPane: pane => $.ui.open(pane),
226 closePane: pane => $.ui.close(pane),
227 registerCommand: spec => $.command.register(spec),
228 }
229 const now = await quietly(() => engine.now(), 0)
230
231 state = { ...Model.initialState(now), startedSurface: e.surface }
232
233 try {
234 await engine.registerCommand({ name: Names.COMMAND_NAME, description: Names.COMMAND_DESCRIPTION })
235 } catch (error) {
236 engine.uiLog(`${Names.REGISTER_FAILED_TEXT}${messageOf(error)}`)
237
238 return next(e)
239 }
240
241 host = engine
242 storedOpen = await quietly(() => engine.storeGet(Names.STORE_OPEN_KEY), undefined)
243 await reconcileNow()
244
245 return next(e)
246 })
247
248 on('ui.render', { component: 'PromptHint' }, ($, e, next) => {
249 const columns = e.viewport?.columns
250
251 if (columns !== undefined && columns !== state.pane.columns) {
252 state = Model.withPane(state, { columns })
253 }
254
255 return next(e)
256 })
257
258 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
259 if (e.requestId !== Names.PANE_ID || host === null) {
260 return next(e)
261 }
262
263 try {
264 const { Box, Text, Button } = await $.ui.resolve(e)
265 const now = await $.clock.now()
266
267 state = Model.withPane(state, {
268 wasDrawnSinceProbe: true,
269 columns: e.viewport?.columns ?? state.pane.columns,
270 placement: e.props.placement,
271 })
272
273 const rows =
274 e.props.placement === 'inline'
275 ? Model.inlineRowsOf(state, now)
276 : Model.rowsOf(state, now, state.pane.expanded)
277
278 return Views.paneView({ Box, Text, Button }, rows, { onToggle: toggleExpanded })
279 } catch (error) {
280 noteFailure('ui.render', error, await quietly(() => $.clock.now(), 0))
281
282 return next(e)
283 }
284 })
285
286 on('command.run', { command: Names.COMMAND_NAME }, async ($, e, next) => {
287 const engine = host
288
289 if (engine === null) {
290 return next(e)
291 }
292
293 try {
294 const now = await engine.now()
295 const args = e.args.trim()
296
297 if (args === 'text') {
298 return { text: textTreeOf(now) }
299 }
300
301 if (args !== '') {
302 return { text: Names.USAGE_TEXT }
303 }
304
305 const wasDrawnWhenProbed = state.pane.isBelievedOpen && (await probeDrawn(engine))
306 const toggle = PaneToggle.paneToggleOf({ isBelievedOpen: state.pane.isBelievedOpen, wasDrawnWhenProbed })
307
308 if (toggle === 'close') {
309 await closePane(engine)
310 storedOpen = false
311 await quietly(() => engine.storeSet(Names.STORE_OPEN_KEY, false), undefined)
312
313 return {}
314 }
315
316 let isDrawn: boolean
317
318 try {
319 isDrawn = await openPane(engine, true)
320 } catch (error) {
321 noteFailure('ui.open', error, now)
322
323 return { text: textTreeOf(now) }
324 }
325
326 if (!isDrawn) {
327 return { text: textTreeOf(now) }
328 }
329
330 storedOpen = true
331 await quietly(() => engine.storeSet(Names.STORE_OPEN_KEY, true), undefined)
332
333 return {}
334 } catch (error) {
335 return { text: `agent flow: ${messageOf(error)}` }
336 }
337 })
338
339 on('command.run', { command: ['clear', 'resume'] }, async ($, e, next) => {
340 const engine = host
341
342 if (engine !== null) {
343 try {
344 const now = await engine.now()
345
346 apply(current => Model.resetSession(current, now))
347 } catch (error) {
348 noteFailure('command.run', error, 0)
349 }
350 }
351
352 return next(e)
353 })
354
355 on('ui.close', ($, e, next) => {
356 try {
357 if (e.id === Names.PANE_ID) {
358 const isByPerson = e.origin.kind === 'person'
359
360 state = Model.withPane(state, { isBelievedOpen: false, ...(isByPerson ? { closedByPerson: true } : {}) })
361
362 if (isByPerson) {
363 storedOpen = false
364
365 const engine = host
366
367 if (engine !== null) {
368 void quietly(() => engine.storeSet(Names.STORE_OPEN_KEY, false), undefined)
369 }
370 }
371
372 syncStatus()
373 }
374 } catch (error) {
375 noteFailure('ui.close', error, 0)
376 }
377
378 return next(e)
379 })
380
381 on('agent.spawn', async ($, e, next) => {
382 const result = await next(e)
383 const engine = host
384
385 if (engine === null) {
386 return result
387 }
388
389 try {
390 const now = await engine.now()
391
392 apply(current =>
393 Model.onSpawn(
394 current,
395 {
396 agentId: result.agentId,
397 deny: result.deny,
398 parentAgentId: e.parentAgentId,
399 subagentType: e.subagentType,
400 description: e.description,
401 model: result.model,
402 name: e.name,
403 background: e.background,
404 fork: e.fork,
405 prompt: e.prompt,
406 },
407 now,
408 ),
409 )
410 await maybeAutoOpen(engine)
411 } catch (error) {
412 noteFailure('agent.spawn', error, await quietly(() => engine.now(), 0))
413 }
414
415 return result
416 })
417
418 on('tool.call', async ($, e, next) => {
419 const engine = host
420
421 if (engine !== null) {
422 try {
423 const now = await engine.now()
424
425 apply(current => Model.onToolStart(current, { agentId: e.agentId, tool: e.tool, toolUseId: e.tool_use_id }, now))
426 } catch (error) {
427 noteFailure('tool.call', error, 0)
428 }
429 }
430
431 const result = await next(e)
432
433 if (engine !== null) {
434 try {
435 const now = await engine.now()
436 const isError = 'isError' in result && result.isError === true
437
438 apply(current => Model.onToolEnd(current, { agentId: e.agentId, tool: e.tool, isError }, now))
439 } catch (error) {
440 noteFailure('tool.call', error, 0)
441 }
442 }
443
444 return result
445 })
446
447 on('turn.start', async ($, e, next) => {
448 const engine = host
449
450 if (engine !== null) {
451 try {
452 const now = await engine.now()
453
454 apply(current => Model.onTurnStart(current, now))
455 } catch (error) {
456 noteFailure('turn.start', error, 0)
457 }
458 }
459
460 return next(e)
461 })
462
463 on('turn.complete', async ($, e, next) => {
464 const engine = host
465
466 if (engine !== null) {
467 try {
468 const now = await engine.now()
469
470 apply(current =>
471 Model.onTurnComplete(
472 current,
473 { agentId: e.agentId, reason: e.reason, durationMs: e.durationMs, usage: e.usage },
474 now,
475 ),
476 )
477 } catch (error) {
478 noteFailure('turn.complete', error, 0)
479 }
480 }
481
482 return next(e)
483 })
484
485 on('classic.PermissionRequest', async ($, e, next) => {
486 const engine = host
487
488 if (engine !== null) {
489 try {
490 const now = await engine.now()
491
492 apply(current => Model.onPermission(current, { agentId: e.agent_id, tool: e.tool_name }, now))
493 } catch (error) {
494 noteFailure('permission', error, 0)
495 }
496 }
497
498 return next(e)
499 })
500
501 on('classic.Notification', { notification_type: 'permission_prompt' }, async ($, e, next) => {
502 const engine = host
503
504 if (engine !== null) {
505 try {
506 const now = await engine.now()
507
508 apply(current => Model.onPermission(current, { agentId: e.agent_id }, now))
509 } catch (error) {
510 noteFailure('permission', error, 0)
511 }
512 }
513
514 return next(e)
515 })
516}
517hooks/host/index.ts 4 lines1export type * from './host.js'
2
3export * as default from '.'
4hooks/limits/index.ts 6 lines1export * from './columns'
2export * from './sizes'
3export * from './timing'
4
5export * as default from '.'
6hooks/model/index.ts 17 lines1export * from './counts-of'
2export * from './elapsed-of'
3export * from './ensure-node'
4export * from './flow-state'
5export * from './on-permission'
6export * from './on-spawn'
7export * from './on-tool'
8export * from './on-turn'
9export * from './prune'
10export * from './reconcile'
11export * from './reset-session'
12export * from './rows-of'
13export * from './signal-of'
14export * from './tokens-text-of'
15
16export * as default from '.'
17hooks/names/index.ts 5 lines1export * from './ids'
2export * from './texts'
3
4export * as default from '.'
5hooks/pane-toggle/index.ts 5 lines1export * from './pane-toggle-of'
2export * from './should-auto-open'
3
4export * as default from '.'
5hooks/views/index.ts 6 lines1export * from './kit'
2export * from './pane-view'
3export * from './text-view'
4
5export * as default from '.'
6hooks/host/host.ts 35 lines1import type { AgentInfo, CommandSpec, PaneCloseArgs, PaneOpenArgs, TimerCall } from 'claude-code'
2
3/**
4 * The engine as `session.start` bound it from its `$`, each member spelled
5 * `$.noun.method(...)` there; used by every later hook, timer and press.
6 */
7export type Host = {
8 /** `$.clock.now`. */
9 now: () => Promise<number>
10 /** `$.clock.after`. */
11 after: TimerCall
12 /** `$.clock.every`. */
13 every: TimerCall
14 /** `$.clock.sleep`, no signal. */
15 sleep: (ms: number) => Promise<void>
16 /** `$.agent.list`. */
17 listAgents: () => Promise<AgentInfo[]>
18 /** `$.store.get`. */
19 storeGet: (key: string) => Promise<unknown>
20 /** `$.store.set`. */
21 storeSet: (key: string, value: unknown) => Promise<void>
22 /** `$.ui.invalidate("ui.render")`: every pane instance draws again. */
23 invalidate: () => void
24 /** `$.ui.status`: the plugin's line under the prompt. */
25 status: (text: string | undefined) => void
26 /** `$.ui.log`: one line under the plugin's name. */
27 uiLog: (text: string) => void
28 /** `$.ui.open`. */
29 openPane: (pane: PaneOpenArgs) => Promise<void>
30 /** `$.ui.close`. */
31 closePane: (pane: PaneCloseArgs) => Promise<void>
32 /** `$.command.register`; rejects while another `/flow` is listed. */
33 registerCommand: (spec: CommandSpec) => Promise<unknown>
34}
35hooks/limits/columns.ts 5 lines1/** The pane opens by itself on the first spawn from this width. */
2export const AUTO_OPEN_MIN_COLUMNS = 144
3/** ... or from this width when the person kept it open before. */
4export const KEPT_OPEN_MIN_COLUMNS = 110
5hooks/limits/sizes.ts 15 lines1/** Consecutive reconciles a running node may miss from the list before it is gone. */
2export const GONE_AFTER_MISSES = 2
3/** The most nodes kept; finished ones are pruned beyond it. */
4export const MAX_NODES = 200
5/** Rows drawn when the pane sits inline above the prompt. */
6export const INLINE_MAX_ROWS = 5
7/** A description longer than this is cut with an ellipsis. */
8export const DESCRIPTION_MAX_CHARS = 60
9/** How much of a spawn prompt the node keeps. */
10export const PROMPT_EXCERPT_CHARS = 120
11/** How many recent tool calls a node keeps. */
12export const RECENT_TOOLS = 5
13/** How many events the ring buffer keeps. */
14export const EVENT_LOG_SIZE = 50
15hooks/limits/timing.ts 13 lines1/** How long an open pane gets to draw before its surface is judged unable to. */
2export const OPEN_PROBE_MS = 300
3/** How often `$.agent.list()` corrects the tree while anything runs. */
4export const RECONCILE_MS = 2000
5/** How often the pane redraws to refresh elapsed times while anything runs. */
6export const TICK_MS = 1000
7/** How long state changes gather before one `$.ui.invalidate`. */
8export const INVALIDATE_DEBOUNCE_MS = 100
9/** A tool call longer than this is flagged slow. */
10export const SLOW_TOOL_MS = 30000
11/** A running agent with no event for this long is flagged quiet. */
12export const QUIET_MS = 120000
13hooks/model/counts-of.ts 33 lines1import Names from '../names'
2import type { FlowState } from './flow-state'
3
4export type Counts = { agents: number; running: number; waiting: number; unlisted: number }
5
6/**
7 * The header's numbers: every node but the root is an agent; running and
8 * unlisted likewise exclude the root; waiting includes it, since the main
9 * loop's permission prompt is the person's to answer too.
10 *
11 * @param state the state
12 * @returns the counts
13 */
14export function countsOf(state: FlowState): Counts {
15 const counts: Counts = { agents: 0, running: 0, waiting: 0, unlisted: 0 }
16
17 for (const node of state.nodes.values()) {
18 if (node.activity.kind === 'permission') {
19 counts.waiting += 1
20 }
21
22 if (node.id === Names.ROOT_ID) {
23 continue
24 }
25
26 counts.agents += 1
27 counts.running += node.status === 'running' ? 1 : 0
28 counts.unlisted += node.source === 'event' ? 1 : 0
29 }
30
31 return counts
32}
33