SLOPSHOPPER

Dr. BOOM

Dr. BOOM's Claude Code setup: names, a status line, a stats band, a spinner, a pane with the repo's other sessions and switchable sound profiles

newpanebandspinnerguardcommand
v0.17.0MITupdated 2026-10-26shanep/claude-DrBOOM
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · drboom
│ ┃ BOOM! ✕ › fix the failing auth test and add an audit log call │ ┃ Session │ ┃ 1 prompts | 9 tool calls | 3 edits ⏺ Read(src/auth.ts) │ ┃ up 0m | last turn: 0s ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Top tools ⎿ Added 2 lines, removed 1 line │ ┃ 4 Bash ⏺ Bash(bun test) │ ┃ 2 Write ⎿ 3 pass, 1 fail │ ┃ 1 Read │ ┃ 1 Grep ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 1 Edit │ ┃ ✻ Detonated for 42s · done 4:20 PM │ ┃ Repo │ ┃ Nothing yet. › /boom │ ┃ ⎿ drboom: BOOM! Pane open. │ ┃ Sounds │ ┃ [ Mute ] │ ┃ Play: │ ┃ [ Start ][ Prompt ][ Notify ][ Permission ][ │ ✻ BOOMing… BOOM! 1 prompts | 9 tool calls | 3 edits | up 0m | last turn: 0s [ Hide ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
BOOM! 1 prompts | 9 tool calls | 3 edits | up 0m | last turn: 0s [ Hide ]
Pane · BOOM!
Session 1 prompts | 9 tool calls | 3 edits up 0m | last turn: 0s Top tools 4 Bash 2 Write 1 Read 1 Grep 1 Edit Repo Nothing yet. Sounds [ Mute ] Play: [ Start ][ Prompt ][ Notify ][ Permission ][ Subagent ][ Sto
README

Dr. BOOM

Dr. BOOM's Claude Code setup as one plugin. It tells Claude what to call you, prints a status line, shows session stats above the prompt, puts Dr. BOOM words on the spinner and the line that closes each turn, opens a pane with stats and a sound board, and plays your own sound files on hook events.

Works on macOS (afplay) and Linux (paplay, pw-play, aplay or ffplay, whichever it finds first).

Names

A hooks module (hooks/register.ts) adds a system prompt section telling Claude to call you Dr. BOOM! and itself Sergeant Stupid. It also shows a toast when you switch profiles, mute or unmute, so you can see the change took.

The fullscreen TUI is not set here. Claude Code drops what a plugin's settings.read hook answers, and it picks the renderer at startup anyway, so put "tui": "fullscreen" in ~/.claude/settings.json instead.

Stats band

A row above the prompt counts what the session has done so far:

BOOM! 3 prompts | 41 tool calls | 6 edits | up 12m | last turn: 37s  [Hide]

Edits are Edit, Write and NotebookEdit calls. Hide drops the row for the rest of the session.

Pane

/boom opens a pane beside the chat with the same counts as the band, the six tools Claude called most, the repo, and a sound board. The repo section comes from the workflow plugin: this session's branch and changed files, its ports, how the worktree setup went, whether it keeps the computer awake, which MCP servers and connectors work, how many check slots are busy, what the startup health check found, the other sessions in the repo and what they are changing, edits it paused, and files to rebase on. With workflow the session counts also show what the session has cost against its budget, how far through its task list it is and a guess at what is left, and each other session gets the same line, yellow once it is over budget. Without workflow it says "Nothing yet." Below it, the newest workflow plans list each task, whether it is waiting, running, landed or failed, and what it cost. An Auto mode section counts the calls auto mode let through and refused in this session and shows the newest three. The board has a button for each profile (the active one has a *), Mute or Unmute, and a button that plays each event's sound. With the pane focused, m mutes or unmutes and 1 to 7 play Start, Prompt, Notify, Permission, Subagent, Stop and End.

The pane docks beside the transcript in fullscreen from 110 columns and sits above the prompt otherwise. It redraws when the session does something and once a minute, so the uptime on it and the band is never more than a minute behind.

Spinner and turn line

While Claude works, the spinner says one of BOOMing, Lighting the fuse, Calling in Sergeant Stupid, Detonating, Packing the charges, Counting down, Making it go BOOM or Consulting Dr. BOOM, and keeps the same one for the whole turn. Status messages Claude Code shows in its place are left alone.

The line that closes a turn (Baked for 3s) gets one of Detonated, Went BOOM, Blew it up, Lit the fuse, Cleared the blast zone, Kaboomed, Rattled the windows or Shook the lab instead.

Status line

scripts/statusline.sh prints Dr. BOOM | model: <model> | folder: <folder> | branch: <branch> | sounds: <profile>, with | worktree: <name> after the branch when the folder is a linked git worktree. | sessions: <n> before the profile counts the running Claude Code sessions in the same repo, this one and any in its worktrees included. When the line is wider than the terminal, it wraps between fields onto more rows. A plugin cannot set the status line either, so point ~/.claude/settings.json at the installed copy. The path comes from Claude Code's install record because the version is part of it:

"statusLine": {
  "type": "command",
  "command": "sh \"$(jq -r '.plugins[\"drboom@shanep\"][0].installPath' ~/.claude/plugins/installed_plugins.json)/scripts/statusline.sh\"",
  "padding": 0
}

Install

/plugin marketplace add shanep/claude-plugins
/plugin install drboom@shanep

To try a local checkout without installing it:

claude --plugin-dir ~/repos/claude-DrBOOM

Sounds

Sounds play on session start and end, when you send a prompt, when Claude finishes a reply or a subagent, needs your attention, or asks for permission.

Adding sounds

Each profile is a folder under sounds/, and each file is named after the event it plays on. The extension can be anything your player understands. Leave out any event you don't want a sound for.

sounds/
  default/
    SessionStart.wav       A session starts or resumes
    UserPromptSubmit.wav   You sent a prompt
    Notification.wav       Claude needs your attention
    PermissionRequest.wav  A tool call is waiting for your approval
    SubagentStop.wav       A subagent finished
    Stop.wav               Claude finished responding
    SessionEnd.wav         The session exits (not on /clear or /resume)
  boom/
    Stop.mp3

If the active profile is missing a file, the plugin plays the one from default instead. If default doesn't have it either, it stays quiet.

Switching profiles

/drboom:profile          list profiles, * marks the active one
/drboom:profile boom     switch to sounds/boom/ and play its Stop sound
/drboom:profile mute     silence everything
/drboom:profile unmute   turn sounds back on

Your choice is saved in the plugin data directory, so it survives plugin updates. Two environment variables override it for a single session:

  • DRBOOM_SOUNDS_PROFILE=boom picks the profile
  • DRBOOM_SOUNDS_MUTE=1 turns sounds off

Testing

npm run check

This runs shellcheck on the scripts, checks the JSON files, and tests profile selection and the status line against a throwaway sounds folder. CI runs the same command on every push, and so does a pre-push hook once you turn it on:

git config core.hooksPath .githooks

License

MIT. See LICENSE.

Source 2 files
hooks/register.tsx 433 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Progress, Sounds, Stats } from '../types'
5
6// How Claude talks to Dr. BOOM, added to the end of the system prompt.
7const NAMES = `# Names
8
9- Call the user Dr. BOOM! in every reply, in every session.
10- Refer to yourself as Sergeant Stupid in chat replies to the user. Keep it out of commit messages, code, comments and anything else that goes out under the user's name.`
11
12// What profile.sh prints first when it changes something. A listing prints
13// none of these.
14const CHANGED = /^(Switched to .+|Sounds muted|Sounds on)\.$/
15
16// The band above the prompt and the pane read these. They live in $.state so
17// a reload keeps the counts. Change the tag when Stats changes, so a reload
18// starts the counts over instead of reading an older shape.
19const stats = atom({ plugin: 'drboom', key: 'stats' } as const, null, { shape: 'stats-2' })
20const sounds = atom({ plugin: 'drboom', key: 'sounds' } as const, null)
21const isBandHidden = atom({ plugin: 'drboom', key: 'isBandHidden' } as const, false)
22
23// The tools the band counts as edits.
24const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
25
26// The pane /boom opens.
27const PANE = 'boom'
28
29// What the workflow plugin knows about the repo: the other sessions, paused
30// edits and what to rebase on. The pane redraws when it changes.
31const repo = { plugin: 'workflow', key: 'repo' } as const
32
33// How many files a pane row lists before "and n more".
34const PANE_FILES = 5
35
36// The events the pane can play, with the label each button shows.
37const EVENTS: readonly [string, string][] = [
38  ['SessionStart', 'Start'],
39  ['UserPromptSubmit', 'Prompt'],
40  ['Notification', 'Notify'],
41  ['PermissionRequest', 'Permission'],
42  ['SubagentStop', 'Subagent'],
43  ['Stop', 'Stop'],
44  ['SessionEnd', 'End'],
45]
46
47// What the spinner says while Claude works, in place of the engine's verbs.
48const SPINNER_WORDS = [
49  'BOOMing',
50  'Lighting the fuse',
51  'Calling in Sergeant Stupid',
52  'Detonating',
53  'Packing the charges',
54  'Counting down',
55  'Making it go BOOM',
56  'Consulting Dr. BOOM',
57]
58
59// What the line that closes a turn says, in place of the engine's word
60// (`Baked for 3s` becomes `Detonated for 3s`).
61const FOOTER_WORDS = [
62  'Detonated',
63  'Went BOOM',
64  'Blew it up',
65  'Lit the fuse',
66  'Cleared the blast zone',
67  'Kaboomed',
68  'Rattled the windows',
69  'Shook the lab',
70]
71
72// The engine samples one word per turn, so hash it to keep ours steady for
73// the turn and on every redraw of the line.
74function pick(words: readonly string[], word: string): string {
75  let n = 0
76  for (const c of word) n = (n * 31 + c.charCodeAt(0)) >>> 0
77  return words[n % words.length] ?? 'BOOMing'
78}
79
80function bump(s: Stats | null, now: number, change: Partial<Stats>): Stats {
81  const base: Stats = s ?? { startedAt: now, prompts: 0, tools: 0, edits: 0, lastTurnSeconds: null, byTool: {} }
82  return { ...base, ...change }
83}
84
85function age(ms: number): string {
86  const minutes = Math.floor(ms / 60000)
87  return minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h ${minutes % 60}m`
88}
89
90// Files as "a, b and 3 more".
91function files(list: readonly string[]): string {
92  const rest = list.length - PANE_FILES
93  return list.slice(0, PANE_FILES).join(', ') + (rest > 0 ? ` and ${rest} more` : '')
94}
95
96function dollars(usd: number): string {
97  return `$${usd.toFixed(2)}`
98}
99
100// A session's progress as "$1.20 of $5.00 | 2 of 5 tasks, on Fix login | about 20m and $1.80 left".
101function progress(p: Progress): string {
102  const parts = [p.budget === null ? dollars(p.usd) : `${dollars(p.usd)} of ${dollars(p.budget)}`]
103  if (p.tasks !== null) {
104    parts.push(`${p.tasks.done} of ${p.tasks.total} tasks${p.tasks.current === null ? '' : `, on ${p.tasks.current}`}`)
105  }
106  if (p.left !== null) {
107    parts.push(`about ${age(Math.max(p.left.ms, 60000))} and ${dollars(p.left.usd)} left`)
108  }
109  if (p.fiveHour !== null) {
110    parts.push(`5h limit ${Math.round(p.fiveHour)}%`)
111  }
112  return parts.join(' | ')
113}
114
115function isOver(p: Progress): boolean {
116  return p.budget !== null && p.usd >= p.budget
117}
118
119// What profile.sh prints for args, an empty list asking for the listing.
120// lib.sh finds the data folder from the plugin root, as it does for the
121// status line. A failed run prints only to stderr, so that comes back then.
122async function profile($: EngineInterface, args: string[]): Promise<string> {
123  const ran = await $.process.run(['sh', `${$.plugin.root}/scripts/profile.sh`, ...args])
124  return ran.stdout !== '' ? ran.stdout : ran.stderr
125}
126
127// profile.sh marks the active profile with a star and ends with a line when
128// sounds are muted.
129function parseProfiles(out: string): Sounds {
130  const profiles: string[] = []
131  let active = 'default'
132  for (const line of out.split('\n')) {
133    const m = /^([* ]) ([^:]+):/.exec(line)
134    if (m === null) continue
135    const name = m[2] ?? ''
136    profiles.push(name)
137    if (m[1] === '*') active = name
138  }
139  return { profiles, active, isMuted: out.includes('Sounds are muted.') }
140}
141
142// Read the listing back into $.state, which redraws the pane. It never
143// throws, since the Bash hook calls it after the tool has run and its .catch
144// would run the tool again. A failed listing keeps the last good one.
145async function refreshSounds($: EngineInterface): Promise<void> {
146  try {
147    const ran = await $.process.run(['sh', `${$.plugin.root}/scripts/profile.sh`])
148    if (ran.exitCode !== 0 || ran.stdout === '') return
149    await update($, sounds, () => parseProfiles(ran.stdout))
150  } catch {
151    return
152  }
153}
154
155export const register: Register = on => {
156  let turnStartedAt = 0
157  // Redraws the band and the pane each minute so the uptime stays current.
158  // session.start runs again after /clear, so a second start replaces it.
159  let tick: Timer | null = null
160
161  on('session.start', async ($, e, next) => {
162    const now = await $.clock.now()
163    await update($, stats, s => s ?? bump(null, now, {}))
164    await $.command.register({ name: 'boom', description: 'Open the Dr. BOOM pane' })
165    await refreshSounds($)
166    tick?.cancel()
167    tick = $.clock.every(60000, () => $.ui.invalidate('ui.render'))
168    return next(e)
169  }).catch(($, e, next) => next(e))
170
171  // Read the sounds again on open, since a shell can switch profiles
172  // without the pane hearing about it.
173  on('command.run', { command: 'boom' }, async $ => {
174    await refreshSounds($)
175    const opened = await $.ui.open({ id: PANE, title: 'BOOM!' })
176    return { text: opened.isPlaced ? 'BOOM! Pane open.' : 'BOOM! The pane opens once the terminal is wide enough.' }
177  })
178
179  on('prompt.submit', async ($, e, next) => {
180    const now = await $.clock.now()
181    turnStartedAt = now
182    await update($, stats, s => bump(s, now, { prompts: (s?.prompts ?? 0) + 1 }))
183    return next(e)
184  }).catch(($, e, next) => next(e))
185
186  on('tool.call', async ($, e, next) => {
187    const now = await $.clock.now()
188    const edit = EDIT_TOOLS.has(e.tool) ? 1 : 0
189    await update($, stats, s =>
190      bump(s, now, {
191        tools: (s?.tools ?? 0) + 1,
192        edits: (s?.edits ?? 0) + edit,
193        byTool: { ...(s?.byTool ?? {}), [e.tool]: (s?.byTool[e.tool] ?? 0) + 1 },
194      }),
195    )
196    return next(e)
197  }).catch(($, e, next) => next(e))
198
199  on('turn.complete', async ($, e, next) => {
200    const now = await $.clock.now()
201    const seconds = turnStartedAt === 0 ? null : Math.round((now - turnStartedAt) / 1000)
202    await update($, stats, s => bump(s, now, { lastTurnSeconds: seconds }))
203    return next(e)
204  }).catch(($, e, next) => next(e))
205
206  // Session stats in one row above the prompt, with a button to hide it for
207  // the rest of the session.
208  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
209    const s = await read($, stats)
210    if (e.props.hasSurvey || s === null || (await read($, isBandHidden))) return next(e)
211
212    const { Box, Button, Text } = $.ui.resolve(e)
213    const now = await $.clock.now()
214    const turn = s.lastTurnSeconds === null ? '' : ` | last turn: ${s.lastTurnSeconds}s`
215
216    return (
217      <Box>
218        <Text dimColor>
219          BOOM! {s.prompts} prompts | {s.tools} tool calls | {s.edits} edits | up {age(now - s.startedAt)}
220          {turn}{' '}
221        </Text>
222        <Button key="hide" label="Hide" onPress={() => update($, isBandHidden, () => true)} />
223      </Box>
224    )
225  }).catch(($, e, next) => next(e))
226
227  // The pane: session stats, the most-called tools, the repo as the workflow
228  // plugin sees it, and a sound board that switches profiles, mutes and plays
229  // each event's sound.
230  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
231    const { Box, Button, Text } = $.ui.resolve(e)
232    const s = await read($, stats)
233    const board = await read($, sounds)
234    const { value: r } = await $.state.get(repo)
235    const now = await $.clock.now()
236
237    // Run profile.sh, read its new listing back, and toast what it said.
238    const change = async (args: string[]) => {
239      const said = (await profile($, args)).split('\n')[0]?.trim() ?? ''
240      await refreshSounds($)
241      $.ui.toast(`BOOM! ${said !== '' ? said : 'profile.sh printed nothing.'}`)
242    }
243    const play = async (event: string) => {
244      await $.process.run(['sh', `${$.plugin.root}/scripts/play.sh`, event])
245    }
246
247    const top = Object.entries(s?.byTool ?? {})
248      .sort((a, b) => b[1] - a[1])
249      .slice(0, 6)
250
251    return (
252      <Box flexDirection="column">
253        <Text bold>Session</Text>
254        {s === null ? (
255          <Text dimColor>Nothing yet.</Text>
256        ) : (
257          <Box flexDirection="column">
258            <Text>
259              {s.prompts} prompts | {s.tools} tool calls | {s.edits} edits
260            </Text>
261            <Text dimColor>
262              up {age(now - s.startedAt)}
263              {s.lastTurnSeconds === null ? '' : ` | last turn: ${s.lastTurnSeconds}s`}
264            </Text>
265          </Box>
266        )}
267        {r?.progress != null && <Text color={isOver(r.progress) ? 'yellow' : undefined}>{progress(r.progress)}</Text>}
268        <Text> </Text>
269        <Text bold>Top tools</Text>
270        {top.length === 0 && <Text dimColor>No tool calls yet.</Text>}
271        {top.map(([tool, n]) => (
272          <Text>
273            {String(n).padStart(4)} {tool}
274          </Text>
275        ))}
276        <Text> </Text>
277        <Text bold>Repo</Text>
278        {r == null ? (
279          <Text dimColor>Nothing yet.</Text>
280        ) : (
281          <Box flexDirection="column">
282            <Text>
283              {r.branch ?? 'no branch'}
284              {r.target === null || r.target === r.branch ? '' : ` | lands on ${r.target}`}
285            </Text>
286            <Text dimColor>{r.files.length === 0 ? 'No changes yet.' : `Changing ${files(r.files)}`}</Text>
287            {r.ports != null && (
288              <Text dimColor>
289                Ports {r.ports} to {r.ports + 99}
290              </Text>
291            )}
292            {r.setup != null && <Text dimColor>{r.setup}</Text>}
293            {r.isAwake === true && <Text dimColor>Keeping this computer awake</Text>}
294            {r.health != null && r.health.problems.length === 0 && (
295              <Text dimColor>Healthy at head, checked {age(now - r.health.at)} ago</Text>
296            )}
297            {r.health != null && r.health.problems.length > 0 && (
298              <Text color="yellow">
299                Health: {r.health.problems.length} problem{r.health.problems.length === 1 ? '' : 's'} at head
300              </Text>
301            )}
302            {r.health?.problems.map(problem => (
303              <Text dimColor>- {problem.split('\n')[0]}</Text>
304            ))}
305            {r.connectors != null && (
306              <Text dimColor={r.connectors.problems.length === 0} color={r.connectors.problems.length === 0 ? undefined : 'yellow'}>
307                Connectors: {r.connectors.ready} connected
308                {r.connectors.problems.length === 0 ? '' : `, ${r.connectors.problems.length} not working`}
309              </Text>
310            )}
311            {r.connectors?.problems.map(problem => (
312              <Text dimColor>- {problem}</Text>
313            ))}
314            {r.checks != null && (
315              <Text dimColor={r.checks.busy < r.checks.slots} color={r.checks.busy < r.checks.slots ? undefined : 'yellow'}>
316                Checks: {r.checks.busy} of {r.checks.slots} slots busy
317              </Text>
318            )}
319            {r.upstream.length > 0 && <Text color="yellow">Rebase: {r.target} changed {files(r.upstream)}</Text>}
320            {r.peers.length === 0 && <Text dimColor>No other sessions.</Text>}
321            {r.peers.map(peer => (
322              <Box flexDirection="column">
323                <Text>
324                  {peer.topic === null ? 'A session' : `"${peer.topic}"`} {peer.where}
325                  {peer.files.length === 0 ? '' : `: ${files(peer.files)}`}
326                </Text>
327                {peer.progress != null && (
328                  <Text dimColor={!isOver(peer.progress)} color={isOver(peer.progress) ? 'yellow' : undefined}>
329                    {'  '}
330                    {progress(peer.progress)}
331                  </Text>
332                )}
333              </Box>
334            ))}
335            {r.paused.map(p => (
336              <Text dimColor>
337                Paused an edit to {p.file} {age(now - p.at)} ago
338              </Text>
339            ))}
340          </Box>
341        )}
342        {r?.audit != null && (
343          <Box flexDirection="column">
344            <Text> </Text>
345            <Text bold>Auto mode</Text>
346            <Text dimColor>
347              Let {r.audit.allowed} through | refused {r.audit.denied} | /audit lists them
348            </Text>
349            {r.audit.recent.map(entry => (
350              <Text dimColor={entry.decision === 'allow'} color={entry.decision === 'deny' ? 'yellow' : undefined}>
351                {age(now - entry.at)} ago {entry.decision === 'allow' ? 'let through' : 'refused'} {entry.tool}: {entry.what}
352              </Text>
353            ))}
354          </Box>
355        )}
356        {r?.plans != null && r.plans.length > 0 && (
357          <Box flexDirection="column">
358            <Text> </Text>
359            <Text bold>Plans</Text>
360            {r.plans.map(plan => (
361              <Box flexDirection="column">
362                <Text color={plan.state === 'failed' ? 'red' : undefined}>
363                  {plan.id}: {plan.goal} | {plan.state}, {plan.tasks.filter(task => task.state === 'landed').length} of {plan.tasks.length} landed
364                </Text>
365                {plan.tasks.map(task => (
366                  <Text dimColor={task.state !== 'failed'} color={task.state === 'failed' ? 'red' : undefined}>
367                    {'  '}
368                    {task.id}: {task.state}
369                    {task.usd == null ? '' : `, ${dollars(task.usd)}`}
370                    {task.note === null ? '' : ` (${task.note.split('\n')[0]})`}
371                  </Text>
372                ))}
373              </Box>
374            ))}
375          </Box>
376        )}
377        <Text> </Text>
378        <Text bold>Sounds{board?.isMuted === true ? ' (muted)' : ''}</Text>
379        <Box flexWrap="wrap">
380          {(board?.profiles ?? []).map(name => (
381            <Button
382              key={`profile:${name}`}
383              label={name === board?.active ? `* ${name}` : name}
384              onPress={() => void change([name])}
385            />
386          ))}
387          <Button
388            key="mute"
389            hotkey="m"
390            label={board?.isMuted === true ? 'Unmute' : 'Mute'}
391            onPress={() => void change([board?.isMuted === true ? 'unmute' : 'mute'])}
392          />
393        </Box>
394        <Text dimColor>Play:</Text>
395        <Box flexWrap="wrap">
396          {EVENTS.map(([event, label], i) => (
397            <Button key={`play:${event}`} hotkey={String(i + 1)} label={label} onPress={() => void play(event)} />
398          ))}
399        </Box>
400      </Box>
401    )
402  })
403
404  on('ui.render', { component: 'Spinner' }, ($, e, next) =>
405    next({ ...e, props: { ...e.props, word: pick(SPINNER_WORDS, e.props.word) } }),
406  ).catch(($, e, next) => next(e))
407
408  on('ui.render', { component: 'TurnDuration' }, ($, e, next) =>
409    next({ ...e, props: { ...e.props, word: pick(FOOTER_WORDS, e.props.word) } }),
410  ).catch(($, e, next) => next(e))
411
412  on('prompt.compose', async ($, e, next) => {
413    const { sections } = await next(e)
414    return {
415      sections: [...sections, { id: `${$.plugin.name}:names`, text: NAMES, scope: 'session' }],
416    }
417  }).catch(($, e, next) => next(e))
418
419  // Toast the first line profile.sh prints when it switches, mutes or
420  // unmutes, so a change made through the profile skill is easy to see, and
421  // update the pane to match.
422  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
423    const ran = await next(e)
424    if (!e.command.includes('scripts/profile.sh')) return ran
425    if (ran.deny !== undefined || ran.isError === true) return ran
426    const first = ran.result.stdout.split('\n')[0]?.trim() ?? ''
427    if (!CHANGED.test(first)) return ran
428    $.ui.toast(`BOOM! ${first}`)
429    await refreshSounds($)
430    return ran
431  }).catch(($, e, next) => next(e))
432}
433
types/index.d.ts 78 lines
1// What the band and the pane count in one session.
2export type Stats = {
3  startedAt: number
4  prompts: number
5  tools: number
6  edits: number
7  lastTurnSeconds: number | null
8  byTool: Record<string, number>
9}
10
11// What profile.sh lists: the profiles, the active one and the mute state.
12export type Sounds = { profiles: string[]; active: string; isMuted: boolean }
13
14// How far a session has got and what it cost, from the workflow plugin.
15export type Progress = {
16  usd: number
17  budget: number | null
18  tasks: { done: number; total: number; current: string | null } | null
19  left: { usd: number; ms: number } | null
20  fiveHour: number | null
21}
22
23// What the workflow plugin keeps in $.state for the pane, copied from its
24// types/index.d.ts. drboom reads it without listing workflow as a dependency,
25// since Claude Code turns off a plugin whose dependency is missing.
26export type Repo = {
27  root: string
28  target: string | null
29  branch: string | null
30  files: string[]
31  // progress is optional, since workflow 0.6.0 and before did not publish it.
32  peers: { topic: string | null; where: string; files: string[]; progress?: Progress | null }[]
33  paused: { file: string; at: number }[]
34  upstream: string[]
35  // Optional, since workflow 0.1.0 published neither.
36  ports?: number | null
37  setup?: string | null
38  // Optional, since workflow 0.2.0 and before did not publish it.
39  checks?: { busy: number; slots: number } | null
40  // Optional, since workflow 0.3.0 and before did not publish it.
41  health?: { at: number; problems: string[] } | null
42  // Optional, since workflow 0.5.0 and before did not publish it. Newest first.
43  plans?: {
44    id: string
45    goal: string
46    state: 'running' | 'done' | 'failed' | 'stopped'
47    tasks: {
48      id: string
49      title: string
50      state: 'waiting' | 'running' | 'landed' | 'failed' | 'stopped'
51      note: string | null
52      session: string | null
53      // Optional, since workflow 0.6.0 and before did not publish it.
54      usd?: number | null
55    }[]
56  }[]
57  // Optional, since workflow 0.6.0 and before did not publish it.
58  progress?: Progress | null
59  // Optional, since workflow 0.7.0 and before did not publish it.
60  isAwake?: boolean
61  // Optional, since workflow 0.8.0 and before did not publish it. Newest last.
62  audit?: {
63    allowed: number
64    denied: number
65    recent: { at: number; tool: string; what: string; decision: 'allow' | 'deny'; reason: string | null }[]
66  } | null
67  // Optional, since workflow 0.9.0 and before did not publish it. Each
68  // problem is "<name>: <status>".
69  connectors?: { at: number; ready: number; problems: string[] } | null
70}
71
72declare module 'claude-code' {
73  interface PluginState {
74    'drboom': { stats: Shaped<Stats | null>; sounds: Sounds | null; isBandHidden: boolean }
75    'workflow': { repo: Repo | null }
76  }
77}
78