SLOPSHOPPER

subagent-tree

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

newpaneguardcommandprompttimer
v0.1.1no licenseupdated 2026-10-04wolf123450/ClaudeSkills/plugins/subagent-tree
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · subagent-tree
│ ┃ Agents ✕ › fix the failing auth test and add an audit log call │ ┃ Agents 0 running · Σ 0 tok · 0 saved │ ┃ ╭─────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ │╭────────────────────────────────────────╮╭ ⎿ Read 6 lines │ ┃ ││Live ││ ⏺ Update(src/auth.ts) │ ┃ │╰────────────────────────────────────────╯╰ ⎿ Added 2 lines, removed 1 line │ ┃ ╰─────────────────────────────────────────── ⏺ Bash(bun test) │ ┃ Search agents, tools, prompts… ⎿ 3 pass, 1 fail │ ┃ ● main 0 · 0s │ ┃ Bash cat .env 0s ago ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /agents │ ⎿ subagent-tree: Agents pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agents
Agents 0 running · Σ 0 tok · 0 saved ╭─────────────────────────────────────────────────────────── │╭────────────────────────────────────────────────────────╮╭ ││Live ││ │╰────────────────────────────────────────────────────────╯╰ ╰─────────────────────────────────────────────────────────── Search agents, tools, prompts… ● main 0 · 0s Bash cat .env 0s ago
README

ClaudeSkills

Clint's personal Claude Code plugin marketplace — skills and agents distributed as installable, auto-updating plugins instead of hand-zipped .skill files.

Plugins

  • 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.

Install

/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.)

Updating

/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.

Adding a new plugin

  1. Create plugins/<name>/.claude-plugin/plugin.json (name, description, version, author).
  2. Add its skills under plugins/<name>/skills/<skill-name>/SKILL.md and/or agents under plugins/<name>/agents/<agent-name>.md.
  3. Add an entry to .claude-plugin/marketplace.json's plugins array with "source": "./plugins/<name>".
  4. Run claude plugin validate . from the repo root to confirm it's picked up cleanly before

committing.

Source 2 files
hooks/register.tsx 407 lines
1import { 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}
407
types/index.d.ts 37 lines
1export 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