SLOPSHOPPER

session-tree

Pane with every Claude session on this machine as a tree under its repo; pressing one focuses its herdr pane, or attaches a background one in a new herdr tab…

panecommandtoastprocesstimer
v0.1.0MITupdated 2026-10-24dboeter/cc-plugins/plugins/session-tree
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-tree
│ ┃ session-tree ✕ › fix the failing auth test and add an audit log call │ ┃ SyntaxError: JSON Parse error: Unexpected │ ┃ EOF ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ 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 │ │ › /sessiontree │ ⎿ session-tree: session tree hidden │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · session-tree
SyntaxError: JSON Parse error: Unexpected EOF
README

cc-plugins

Claude Code mods. Each one is a folder under plugins/, listed in .claude-plugin/marketplace.json.

PluginWhat it does
session-paneA pane with the session's goal, what Claude is doing, the context, and the changes so far. /session-pane toggles it.
auto-titleNames the session with Haiku from the first prompt after start, /clear, or /compact when it has no title yet.
context-budgetA pane that explains the context window to people new to Claude Code: the room left before auto-compact, the window as one stacked bar in category colours with a note per category, what every request carries, the window after each turn with its change, and where auto-compact starts. /contextbudget toggles it.
notepadA notepad pane, one note per project, kept between sessions. Claude does not see it. /notepad toggles it.
agent-logWhy was that turn slow, and who allowed that call? A pane that splits each turn's time across the model (first token, thinking), tools, hooks and waiting on you, read from Claude Code's own model stream. With OTEL on (CLAUDE_CODE_ENABLE_TELEMETRY=1), also who decided each call (you, a hook or the classifier) and request prices. Per-hook times need its hook-timer wrapper. It never opens by itself: a turn that ended unusually says so above the prompt, and /agentlog toggles it.
event-logThe raw record that goes with agent-log's interpreted view: every event in a session, whoever raised it (you, the model, the engine, hooks, plugins), with its full payload on disk under ~/.claude/event-log/<session>/, and a peek per event in a live pane. /eventlog toggles it, `/eventlog <you\claude\engine\hook\plugin\all>` filters by who.
redactLets Claude use your API keys, tokens, passwords and other secrets, hiding what your rules match from the model. Matches are swapped for stable placeholders in everything sent to the model, kept in a local vault, and restored in tool calls, remote ones included, and in your terminal. Fails closed. No rule is on by default: rules live in ~/.claude/redact-rules.json, starting with two examples (vendor tokens, email addresses), off; each has its own pattern, replacement label and on/off switch, managed with /redact. Its README says what it does and does not protect against; HARDENING.md has permission rules and a managed-settings entry that hold where its own guard can't.
session-treeA pane with every Claude session on this machine as a tree under its repo; pressing one focuses its herdr pane or attaches a background one in a new tab. /sessiontree toggles it.
skin-spinnersGives every skins skin its own spinner row and turn footer: a small looping sprite, an effect on the spinner word, a themed clock or tag, and themed done words. Needs the skins mod.

Install one:

/plugin install <plugin> --marketplace dboeter/cc-plugins

Adding a plugin: put it in plugins/<name>/ and add { "name": "<name>", "source": "./plugins/<name>" } to the marketplace's plugins.

Develop

Every plugin is typed against one copy of Claude Code's plugin API, plugins/types/claude-code.d.ts (its first line names the Claude Code version), through plugins/tsconfig.json:

tsc -p plugins

A plugin loads no file outside its own folder, so code two plugins share lives once in plugins/shared/ and is copied into each by plugins/shared/sync.sh (--check exits 1 when a copy is stale). Today that is the secret scrub agent-log and event-log use.

After a Claude Code update, replace that file with the new build's: the plugin-authoring skill writes it as types/claude-code.d.ts each time the skill loads. A plugin loaded with --plugin-dir still gets its own .claude-plugin/types/ and, without one of its own, a tsconfig.json; both are gitignored.

plugins/types/skins.d.ts is the skins mod's state contract, copied whole so tsc -p plugins can type what skin-spinners reads from skins; replace it with the skins mod's types/index.d.ts (keeping its first line) when skins changes its contract.

Source 1 files
hooks/session-tree.tsx 319 lines
1import type { Register } from 'claude-code'
2
3// One row of `claude agents --json`, which lists the sessions on this machine: background ones carry the short `id`
4// and a `state`, interactive ones a `pid` and a `status`. A background one with no `pid` has exited but stays listed
5// with its last state (seen: five `blocked`, `claude logs` "job not found") until `claude rm`; `claude stop` unlists it
6export type Session = {
7  id?: string; pid?: number; cwd: string; kind: string; startedAt: number
8  sessionId: string; name: string; state?: string; status?: string
9}
10export type Group = { root: string; sessions: Session[] }
11// What the ⋯ block reads from a session's transcript tail and, while it runs, its process record
12export type Info = { branch?: string; model?: string; lastPrompt?: string; lastReply?: string; activeAt?: number; since?: number }
13
14// The look of herdr-agent-tree (~/.config/herdr/local-plugins/herdr-agent-tree/agent_tree.py): its STATUS glyphs
15// and colours, DIM, GROUP and its Nerd Font carets (Nerd Fonts 3.5.0+, else boxes)
16const STATUS = {
17  blocked: ['●', '#f7768e'],
18  working: ['◐', '#e0af68'],
19  done: ['✓', '#9ece6a'],
20  idle: ['○', '#6e6e78'],
21  failed: ['✗', '#f7768e'], // herdr has no failed; a background session can end so
22  unknown: ['·', '#6e6e78'],
23  exited: ['◌', '#6e6e78'], // not herdr's: a background session whose process is gone
24} satisfies Record<string, [string, string]>
25type Status = keyof typeof STATUS
26const ORDER: Status[] = ['blocked', 'working', 'done', 'idle', 'failed', 'exited']
27const DIM = '#6e6e78'
28const GROUP = '#7dcfff'
29const OPEN = '' // fa-caret_down
30const SHUT = '' // fa-caret_right
31
32const PANE = 'session-tree'
33const SHOWN = 'shown' // $.store key: the pane's toggle, kept across sessions so a /resume or a new session reopens it
34const FOLDED = 'folded' // $.store key: the repo roots folded shut
35
36let sessions: Session[] = []
37let self = ''
38let home = ''
39let failed = ''
40let fetched = false
41let folded = new Set<string>()
42let timer: { cancel: () => void } | undefined
43let expanded: string | undefined // the session whose ⋯ block is open
44let armed: string | undefined // the session whose stop/remove was pressed once: the next press runs it
45const infos = new Map<string, Info>()
46const roots = new Map<string, string>() // cwd → its git toplevel, or the cwd itself outside a repo
47
48// sessions under their repo root, roots by path, newest session first
49export const groupOf = (list: readonly Session[], rootOf: (cwd: string) => string): Group[] => {
50  const by = new Map<string, Session[]>()
51  for (const s of list) { const r = rootOf(s.cwd); by.set(r, [...(by.get(r) ?? []), s]) }
52  return [...by].sort(([a], [b]) => a.localeCompare(b)).map(([root, ss]) => ({ root, sessions: ss.sort((a, b) => b.startedAt - a.startedAt) }))
53}
54
55// a background session's `state` or an interactive one's `status`, as one of herdr's statuses
56export const statusOf = (s: Session): Status => {
57  if (s.kind === 'background' && s.pid === undefined) return 'exited'
58  const v = s.state ?? s.status ?? ''
59  return v === 'busy' || v === 'running' ? 'working' : Object.hasOwn(STATUS, v) ? v as Status : 'unknown'
60}
61
62// the facts a transcript's tail gives, the latest of each winning; typed prompts only, not command or reminder rows
63export const infoOf = (jsonl: string): Info => {
64  const info: Info = {}
65  for (const line of jsonl.split('\n')) {
66    let r: any
67    try { r = JSON.parse(line) } catch { continue }
68    if (r.gitBranch) info.branch = r.gitBranch
69    if (r.timestamp) info.activeAt = Date.parse(r.timestamp)
70    if (r.type === 'last-prompt' && r.lastPrompt) info.lastPrompt = r.lastPrompt
71    const said = r.message?.content
72    if (r.type === 'user' && !r.isMeta && typeof said === 'string' && !said.startsWith('<')) info.lastPrompt = said
73    if (r.type === 'assistant') {
74      if (r.message?.model && !r.message.model.startsWith('<')) info.model = r.message.model
75      const text = Array.isArray(said) ? said.find((b: any) => b.type === 'text' && b.text?.trim())?.text : undefined
76      if (text) info.lastReply = text
77    }
78  }
79  return info
80}
81
82export const ago = (ms: number, now: number) => {
83  const m = Math.max(0, Math.floor((now - ms) / 60000))
84  return m < 60 ? `${m}m` : m < 1440 ? `${Math.floor(m / 60)}h` : `${Math.floor(m / 1440)}d`
85}
86
87const clip = (t: string, n: number) => (t.length > n ? t.slice(0, Math.max(1, n - 1)) + '…' : t)
88const countsOf = (list: readonly Session[], order: readonly Status[]) =>
89  order.map(st => [st, list.filter(s => statusOf(s) === st).length] as const).filter(([, n]) => n > 0)
90
91const fetchAll = async ($: any) => {
92  fetched = true
93  try {
94    self = await $.session.id() // read each time: a /resume moves this process to another session
95    const r = await $.process.run(['claude', 'agents', '--json'], { timeoutMs: 10_000 })
96    if (r.exitCode !== 0) throw new Error(r.stderr.trim() || `claude agents exited ${r.exitCode}`)
97    const list: Session[] = JSON.parse(r.stdout)
98    for (const s of list) {
99      if (roots.has(s.cwd)) continue
100      const g = await $.process.run(['git', '-C', s.cwd, 'rev-parse', '--show-toplevel']).catch(() => undefined)
101      roots.set(s.cwd, g?.exitCode === 0 ? g.stdout.trim() : s.cwd)
102    }
103    sessions = list
104    failed = ''
105  } catch (err) { failed = String(err) }
106}
107
108const tick = async ($: any) => {
109  await fetchAll($)
110  $.ui.invalidate('ui.render')
111}
112
113const show = async ($: any) => {
114  await $.ui.open({ id: PANE, title: 'Sessions' })
115  timer ??= $.clock.every(3000, () => { tick($) })
116}
117
118const hide = async ($: any) => {
119  await $.ui.close({ id: PANE })
120  timer?.cancel()
121  timer = undefined
122}
123
124// A pane of herdr's `agent list`: the Claude session it runs, when it reports one
125export type HerdrAgent = { agent_session?: { value?: string }; pane_id: string; workspace_id: string; cwd: string }
126export type Action = { kind: 'here' } | { kind: 'focus'; pane: string } | { kind: 'attach'; workspace: string } | { kind: 'copy' } | { kind: 'elsewhere' }
127
128// How a press reaches a session. Not /resume: every listed session is live, and /resume opens a live one a second
129// time (seen: a --bg session open twice) and finds no session of another repo. So a session in a herdr pane is
130// focused there, a background one is attached in a new herdr tab, by the workspace of a pane in its repo or else
131// ours; with this session in no herdr pane, the attach command is copied
132export const actionOf = (s: Session, self: string, root: string, agents: readonly HerdrAgent[]): Action => {
133  if (s.sessionId === self) return { kind: 'here' }
134  const pane = agents.find(a => a.agent_session?.value === s.sessionId)?.pane_id
135  if (pane) return { kind: 'focus', pane }
136  if (s.kind !== 'background') return { kind: 'elsewhere' }
137  const mine = agents.find(a => a.agent_session?.value === self)
138  if (!mine) return { kind: 'copy' }
139  const near = agents.find(a => a.cwd === root || a.cwd.startsWith(root + '/'))
140  return { kind: 'attach', workspace: near?.workspace_id ?? mine.workspace_id }
141}
142
143const open = async ($: any, s: Session) => {
144  const run = async (argv: string[]) => {
145    const r = await $.process.run(argv)
146    if (r.exitCode !== 0) throw new Error(r.stderr.trim() || `${argv.slice(0, 3).join(' ')} exited ${r.exitCode}`)
147    return r.stdout as string
148  }
149  try {
150    const agents: HerdrAgent[] = await run(['herdr', 'agent', 'list']).then(out => JSON.parse(out).result.agents, () => [])
151    const a = actionOf(s, self, roots.get(s.cwd) ?? s.cwd, agents)
152    const attach = `claude attach ${s.id}`
153    if (a.kind === 'here') $.ui.toast('already in this session')
154    else if (a.kind === 'focus') await run(['herdr', 'agent', 'focus', a.pane])
155    else if (a.kind === 'attach') {
156      const tab = JSON.parse(await run(['herdr', 'tab', 'create', '--workspace', a.workspace, '--cwd', s.cwd, '--label', s.name, '--focus'])).result
157      await run(['herdr', 'pane', 'run', tab.root_pane.pane_id, attach])
158    } else if (a.kind === 'copy') $.ui.toast((await $.ui.copy({ text: attach })).isCopied ? `copied: ${attach}` : `run: ${attach}`)
159    else $.ui.toast(`${s.name} runs in a terminal outside herdr (pid ${s.pid})`)
160  } catch (err) { $.ui.toast(`session-tree: ${err}`) }
161}
162
163const more = async ($: any, s: Session) => {
164  armed = undefined
165  expanded = expanded === s.sessionId ? undefined : s.sessionId
166  $.ui.invalidate('ui.render')
167  if (expanded === undefined) return
168  const info: Info = {}
169  try {
170    const found = await $.process.run(['find', `${home}/.claude/projects`, '-maxdepth', '2', '-name', `${s.sessionId}.jsonl`, '-print', '-quit'])
171    const path = found.stdout.trim()
172    if (path) Object.assign(info, infoOf((await $.process.run(['tail', '-n', '300', path])).stdout))
173  } catch {}
174  if (s.pid !== undefined) {
175    try { info.since = JSON.parse(await $.fs.read(`${home}/.claude/sessions/${s.pid}.json`)).statusUpdatedAt } catch {}
176  }
177  infos.set(s.sessionId, info)
178  $.ui.invalidate('ui.render')
179}
180
181// A running background session is stopped (its conversation kept, `claude attach` reopens it), an exited one
182// removed from the list (`claude rm`: its worktree too where safe, its transcript kept), an interactive one sent
183// SIGTERM (it exits within seconds, its transcript kept for --resume, unsent typed text lost); this session never
184const KILLS = {
185  stop: ['stop', 'stopped'], rm: ['remove', 'removed'], kill: ['kill', 'killed'],
186} as const
187export const killOf = (s: Session, self: string): keyof typeof KILLS | undefined =>
188  s.sessionId === self ? undefined
189  : s.kind !== 'background' ? (s.pid === undefined ? undefined : 'kill')
190  : !s.id ? undefined : s.pid === undefined ? 'rm' : 'stop'
191
192// the first press arms it
193const kill = async ($: any, s: Session) => {
194  const verb = killOf(s, self)
195  if (!verb) return
196  if (armed !== s.sessionId) { armed = s.sessionId; return void $.ui.invalidate('ui.render') }
197  armed = undefined
198  if (verb === 'kill') {
199    // the pid as listed now, so a pid reused since the last poll is never signalled
200    await fetchAll($)
201    if (!sessions.some(x => x.sessionId === s.sessionId && x.pid === s.pid)) { $.ui.toast(`${s.name}: already gone`); return void $.ui.invalidate('ui.render') }
202  }
203  const argv = verb === 'kill' ? ['kill', '-TERM', String(s.pid)] : ['claude', verb, s.id!]
204  const r = await $.process.run(argv, { timeoutMs: 30_000 }).catch((err: unknown) => ({ exitCode: -1, stdout: '', stderr: String(err) }))
205  $.ui.toast(r.exitCode === 0 ? `${s.name}: ${KILLS[verb][1]}` : `${argv.join(' ')}: ${(r.stderr || r.stdout).trim()}`)
206  if (r.exitCode === 0) expanded = undefined
207  await tick($)
208}
209
210const fold = ($: any, root: string) => {
211  if (!folded.delete(root)) folded.add(root)
212  $.store.set(FOLDED, [...folded]).catch(() => {})
213  $.ui.invalidate('ui.render')
214}
215
216export const register: Register = on => {
217  on('session.start', async ($, e, next) => {
218    try { await $.command.register({ name: 'sessiontree', description: 'Toggle the tree of this machine\'s Claude sessions; press one to focus its herdr pane, or attach a background one in a new herdr tab; ⋯ for info, stop, remove and kill.', immediate: true }) } catch {} // agent-log saw this throw on hot reload
219    home = (await $.process.run(['printenv', 'HOME']).catch(() => undefined))?.stdout.trim() ?? ''
220    folded = new Set(((await $.store.get(FOLDED).catch(() => undefined)) as string[] | undefined) ?? [])
221    if (e.isInteractive && (await $.store.get(SHOWN).catch(() => undefined)) !== false) await show($)
222    return next(e)
223  })
224
225  on('command.run', { command: 'sessiontree' }, async $ => {
226    const up = (await $.ui.panes()).some((p: any) => p.id === PANE)
227    if (up) await hide($)
228    else await show($)
229    await $.store.set(SHOWN, !up)
230    return { text: up ? 'session tree hidden' : 'session tree shown' }
231  })
232
233  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
234    const { Box, Text, Button } = $.ui.resolve(e)
235    if (!fetched) await fetchAll($) // the first draw waits for the list rather than drawing "no sessions"
236    const cols = (e.props.bodyColumns ?? 41) - 1 // herdr's PAD: one blank column inside the border
237    const groups = groupOf(sessions, cwd => roots.get(cwd) ?? cwd)
238    const total = countsOf(sessions, ORDER)
239    const now = Date.now()
240    const first = (t?: string) => t?.split('\n').find(l => l.trim())?.trim()
241    // the ⋯ block under a row, its rail carrying on the tree's │ while more rows follow
242    const detail = (s: Session, rail: string) => {
243      const info = infos.get(s.sessionId)
244      const line = (t: string, color: string | undefined = DIM) => <Text color={color}>{rail}{clip(t, cols - rail.length)}</Text>
245      const which = killOf(s, self)
246      const verb = which && KILLS[which][0]
247      const isArmed = armed === s.sessionId
248      return (
249        <Box key={`d${s.sessionId}`} flexDirection="column">
250          {line([s.id && `id ${s.id}`, s.pid ? `pid ${s.pid}` : 'not running', s.kind].filter(Boolean).join(' · '))}
251          {!info ? line('…') : null}
252          {info?.branch || info?.model ? line([info.branch, info.model].filter(Boolean).join(' · ')) : null}
253          {info?.since ? line(`${statusOf(s)} for ${ago(info.since, now)}`) : null}
254          {info?.activeAt ? line(`last active ${ago(info.activeAt, now)} ago`) : null}
255          {first(info?.lastPrompt) ? line(`› ${first(info?.lastPrompt)}`, undefined) : null}
256          {first(info?.lastReply) ? line(`‹ ${first(info?.lastReply)}`) : null}
257          {verb ? (
258            <Box flexDirection="row">
259              <Text color={DIM}>{rail}</Text>
260              <Button key={`k${s.sessionId}`} plain onPress={() => { void kill($, s) }}>
261                <Text color={isArmed ? STATUS.blocked[1] : DIM}>{isArmed ? `press again to ${verb}` : verb}</Text>
262              </Button>
263            </Box>
264          ) : null}
265        </Box>
266      )
267    }
268    return (
269      <Box flexDirection="column" paddingLeft={1}>
270        {failed ? <Text color={STATUS.blocked[1]}>{clip(failed, cols)}</Text> : null}
271        {!failed && !groups.length ? <Text color={DIM}>no sessions</Text> : null}
272        {groups.map((g, i) => {
273          const isOpen = !folded.has(g.root)
274          const label = g.root === home ? '~' : g.root.slice(g.root.lastIndexOf('/') + 1) || g.root
275          // the rows say it while open; folded, the counts stand in for them, idle included
276          const counts = isOpen ? [] : countsOf(g.sessions, ORDER)
277          const room = cols - `${i + 1}  `.length - 2 - counts.reduce((n, [, c]) => n + ` ● ${c}`.length, 0)
278          return (
279            <Box key={`g${g.root}`} flexDirection="column">
280              <Button key={`f${g.root}`} plain onPress={() => fold($, g.root)}>
281                <Text color={DIM}>{i + 1} {isOpen ? OPEN : SHUT} </Text>
282                <Text bold color={GROUP}>{clip(label, room)}</Text>
283                {counts.map(([st, n]) => <Text color={STATUS[st][1]}> {STATUS[st][0]} {n}</Text>)}
284              </Button>
285              {isOpen && g.sessions.map((s, j) => {
286                const [glyph, colour] = STATUS[statusOf(s)]
287                const here = s.sessionId === self
288                const isLast = j === g.sessions.length - 1
289                return (
290                  <Box key={`r${s.sessionId}`} flexDirection="column">
291                    <Box flexDirection="row">
292                      <Button key={s.sessionId} plain onPress={() => { void open($, s) }}>
293                        <Text color={colour}>{here ? '▶ ' : '  '}</Text>
294                        <Text color={DIM}>{isLast ? '└ ' : '├ '}</Text>
295                        <Text color={colour}>{glyph} </Text>
296                        <Text bold={here} dimColor={statusOf(s) === 'exited'}>{clip(s.name, cols - 8)}</Text>
297                      </Button>
298                      <Button key={`i${s.sessionId}`} plain onPress={() => { void more($, s) }}>
299                        <Text color={expanded === s.sessionId ? GROUP : DIM}> ⋯</Text>
300                      </Button>
301                    </Box>
302                    {expanded === s.sessionId ? detail(s, isLast ? '      ' : '  │   ') : null}
303                  </Box>
304                )
305              })}
306            </Box>
307          )
308        })}
309        {sessions.length ? (
310          <Box flexDirection="row">
311            <Text color={DIM}>{sessions.length} session{sessions.length === 1 ? '' : 's'}</Text>
312            {total.map(([st, n]) => <Text color={STATUS[st][1]}>  {STATUS[st][0]} {n}</Text>)}
313          </Box>
314        ) : null}
315      </Box>
316    )
317  })
318}
319