SLOPSHOPPER

subagent-tree

A live /agents-tree pane showing the session's subagents as a tree — status, how long each has run, its tool calls and steps, the tool it is on now and the…

newpaneguardcommandtimer
★ 2v0.1.5MITupdated 2026-10-07bobtat/claude-plugins/plugins/subagent-tree
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · subagent-tree
│ ┃ Subagents ✕ › fix the failing auth test and add an audit log call │ ┃ No subagents yet. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ 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 │ │ › /agents-tree │ ⎿ subagent-tree: Subagent tree opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Subagents
No subagents yet.
README

subagent-tree

A Claude Code mod that shows the session's subagents as a live tree in a pane. Like cost-ledger it is code: a TypeScript hooks module that Claude Code loads and runs in the session.

Installation

/plugin marketplace add bobtat/claude-plugins
/plugin install subagent-tree@bobtat-plugins

Using it

Type /agents-tree (or /agents-tree open) to open the pane and /agents-tree close to close it; any other argument replies with the usage. If a hook refuses the close the reply says why, and if a hook keeps the pane open without refusing it says the tree stays open. The pane lists every subagent the session has started, children under the agent that spawned them:

● Explore: scan the repo
    running · 1m05s · 3 tools · 2 steps · on Grep · 1k out
  ✓ general-purpose: read the config
      completed · 12s · 4 tools · 3 steps · 2k out
2 agents · 1 active · 3k tokens out
MarkStatus
●running
◐waiting
·pending
○idle (a teammate waiting for a message)
✓completed
✗failed or killed

While the pane is drawn it redraws once a second as long as an agent is pending, running or waiting, so the elapsed times keep moving, and once more when the last one finishes. With nothing running it redraws every fifth second, so a status that changed with no event of the mod's own (a pending agent starting, a teammate in a terminal pane of its own) still shows within five seconds. Each second the mod also asks the engine for the agent list and for its list of the plugin's panes.

The timer stops at the next tick once the engine no longer lists the pane, and also when the pane has not been drawn for about twelve seconds (a fallback for when the engine cannot say). The pane's next draw starts it again. After a hot reload the timer restarts when the pane next draws. If a tick fails, the error goes to the debug log (claude --debug), the timer stops, and the next draw starts it again.

The list is drawn whole and the pane scrolls it. Each line is set to truncate (wrap="truncate") at the pane's width; the mod's tests do not measure the truncation or the indent, which the surface does.

Where the numbers come from

  • Which agents exist, their status and parent come from the engine's agent list ($.agent.list()), so subagents started by other plugins and teammates appear too.
  • Tool calls, steps and the current tool are counted by the mod from each agent's own tool.call and turn.step events, so they cover only what happened while the mod was loaded. The current tool (on Grep) shows from its call until the agent's next model request starts, so it is not shown while the model is thinking. A tool call that is interrupted before its count is written may go uncounted.
  • Time is the agent's active time: its finished runs plus the run in progress. A teammate's idle gaps between runs are left out, and an agent that was killed or failed stops at its last event.
  • Output tokens are summed from the usage on each finished turn of the agent, so a running agent's figure appears when its turn ends. The footer total counts only the agents the tree shows; the engine's own forks (compaction, memory) and workflow agents are not in the list and are left out. The pane shows output tokens only; it does not price anything (cost-ledger does that).
  • Stats for ids the list does not show are dropped once idle for ten minutes, and past 200 entries unlisted ones go first. This runs when an agent's turn finishes and, while the pane is drawn, on the timer; it writes only when something is dropped. With the pane closed, stats of forks and workflow agents stay until the next agent turn finishes.
  • Active in the footer counts pending, running and waiting agents.

Nothing is blocked or rewritten: every hook passes the event on unchanged.

Source 3 files
hooks/register.tsx 250 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { AgentStats } from '../types'
5import { buildRows, formatTokens, liveCount, prune } from './tree'
6
7const PANE = 'subagent-tree'
8const stats = atom(
9  { plugin: 'subagent-tree', key: 'stats' } as const,
10  {},
11  { shape: 'stats-v2' },
12)
13
14const blank = (now: number): AgentStats => ({
15  tools: 0,
16  steps: 0,
17  tokensOut: 0,
18  activeMs: 0,
19  lastEventAt: now,
20})
21
22async function touch(
23  $: EngineInterface,
24  id: string,
25  change: (s: AgentStats) => AgentStats,
26) {
27  const now = await $.clock.now()
28  await update($, stats, all => {
29    const current = all[id] ?? blank(now)
30    // A run opens at the agent's first event: subagents raise no turn.start.
31    const open = { ...current, runStartedAt: current.runStartedAt ?? now, lastEventAt: now }
32    return { ...all, [id]: change(open) }
33  })
34}
35
36// Module state: a reload starts it over, and the next draw of the pane (a
37// reload redraws it) or /agents-tree restores it.
38let timer: Timer | undefined
39let lastRenderAt = 0
40let wasLive = false
41let idleTicks = 0
42// A pane that is drawn is redrawn on every invalidate, and at least every
43// IDLE_EVERY ticks even when nothing runs, so a pane not drawn for this long
44// is closed or was dropped without ui.close reaching this plugin; the timer
45// stops and the next draw restarts it.
46const IDLE_EVERY = 5
47const STALE_MS = 12_000
48
49function stopTimer() {
50  timer?.cancel()
51  timer = undefined
52  wasLive = false
53  idleTicks = 0
54}
55
56async function pruneUnlisted($: EngineInterface, agents: { id: string }[], now: number) {
57  const listed = new Set(agents.map(agent => agent.id))
58  const all = await read($, stats)
59  if (Object.keys(prune(all, listed, now)).length !== Object.keys(all).length) {
60    await update($, stats, current => prune(current, listed, now))
61  }
62}
63
64// Whether the engine still lists the pane; undefined when it cannot say.
65async function isPaneOpen($: EngineInterface): Promise<boolean | undefined> {
66  try {
67    return (await $.ui.panes()).some(pane => pane.id === PANE)
68  } catch {
69    return undefined
70  }
71}
72
73async function redrawTick($: EngineInterface) {
74  try {
75    const now = await $.clock.now()
76    if (now - lastRenderAt > STALE_MS) return stopTimer()
77    // The engine's own record: a pane it no longer lists is gone, whatever the clock says.
78    if ((await isPaneOpen($)) === false) return stopTimer()
79
80    const agents = await $.agent.list()
81    const isLive = liveCount(agents) > 0
82    idleTicks = isLive || wasLive ? 0 : idleTicks + 1
83    // Every second while something runs, and once more after the last agent
84    // finishes so the final frame is not left showing it running. When idle, a
85    // slow heartbeat: the draw reads the agent list, so a status that changed
86    // with no state write (a pending agent starting) still shows.
87    if (isLive || wasLive || idleTicks >= IDLE_EVERY) {
88      $.ui.invalidate('ui.render')
89      idleTicks = 0
90    }
91    wasLive = isLive
92    await pruneUnlisted($, agents, now)
93  } catch (error) {
94    // A failed period ends the interval; forget it so the next draw restarts it.
95    $.ui.log(`redraw tick failed: ${String(error)}`, { to: 'debug' })
96    stopTimer()
97  }
98}
99
100// A hook's refusal arrives as 'HooksError: <plugin>: $.ui.close: <reason>'.
101function reasonOf(error: unknown): string {
102  const text = error instanceof Error ? error.message : String(error)
103
104  return text.replace(/^.*?\$\.ui\.close: /, '')
105}
106
107async function watch($: EngineInterface) {
108  lastRenderAt = await $.clock.now()
109  // The timer keeps the `$` of the hook that started it, as the types' own
110  // example does; a tick that fails stops it and the next draw starts it again.
111  timer ??= $.clock.every(1000, () => redrawTick($))
112}
113
114export const register: Register = on => {
115  on('session.start', async ($, e, next) => {
116    await $.command.register({
117      name: 'agents-tree',
118      description: 'Show the session\'s subagents as a live tree in a pane',
119      argumentHint: '[close]',
120    })
121
122    return next(e)
123  })
124
125  on('command.run', { command: 'agents-tree' }, async ($, e) => {
126    const argument = e.args.trim()
127
128    if (argument === 'close') {
129      try {
130        await $.ui.close({ id: PANE })
131      } catch (error) {
132        // A hook beneath refused the close.
133        return { text: `Could not close the subagent tree: ${reasonOf(error)}` }
134      }
135      // A hook beneath may also keep the pane open without refusing.
136      if ((await isPaneOpen($)) === true) return { text: 'Subagent tree stays open.' }
137      stopTimer()
138
139      return { text: 'Subagent tree closed.' }
140    }
141    if (argument !== '' && argument !== 'open') {
142      return { text: 'Usage: /agents-tree [close]' }
143    }
144    await $.ui.open({ id: PANE, title: 'Subagents' })
145    await watch($)
146
147    return { text: 'Subagent tree opened.' }
148  })
149
150  on('tool.call', async ($, e, next) => {
151    if (e.agentId) {
152      // Written before the call runs, so the pane shows the tool while it runs;
153      // not awaited, so the call is not held up by the write. update() retries
154      // on a version miss, so the write is not lost to the turn.step and
155      // turn.complete writes; on an interrupt it may be aborted and that call
156      // goes uncounted.
157      touch($, e.agentId, s => ({ ...s, tools: s.tools + 1, lastTool: e.tool })).catch(
158        () => undefined,
159      )
160    }
161
162    return next(e)
163  })
164
165  on('turn.step', async function* ($, e, next) {
166    if (e.agentId) {
167      // A new request means the last tool call has finished.
168      await touch($, e.agentId, s => ({ ...s, steps: s.steps + 1, lastTool: undefined }))
169    }
170
171    return yield* next(e)
172  })
173
174  on('turn.complete', async ($, e, next) => {
175    const result = await next(e)
176    if (e.agentId) {
177      const id = e.agentId
178      const now = await $.clock.now()
179      const listed = new Set((await $.agent.list()).map(agent => agent.id))
180      await update($, stats, all => {
181        const current = all[id] ?? blank(now)
182        // Loaded mid-run: the run began when the turn's length says it did.
183        const began = current.runStartedAt ?? now - e.durationMs
184        const updated = {
185          ...all,
186          [id]: {
187            ...current,
188            runStartedAt: undefined,
189            activeMs: current.activeMs + Math.max(0, now - began),
190            lastEventAt: now,
191            // No usage on an interrupt or an API error.
192            tokensOut: current.tokensOut + (e.usage?.output_tokens ?? 0),
193          },
194        }
195
196        return prune(updated, listed, now)
197      })
198    }
199
200    return result
201  })
202
203  on('ui.close', { id: PANE }, async ($, e, next) => {
204    const result = await next(e)
205    // A hook beneath may refuse the close ({ deny }) or keep the pane open by
206    // answering without next; the engine's list of panes says which happened.
207    // When it cannot say, only a refusal keeps the timer running.
208    const isOpen = await isPaneOpen($)
209    const isRefused = Boolean((result as { deny?: string } | undefined)?.deny)
210    if (isOpen === false || (isOpen === undefined && !isRefused)) stopTimer()
211
212    return result
213  })
214
215  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
216    const { Box, Text } = $.ui.resolve(e)
217    await watch($)
218    const all = await read($, stats)
219    const agents = await $.agent.list()
220    const now = await $.clock.now()
221    const rows = buildRows(agents, all, now)
222    const live = liveCount(agents)
223    // Only agents the tree shows: forks and workflow agents carry ids no list names.
224    const tokens = agents.reduce((sum, agent) => sum + (all[agent.id]?.tokensOut ?? 0), 0)
225
226    return (
227      <Box flexDirection="column">
228        {rows.length === 0 && <Text dimColor>No subagents yet.</Text>}
229        {rows.map(row => (
230          <Box key={row.id} flexDirection="column" paddingLeft={row.depth * 2}>
231            <Text bold={row.isLive} dimColor={!row.isLive} wrap="truncate">
232              {row.glyph} {row.label}
233            </Text>
234            <Box paddingLeft={2}>
235              <Text dimColor wrap="truncate">
236                {row.detail}
237              </Text>
238            </Box>
239          </Box>
240        ))}
241        {rows.length > 0 && (
242          <Text dimColor>
243            {rows.length} agents · {live} active · {formatTokens(tokens)} tokens out
244          </Text>
245        )}
246      </Box>
247    )
248  })
249}
250
hooks/tree.ts 149 lines
1import type { AgentStats } from '../types'
2
3/** The part of `$.agent.list()`'s entries the tree reads. */
4export type AgentLike = {
5  id: string
6  description: string
7  type: string
8  status: string
9  parentId?: string
10  name?: string
11}
12
13export type Row = {
14  id: string
15  depth: number
16  glyph: string
17  label: string
18  detail: string
19  isLive: boolean
20}
21
22const LIVE = new Set(['pending', 'running', 'waiting'])
23
24const GLYPHS: Record<string, string> = {
25  pending: '·',
26  running: '●',
27  waiting: '◐',
28  idle: '○',
29  completed: '✓',
30  failed: '✗',
31  killed: '✗',
32}
33
34export const isLive = (status: string): boolean => LIVE.has(status)
35
36export const formatElapsed = (ms: number): string => {
37  const seconds = Math.max(0, Math.floor(ms / 1000))
38  if (seconds < 60) return `${seconds}s`
39  const minutes = Math.floor(seconds / 60)
40  if (minutes < 60) return `${minutes}m${String(seconds % 60).padStart(2, '0')}s`
41
42  return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`
43}
44
45export const formatTokens = (tokens: number): string =>
46  tokens >= 1_000_000
47    ? `${(tokens / 1_000_000).toFixed(1)}M`
48    : tokens >= 1_000
49      ? `${Math.round(tokens / 1_000)}k`
50      : String(tokens)
51
52/**
53 * How long the agent has been active: its finished runs, plus the run in
54 * progress. A run left open by an agent that is no longer live stops at its
55 * last event, so a killed or failed agent's clock does not keep moving.
56 */
57export const elapsedMs = (stats: AgentStats, status: string, now: number): number => {
58  if (stats.runStartedAt === undefined) return stats.activeMs
59  const end = isLive(status) ? now : stats.lastEventAt
60
61  return stats.activeMs + Math.max(0, end - stats.runStartedAt)
62}
63
64const PRUNE_AFTER_MS = 10 * 60 * 1000
65const PRUNE_CAP = 200
66
67/**
68 * Drops the stats of ids the agent list does not show once they have been
69 * idle for ten minutes, and past 200 entries drops unlisted ones first, oldest
70 * first. A listed agent's stats are never dropped.
71 */
72export const prune = (
73  stats: Record<string, AgentStats>,
74  listed: Set<string>,
75  now: number,
76): Record<string, AgentStats> => {
77  const kept = Object.entries(stats).filter(
78    ([id, s]) => listed.has(id) || now - s.lastEventAt < PRUNE_AFTER_MS,
79  )
80  if (kept.length <= PRUNE_CAP) return Object.fromEntries(kept)
81
82  const rank = ([id, s]: [string, AgentStats]) => (listed.has(id) ? Infinity : s.lastEventAt)
83  const newest = [...kept].sort((a, b) => rank(b) - rank(a)).slice(0, PRUNE_CAP)
84
85  return Object.fromEntries(newest)
86}
87
88const detailOf = (agent: AgentLike, stats: AgentStats | undefined, now: number): string => {
89  if (!stats) return agent.status
90  const parts = [
91    agent.status,
92    formatElapsed(elapsedMs(stats, agent.status, now)),
93    `${stats.tools} tool${stats.tools === 1 ? '' : 's'}`,
94    `${stats.steps} step${stats.steps === 1 ? '' : 's'}`,
95  ]
96  if (isLive(agent.status) && stats.lastTool) parts.push(`on ${stats.lastTool}`)
97  if (stats.tokensOut > 0) parts.push(`${formatTokens(stats.tokensOut)} out`)
98
99  return parts.join(' · ')
100}
101
102/**
103 * Flattens the agents into display rows, children under their parent in the
104 * order the list gives them. An agent whose parent is not in the list (or is
105 * the main loop) is a root; a cycle in the list cannot loop forever.
106 */
107export const buildRows = (
108  agents: AgentLike[],
109  stats: Record<string, AgentStats>,
110  now: number,
111): Row[] => {
112  const ids = new Set(agents.map(agent => agent.id))
113  const children = new Map<string, AgentLike[]>()
114  const roots: AgentLike[] = []
115
116  for (const agent of agents) {
117    if (agent.parentId && ids.has(agent.parentId) && agent.parentId !== agent.id) {
118      children.set(agent.parentId, [...(children.get(agent.parentId) ?? []), agent])
119    } else {
120      roots.push(agent)
121    }
122  }
123
124  const rows: Row[] = []
125  const seen = new Set<string>()
126  const visit = (agent: AgentLike, depth: number) => {
127    if (seen.has(agent.id)) return
128    seen.add(agent.id)
129    const title = agent.description || agent.name || agent.id
130    rows.push({
131      id: agent.id,
132      depth,
133      glyph: GLYPHS[agent.status] ?? '?',
134      label: `${agent.type}: ${title}`,
135      detail: detailOf(agent, stats[agent.id], now),
136      isLive: isLive(agent.status),
137    })
138    for (const child of children.get(agent.id) ?? []) visit(child, depth + 1)
139  }
140  for (const root of roots) visit(root, 0)
141  // Agents left over are in a parent cycle: show them rather than hide them.
142  for (const agent of agents) visit(agent, 0)
143
144  return rows
145}
146
147export const liveCount = (agents: AgentLike[]): number =>
148  agents.filter(agent => isLive(agent.status)).length
149
types/index.d.ts 25 lines
1export type AgentStats = {
2  /** Tool calls the agent's loop has made. */
3  tools: number
4  /** Model requests its loop has made. */
5  steps: number
6  /** The tool it called last. */
7  lastTool?: string
8  /** Output tokens summed over its finished turns. */
9  tokensOut: number
10  /** Milliseconds its finished runs were active, idle gaps between runs left out. */
11  activeMs: number
12  /** Milliseconds since the epoch when the run in progress began; unset between runs. */
13  runStartedAt?: number
14  /** Milliseconds since the epoch of the latest event seen from it. */
15  lastEventAt: number
16}
17
18declare module 'claude-code' {
19  interface PluginState {
20    'subagent-tree': {
21      stats: Shaped<Record<string, AgentStats>>
22    }
23  }
24}
25