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…

Claude Code mods. Each one is a folder under plugins/, listed in .claude-plugin/marketplace.json.
| Plugin | What it does | |||||
|---|---|---|---|---|---|---|
| session-pane | A pane with the session's goal, what Claude is doing, the context, and the changes so far. /session-pane toggles it. | |||||
| auto-title | Names the session with Haiku from the first prompt after start, /clear, or /compact when it has no title yet. | |||||
| context-budget | A 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. | |||||
| notepad | A notepad pane, one note per project, kept between sessions. Claude does not see it. /notepad toggles it. | |||||
| agent-log | Why 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-log | The 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. |
| redact | Lets 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-tree | A 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-spinners | Gives 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.
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.
hooks/session-tree.tsx 319 lines1import 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