SLOPSHOPPER

bostonaholic

Matthew Boston's personal Claude Code mods.

newpanecommandtimeragents
★ 1v0.1.2MITupdated 2026-10-06bostonaholic/claude-mods
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · bostonaholic
│ ┃ Agents ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ Agents in this project │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ No agent types yet. They appear after your f ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ click ▶ run to start one · /agent-pane to hi ⏺ 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 │ │ › /agent-pane │ ⎿ bostonaholic: Opened the agent pane. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agents
◆ Agents in this project No agent types yet. They appear after your first prompt. click ▶ run to start one · /agent-pane to hide
README

Claude Code Mods

The Claude Code mods I use.

Mods run code inside Claude Code; read a mod before you enable it.

Install

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

Local development

claude --plugin-dir /Users/matthew/code/bostonaholic/claude-mods

Claude Code hot-reloads the plugin on each save.

Contributing

Read AGENTS.md before making changes.

License

MIT. See LICENSE.

Source 2 files
hooks/agent-side-pane.mjs 552 lines
1import { 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}
552
types/index.d.ts 21 lines
1export 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