Matthew Boston's personal Claude Code mods.

The Claude Code mods I use.
Mods run code inside Claude Code; read a mod before you enable it.
claude plugin marketplace add bostonaholic/claude-mods
claude plugin install bostonaholic@claude-mods
Then run /reload-plugins in an open session.
Update the marketplace first, then the plugin:
claude plugin marketplace update claude-mods
claude plugin update bostonaholic@claude-mods
claude --plugin-dir /Users/matthew/code/bostonaholic/claude-mods
Claude Code hot-reloads the plugin on each save.
Read AGENTS.md before making changes.
MIT. See LICENSE.
hooks/agent-side-pane.mjs 552 lines1import { atom, read, update } from 'claude-code'
2
3const PANE_ID = 'agent-pane'
4const PANE_TITLE = 'Agents'
5const COMMAND = 'agent-pane'
6const SUMMARY_MAX_CHARS = 160
7const OFFERED_EMPTY = 'No agent types are offered in this session.'
8const FIRST_PROMPT_EMPTY = 'No agent types yet. They appear after your first prompt.'
9const NOTE = 'Custom agents only. Built-in agents and descriptions appear after your first prompt.'
10const FOOTER = `click ▶ run to start one · /${COMMAND} to hide`
11const START_PROMPT =
12 'You were started from the agent side pane with no specific task. Do the work your agent definition describes for the current project, then report what you did.'
13const SPAWN_DESCRIPTION = 'Started from agent pane'
14const TICK_MS = 1000
15
16// Theme keys only, never raw colors, so every run follows the person's Claude
17// Code theme: dark, light, their daltonized (color-blind) variants and the
18// ANSI ones that defer to the terminal's own palette. The pane paints the
19// theme's background, so text with no color would fall back to the
20// terminal's foreground and vanish when terminal and theme disagree (a light
21// theme in a dark terminal). Text is drawn in `text`, dim, or `error`; hue
22// marks only glyphs, which the name beside them always explains.
23const ACCENT = 'claude'
24const TEXT = 'text'
25const ERROR_COLOR = 'error'
26// The theme's agent colors, as Claude Code draws agents. Blue takes `ide`, the
27// theme's blue in every variant: `blue_FOR_SUBAGENTS_ONLY` draws no color under
28// the ANSI themes.
29const DOT_COLORS = {
30 blue: 'ide',
31 cyan: 'cyan_FOR_SUBAGENTS_ONLY',
32 yellow: 'yellow_FOR_SUBAGENTS_ONLY',
33 orange: 'orange_FOR_SUBAGENTS_ONLY',
34 purple: 'purple_FOR_SUBAGENTS_ONLY',
35 red: 'red_FOR_SUBAGENTS_ONLY',
36 green: 'green_FOR_SUBAGENTS_ONLY',
37 pink: 'pink_FOR_SUBAGENTS_ONLY',
38}
39const DOT_PALETTE = Object.values(DOT_COLORS)
40
41/** Display order and labels for the engine's agent `source` values. */
42const SECTIONS = [
43 { source: 'projectSettings', label: 'PROJECT', hint: '.claude/agents' },
44 { source: 'userSettings', label: 'USER', hint: '~/.claude/agents' },
45 { source: 'plugin', label: 'PLUGIN', hint: 'plugins' },
46 { source: 'built-in', label: 'BUILT-IN', hint: 'Claude Code' },
47]
48
49const catalog = atom({ plugin: 'bostonaholic', key: 'agentPaneCatalog' }, { isListed: false, types: [] })
50const starts = atom({ plugin: 'bostonaholic', key: 'agentPaneStarts' }, {})
51const meta = atom({ plugin: 'bostonaholic', key: 'agentPaneMeta' }, {})
52
53/**
54 * @param {string} description
55 * @returns {string} the first line, at most SUMMARY_MAX_CHARS long
56 */
57function summarize(description) {
58 const firstLine = description.split('\n')[0]
59
60 return firstLine.length > SUMMARY_MAX_CHARS ? `${firstLine.slice(0, SUMMARY_MAX_CHARS - 1)}…` : firstLine
61}
62
63function compareText(left, right) {
64 return left < right ? -1 : left > right ? 1 : 0
65}
66
67/**
68 * Case-insensitive, so built-in `Explore` sorts among custom lowercase names.
69 *
70 * @param {{ name: string }} a
71 * @param {{ name: string }} b
72 */
73function byName(a, b) {
74 return compareText(a.name.toLowerCase(), b.name.toLowerCase()) || compareText(a.name, b.name)
75}
76
77/**
78 * @param {import('../types').AgentPaneCatalog} current
79 * @param {{ name: string; summary: string; source: string; isOffered: boolean }} offer
80 * @returns {import('../types').AgentPaneCatalog} `current` itself when the offer changes nothing
81 */
82function applyOffer(current, { name, summary, source, isOffered }) {
83 const listed = current.types.find(type => type.name === name)
84 const isSame = listed?.summary === summary && listed?.source === source
85 const isUnchanged = current.isListed && (isOffered ? isSame : listed === undefined)
86 if (isUnchanged) {
87 return current
88 }
89
90 const others = current.types.filter(type => type.name !== name)
91 const types = isOffered ? [...others, { name, summary, source }].sort(byName) : others
92
93 return { isListed: true, types }
94}
95
96/** @param {unknown} error */
97function reasonOf(error) {
98 return error instanceof Error ? error.message : String(error)
99}
100
101/**
102 * @param {import('claude-code').SessionUsage} usage
103 * @returns {import('../types').AgentPaneType[]} the custom agent types, in breakdown order
104 */
105function usageAgentTypes(usage) {
106 const agents = usage.context.breakdown?.agents
107 if (!Array.isArray(agents)) {
108 return []
109 }
110
111 return agents
112 .filter(agent => typeof agent.agentType === 'string' && agent.agentType !== '')
113 .map(agent => ({ name: agent.agentType, summary: '', source: String(agent.source ?? '') }))
114}
115
116/**
117 * The engine offers no agent listing before the first prompt, and the usage
118 * breakdown is the one source that names custom agent types until then.
119 *
120 * @param {import('claude-code').EngineInterface} $
121 */
122async function readUsageTypes($) {
123 let usage
124 try {
125 usage = await $.session.usage({ breakdown: 'summary' })
126 } catch (error) {
127 $.ui.log(`agent pane: reading custom agent types failed: ${reasonOf(error)}`, { to: 'debug' })
128
129 return []
130 }
131
132 return usageAgentTypes(usage)
133}
134
135/**
136 * Reads the `name`, `model` and `color` keys of a Markdown file's YAML front
137 * matter. Only flat `key: value` lines are read; anything else is ignored.
138 *
139 * @param {string} text
140 * @returns {{ name?: string; model?: string; color?: string }}
141 */
142function frontMatter(text) {
143 const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)
144 if (match === null) {
145 return {}
146 }
147
148 const fields = {}
149 for (const line of match[1].split(/\r?\n/)) {
150 const field = /^(name|model|color):\s*['"]?([^'"#]*?)['"]?\s*$/.exec(line)
151 if (field !== null && field[2] !== '') {
152 fields[field[1]] = field[2]
153 }
154 }
155
156 return fields
157}
158
159/**
160 * @param {import('claude-code').EngineInterface} $
161 * @param {string} dir
162 * @returns {Promise<Record<string, import('../types').AgentPaneMeta>>} model and color by agent name
163 */
164async function readAgentDir($, dir) {
165 const found = {}
166 let entries
167 try {
168 entries = await $.fs.list(dir)
169 } catch {
170 return found
171 }
172
173 for (const entry of entries) {
174 if (entry.kind !== 'file' || !entry.name.endsWith('.md')) {
175 continue
176 }
177
178 try {
179 const { name, model, color } = frontMatter(await $.fs.read(`${dir}/${entry.name}`))
180 found[name ?? entry.name.slice(0, -3)] = { model, color }
181 } catch (error) {
182 $.ui.log(`agent pane: reading ${entry.name} failed: ${reasonOf(error)}`, { to: 'debug' })
183 }
184 }
185
186 return found
187}
188
189/**
190 * Model and color from the user's and the project's agent files; the
191 * project's definition wins a name both define, as it does in the engine.
192 *
193 * @param {import('claude-code').EngineInterface} $
194 */
195async function readAgentMeta($) {
196 let home
197 try {
198 home = await $.env.get('HOME')
199 } catch {
200 home = undefined
201 }
202
203 const user = home === undefined ? {} : await readAgentDir($, `${home}/.claude/agents`)
204
205 return { ...user, ...(await readAgentDir($, '.claude/agents')) }
206}
207
208/** @param {import('claude-code').EngineInterface} $ */
209async function isPaneShown($) {
210 try {
211 return (await $.ui.panes()).some(pane => pane.id === PANE_ID && pane.isShown)
212 } catch {
213 return false
214 }
215}
216
217/** @param {import('claude-code').EngineInterface} $ */
218async function togglePane($) {
219 if (await isPaneShown($)) {
220 await $.ui.close({ id: PANE_ID })
221
222 return 'Closed the agent pane.'
223 }
224
225 const found = await readAgentMeta($)
226 await update($, meta, () => found)
227
228 let opened
229 try {
230 opened = await $.ui.open({ id: PANE_ID, title: PANE_TITLE, focus: true })
231 } catch (error) {
232 return `The agent pane could not open: ${reasonOf(error)}`
233 }
234
235 return opened.isPlaced ? 'Opened the agent pane.' : `The agent pane waits: ${opened.reason}`
236}
237
238/**
239 * @param {import('claude-code').EngineInterface} $
240 * @param {Map<string, { type: string; startedAt: number }>} runs
241 * @param {string} name
242 * @returns {Promise<import('../types').AgentPaneStart>}
243 */
244async function spawnAgent($, runs, name) {
245 let spawned
246 try {
247 spawned = await $.agent.spawn({ prompt: START_PROMPT, subagentType: name, description: SPAWN_DESCRIPTION })
248 } catch (error) {
249 $.ui.log(`agent pane: starting ${name} failed: ${reasonOf(error)}`, { to: 'debug' })
250
251 return { status: 'failed', reason: reasonOf(error) }
252 }
253
254 if (spawned.deny !== undefined) {
255 return { status: 'failed', reason: spawned.deny }
256 }
257
258 if (spawned.agentId === undefined) {
259 return { status: 'started' }
260 }
261
262 runs.set(spawned.agentId, { type: name, startedAt: await $.clock.now() })
263
264 return { status: 'started', agentId: spawned.agentId }
265}
266
267/**
268 * @param {import('claude-code').EngineInterface} $
269 * @param {string} name
270 * @param {import('../types').AgentPaneStart} start
271 */
272function recordStart($, name, start) {
273 return update($, starts, current => ({ ...current, [name]: start }))
274}
275
276/**
277 * Starts one agent of type `name` unless a start of that type is still in flight.
278 *
279 * @param {import('claude-code').EngineInterface} $
280 * @param {{ inFlight: Set<string>; runs: Map<string, { type: string; startedAt: number }> }} session
281 * @param {string} name
282 */
283async function startAgent($, { inFlight, runs }, name) {
284 if (inFlight.has(name)) {
285 return
286 }
287
288 inFlight.add(name)
289 try {
290 await recordStart($, name, { status: 'starting' })
291 await recordStart($, name, await spawnAgent($, runs, name))
292 } finally {
293 inFlight.delete(name)
294 }
295}
296
297/**
298 * The agents running now, by type: the earliest start time this module saw
299 * for that type, or `undefined` for one started before the module loaded.
300 * Forgets the runs that ended.
301 *
302 * @param {import('claude-code').EngineInterface} $
303 * @param {Map<string, { type: string; startedAt: number }>} runs
304 * @returns {Promise<{ count: number; byType: Map<string, number | undefined> }>}
305 */
306async function readRunning($, runs) {
307 let loops
308 try {
309 loops = await $.agent.list()
310 } catch {
311 loops = []
312 }
313
314 const running = loops.filter(loop => loop.status === 'running')
315 const runningIds = new Set(running.map(loop => loop.id))
316 for (const id of runs.keys()) {
317 if (!runningIds.has(id)) {
318 runs.delete(id)
319 }
320 }
321
322 const byType = new Map()
323 for (const loop of running) {
324 const startedAt = runs.get(loop.id)?.startedAt
325 const earliest = byType.get(loop.type)
326 byType.set(loop.type, earliest === undefined ? startedAt : Math.min(earliest, startedAt ?? earliest))
327 }
328
329 return { count: running.length, byType }
330}
331
332/** @param {number} ms */
333function elapsed(ms) {
334 const seconds = Math.max(0, Math.floor(ms / 1000))
335
336 return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`
337}
338
339/** @param {string} name */
340function hashColor(name) {
341 let hash = 0
342 for (const char of name) {
343 hash = (hash * 31 + char.codePointAt(0)) >>> 0
344 }
345
346 return DOT_PALETTE[hash % DOT_PALETTE.length]
347}
348
349/**
350 * @param {string} name
351 * @param {string | undefined} color an agent file's `color`, a palette name or a raw color
352 */
353function dotColor(name, color) {
354 return color === undefined ? hashColor(name) : (DOT_COLORS[color] ?? color)
355}
356
357function header({ Box, Text }, runningCount) {
358 const running = runningCount === 0 ? [] : [h(Text, { dimColor: true }, `◌ ${runningCount} running`)]
359
360 return h(
361 Box,
362 { key: 'header', flexDirection: 'row', justifyContent: 'space-between', marginBottom: 1 },
363 h(
364 Box,
365 { flexDirection: 'row', gap: 1 },
366 h(Text, { color: ACCENT }, '◆'),
367 h(Text, { bold: true, color: TEXT }, 'Agents'),
368 h(Text, { dimColor: true }, 'in this project'),
369 ),
370 ...running,
371 )
372}
373
374function sectionHeader({ Box, Text }, section, count, columns) {
375 const caption = `${section.hint} · ${count}`
376 const ruleWidth = Math.max(0, columns - section.label.length - caption.length - 2)
377
378 return h(
379 Box,
380 { key: `section:${section.source}`, flexDirection: 'row', gap: 1, marginTop: 1 },
381 h(Text, { bold: true, color: TEXT }, section.label),
382 h(Text, { dimColor: true }, caption),
383 h(Box, { flexGrow: 1 }, h(Text, { dimColor: true, wrap: 'truncate-end' }, '─'.repeat(ruleWidth))),
384 )
385}
386
387/**
388 * The right side of a row: the run time while an agent of this type runs,
389 * otherwise the run button, led by `starting` while a start is in flight.
390 */
391function rowAction({ Box, Button, Text }, name, start, runningSince, now, onStart) {
392 if (runningSince !== null) {
393 const label = runningSince === undefined ? 'running' : `running · ${elapsed(now - runningSince)}`
394
395 return [
396 h(
397 Box,
398 { key: `status:${name}`, flexDirection: 'row', gap: 1 },
399 h(Text, { color: ACCENT }, '◌'),
400 h(Text, { color: TEXT }, label),
401 ),
402 ]
403 }
404
405 const starting = start?.status === 'starting' ? [h(Box, { key: `status:${name}` }, h(Text, { dimColor: true }, 'starting'))] : []
406
407 return [...starting, h(Button, { key: `start:${name}`, label: '▶ run', variant: 'primary', onPress: onStart })]
408}
409
410function agentRow(elements, type, info, onStart) {
411 const { Box, Text } = elements
412 const { name, summary } = type
413 const { start, model, color, runningSince, now } = info
414 const modelText = model === undefined ? [] : [h(Text, { dimColor: true }, model)]
415 const summaryText =
416 summary === '' ? [] : [h(Box, { paddingLeft: 2 }, h(Text, { dimColor: true, wrap: 'truncate-end' }, summary))]
417 const failed =
418 start?.status === 'failed'
419 ? [h(Box, { key: `status:${name}`, paddingLeft: 2 }, h(Text, { color: ERROR_COLOR }, `failed: ${start.reason}`))]
420 : []
421
422 return h(
423 Box,
424 { key: `row:${name}`, flexDirection: 'column', marginTop: 1 },
425 h(
426 Box,
427 { flexDirection: 'row', justifyContent: 'space-between' },
428 h(
429 Box,
430 { flexDirection: 'row', gap: 1, flexShrink: 1 },
431 h(Text, { color: dotColor(name, color) }, '●'),
432 h(Text, { bold: true, color: TEXT }, name),
433 ...modelText,
434 ),
435 h(Box, { flexDirection: 'row', gap: 1 }, ...rowAction(elements, name, start, runningSince, now, onStart)),
436 ),
437 ...summaryText,
438 ...failed,
439 )
440}
441
442/**
443 * @param {import('../types').AgentPaneType[]} types
444 * @returns {{ section: { source: string; label: string; hint: string }; types: import('../types').AgentPaneType[] }[]}
445 */
446function groupBySource(types) {
447 const known = SECTIONS.map(section => ({ section, types: types.filter(type => type.source === section.source) }))
448 const otherSources = [...new Set(types.map(type => type.source))].filter(
449 source => !SECTIONS.some(section => section.source === source),
450 )
451 const others = otherSources.map(source => ({
452 section: { source, label: (source.replace(/Settings$/, '') || 'OTHER').toUpperCase(), hint: 'settings' },
453 types: types.filter(type => type.source === source),
454 }))
455
456 return [...known, ...others].filter(group => group.types.length > 0)
457}
458
459function emptyText(Box, Text, text) {
460 return h(Box, { key: 'empty', marginTop: 1 }, h(Text, { dimColor: true }, text))
461}
462
463function body(elements, types, row, columns) {
464 return groupBySource(types).flatMap(({ section, types: sectionTypes }) => [
465 sectionHeader(elements, section, sectionTypes.length, columns),
466 ...sectionTypes.map(row),
467 ])
468}
469
470/** @type {import('claude-code').Register} */
471export const register = on => {
472 const session = {
473 /** @type {Set<string>} */
474 inFlight: new Set(),
475 /** Agents this module saw start, by agent id. */
476 /** @type {Map<string, { type: string; startedAt: number }>} */
477 runs: new Map(),
478 }
479
480 on('session.start', async ($, e, next) => {
481 await $.command.register({ name: COMMAND, description: 'Show or hide the agent side pane', immediate: true })
482 $.clock.every(TICK_MS, () => {
483 if (session.runs.size > 0) {
484 $.ui.invalidate('ui.render')
485 }
486 })
487
488 return next(e)
489 })
490
491 on('command.run', { command: COMMAND }, async $ => ({ text: await togglePane($) }))
492
493 on('agent.offer', async ($, e, next) => {
494 const result = await next(e)
495 const offer = { name: e.agent, summary: summarize(e.description), source: e.source, isOffered: result.isOffered }
496 const current = await read($, catalog)
497 if (applyOffer(current, offer) !== current) {
498 await update($, catalog, latest => applyOffer(latest, offer))
499 }
500
501 return result
502 })
503
504 on('agent.spawn', async ($, e, next) => {
505 const result = await next(e)
506 if (result.agentId !== undefined && e.subagentType !== undefined) {
507 session.runs.set(result.agentId, { type: e.subagentType, startedAt: await $.clock.now() })
508 }
509
510 return result
511 })
512
513 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
514 const elements = $.ui.resolve(e)
515 const { Box, Text } = elements
516 const columns = e.props.bodyColumns
517 const { isListed, types: offered } = await read($, catalog)
518 const startsByName = await read($, starts)
519 const metaByName = await read($, meta)
520 const running = await readRunning($, session.runs)
521 const now = await $.clock.now()
522 const types = isListed ? offered : await readUsageTypes($)
523
524 const row = type => {
525 const found = Object.hasOwn(metaByName, type.name) ? metaByName[type.name] : {}
526 const info = {
527 start: Object.hasOwn(startsByName, type.name) ? startsByName[type.name] : undefined,
528 model: found.model,
529 color: found.color,
530 runningSince: running.byType.has(type.name) ? running.byType.get(type.name) : null,
531 now,
532 }
533
534 return agentRow(elements, type, info, () => startAgent($, session, type.name))
535 }
536
537 const note = !isListed && types.length > 0 ? [h(Box, { key: 'note' }, h(Text, { dimColor: true }, NOTE))] : []
538 const rows = types.length === 0 ? [emptyText(Box, Text, isListed ? OFFERED_EMPTY : FIRST_PROMPT_EMPTY)] : body(elements, types, row, columns)
539
540 return /** @type {import('claude-code').RenderElement} */ (
541 h(
542 Box,
543 { flexDirection: 'column', width: columns },
544 header(elements, running.count),
545 ...note,
546 ...rows,
547 h(Box, { key: 'footer', marginTop: 1 }, h(Text, { dimColor: true }, FOOTER)),
548 )
549 )
550 })
551}
552types/index.d.ts 21 lines1export type AgentPaneType = { name: string; summary: string; source: string }
2
3export type AgentPaneCatalog = { isListed: boolean; types: AgentPaneType[] }
4
5export type AgentPaneStart =
6 | { status: 'starting' }
7 | { status: 'started'; agentId?: string }
8 | { status: 'failed'; reason: string }
9
10export type AgentPaneMeta = { model?: string; color?: string }
11
12declare module 'claude-code' {
13 interface PluginState {
14 'bostonaholic': {
15 agentPaneCatalog: AgentPaneCatalog
16 agentPaneStarts: Record<string, AgentPaneStart>
17 agentPaneMeta: Record<string, AgentPaneMeta>
18 }
19 }
20}
21