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

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

⎿ line under each running agent shows the tool it is running now.h to hide them.t to toggle it.Version 0.1. The Claude Code mod API it uses is early access and may change.
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.
hooks/register.tsx 310 lines1import { 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}
310hooks/format.ts 105 lines1import 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}
105types/index.d.ts 38 lines1export 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