Side pane showing subagents as a tree with last-tool activity, tokens and runtime per agent, click-through detail, search, and saved history of past sessions

Clint's personal Claude Code plugin marketplace — skills and agents distributed as installable, auto-updating plugins instead of hand-zipped .skill files.
dev-workflow — Playwright testing tips (playwright-testing skill), a playwright-runner subagent that executes Playwright tests/browser steps and reports back a compact summary, and a demoing-work skill for walking the user through completed work and gating "done" on their explicit confirmation.game-catchup — Generates a structured "what you missed" recap for a game you haven't played in a while.feature-maturity — Shared vocabulary for how done a feature actually is: a Completeness ladder (Spike/Prototype/Functional/Finished) plus independent quality axes (Polish, Robustness, Architecture, Verification, Documentation, Accessibility). Use for scoping work, self-reporting before declaring something "done," auditing existing code, or resolving disagreements about whether something is really Finished/Polished/Hardened.desktop-statusline — A hooks-module mod that mirrors the terminal status line (model, session tokens, context %, 5h/7d rate limits with reset countdown) as a band above the prompt in the Claude desktop app's Code tab. Hidden in the terminal, where the real status line runs.subagent-tree — A hooks-module mod that adds an "Agents" side pane: subagents in a tree with the last tool and how long ago it ran, tokens and runtime per agent, click-through detail (prompt, tool log, result), search, and saved history of past sessions./plugin marketplace add wolf123450/ClaudeSkills
/plugin install dev-workflow@claude-skills
/plugin install game-catchup@claude-skills
/plugin install feature-maturity@claude-skills
/plugin install desktop-statusline@claude-skills
/plugin install subagent-tree@claude-skills
(Or via the claude CLI: claude plugin marketplace add wolf123450/ClaudeSkills, then claude plugin install dev-workflow@claude-skills.)
/plugin marketplace update claude-skills
Or just wait — Claude Code periodically checks configured marketplaces for updates. Since plugins live in this git repo, any commit pushed here is what the next update pulls; no manual re-zipping or redistribution step.
plugins/<name>/.claude-plugin/plugin.json (name, description, version, author).plugins/<name>/skills/<skill-name>/SKILL.md and/or agents under plugins/<name>/agents/<agent-name>.md..claude-plugin/marketplace.json's plugins array with "source": "./plugins/<name>".claude plugin validate . from the repo root to confirm it's picked up cleanly beforecommitting.
hooks/register.tsx 407 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Rec, Scope, Ui } from '../types'
5
6const PANE = 'subagent-tree'
7const MAIN = 'main'
8const QUIET_MS = 30_000
9const MAX_HISTORY = 150
10const MAX_LOG = 40
11
12const activity = atom({ plugin: 'subagent-tree', key: 'activity' } as const, {})
13const past = atom({ plugin: 'subagent-tree', key: 'past' } as const, [])
14const now = atom({ plugin: 'subagent-tree', key: 'now' } as const, 0)
15const collapsed = atom({ plugin: 'subagent-tree', key: 'collapsed' } as const, [])
16const ui = atom({ plugin: 'subagent-tree', key: 'ui' } as const, { selected: null, scope: 'live', query: '' })
17
18// The module's working copy; `activity` is its projection for drawing, `$.store` its memory between sessions.
19let live: Record<string, Rec> = {}
20let pastRecs: Rec[] = []
21let sid = 0
22let dirty = false
23let isLight = false
24
25// Segmented control colors: the light pair is the pattern lexicon's `.seg`; the dark pair sits just above the dark pane.
26const SEG = {
27 light: { track: '#DEE4EC', on: '#FFFFFF', line: '#C9D2DE' },
28 dark: { track: '#262624', on: '#3B3B38', line: '#3F3F3C' },
29}
30
31async function readTheme($: EngineInterface) {
32 try {
33 const row = (await $.config.list()).find(r => r.key === 'theme') as { value?: unknown } | undefined
34 isLight = String(row?.value ?? '').toLowerCase().startsWith('light')
35 } catch {
36 isLight = false
37 }
38}
39
40const fresh = (id: string, t: number): Rec => ({
41 id,
42 sid,
43 label: id === MAIN ? 'main' : id,
44 type: id === MAIN ? 'main' : 'agent',
45 status: 'running',
46 log: [],
47 lastAt: t,
48 tokens: 0,
49 startedAt: t,
50})
51
52const ago = (ms: number) => {
53 const s = Math.max(0, Math.floor(ms / 1000))
54 if (s < 60) return `${s}s`
55 const m = Math.floor(s / 60)
56 return m < 60 ? `${m}m${String(s % 60).padStart(2, '0')}` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
57}
58
59const tok = (n: number) => (n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`)
60
61const shorten = (s: string, n = 40) => (s.length > n ? `…${s.slice(-(n - 1))}` : s)
62
63const stamp = (ms: number) => {
64 const d = new Date(ms)
65 const p = (n: number) => String(n).padStart(2, '0')
66 return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
67}
68
69const targetOf = (e: Record<string, unknown>) => {
70 const v = e.file_path ?? e.path ?? e.command ?? e.pattern ?? e.url ?? e.query ?? e.description
71 return typeof v === 'string' ? shorten(v.replace(/\s+/g, ' ')) : undefined
72}
73
74const key = (r: Rec) => `${r.sid}:${r.id}`
75
76const push = ($: EngineInterface) => update($, activity, () => ({ ...live }))
77
78async function bump($: EngineInterface, id: string, fn: (a: Rec, t: number) => Rec) {
79 const t = await $.clock.now()
80 live[id] = fn(live[id] ?? fresh(id, t), t)
81 await push($)
82}
83
84async function persist($: EngineInterface) {
85 const mine = Object.values(live)
86 if (mine.length < 2) return
87 const merged = [...pastRecs.filter(r => r.sid !== sid), ...mine].sort((a, b) => a.startedAt - b.startedAt).slice(-MAX_HISTORY)
88 await $.store.set('history', merged)
89 pastRecs = merged.filter(r => r.sid !== sid)
90 await update($, past, () => pastRecs)
91}
92
93// Loads history and adopts the session's identity; a new session id (a /clear) files the old one away first.
94async function syncSession($: EngineInterface) {
95 const u = await $.session.usage()
96 if (u.startedAt === sid) return
97 if (sid !== 0) {
98 await persist($)
99 live = {}
100 } else {
101 const stored = ((await $.store.get('history')) as Rec[] | undefined) ?? []
102 const { value } = await $.state.get({ plugin: 'subagent-tree', key: 'activity' } as const)
103 const kept = Object.values(value ?? {}).filter(r => r.sid === u.startedAt)
104 live = Object.fromEntries(kept.map(r => [r.id, r]))
105 pastRecs = stored.filter(r => r.sid !== u.startedAt)
106 }
107 sid = u.startedAt
108 await update($, past, () => pastRecs)
109 await push($)
110}
111
112async function refresh($: EngineInterface) {
113 const [list, t] = await Promise.all([$.agent.list(), $.clock.now()])
114 live[MAIN] ??= fresh(MAIN, t)
115 for (const a of list) {
116 const c = live[a.id] ?? fresh(a.id, t)
117 const isDone = a.status !== 'running'
118 if (isDone && c.endedAt === undefined) dirty = true
119 live[a.id] = {
120 ...c,
121 label: a.description || c.label,
122 type: a.type,
123 status: a.status,
124 parentId: a.parentId ?? MAIN,
125 endedAt: isDone ? (c.endedAt ?? t) : undefined,
126 }
127 }
128 await push($)
129 await update($, now, () => t)
130 if (dirty) {
131 dirty = false
132 await persist($)
133 }
134}
135
136export const register: Register = on => {
137 on('session.start', async ($, e, next) => {
138 await $.command.register({ name: 'agents', description: 'Show the subagent tree pane' })
139 void $.ui.open({ id: PANE, title: 'Agents' })
140 const result = await next(e)
141 await readTheme($)
142 await syncSession($)
143 await bump($, MAIN, a => a)
144 await refresh($)
145 $.clock.every(1000, () => void refresh($))
146 return result
147 })
148
149 on('command.run', { command: 'agents' }, async $ => {
150 await $.ui.open({ id: PANE, title: 'Agents' })
151 return { text: 'Agents pane opened.' }
152 })
153
154 on('prompt.submit', async ($, e, next) => {
155 await bump($, MAIN, a => ({ ...a, prompt: e.text.slice(0, 1500), title: a.title ?? e.text.replace(/\s+/g, ' ').slice(0, 60) }))
156 return next(e)
157 })
158
159 on('agent.spawn', async ($, e, next) => {
160 const r = await next(e)
161 if ('agentId' in r && r.agentId) {
162 const parent = (e as { parentAgentId?: string }).parentAgentId
163 await bump($, r.agentId, a => ({ ...a, prompt: e.prompt.slice(0, 1500), label: e.description, type: e.subagentType, parentId: parent ?? MAIN }))
164 }
165 return r
166 })
167
168 on('tool.call', async ($, e, next) => {
169 const target = targetOf(e as unknown as Record<string, unknown>)
170 await bump($, e.agentId ?? MAIN, (a, t) => ({
171 ...a,
172 tool: e.tool,
173 target,
174 lastAt: t,
175 log: [...a.log, { t, tool: e.tool, target }].slice(-MAX_LOG),
176 }))
177 return next(e)
178 })
179
180 on('turn.step', async function* ($, e, next) {
181 const result = yield* next(e)
182 const u = result.usage
183 if (u) await bump($, e.agentId ?? MAIN, a => ({ ...a, tokens: a.tokens + u.input_tokens + u.output_tokens }))
184 return result
185 })
186
187 on('turn.complete', async ($, e, next) => {
188 const result = await next(e)
189 const text = result.text
190 if (text) await bump($, e.agentId ?? MAIN, a => ({ ...a, report: text.slice(0, 3000) }))
191 await syncSession($)
192 await refresh($)
193 await persist($)
194 return result
195 })
196
197 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
198 const { Box, Text, Button, Input } = $.ui.resolve(e)
199 const [rawAct, rawOld, nowMs, hidden, view] = await Promise.all([read($, activity), read($, past), read($, now), read($, collapsed), read($, ui)])
200 const setUi = (fn: (u: Ui) => Ui) => void update($, ui, fn)
201
202 try {
203 // State from an older version of this mod, or a store file, may lack fields: fill them in.
204 const sane = (r: Rec): Rec => ({
205 ...r,
206 sid: r.sid ?? 0,
207 label: r.label ?? r.id,
208 type: r.type ?? 'agent',
209 status: r.status ?? 'completed',
210 log: Array.isArray(r.log) ? r.log : [],
211 tokens: r.tokens ?? 0,
212 startedAt: r.startedAt ?? 0,
213 lastAt: r.lastAt ?? 0,
214 })
215 const usable = (r: Rec | null | undefined): r is Rec => !!r && typeof r.id === 'string'
216 const act: Record<string, Rec> = Object.fromEntries(Object.values(rawAct ?? {}).filter(usable).map(r => [r.id, sane(r)]))
217 const old: Rec[] = (rawOld ?? []).filter(usable).map(sane)
218
219 const all: Rec[] = [...Object.values(act), ...old]
220 const runtime = (r: Rec) => ago((r.endedAt ?? nowMs) - r.startedAt)
221
222 const nav = (trail: Rec[], suffix: string) => (
223 <Box key={`nav-${suffix}`}>
224 <Button key={`top-${suffix}`} label="↑ Agents" plain hotkey={suffix === 'top' ? 't' : undefined} onPress={() => setUi(u => ({ ...u, selected: null }))} />
225 {trail.map((r, i) => (
226 <Button
227 key={`bc-${suffix}-${i}`}
228 label={`› ${r.id === MAIN ? 'main' : r.label}`}
229 plain
230 onPress={() => setUi(u => ({ ...u, selected: key(r) }))}
231 />
232 ))}
233 </Box>
234 )
235
236 const statusColor = (r: Rec) => {
237 if (r.status !== 'running') return r.status === 'completed' ? 'gray' : 'red'
238 return nowMs - r.lastAt > QUIET_MS ? 'yellow' : 'green'
239 }
240
241 // ---- detail view ----
242 const sel = view.selected === null ? undefined : all.find(r => key(r) === view.selected)
243 if (sel) {
244 const color = statusColor(sel)
245 // Ancestors, outermost first, then the agent itself; breadcrumbs repeat below so neither end is far.
246 const trail: Rec[] = [sel]
247 for (let p = sel.parentId; p && trail.length < 8; ) {
248 const up = all.find(r => r.sid === sel.sid && r.id === p)
249 if (!up || trail.includes(up)) break
250 trail.unshift(up)
251 p = up.parentId
252 }
253 const children = all.filter(r => r.sid === sel.sid && r.parentId === sel.id && r.id !== sel.id)
254 const room = Math.max(6, (e.viewport?.rows ?? 30) - 16)
255 const lines = sel.log.slice().reverse().slice(0, room)
256 return (
257 <Box flexDirection="column">
258 {nav(trail, 'top')}
259 <Box>
260 <Text color={color}>{sel.status === 'running' ? '● ' : '○ '}</Text>
261 <Text bold>{sel.id === MAIN ? 'main' : `${sel.type}: ${sel.label}`}</Text>
262 </Box>
263 <Text dimColor>{`${sel.status} · ${tok(sel.tokens)} tok · ${runtime(sel)} · started ${stamp(sel.startedAt)}`}</Text>
264 <Text bold>Prompt</Text>
265 <Text>{sel.prompt ?? '(not captured)'}</Text>
266 <Text bold>Result</Text>
267 <Text>{sel.report ?? (sel.status === 'running' ? 'Still running…' : '(not captured)')}</Text>
268 {children.length > 0 && <Text bold>{`Subagents (${children.length})`}</Text>}
269 {children.map(c => (
270 <Box key={`k-${key(c)}`}>
271 <Text color={statusColor(c)}>{c.status === 'running' ? '● ' : '○ '}</Text>
272 <Button key={`kb-${key(c)}`} label={`${c.type}: ${c.label}`} plain onPress={() => setUi(u => ({ ...u, selected: key(c) }))} />
273 <Text dimColor>{` ${tok(c.tokens)} · ${runtime(c)}`}</Text>
274 </Box>
275 ))}
276 <Text bold>{`Activity (${lines.length} of ${sel.log.length}, newest first)`}</Text>
277 {sel.log.length === 0 && <Text dimColor>No tool calls recorded.</Text>}
278 {lines.map((l, i) => (
279 <Text key={`l${i}`} dimColor>{`+${ago(l.t - sel.startedAt)} ${l.tool}${l.target ? ` ${l.target}` : ''}`}</Text>
280 ))}
281 {nav(trail, 'bottom')}
282 </Box>
283 )
284 }
285
286 // ---- list view ----
287 const q = view.query.trim().toLowerCase()
288 const inScope = (r: Rec) => (view.scope === 'all' ? true : view.scope === 'live' ? r.sid === sid : r.sid !== sid)
289 const hay = (r: Rec) =>
290 [r.label, r.type, r.status, r.title, r.prompt, r.report, r.tool, r.target, ...r.log.map(l => `${l.tool} ${l.target ?? ''}`)].join(' ').toLowerCase()
291 const shown = all.filter(r => inScope(r) && (q === '' || hay(r).includes(q)))
292
293 const live_ = Object.values(act)
294 const running = live_.filter(r => r.status === 'running' && r.id !== MAIN).length
295 const total = live_.reduce((s, r) => s + r.tokens, 0)
296
297 const rowFor = (r: Rec, depth: number, kids: Rec[], byParent: (id: string) => Rec[]): unknown[] => {
298 const color = statusColor(r)
299 const idle = nowMs - r.lastAt
300 const isRun = r.status === 'running'
301 const isOpen = !hidden.includes(key(r))
302 const label = r.id === MAIN ? (r.sid === sid ? 'main' : `main · ${r.title ?? 'session'}`) : `${r.type}: ${r.label}`
303 const last = isRun ? (r.tool ? `${r.tool}${r.target ? ` ${r.target}` : ''}` : 'starting…') : `${r.status}${r.tool ? ` · ${r.tool}` : ''}`
304 const out: unknown[] = [
305 <Box key={key(r)} flexDirection="column" paddingLeft={depth * 2}>
306 <Box>
307 {kids.length > 0 ? (
308 <Button
309 key={`t-${key(r)}`}
310 label={isOpen ? '▾' : '▸'}
311 plain
312 onPress={() => void update($, collapsed, (c: string[]) => (c.includes(key(r)) ? c.filter(i => i !== key(r)) : [...c, key(r)]))}
313 />
314 ) : (
315 <Text> </Text>
316 )}
317 <Text color={color}>{isRun && idle <= QUIET_MS ? '● ' : '○ '}</Text>
318 <Button key={`o-${key(r)}`} label={label} plain onPress={() => setUi(u => ({ ...u, selected: key(r) }))} />
319 <Text dimColor>{` ${tok(r.tokens)} · ${runtime(r)}`}</Text>
320 </Box>
321 <Box paddingLeft={4}>
322 <Text dimColor>{last}</Text>
323 {r.sid === sid && (
324 <Text color={color === 'yellow' ? 'yellow' : undefined} dimColor={color !== 'yellow'}>{` ${ago(idle)} ago${color === 'yellow' ? ' · quiet' : ''}`}</Text>
325 )}
326 </Box>
327 </Box>,
328 ]
329 if (isOpen) for (const c of kids) out.push(...rowFor(c, depth + 1, byParent(c.id), byParent))
330 return out
331 }
332
333 // Groups by session, the current one first; a search lists matches flat.
334 const sessions = [...new Set(shown.map(r => r.sid))].sort((a, b) => (a === sid ? -1 : b === sid ? 1 : b - a))
335 const body: unknown[] = []
336 if (shown.length === 0) {
337 body.push(<Text key="none" dimColor>{q ? 'No agents match.' : view.scope === 'past' ? 'No past agents saved yet.' : 'No agents yet.'}</Text>)
338 } else if (q !== '') {
339 for (const r of shown) body.push(...rowFor(r, 0, [], () => []))
340 } else {
341 for (const s of sessions) {
342 const group = shown.filter(r => r.sid === s)
343 const ids = new Set(group.map(r => r.id))
344 const byParent = (id: string) => group.filter(r => r.parentId === id && r.id !== id)
345 const roots = group.filter(r => !r.parentId || !ids.has(r.parentId))
346 if (s !== sid) {
347 const title = group.find(r => r.id === MAIN)?.title ?? 'session'
348 body.push(<Text key={`h${s}`} dimColor>{`${stamp(s)} · ${title} · ${group.length} agents`}</Text>)
349 }
350 for (const r of roots) body.push(...rowFor(r, 0, byParent(r.id), byParent))
351 }
352 }
353
354 const seg = isLight ? SEG.light : SEG.dark
355 const chip = (scope: Scope, label: string) => (
356 <Box
357 key={`seg-${scope}`}
358 backgroundColor={view.scope === scope ? seg.on : undefined}
359 borderStyle="round"
360 borderColor={seg.line}
361 borderDimColor
362 hover={{ borderDimColor: false }}
363 padding={0}
364 >
365 <Button
366 key={`c-${scope}`}
367 label={label}
368 plain
369 dimColor={view.scope !== scope}
370 onPress={() => setUi(u => ({ ...u, scope }))}
371 />
372 </Box>
373 )
374
375 return (
376 <Box flexDirection="column">
377 <Box>
378 <Text bold>Agents</Text>
379 <Text dimColor>{` ${running} running · Σ ${tok(total)} tok · ${old.length} saved`}</Text>
380 </Box>
381 <Box backgroundColor={seg.track} borderStyle="round" borderColor={seg.line} alignSelf="flex-start">
382 {chip('live', 'Live')}
383 {chip('past', 'Past')}
384 {chip('all', 'All')}
385 </Box>
386 <Input
387 key="q"
388 placeholder="Search agents, tools, prompts…"
389 value={view.query}
390 onInput={v => setUi(u => ({ ...u, query: v }))}
391 onSubmit={() => {}}
392 />
393 {body}
394 </Box>
395 )
396 } catch (err) {
397 const msg = err instanceof Error ? err.message : String(err)
398 return (
399 <Box flexDirection="column">
400 <Button key="top-err" label="↑ Agents" plain onPress={() => setUi(u => ({ ...u, selected: null }))} />
401 <Text color="red">{`subagent-tree failed to draw: ${msg}`}</Text>
402 </Box>
403 )
404 }
405 })
406}
407types/index.d.ts 37 lines1export type LogEntry = { t: number; tool: string; target?: string }
2
3export type Rec = {
4 id: string
5 sid: number
6 label: string
7 type: string
8 status: string
9 parentId?: string
10 title?: string
11 prompt?: string
12 report?: string
13 log: LogEntry[]
14 tool?: string
15 target?: string
16 lastAt: number
17 tokens: number
18 startedAt: number
19 endedAt?: number
20}
21
22export type Scope = 'live' | 'past' | 'all'
23
24export type Ui = { selected: string | null; scope: Scope; query: string }
25
26declare module 'claude-code' {
27 interface PluginState {
28 'subagent-tree': {
29 activity: Record<string, Rec>
30 past: Rec[]
31 now: number
32 collapsed: string[]
33 ui: Ui
34 }
35 }
36}
37