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

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).
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.
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.
/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.
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.
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
}
/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 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.
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.
/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 profileDRBOOM_SOUNDS_MUTE=1 turns sounds offnpm 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
MIT. See LICENSE.
hooks/register.tsx 433 lines1import { 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}
433types/index.d.ts 78 lines1// 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