SLOPSHOPPER

agent-flow

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…

newpanespinnerguardcommandstatus
v0.1.0Apache-2.0updated 2026-10-06JinhaoFang/claude-mods/mods/agent-flow
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-flow
│ ┃ Agent flow ✕ › fix the failing auth test and add an audit log call │ ┃ Agent flow · 0 agents · 0 running · 0 waiti… │ ┃ main · idle ⏺ Read(src/auth.ts) │ ┃ no subagents yet; ask Claude to use the … ⎿ Read 6 lines │ ┃ last: turn.complete main answer 42000ms ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /flow │ ⎿ agent-flow: Agent flow · 0 agents · 0 running · 0 waiting │ ⎿ agent-flow: main · idle │ ⎿ agent-flow: no subagents yet; ask Claude to use the Agent too │ ⎿ agent-flow: last: turn.complete main answer 42000ms │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · agent-flow · while holding a tool call
Agent flow · 0 agents · 0 running · 0 waiting main · idle no subagents yet; ask Claude to use the Agent tool last: turn.complete main answer 42000ms
README

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.

agent-flow

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.

What it hooks

eventwhat the hook does
session.startBinds the engine, registers /flow (stands down when another /flow is listed), reads the open preference, reconciles once.
ui.render of PromptHintReads the terminal's width for the auto-open decision.
ui.render of PaneDraws the pane: header, root, tree, unlisted group, last event; the inline summary when seated above the prompt.
command.run of flowToggles the pane, printing the text tree where no surface draws it; text prints it outright.
command.run of clear, resumeForgets the tree; the pane's state is kept.
ui.closeForgets an open pane the person closed, and remembers not to auto-open again.
agent.spawnAdds the new agent under its parent; opens the pane on the session's first spawn.
tool.callMarks the loop busy in the tool, then counts the call and its duration.
turn.start, turn.completeThe root's busy state; a subagent's end status, duration and tokens.
classic.PermissionRequest, classic.NotificationMarks the loop waiting for approval.

What it calls on $

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.

Install

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.

Tests

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.

Development

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.

License

Apache License 2.0, see LICENSE. Copyright 2026 Charles Tao.

Source 33 files
hooks/register.ts 517 lines
1import 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}
517
hooks/host/index.ts 4 lines
1export type * from './host.js'
2
3export * as default from '.'
4
hooks/limits/index.ts 6 lines
1export * from './columns'
2export * from './sizes'
3export * from './timing'
4
5export * as default from '.'
6
hooks/model/index.ts 17 lines
1export * 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 '.'
17
hooks/names/index.ts 5 lines
1export * from './ids'
2export * from './texts'
3
4export * as default from '.'
5
hooks/pane-toggle/index.ts 5 lines
1export * from './pane-toggle-of'
2export * from './should-auto-open'
3
4export * as default from '.'
5
hooks/views/index.ts 6 lines
1export * from './kit'
2export * from './pane-view'
3export * from './text-view'
4
5export * as default from '.'
6
hooks/host/host.ts 35 lines
1import 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}
35
hooks/limits/columns.ts 5 lines
1/** 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
5
hooks/limits/sizes.ts 15 lines
1/** 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
15
hooks/limits/timing.ts 13 lines
1/** 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
13
hooks/model/counts-of.ts 33 lines
1import 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