サブエージェントの親子関係と実行状態を枠付きペインにツリー表示する mod

サブエージェントを親子ツリーで枠付きペインに表示する Claude Code mod。
入力欄の上に、次のツリーを角丸の枠で囲んで表示する(ターミナルでの例)。
Subagents 2 running · 2 done
├─ ✅ 認証まわりを調査 Explore · 12s
└─ ⠹ 実装プランを作る general-purpose · 20s
├─ ✅ 型定義を読む Explore · 3s
└─ ⠹ テストを確認 Explore · 8s
Desktop アプリでは、罫線を SVG の線で描く。
/subagent-tree でペインにも開ける)Claude Code v2.1.287 以上(Desktop アプリは同梱版が v2.1.286 以上)。
次のいずれかで読み込む。
claude --plugin-dir /path/to/ai-tools/mods/subagent-treemods/subagent-tree をそのプロジェクトの .claude/skills/subagent-tree/ にコピーする(プロジェクトの .claude/skills/ のプラグインは、そのプロジェクトのセッションで自動で読み込まれる)~/.claude/settings.json の env に CLAUDE_CODE_PLUGIN_DIRS(絶対パス)を設定するclaude plugin validate --strict mods/subagent-tree
(cd mods/subagent-tree && claude plugin test)hooks/register.js 260 lines1// サブエージェントを親子ツリーで表示する。
2// サブエージェントがいる間は入力欄の上の帯に枠付きで出し、/subagent-tree でペインにも開ける。
3// 実行中はスピナーが回り、終わると ✅ に変わる。
4
5import { atom, read, update } from 'claude-code'
6
7const PANE = 'subagent-tree'
8const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
9const TICK_MS = 100
10// 罫線 1 段とアイコンの表示幅 (セル数)
11const GUIDE_WIDTH = 3
12const ICON_WIDTH = 3
13// $.agent.list() で状態を突き合わせる間隔 (tick 数)
14const SYNC_EVERY = 5
15
16// 記録はリロードで消えないよう $.state に置く (types/index.d.ts の AgentRecord[])
17const agents = atom({ plugin: 'subagent-tree', key: 'agents' }, [])
18// 片付け済みの agentId
19const dismissed = atom({ plugin: 'subagent-tree', key: 'dismissed' }, [])
20// ユーザー自身の入力 (ターミナル / Remote Control / Desktop などの SDK ホスト)
21const USER_ORIGINS = new Set(['composer', 'bridge', 'sdk'])
22// スピナーのコマ。消えても困らないのでモジュール変数
23let frame = 0
24let ticks = 0
25
26// waiting は子の完了待ちで、まだ進行中
27const isRunning = (a) => a.status === 'running' || a.status === 'pending' || a.status === 'waiting'
28
29function iconOf(a) {
30 if (isRunning(a)) return { text: SPINNER[frame % SPINNER.length], color: 'cyan' }
31 if (a.status === 'completed' || a.status === 'idle') return { text: '✅', color: undefined }
32 if (a.status === 'killed') return { text: '⏹', color: 'yellow' }
33 return { text: '❌', color: 'red' }
34}
35
36function formatElapsed(ms) {
37 const s = Math.max(0, Math.floor(ms / 1000))
38 return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
39}
40
41// 1 件を追加・更新する
42const upsert = ($, record) =>
43 update($, agents, (list) => [...list.filter((a) => a.id !== record.id), record])
44
45// エンジンが持つ一覧 ($.agent.list) を正として記録を更新する
46async function sync($) {
47 const list = await $.agent.list()
48 if (list.length === 0) return
49 const now = await $.clock.now()
50 const skip = new Set(await read($, dismissed))
51 await update($, agents, (current) => {
52 const byId = new Map(current.map((a) => [a.id, a]))
53 for (const info of list) {
54 if (skip.has(info.id)) continue
55 const prev = byId.get(info.id)
56 const running = isRunning(info)
57 byId.set(info.id, {
58 id: info.id,
59 parentId: info.parentId ?? prev?.parentId,
60 description: info.description || prev?.description || info.type,
61 type: info.type,
62 status: info.status,
63 startedAt: prev?.startedAt ?? now,
64 endedAt: running ? undefined : (prev?.endedAt ?? now),
65 })
66 }
67 return [...byId.values()]
68 })
69}
70
71// 親子関係から、罫線つきの表示行を開始順に作る。
72// guides は字下げ 1 段ごとの罫線 ('│' か ''、最後が '├─' か '└─')
73function buildRows(list) {
74 const ids = new Set(list.map((a) => a.id))
75 const children = new Map()
76 const roots = []
77 for (const a of list) {
78 if (a.parentId && ids.has(a.parentId)) {
79 if (!children.has(a.parentId)) children.set(a.parentId, [])
80 children.get(a.parentId).push(a)
81 } else {
82 roots.push(a)
83 }
84 }
85 const byStart = (x, y) => x.startedAt - y.startedAt
86 const rows = []
87 const walk = (nodes, guides) => {
88 nodes.sort(byStart).forEach((node, i) => {
89 const isLast = i === nodes.length - 1
90 rows.push({ node, guides: [...guides.map((g) => (g ? '' : '│')), isLast ? '└─' : '├─'] })
91 walk(children.get(node.id) ?? [], [...guides, isLast])
92 })
93 }
94 walk(roots, [])
95 return rows
96}
97
98// Desktop 用: 1 行分の罫線を SVG で描く。行の高さいっぱいに線を引くので上下の行とつながる
99const SVG_STEP = 16
100const SVG_ROW_HEIGHT = 20
101function guidesSvg(guides) {
102 const mid = SVG_ROW_HEIGHT / 2
103 const lines = guides.map((g, i) => {
104 const x = i * SVG_STEP + SVG_STEP / 2
105 if (g === '│') return `<line x1="${x}" y1="0" x2="${x}" y2="${SVG_ROW_HEIGHT}"/>`
106 if (g === '') return ''
107 // ├─ は下へ続け、└─ は中央で止める
108 const bottom = g === '├─' ? SVG_ROW_HEIGHT : mid
109 return `<line x1="${x}" y1="0" x2="${x}" y2="${bottom}"/><line x1="${x}" y1="${mid}" x2="${(i + 1) * SVG_STEP}" y2="${mid}"/>`
110 })
111 const width = guides.length * SVG_STEP
112 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${SVG_ROW_HEIGHT}" viewBox="0 0 ${width} ${SVG_ROW_HEIGHT}"><g stroke="#8a8a8a" stroke-opacity="0.7" stroke-width="1" fill="none">${lines.join('')}</g></svg>`
113}
114
115// 枠付きのツリー。maxRows を超える分は古い行から省く
116async function drawTree($, e, list, maxRows) {
117 const { Box, Text, Svg } = $.ui.resolve(e)
118 const now = await $.clock.now()
119 const running = list.filter(isRunning).length
120 const done = list.length - running
121
122 const header = Box({
123 flexDirection: 'row',
124 columnGap: 1,
125 children: [
126 Text({ bold: true, children: ['Subagents'] }),
127 Text({ dimColor: true, children: [`${running} running · ${done} done`] }),
128 ],
129 })
130
131 let rows = buildRows([...list])
132 let hidden = 0
133 if (maxRows !== undefined && rows.length > maxRows) {
134 hidden = rows.length - maxRows
135 rows = rows.slice(hidden)
136 }
137 const body = rows.length === 0
138 ? [Text({ dimColor: true, children: ['サブエージェントはまだ起動していません'] })]
139 : rows.map(({ node, guides }) => {
140 const icon = iconOf(node)
141 const elapsed = formatElapsed((node.endedAt ?? now) - node.startedAt)
142 // Desktop は等幅フォントではないため、罫線は SVG、アイコンは幅を固定した Box に入れて縦をそろえる
143 const cell = (key, width, text) =>
144 Box({ key, width, flexShrink: 0, children: [Text(text)] })
145 return Box({
146 key: 'agent-' + node.id,
147 flexDirection: 'row',
148 alignItems: 'center',
149 children: [
150 ...(e.surface === 'desktop'
151 ? [Svg({ source: guidesSvg(guides), alt: guides.join(''), width: guides.length * SVG_STEP, height: SVG_ROW_HEIGHT })]
152 : guides.map((g, i) => cell(`guide-${i}`, GUIDE_WIDTH, { dimColor: true, children: [g || ' '] }))),
153 cell('icon', ICON_WIDTH, icon.color ? { color: icon.color, children: [icon.text] } : { children: [icon.text] }),
154 Text({ wrap: 'truncate-end', children: [node.description] }),
155 Text({ dimColor: true, children: [` ${node.type} · ${elapsed}`] }),
156 ],
157 })
158 })
159 if (hidden > 0) body.unshift(Text({ dimColor: true, children: [`… ほか ${hidden} 件`] }))
160
161 return Box({
162 flexDirection: 'column',
163 borderStyle: 'round',
164 paddingX: 1,
165 children: [header, ...body],
166 })
167}
168
169export function register(on) {
170 on('session.start', async ($, e, next) => {
171 await $.command.register({
172 name: 'subagent-tree',
173 description: 'サブエージェントのツリー表示ペインを開く',
174 immediate: true,
175 })
176 $.clock.every(TICK_MS, async () => {
177 ticks += 1
178 const running = (await read($, agents)).some(isRunning)
179 if (running) {
180 frame += 1
181 $.ui.invalidate('ui.render')
182 }
183 // 実行中はこまめに、待機中はゆっくり状態を突き合わせる
184 if (ticks % (running ? SYNC_EVERY : SYNC_EVERY * 4) === 0) void sync($)
185 })
186 return next(e)
187 })
188
189 on('command.run', { command: 'subagent-tree' }, async ($) => {
190 await $.ui.open({ id: PANE, title: 'Subagents' })
191 await sync($)
192 return {}
193 })
194
195 // サブエージェントが起動した瞬間にツリーへ追加する
196 on('agent.spawn', async ($, e, next) => {
197 const result = await next(e)
198 if (result.agentId) {
199 await upsert($, {
200 id: result.agentId,
201 parentId: e.parentAgentId,
202 description: e.description || e.subagentType,
203 type: e.subagentType,
204 status: 'running',
205 startedAt: await $.clock.now(),
206 })
207 }
208 return result
209 })
210
211 // サブエージェントのターン終了で即座に ✅ にする (list の反映を待たない)
212 on('turn.complete', async ($, e, next) => {
213 if (e.agentId) {
214 const now = await $.clock.now()
215 const status = e.isAborted ? 'killed' : e.reason === 'error' ? 'failed' : 'completed'
216 await update($, agents, (list) =>
217 list.map((a) => (a.id === e.agentId && isRunning(a) ? { ...a, status, endedAt: now } : a)),
218 )
219 }
220 return next(e)
221 })
222
223 // ユーザーが次の指示を送ったら、終わったものだけのツリーを片付ける。
224 // サブエージェントの完了通知もターンを始めるので、turn.start ではなく入力の種類で判断する
225 on('prompt.submit', async ($, e, next) => {
226 if (!USER_ORIGINS.has(e.origin?.kind)) return next(e)
227 const list = await read($, agents)
228 const byId = new Map(list.map((a) => [a.id, a]))
229 const keep = new Set()
230 for (const a of list) {
231 if (!isRunning(a)) continue
232 // 実行中のものとその祖先は残す
233 for (let cur = a; cur && !keep.has(cur.id); cur = cur.parentId ? byId.get(cur.parentId) : undefined) {
234 keep.add(cur.id)
235 }
236 }
237 const removed = list.filter((a) => !keep.has(a.id)).map((a) => a.id)
238 if (removed.length > 0) {
239 // 片付けたものを $.agent.list() から拾い直さないよう覚えておく
240 await update($, dismissed, (ids) => [...ids, ...removed].slice(-500))
241 await update($, agents, (current) => current.filter((a) => keep.has(a.id)))
242 }
243 return next(e)
244 })
245
246 // 入力欄の上の帯: サブエージェントがいる間だけ表示する
247 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
248 const list = await read($, agents)
249 if (list.length === 0) return next(e)
250 // 枠の上下 2 行と見出し 1 行を除いた行数までツリーを出す
251 const maxRows = typeof e.props.maxRows === 'number' ? Math.max(1, e.props.maxRows - 3) : undefined
252 return drawTree($, e, list, maxRows)
253 })
254
255 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
256 if (e.requestId !== PANE) return next(e)
257 return drawTree($, e, await read($, agents))
258 })
259}
260types/index.d.ts 16 lines1export type AgentRecord = {
2 id: string
3 parentId?: string
4 description: string
5 type: string
6 status: string
7 startedAt: number
8 endedAt?: number
9}
10
11declare module 'claude-code' {
12 interface PluginState {
13 'subagent-tree': { agents: AgentRecord[]; dismissed: string[] }
14 }
15}
16