SLOPSHOPPER

subagents-tree

A docked pane with every subagent of the session as a tree: model, effort, live tool, elapsed time, tokens and a timeline.

newpaneguardcommandtimeragents
v0.1.1no licenseupdated 2026-10-16kargarisaac/subagents-tree
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · subagents-tree
│ ┃ Subagents ✕ › fix the failing auth test and add an audit log call │ ┃ 0 running · 0 done · 0 tok · $0.42 session │ ┃ main ⏺ Read(src/auth.ts) │ ┃ No subagents yet. ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ t: timeline h: hide done ⎿ 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 │ │ › /subagents │ ⎿ subagents-tree: Subagents pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Subagents
0 running · 0 done · 0 tok · $0.42 session main No subagents yet. t: timeline h: hide done
README

Subagents Tree

A Claude Code mod that shows every subagent of your session in a pane docked beside the transcript.

Subagents pane

  • Agents appear as a tree, and subagents spawned by subagents are nested under their parent.
  • Each row shows the model and effort, the elapsed time, and the context size or tokens spent.
  • A ⎿ line under each running agent shows the tool it is running now.
  • Finished agents stay in the list. Press h to hide them.
  • A timeline shows when each agent ran. Press t to toggle it.
  • The header shows the session's cost.
  • You get a warning when two agents write the same file.
  • Click a row to open that agent's transcript.

Version 0.1. The Claude Code mod API it uses is early access and may change.

Install

At the prompt of a Claude Code terminal session:

/plugin install subagents-tree --marketplace kargarisaac/subagents-tree

Then run /subagents. The pane docks beside the transcript in fullscreen mode at 110 columns or wider. Narrower windows show it above the prompt.

Source 3 files
hooks/register.tsx 310 lines
1import { atom, read, update } from 'claude-code'
2import type { AgentStatus, Register } from 'claude-code'
3
4import type { Agent, Main, Status, View } from '../types'
5import {
6  bar,
7  collisions,
8  duration,
9  effortLabel,
10  emptyTokens,
11  isStalled,
12  kilo,
13  shortModel,
14  spentOf,
15  toolLabel,
16  treeRows,
17  writtenFile,
18} from './format'
19
20const PANE = 'subagents-tree'
21const TITLE = 'Subagents'
22
23const agents = atom({ plugin: 'subagents-tree', key: 'agents' } as const, {} as Record<string, Agent>)
24const main = atom({ plugin: 'subagents-tree', key: 'main' } as const, { files: [] } as Main)
25const view = atom({ plugin: 'subagents-tree', key: 'view' } as const, { timeline: true, hideDone: false } as View)
26const now = atom({ plugin: 'subagents-tree', key: 'now' } as const, 0)
27
28const statusOf = (s: AgentStatus): Status =>
29  s === 'completed' || s === 'failed' || s === 'killed' ? s : 'running'
30
31const patch = (id: string, fn: (a: Agent) => Agent) => (all: Record<string, Agent>) =>
32  all[id] === undefined ? all : { ...all, [id]: fn(all[id]) }
33
34const fresh = (id: string, at: number, fields: Partial<Agent>): Agent => ({
35  id,
36  type: 'agent',
37  description: '',
38  status: 'running',
39  startedAt: at,
40  context: 0,
41  spent: emptyTokens(),
42  steps: 0,
43  tools: 0,
44  files: [],
45  ...fields,
46})
47
48const addFile = (files: string[], file?: string) =>
49  file === undefined || files.includes(file) ? files : [...files, file]
50
51// Drops keys rather than storing `undefined`, which state values do not carry.
52const omit = <K extends keyof Agent>(a: Agent, ...keys: K[]): Agent => {
53  const copy = { ...a }
54  for (const k of keys) delete copy[k]
55  return copy
56}
57
58// The dashboard's bookkeeping never fails the event it watches.
59const quiet = () => undefined
60
61export const register: Register = on => {
62  on('session.start', async ($, e, next) => {
63    await $.command.register({ name: 'subagents', description: 'Show every subagent of this session in a docked pane' }).catch(quiet)
64
65    // Once a second while anything runs: move the clock the pane draws with and take
66    // statuses and agents this mod did not see spawn from the engine's list.
67    $.clock.every(1000, async () => {
68      const known = await read($, agents)
69      const list = await $.agent.list()
70      const isBusy = list.some(a => statusOf(a.status) === 'running') ||
71        Object.values(known).some(a => a.status === 'running')
72      if (!isBusy) return
73      const at = await $.clock.now()
74      await update($, agents, all => {
75        const merged = { ...all }
76        for (const info of list) {
77          const status = statusOf(info.status)
78          const was = merged[info.id]
79          if (was === undefined) {
80            merged[info.id] = fresh(info.id, at, {
81              ...(info.parentId === undefined ? {} : { parentId: info.parentId }),
82              type: info.type,
83              description: info.description,
84              status,
85              ...(status === 'running' ? {} : { endedAt: at }),
86            })
87          } else if (was.status !== status && (status !== 'running' || was.endedAt === undefined)) {
88            merged[info.id] = { ...was, status, ...(status === 'running' ? {} : { endedAt: was.endedAt ?? at }) }
89          }
90        }
91        return merged
92      })
93      await update($, now, () => at)
94    })
95
96    return next(e)
97  })
98
99  on('command.run', { command: 'subagents' }, async $ => {
100    await $.ui.open({ id: PANE, title: TITLE })
101    return { text: 'Subagents pane opened.' }
102  })
103
104  on('agent.spawn', async ($, e, next) => {
105    const result = await next(e)
106    const id = result.agentId
107    if (id === undefined) return result
108
109    const track = async () => {
110      const at = await $.clock.now()
111      const isFirst = Object.keys(await read($, agents)).length === 0
112      await update($, agents, all => ({
113        ...all,
114        [id]: fresh(id, at, {
115          ...(e.parentAgentId === undefined ? {} : { parentId: e.parentAgentId }),
116          type: e.subagentType,
117          description: e.description,
118          model: result.model,
119        }),
120      }))
121      await update($, now, () => at)
122      if (isFirst) await $.ui.open({ id: PANE, title: TITLE })
123    }
124    await track().catch(quiet)
125
126    return result
127  })
128
129  on('turn.step', async function* ($, e, next) {
130    const response = yield* next(e)
131    const usage = response.usage
132    const model = usage?.model ?? e.model
133    const effort = e.effort === undefined ? undefined : String(e.effort)
134
135    if (e.agentId === undefined) {
136      await update($, main, m => ({ ...m, model, effort: effort ?? m.effort }))
137      return response
138    }
139
140    const at = await $.clock.now()
141    const id = e.agentId
142    await update($, agents, all => {
143      const was = all[id] ?? fresh(id, at, {})
144      return {
145        ...all,
146        [id]: {
147          ...omit(was, 'endedAt'),
148          model,
149          ...(effort ?? was.effort) === undefined ? {} : { effort: effort ?? was.effort },
150          status: 'running' as const,
151          lastStepAt: at,
152          steps: was.steps + 1,
153          context: usage
154            ? usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
155            : was.context,
156          spent: usage
157            ? {
158                input: was.spent.input + usage.input_tokens,
159                output: was.spent.output + usage.output_tokens,
160                cacheRead: was.spent.cacheRead + usage.cache_read_input_tokens,
161                cacheWrite: was.spent.cacheWrite + usage.cache_creation_input_tokens,
162              }
163            : was.spent,
164        },
165      }
166    })
167
168    return response
169  })
170
171  on('tool.call', async ($, e, next) => {
172    const args = e as unknown as Record<string, unknown>
173    const file = writtenFile(e.tool, args)
174
175    if (e.agentId === undefined) {
176      if (file !== undefined) await update($, main, m => ({ ...m, files: addFile(m.files, file) })).catch(quiet)
177      return next(e)
178    }
179
180    const id = e.agentId
181    const begin = async () => {
182      const at = await $.clock.now()
183      await update($, agents, patch(id, a => ({ ...a, tool: { label: toolLabel(e.tool, args), startedAt: at } })))
184    }
185    await begin().catch(quiet)
186    try {
187      return await next(e)
188    } finally {
189      await update($, agents, patch(id, a => ({ ...omit(a, 'tool'), tools: a.tools + 1, files: addFile(a.files, file) }))).catch(quiet)
190    }
191  })
192
193  on('turn.complete', async ($, e, next) => {
194    const result = await next(e)
195    const id = e.agentId
196    if (id !== undefined) {
197      const at = await $.clock.now()
198      await update($, agents, patch(id, a => ({
199        ...omit(a, 'tool'),
200        status: e.isAborted ? 'killed' : 'completed',
201        endedAt: at,
202      })))
203    }
204    return result
205  })
206
207  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
208    const { Box, Text, Button } = $.ui.resolve(e)
209    const all = await read($, agents)
210    const m = await read($, main)
211    const v = await read($, view)
212    const clock = Math.max(await read($, now), ...Object.values(all).map(a => a.endedAt ?? a.lastStepAt ?? a.startedAt))
213    const cost = (await $.session.usage().catch(quiet))?.cost?.usd
214    const width = Math.max(20, e.props.bodyColumns)
215    const inView = e.props.view.agentId
216
217    const list = Object.values(all)
218    const running = list.filter(a => a.status === 'running').length
219    const spent = list.reduce((sum, a) => sum + spentOf(a.spent), 0)
220    const rows = treeRows(all, v.hideDone)
221    const clashes = collisions(all, m)
222
223    const glyph = (a: Agent) =>
224      isStalled(a, clock)
225        ? { mark: '◌', color: 'warning' }
226        : a.status === 'running'
227          ? { mark: '●', color: 'claude' }
228          : a.status === 'completed'
229            ? { mark: '✓', color: 'success' }
230            : { mark: '✗', color: 'error' }
231
232    const header = [
233      `${running} running`,
234      `${list.length - running} done`,
235      `${kilo(spent)} tok`,
236      ...(cost === undefined ? [] : [`$${cost.toFixed(2)} session`]),
237    ].join(' · ')
238
239    const start = Math.min(clock, ...list.map(a => a.startedAt))
240    const label = 10
241    const lane = Math.max(4, width - label - 1)
242
243    return (
244      <Box flexDirection="column">
245        <Text dimColor>{header}</Text>
246        <Button key="main" plain onPress={() => $.ui.view({})}>
247          <Text bold={inView === undefined}>{inView === undefined ? '▸ ' : '  '}main </Text>
248          <Text color="claude">{shortModel(m.model)}</Text>
249          <Text> </Text>
250          <Text color="suggestion">{effortLabel(m.effort)}</Text>
251        </Button>
252        {rows.length === 0 && <Text dimColor>  No subagents yet.</Text>}
253        {rows.map(({ agent: a, prefix }) => {
254          const g = glyph(a)
255          const elapsed = duration((a.endedAt ?? clock) - a.startedAt)
256          const tokens = a.status === 'running'
257            ? `ctx ${kilo(a.context)}`
258            : `${kilo(spentOf(a.spent))} · ${a.tools} tools`
259          const toolFor = a.tool === undefined ? undefined : duration(clock - a.tool.startedAt)
260          return (
261            <Box key={a.id} flexDirection="column">
262              <Button key={`row-${a.id}`} plain onPress={() => $.ui.view({ agentId: a.id })}>
263                <Text bold={inView === a.id}>{inView === a.id ? '▸' : ' '}{prefix}</Text>
264                <Text color={g.color}>{g.mark} </Text>
265                <Text color="claude">{shortModel(a.model)}</Text>
266                <Text> </Text>
267                <Text color="suggestion">{effortLabel(a.effort)}</Text>
268                <Text dimColor> {elapsed} {tokens} </Text>
269                <Text wrap="truncate-end">{a.type}: {a.description}</Text>
270              </Button>
271              {a.tool !== undefined && (
272                <Text dimColor wrap="truncate-end">
273                  {' '}{prefix.replace(/[├└]─$/, prefix.endsWith('└─') ? '  ' : '│ ')}  ⎿ {a.tool.label} {toolFor}
274                </Text>
275              )}
276              {isStalled(a, clock) && (
277                <Text color="warning">{'   '}no step for {duration(clock - (a.lastStepAt ?? a.startedAt))}</Text>
278              )}
279            </Box>
280          )
281        })}
282        {v.timeline && rows.length > 0 && (
283          <Box flexDirection="column" marginTop={1}>
284            <Text dimColor>timeline {'─'.repeat(Math.max(0, width - 13))} now</Text>
285            {rows.map(({ agent: a, depth }) => (
286              <Box key={`lane-${a.id}`}>
287                <Text dimColor>{`${' '.repeat(depth)}${a.type}`.slice(0, label).padEnd(label)} </Text>
288                <Text color={a.status === 'running' ? 'claude' : 'inactive'}>{bar(a, start, clock, lane)}</Text>
289              </Box>
290            ))}
291          </Box>
292        )}
293        {clashes.map(c => (
294          <Text key={`clash-${c.file}`} color="warning" wrap="truncate-end">
295            ⚠ {c.count} loops wrote {c.file.split('/').slice(-3).join('/')}
296          </Text>
297        ))}
298        <Box marginTop={1} gap={2}>
299          <Button key="toggle-timeline" plain hotkey="t" onPress={() => update($, view, x => ({ ...x, timeline: !x.timeline }))}>
300            timeline
301          </Button>
302          <Button key="toggle-done" plain hotkey="h" onPress={() => update($, view, x => ({ ...x, hideDone: !x.hideDone }))}>
303            {v.hideDone ? 'show done' : 'hide done'}
304          </Button>
305        </Box>
306      </Box>
307    )
308  })
309}
310
hooks/format.ts 105 lines
1import type { Agent, Main, Tokens } from '../types'
2
3export const STALL_MS = 90_000
4
5export const emptyTokens = (): Tokens => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 })
6
7export const shortModel = (model?: string) =>
8  model === undefined ? '?' : model.replace(/^claude-/, '').replace(/-\d{8}$/, '')
9
10export const effortLabel = (effort?: string | number) => (effort === undefined ? '…' : String(effort))
11
12export const kilo = (n: number) =>
13  n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`
14
15export const duration = (ms: number) => {
16  const s = Math.max(0, Math.floor(ms / 1000))
17  if (s < 60) return `${s}s`
18  const m = Math.floor(s / 60)
19  if (m < 60) return `${m}m${String(s % 60).padStart(2, '0')}`
20  return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
21}
22
23// Tokens the agent paid for fresh: new input, cache writes and output; cache reads are left out.
24export const spentOf = (t: Tokens) => t.input + t.cacheWrite + t.output
25
26const text = (v: unknown) => (typeof v === 'string' ? v : undefined)
27
28const tail = (path: string) => path.split('/').slice(-2).join('/')
29
30export const toolLabel = (tool: string, args: Record<string, unknown>) => {
31  const path = text(args.file_path) ?? text(args.notebook_path) ?? text(args.path)
32  switch (tool) {
33    case 'Bash':
34      return `$ ${text(args.command) ?? ''}`
35    case 'Read':
36    case 'Edit':
37    case 'Write':
38    case 'NotebookEdit':
39      return `${tool} ${path === undefined ? '' : tail(path)}`
40    case 'Grep':
41      return `Grep /${text(args.pattern) ?? ''}/${path === undefined ? '' : ` in ${tail(path)}`}`
42    case 'Glob':
43      return `Glob ${text(args.pattern) ?? ''}`
44    case 'WebFetch':
45      return `WebFetch ${(text(args.url) ?? '').replace(/^https?:\/\//, '')}`
46    case 'WebSearch':
47      return `WebSearch ${text(args.query) ?? ''}`
48    case 'Agent':
49      return `spawn ${text(args.subagent_type) ?? 'agent'}: ${text(args.description) ?? ''}`
50    default:
51      return tool
52  }
53}
54
55export const writtenFile = (tool: string, args: Record<string, unknown>) =>
56  tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit'
57    ? (text(args.file_path) ?? text(args.notebook_path))
58    : undefined
59
60export const isStalled = (a: Agent, now: number) =>
61  a.status === 'running' && a.tool === undefined && now - (a.lastStepAt ?? a.startedAt) > STALL_MS
62
63export type Row = { agent: Agent; prefix: string; depth: number }
64
65// Depth-first rows; an agent whose parent is unknown hangs under main.
66export const treeRows = (agents: Record<string, Agent>, hideDone: boolean): Row[] => {
67  const visible = Object.values(agents).filter(a => !hideDone || a.status === 'running')
68  const ids = new Set(visible.map(a => a.id))
69  const children = new Map<string | undefined, Agent[]>()
70  for (const a of visible) {
71    const parent = a.parentId !== undefined && ids.has(a.parentId) ? a.parentId : undefined
72    children.set(parent, [...(children.get(parent) ?? []), a])
73  }
74  const rows: Row[] = []
75  const walk = (parent: string | undefined, indent: string, depth: number) => {
76    const kids = (children.get(parent) ?? []).sort((x, y) => x.startedAt - y.startedAt)
77    kids.forEach((a, i) => {
78      const last = i === kids.length - 1
79      rows.push({ agent: a, prefix: `${indent}${last ? '└─' : '├─'}`, depth })
80      walk(a.id, `${indent}${last ? '  ' : '│ '}`, depth + 1)
81    })
82  }
83  walk(undefined, '', 0)
84  return rows
85}
86
87// Files written by more than one loop (main counts as one).
88export const collisions = (agents: Record<string, Agent>, main: Main) => {
89  const owners = new Map<string, Set<string>>()
90  const add = (who: string, files: string[]) => {
91    for (const f of files) owners.set(f, (owners.get(f) ?? new Set()).add(who))
92  }
93  add('main', main.files)
94  for (const a of Object.values(agents)) add(a.id, a.files)
95  return [...owners].filter(([, who]) => who.size > 1).map(([file, who]) => ({ file, count: who.size }))
96}
97
98export const bar = (a: Agent, start: number, now: number, width: number) => {
99  const span = Math.max(1, now - start)
100  const from = Math.floor(((a.startedAt - start) / span) * width)
101  const to = Math.ceil((((a.endedAt ?? now) - start) / span) * width)
102  const lead = Math.min(width - 1, Math.max(0, from))
103  return ' '.repeat(lead) + '█'.repeat(Math.max(1, Math.min(width, to) - lead))
104}
105
types/index.d.ts 38 lines
1export type Status = 'running' | 'completed' | 'failed' | 'killed'
2
3export type Tokens = { input: number; output: number; cacheRead: number; cacheWrite: number }
4
5export type Agent = {
6  id: string
7  parentId?: string
8  type: string
9  description: string
10  model?: string
11  effort?: string
12  status: Status
13  startedAt: number
14  endedAt?: number
15  lastStepAt?: number
16  tool?: { label: string; startedAt: number }
17  context: number
18  spent: Tokens
19  steps: number
20  tools: number
21  files: string[]
22}
23
24export type Main = { model?: string; effort?: string; files: string[] }
25
26export type View = { timeline: boolean; hideDone: boolean }
27
28declare module 'claude-code' {
29  interface PluginState {
30    'subagents-tree': {
31      agents: Record<string, Agent>
32      main: Main
33      view: View
34      now: number
35    }
36  }
37}
38