A game announcer's call when Claude finishes a task, and a sound for what it does on the way (asks, commits, pushes, fails a test).

Claude Code mod: a game announcer's call and its sound when Claude finishes a task. A line in global CLAUDE.md (the voice's calls, each with when it fits) has Claude open its wrap-up with the fitting one, e.g. H E A D S H O T, and the mod plays it. A turn that edited files and whose last test/build/lint passed counts too. Along the way, a short sound marks what Claude does (action sounds, below).
The calls live in CLAUDE.md, not a mod prompt.compose section: since Claude Code 2.1.304 the managed cc-plugin-sec-default skips user-tier hooks there ("its prompt.compose hook was skipped"). Changing theme means rewriting that line from the voice's calls in themes/index.ts.
In Claude Code 2.1.275 or later:
/plugin marketplace add asfbr/claude-code-vibe-mods
/plugin install announcer@claude-code-vibe-mods
Or from a clone, in ~/.claude/settings.json (a clone is the better home for the game voices, below):
"env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/claude-code-vibe-mods/announcer" }
Add the calls to ~/.claude/CLAUDE.md (this is the UT99 voice's line):
- Announcer (mod `announcer`, UT99 voice): **every time a requested task is finished and its checks pass, open the wrap-up with one call in bold spaced caps** (`**H E A D S H O T**`), picked by the work: HEADSHOT (one precise fix), DOUBLE / TRIPLE KILL (two / three things done), MULTI KILL (several parts landed), MEGA / ULTRA / MONSTER KILL (big → huge job, all green), NICE CATCH (caught a bug before it shipped), TAKEN THE LEAD (green after red), ASSIST (helped the user's own fix), WINNER (shipped or deployed). Caveats or open follow-ups are no reason to skip it: say the call, then list them. Skip only when the task failed or is unfinished, and in subagents.
Build the game voices once (Python 3, stdlib only; ~50 MB of downloads, kept in the cache dir):
python scripts/sounds.py ~/.cache/announcer
Until then a win sounds the free pack's task-done clip. The voices are built into the mod's own folder, so on a marketplace install run the script from the installed copy (under ~/.claude/plugins/cache/claude-code-vibe-mods/announcer/), and again after each update, which installs a fresh copy (the downloads stay cached, so a rerun is quick). Restart Claude Code. claude plugin list should show announcer@... loaded.
/config)detect: both (default), marker (Claude's call only), tools (edits + green check; Claude's call is ignored, so drop the CLAUDE.md line too), off (no win calls at all: action sounds only, no CLAUDE.md line). The line costs ~150 tokens (cached) and ~10 per call. For win calls only, set actions to off.theme: ut99 (default), ut2003, ut2004-female, xan (the UT99 boss), quake3.sound: the win call's sound; off = the call is text only, and the action pack's task-done marks the win. volume: 100% … 10% (default 35%), for calls and actions alike.actions: free (default), personal (your own clips, the free one where you have none), off.spacing: any time (default), 2 min, 5 min, 15 min: an action that sounded this recently stays silent, its cue still showing (fewer edit, test and error sounds on a busy run). question, permission, force, destructive and the win always sound.push, big_refactor …: setting keys take _ for -): default follows actions; free, personal or off overrides it for that action alone. An action set off gets no cue either. These 17 rows show in /config only with each_action ("one row each") on; hidden, what they are set to still applies, and /announcer list shows it.cues (default on): each action's visual cue, below. Off = sounds only. cue_duration: 3 sec, 6 sec (default), 10 sec, 20 sec, 30 sec, how long it stays: longer leaves time to find which of several terminals just sounded.calls (default on): no sound, win calls included, while any app is using the microphone (a call, a meeting recorder, dictation), read from Windows' own microphone-use list; cues still show. Windows only.subagents (default off): off, spawning a subagent and its end notification (Agent "…" finished) make no sound and no cue; a background command's or a monitor's notification still does. A subagent's permission dialog always sounds: it waits on you.One win, one clip: Claude's own call, or, when Claude made none (a tools win, or a call the voice lacks), the streak's rung (FIRST BLOOD, KILLING SPREE … one up per win, the last repeating). A win while another win's clip plays stays silent; one while an action's clip plays waits for it. The hint row under the prompt (below a custom status line) keeps CALL · streak n; a win with no call in Claude's reply also gets a one-line toast. The streak counts wins per session; a failed check resets it.
What Claude does, not what it writes. One clip at a time: an action landing while one plays is dropped. A subagent's own calls make no sound. /announcer list shows each action's clip; /announcer play <action> plays it (to audition a pack) and waits for it, so a clip that cannot play says why in the reply.
Each action also gets a cue for cue_duration: a five-cell animation, its name and what triggered it (the command, the file, the tool), e.g. ──●── commit git commit -m "fix the login…". It draws as one line in the band directly above the prompt (the AbovePrompt site; the hint row under the prompt is not drawn while a custom status line is set). A survey keeps the band; a new cue replaces the one on screen, even when its clip was dropped. The motions and colours are in hooks/cues.ts.
| Action | When | |
|---|---|---|
question | Claude asks you (AskUserQuestion, a plan to approve) | |
permission | a permission dialog opens | |
subagent | Claude spawns a subagent (with subagents on) | |
edit / big-refactor | the first file edited in a turn / the tenth (once per turn each) | |
tests-fail / green-after-red | a test/build/lint run fails / passes after one failed (once per turn); a piped run (`npm test \ | tail) exits 0, so its summary (1 failed, error TS…`) counts too |
tool-error | an edit or an MCP call errors (once per turn) | |
commit | git commit succeeds: git prints the new commit (a piped commit a hook rejected still exits 0) | |
push / force / destructive | as git push, --force/--no-verify, or rm -rf, reset --hard, a force push, DROP TABLE start | |
install | a package install starts (npm, pip, uv, cargo, winget …) | |
compacted | the conversation is compacted | |
background-done | a background task's notification arrives (a subagent's only with subagents on) | |
interrupted | you interrupt a turn | |
task-done | a win, when the win call's sound is off |
Quoted text in a command is ignored when matching it: git commit -m "fix npm test" is a commit, not a test run. The free pack's root-cause clip has no action yet: nothing Claude does signals it. permission rides Claude Code's own PermissionRequest hook; a managed policy that skips user hooks there silences it.
python scripts/sounds.py <cache dir> fetches each source and rebuilds sounds/<theme>/ small (11025 Hz 8-bit mono, peak-normalised, silence trimmed; ~1.5 MB for all five). A new voice = an entry in its SOURCES, in themes/index.ts and in plugin.json's theme options.
Sounds/Announcer.uax, Sounds/BossVoice.uax on the UT GOTY ISO (archive.org/details/ut-goty).CD2/Sounds/AnnouncerMain.uax.uz2, announcerfemale2k4.uax.uz2 on the UT2004 DVD (archive.org/details/ut-2004-dvd), which Epic lets OldUnreal host.sound/feedback/ in the demo's pak0.pk3 (linuxq3ademo-1.11-6.x86.gz.sh on id's mirror at ftp.gwdg.de/pub/misc/ftp.idsoftware.com/idstuff/quake3/linux/, md5 484610c1ce34272223a52ec331c99d5d).The game voices are Epic's and id Software's, so the repo ships the script, not the clips: sounds/<theme>/ is gitignored and each machine builds its own. On Windows the clips play through PowerShell's SoundPlayer.PlaySync, the 8-bit samples scaled for volume (the engine's own player is silent there).
Action packs: scripts/packs.json lists one clip per action with its download link, licence, credit and an optional cut window in seconds. python scripts/packs.py <cache dir> [free|personal] builds:
sounds/free/: CC0, NASA and CC-BY clips, shippable, with CREDITS.md written from the same list (the CC-BY scream requires its credit).sounds/personal/: copyrighted clips, gitignored. The personal rows of packs.json are the download list: each machine (or anyone else) runs the script to fetch its own.Every build also writes sounds/index.html: each action, its cue (looping, read from hooks/cues.ts), when it fires and its clips, with a play button per clip that also plays the cue in a band at the bottom, to audition the packs in a browser (python scripts/packs.py board rewrites just that page).
Same small format, plus loudness matching and ~150 ms of silence either side so a player waking up does not eat the first word. It needs numpy, soundfile and soxr (pip install numpy soundfile soxr in a venv).
claude plugin validate . and claude plugin test . from this folder.
hooks/register.tsx 330 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import { THEMES } from '../themes'
5import type { Call, Theme } from '../themes'
6import type { ShownCue, WinLine } from '../types'
7import { ACTIONS, WHEN, afterRun, beforeRun, failedOutput, isCheck } from './actions'
8import type { Action } from './actions'
9import { CUES, CUE_MS, TICK_MS, base, cut } from './cues'
10
11const streak = atom({ plugin: 'announcer', key: 'streak' } as const, 0)
12const shown = atom({ plugin: 'announcer', key: 'cue' } as const, null as ShownCue | null)
13const winLine = atom({ plugin: 'announcer', key: 'win' } as const, null as WinLine | null)
14
15const EDITS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
16const SHELLS = new Set(['Bash', 'PowerShell'])
17const QUESTIONS = new Set(['AskUserQuestion', 'ExitPlanMode'])
18const SPAWNS = new Set(['Agent', 'Task'])
19// A subagent's end notification; a background command's reads `Background command "…"`, a monitor's `Monitor "…"`.
20const AGENT_DONE = /<summary>Agent "/
21const BIG = 10 // distinct files edited in one turn that make it a big refactor
22const THROWAWAY = /[\\/](scratchpad|temp|tmp)[\\/]/i
23// Claude's own call in its answer, e.g. **U N S T O P P A B L E**.
24const SPOKEN = /\*\*\s*([A-Z](?: +[A-Z]){2,})\s*\*\*/g
25
26const squash = (text: string) => text.replace(/\s+/g, '')
27const spaced = (text: string) => text.split(' ').map(w => w.split('').join(' ')).join(' ')
28
29// The call Claude wrote: one of the theme's calls, `true` for any other (a rung included), else undefined.
30const spoken = (answer: string, theme: Theme): Call | true | undefined => {
31 const said = [...answer.matchAll(SPOKEN)].map(m => squash(m[1] ?? ''))
32 if (said.length === 0) return undefined
33 return theme.calls.find(c => said.includes(squash(c.text))) ?? true
34}
35
36// The streak's rung: FIRST BLOOD at the first win, one up per win, the last repeating.
37const rung = (theme: Theme, n: number) => theme.spree[Math.min(n, theme.spree.length) - 1]!
38
39// One clip at a time. An action sound while one plays is dropped; a win waits for an action's clip to end,
40// and a win while another win plays stays silent rather than talking over it.
41let playing: Promise<unknown> | undefined
42let winPlaying = false
43let quietOnMic = true // the `calls` setting
44// Windows lists each app's microphone use; a LastUsedTimeStop of 0 is one using it now (a call, dictation).
45const MIC = `if (Get-ChildItem 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\CapabilityAccessManager\\ConsentStore\\microphone' -Recurse -ErrorAction SilentlyContinue | Where-Object { $_.GetValue('LastUsedTimeStop') -eq 0 }) { exit 3 }; `
46const MIC_IN_USE = 3
47// Resolves undefined once the clip played, else why it did not: a clip that cannot play never fails the turn.
48const play = async ($: EngineInterface, asset: string, volume: number): Promise<string | undefined> => {
49 try {
50 if (!/^[A-Za-z]:[\\/]/.test($.plugin.root)) return void await $.audio.play({ asset }, { gain: volume })
51 // Windows: the engine's player is silent there, and WPF's MediaPlayer never learns a clip's length in a
52 // console (no message loop), so SoundPlayer plays it whole, the 8-bit samples scaled for volume first.
53 const path = `${$.plugin.root}/${asset}`.replace(/'/g, "''")
54 const scale = volume >= 1 ? '' : `for ($i = 44; $i -lt $b.Length; $i++) { $b[$i] = [byte](128 + [int](($b[$i] - 128) * ${volume})) }; `
55 const ran = await $.process.run(['powershell.exe', '-NoProfile', '-NonInteractive', '-Command',
56 `${quietOnMic ? MIC : ''}$b = [IO.File]::ReadAllBytes('${path}'); ${scale}(New-Object Media.SoundPlayer([IO.MemoryStream]::new($b))).PlaySync()`],
57 { timeoutMs: 60_000 })
58 if (ran.exitCode === MIC_IN_USE) return 'silent: the microphone is in use (a call?)'
59 return ran.exitCode === 0 ? undefined : `powershell exit ${ran.exitCode}: ${cut(ran.stderr || ran.stdout || 'no output', 300)}`
60 } catch (err) {
61 return cut(err instanceof Error ? err.message : String(err), 300)
62 }
63}
64// False when dropped, another clip playing.
65const playAction = ($: EngineInterface, asset: string, volume: number) => {
66 if (playing) return false
67 playing = play($, asset, volume).finally(() => { playing = undefined })
68 return true
69}
70const playWin = async ($: EngineInterface, asset: string, volume: number) => {
71 if (winPlaying) return
72 winPlaying = true
73 try {
74 await playing
75 playing = play($, asset, volume).finally(() => { playing = undefined })
76 await playing
77 } finally {
78 winPlaying = false
79 }
80}
81
82type Choice = 'free' | 'personal' | 'off'
83type Pack = {
84 fallback: Choice // the `actions` setting, for every action left on `default`
85 choice: Record<Action, Choice> // each action's own setting, `default` resolved
86 volume: number
87 found: Map<Action, string[]>
88 played: Map<Action, number>
89 heard: Set<Action> // once-per-turn actions already sounded this turn
90 spacing: number // the `spacing` setting: an action sounds again only this long after it last did
91 soundedAt: Map<Action, number>
92 cues: boolean
93 cueMs: number // the `cue_duration` setting: how long a cue stays on screen
94 timer?: Timer // the cue on screen's frames
95 shownId: number
96}
97
98// An action's clips: personal first when that pack is chosen (it is gitignored, so it may be missing),
99// else the free pack; `<action>-2` a variant played in turn. None for an action no pack has. An action set
100// off still has the clips it would play, for `/announcer play`.
101async function clips($: EngineInterface, pack: Pack, action: Action) {
102 const cached = pack.found.get(action)
103 if (cached) return cached
104 const from = async (folder: string) => {
105 const ids = [`sounds/${folder}/${action}.wav`, `sounds/${folder}/${action}-2.wav`]
106 const ok = await Promise.all(ids.map(id => $.fs.exists(`${$.plugin.root}/${id}`).catch(() => false)))
107 return ids.filter((_, i) => ok[i])
108 }
109 const chosen = pack.choice[action] === 'off' ? pack.fallback : pack.choice[action]
110 const personal = chosen === 'personal' ? await from('personal') : []
111 const list = personal.length > 0 ? personal : await from('free')
112 pack.found.set(action, list)
113 return list
114}
115
116async function clipFor($: EngineInterface, pack: Pack, action: Action) {
117 const list = await clips($, pack, action)
118 const n = pack.played.get(action) ?? 0
119 pack.played.set(action, n + 1)
120 return list[n % Math.max(1, list.length)]
121}
122
123// Shows an action's cue above the prompt for the `cue_duration` setting, replacing the one on screen; a timer
124// steps its frames and stops with it, so nothing runs while idle.
125async function cue($: EngineInterface, pack: Pack, action: Action, detail: string) {
126 if (!pack.cues) return
127 pack.timer?.cancel()
128 const id = ++pack.shownId
129 let frame = 0
130 try {
131 await update($, shown, () => ({ id, action, detail: cut(detail), frame }))
132 const timer = $.clock.every(TICK_MS, () => {
133 frame += 1
134 const done = frame * TICK_MS >= pack.cueMs
135 if (done) timer.cancel()
136 void update($, shown, c => c?.id !== id ? c : done ? null : { ...c, frame })
137 })
138 pack.timer = timer
139 } catch {
140 await update($, shown, () => null).catch(() => {}) // a cue that cannot animate never stays frozen
141 }
142}
143
144// These wait on you or warn you, so they sound every time, whatever `spacing` says.
145const UNSPACED = new Set<Action>(['question', 'permission', 'force', 'destructive', 'task-done'])
146
147// The action's clip, then its cue: an action dropped while another clip plays, or too soon after its last
148// sound (`spacing`), still shows, replacing that cue.
149async function sound($: EngineInterface, pack: Pack, action: Action, detail = '', once = false) {
150 if (pack.choice[action] === 'off' || (once && pack.heard.has(action))) return
151 pack.heard.add(action)
152 const now = await $.clock.now()
153 const soon = !UNSPACED.has(action) && now - (pack.soundedAt.get(action) ?? -Infinity) < pack.spacing
154 const clip = soon ? undefined : await clipFor($, pack, action)
155 if (clip !== undefined && playAction($, clip, pack.volume)) pack.soundedAt.set(action, now)
156 await cue($, pack, action, detail)
157}
158
159export const register: Register = (on, options) => {
160 const theme = THEMES[String(options.theme)] ?? THEMES.ut99!
161 const detect = String(options.detect)
162 const volume = Math.min(100, parseInt(String(options.volume), 10) || 35) / 100 // "35%" → 0.35
163 const fallback = String(options.actions ?? 'free') as Choice
164 const own = (a: Action) => String(options[a.replace(/-/g, '_')] ?? 'default') // setting keys take no hyphens
165 const choice = Object.fromEntries(ACTIONS.map(a => [a, own(a) === 'default' ? fallback : own(a)])) as Record<Action, Choice>
166 const cues = options.cues !== false
167 const cueMs = parseInt(String(options.cue_duration), 10) * 1000 || CUE_MS // "6 sec" → 6000
168 const subagents = options.subagents === true // a subagent's spawn and its end notification sound too
169 quietOnMic = options.calls !== false
170 const spacing = (parseInt(String(options.spacing), 10) || 0) * 60_000 // "5 min" → 300000; "any time" → 0
171 const eachAction = options.each_action === true // the per-action rows show in /config
172 const pack: Pack = { fallback, choice, volume, found: new Map(), played: new Map(), heard: new Set(), spacing,
173 soundedAt: new Map(), cues, cueMs, shownId: 0 }
174 let edited = false
175 let check: boolean | undefined // the last check of the turn: passed, failed, or none ran
176 let lastCheck: boolean | undefined // the last check of any turn, for green after red
177 let files = new Set<string>()
178
179 on('session.start', async ($, e, next) => {
180 await $.command.register({
181 name: 'announcer',
182 description: "List what each action sound fires on, or hear one",
183 argumentHint: 'list | <action>',
184 immediate: true,
185 })
186 await update($, shown, () => null) // a reload stops a cue's timer mid-way; never leave it frozen on screen
187 return next(e)
188 })
189
190 // Each action's /config row says what its `default` stands for now: the `actions` setting's pack. The 17
191 // rows show only with `each_action` on; hidden, they still apply and still answer /config key=value.
192 on('config.describe', async ($, e, next) => {
193 const field = /^announcer(?:@[\w-]+)?\.(\w+)$/.exec(e.key)?.[1]
194 const shown = await next(e)
195 if (field === undefined || !ACTIONS.includes(field.replace(/_/g, '-') as Action)) return shown
196 return { ...shown, label: `${shown.label} (default = ${fallback})`, isHidden: shown.isHidden || !eachAction }
197 })
198
199 // Under the prompt, below the custom status line: the last win's line, dim at the hint row's end.
200 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
201 const win = await read($, winLine)
202 return win === null || e.surface !== 'terminal' ? next(e) : next({ ...e, props: { ...e.props, tail: win } })
203 })
204
205 // The cue: one line directly above the prompt, while its sound plays. The hint row under the status line
206 // is not drawn while a custom status line is set, so a cue there never showed.
207 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
208 const now = await read($, shown)
209 if (now === null || e.props.hasSurvey) return next(e)
210 const look = CUES[now.action as Action]
211 if (look === undefined) return next(e)
212 const { Box, Text } = $.ui.resolve(e)
213 return (
214 <Box>
215 <Text color={look.color}>{look.frames[now.frame % look.frames.length]} {now.action}</Text>
216 <Text dimColor wrap="truncate">{now.detail === '' ? '' : ` ${now.detail}`}</Text>
217 </Box>
218 )
219 })
220
221 on('command.run', { command: 'announcer' }, async ($, e) => {
222 const words = e.args.trim().split(/\s+/)
223 const [verb, name] = words.length === 1 && ACTIONS.includes(words[0] as Action) ? ['play', words[0]] : words // `/announcer push` plays it
224 if (verb === 'play' && ACTIONS.includes(name as Action)) {
225 const action = name as Action
226 const clip = await clipFor($, pack, action)
227 await cue($, pack, action, 'audition')
228 if (clip === undefined) return { text: `${name}: no clip in the ${pack.choice[action] === 'personal' ? 'personal or free' : 'free'} pack` }
229 const failed = await play($, clip, volume) // awaited, so a clip that cannot play says why
230 const off = pack.choice[action] === 'off' ? ' (set off: silent when it happens)' : ''
231 return { text: `${name}: ${clip}${off}${failed === undefined ? '' : `, did not play: ${failed}`}` }
232 }
233 if (verb === 'list') {
234 const rows = await Promise.all(ACTIONS.map(async a => {
235 const files = (await clips($, pack, a)).map(f => f.replace(/^sounds\//, '')).join(', ')
236 return `${a} [${pack.choice[a]}]: ${WHEN[a]} → ${pack.choice[a] === 'off' ? 'off' : files || 'silent, no clip'}`
237 }))
238 const where = eachAction ? 'its own row in /config' : "/config, with 'Action sounds: one row each' on"
239 const gap = spacing > 0 ? `; each sounds again ${spacing / 60_000} min after it last did, but ${[...UNSPACED].join(', ')}` : ''
240 return { text: `Action sounds [pack] (default ${pack.fallback}; set one in ${where}${gap}), when → clip:\n${rows.join('\n')}` }
241 }
242 const asked = e.args.trim() === '' ? '' : `no action "${name ?? verb}". `
243 return { text: `${asked}/announcer list, or /announcer <action> to hear one: ${ACTIONS.join(', ')}` }
244 })
245
246 // A subagent's permission dialog sounds too, whatever `subagents` says: it waits on you.
247 on('classic.PermissionRequest', async ($, e, next) => {
248 const input = (e.tool_input ?? {}) as { command?: string; file_path?: string }
249 await sound($, pack, 'permission', `${e.tool_name} ${input.command ?? (input.file_path ? base(input.file_path) : '')}`)
250 return next(e)
251 })
252 on('session.compact', async ($, e, next) => {
253 if (e.agentId === undefined && e.trigger !== 'precompute') await sound($, pack, 'compacted', e.trigger)
254 return next(e)
255 })
256 on('prompt.submit', async ($, e, next) => {
257 if ((e.origin as { kind?: string } | undefined)?.kind === 'task-notification' && (subagents || !AGENT_DONE.test(e.text))) {
258 await sound($, pack, 'background-done', /<summary>([^<]*)<\/summary>/.exec(e.text)?.[1] ?? '')
259 }
260 return next(e)
261 }).catch(($, e, next) => next(e))
262
263 on('turn.start', ($, e, next) => {
264 if (e.agentId === undefined) { edited = false; check = undefined; files = new Set(); pack.heard = new Set() }
265 return next(e)
266 })
267
268 on('tool.call', async ($, e, next) => {
269 const tool = String(e.tool)
270 const main = e.agentId === undefined // a subagent's own calls make no sound
271 const input = e as unknown as {
272 file_path?: string; notebook_path?: string; command?: string; run_in_background?: boolean
273 description?: string; questions?: { question?: string }[]
274 }
275 const command = input.command ?? ''
276 if (main && QUESTIONS.has(tool)) await sound($, pack, 'question', tool === 'ExitPlanMode' ? 'a plan to approve' : input.questions?.[0]?.question ?? '')
277 if (main && subagents && SPAWNS.has(tool)) await sound($, pack, 'subagent', input.description ?? '')
278 if (main && SHELLS.has(tool)) { const a = beforeRun(command); if (a) await sound($, pack, a, command) }
279 const ran = await next(e)
280 const isOk = ran.deny === undefined && ran.isError !== true
281 if (EDITS.has(tool)) {
282 const path = input.file_path ?? input.notebook_path ?? ''
283 if (isOk && !THROWAWAY.test(path)) {
284 edited = true
285 files.add(path)
286 if (main) await sound($, pack, files.size >= BIG ? 'big-refactor' : 'edit', files.size >= BIG ? `${files.size} files this turn` : base(path), true)
287 } else if (main && ran.deny === undefined && ran.isError === true) await sound($, pack, 'tool-error', `${tool} ${base(path)}`, true)
288 } else if (SHELLS.has(tool)) {
289 const result = ran.result as { stdout?: string; stderr?: string; interrupted?: boolean } | undefined
290 if (isCheck(command) && !input.run_in_background) {
291 check = isOk && result?.interrupted !== true && !failedOutput(`${result?.stdout ?? ''}\n${result?.stderr ?? ''}`)
292 if (main && check === false) await sound($, pack, 'tests-fail', command, true)
293 else if (main && lastCheck === false) await sound($, pack, 'green-after-red', command, true)
294 lastCheck = check
295 } else if (main && !input.run_in_background) {
296 const a = afterRun(command, isOk, `${result?.stdout ?? ''}
297${result?.stderr ?? ''}`)
298 if (a) await sound($, pack, a, command)
299 }
300 } else if (main && tool.startsWith('mcp__') && ran.deny === undefined && ran.isError === true) {
301 await sound($, pack, 'tool-error', tool.replace(/^mcp__/, ''), true)
302 }
303 return ran
304 }).catch(($, e, next) => next(e)) // only watches: a failure here never blocks the tool
305
306 // One win, one clip: Claude's own call, else the streak's rung. Claude's call line stays in its reply.
307 // With the call's sound off, the action pack's task-done sounds the win instead.
308 on('turn.complete', async ($, e, next) => {
309 if (e.agentId !== undefined) return next(e)
310 if (e.isAborted) { await sound($, pack, 'interrupted', 'you stopped the turn'); return next(e) }
311 if (detect === 'off' || e.reason === 'error' || e.reason === 'refusal') return next(e) // a dead turn is no win
312 const said = detect === 'tools' ? undefined : spoken(e.answer, theme)
313 const earned = detect !== 'marker' && edited && check === true
314 if (said === undefined && !earned) {
315 if (detect !== 'marker' && check === false) await update($, streak, () => 0) // a failed check ends the spree
316 return next(e)
317 }
318 const n = await update($, streak, s => s + 1)
319 const call = (typeof said === 'object' ? said : undefined) ?? rung(theme, n)
320 await update($, winLine, () => `${call.text} · streak ${n}`) // drawn under the prompt (PromptHint above)
321 if (said === undefined) $.ui.toast(spaced(call.text), { timeoutMs: 6000 }) // nothing in the reply shows it
322 // The game voices are built locally (scripts/sounds.py); until then the win sounds task-done.
323 const voice = `sounds/${theme.id}/${call.clip}.wav`
324 const built = options.sound === true && await $.fs.exists(`${$.plugin.root}/${voice}`).catch(() => false)
325 if (built) void playWin($, voice, volume)
326 else await sound($, pack, 'task-done', call.text)
327 return next(e)
328 })
329}
330themes/index.ts 79 lines1// A theme is a folder of clips under sounds/<id>/ (built by scripts/sounds.py) plus its calls;
2// add one here, to plugin.json's theme options and to SOURCES in the script.
3export type Call = { text: string; clip: string; when?: string }
4export type Theme = {
5 id: string
6 name: string // how the prompt section names the voice
7 spree: readonly Call[] // the streak's rungs, the mod's own; the last repeats
8 calls: readonly Call[] // what Claude picks from, each with when it fits
9}
10
11// One "when" per clip id, shared by every theme that has the clip: what the finished task looked like.
12const WHEN: Record<string, string> = {
13 headshot: 'one precise fix', accuracy: 'one precise fix', doublekill: 'two things done', triplekill: 'three things done',
14 multikill: 'several parts landed', megakill: 'a big multi-part job', ultrakill: 'a bigger one, all green',
15 monsterkill: 'a huge job, all green', ludicrouskill: 'an absurdly large job', holyshit: 'far beyond the ask',
16 nicecatch: 'caught a bug before it shipped', eagleeye: 'spotted a subtle bug', denied: 'stopped a bad change or premise',
17 takenlead: 'green again after red', assist: "helped the user's own fix", winner: 'shipped or deployed',
18 wonmatch: 'shipped or deployed', youwin: 'shipped or deployed', flawless: 'right first try, nothing to fix',
19 perfect: 'right first try, nothing to fix', hattrick: 'third of a kind in a row', lastsecondsave: 'saved at the last moment',
20 averted: 'dodged a disaster', brutalmatch: 'a long hard fight', topgun: 'the best possible result',
21 pancake: 'flattened a tangled mess', humiliation: 'a trivial fix for a nasty bug', eliminated: 'a bug gone for good',
22 obsolete: 'old code replaced', useless: 'dead code deleted', stepaside: 'a rewrite took over', superior: 'a better design',
23 perfection: 'right first try, nothing to fix', bowdown: 'a huge win', fearme: 'a scary job done',
24 inferior: 'beat a slower way', myhouse: 'a codebase-wide cleanup', hadtohurt: 'a painful migration',
25}
26
27const call = (text: string, clip: string): Call => ({ text, clip, when: WHEN[clip] })
28const LADDER = [
29 call('FIRST BLOOD', 'firstblood'), call('KILLING SPREE', 'killingspree'), call('RAMPAGE', 'rampage'),
30 call('DOMINATING', 'dominating'), call('UNSTOPPABLE', 'unstoppable'), call('GODLIKE', 'godlike'),
31]
32const KILLS = [
33 call('HEADSHOT', 'headshot'), call('DOUBLE KILL', 'doublekill'), call('MULTI KILL', 'multikill'),
34 call('MEGA KILL', 'megakill'), call('MONSTER KILL', 'monsterkill'),
35]
36const UT2K = [
37 call('LUDICROUS KILL', 'ludicrouskill'), call('HOLY SHIT', 'holyshit'), call('FLAWLESS VICTORY', 'flawless'),
38 call('HAT TRICK', 'hattrick'), call('LAST SECOND SAVE', 'lastsecondsave'), call('WON THE MATCH', 'wonmatch'),
39]
40
41export const THEMES: Record<string, Theme> = {
42 ut99: {
43 id: 'ut99', name: 'UT99 announcer', spree: LADDER,
44 calls: [
45 ...KILLS, call('TRIPLE KILL', 'triplekill'), call('ULTRA KILL', 'ultrakill'), call('NICE CATCH', 'nicecatch'),
46 call('TAKEN THE LEAD', 'takenlead'), call('ASSIST', 'assist'), call('WINNER', 'winner'),
47 ],
48 },
49 ut2003: {
50 id: 'ut2003', name: 'UT2003 announcer', spree: [...LADDER, call('WICKED SICK', 'wickedsick')],
51 calls: [
52 ...KILLS, call('TRIPLE KILL', 'triplekill'), call('ULTRA KILL', 'ultrakill'), ...UT2K,
53 call('NARROWLY AVERTED', 'averted'), call('BRUTAL MATCH', 'brutalmatch'),
54 ],
55 },
56 'ut2004-female': {
57 id: 'ut2004-female', name: 'UT2004 female announcer', spree: [...LADDER, call('WICKED SICK', 'wickedsick')],
58 calls: [
59 ...KILLS, ...UT2K, call('DENIED', 'denied'), call('EAGLE EYE', 'eagleeye'), call('TOP GUN', 'topgun'),
60 call('PANCAKE', 'pancake'),
61 ],
62 },
63 xan: {
64 id: 'xan', name: 'Xan, the UT99 boss', spree: [call('NEXT', 'next'), call('BOOM', 'boom'), call('BURN BABY', 'burnbaby')],
65 calls: [
66 call('ELIMINATED', 'eliminated'), call('OBSOLETE', 'obsolete'), call('USELESS', 'useless'), call('STEP ASIDE', 'stepaside'),
67 call('SUPERIOR', 'superior'), call('INFERIOR', 'inferior'), call('PERFECTION', 'perfection'), call('BOW DOWN', 'bowdown'),
68 call('FEAR ME', 'fearme'), call('MY HOUSE', 'myhouse'), call('HAD TO HURT', 'hadtohurt'),
69 ],
70 },
71 quake3: {
72 id: 'quake3', name: 'Quake III announcer', spree: [call('EXCELLENT', 'excellent'), call('IMPRESSIVE', 'impressive')],
73 calls: [
74 call('ACCURACY', 'accuracy'), call('PERFECT', 'perfect'), call('DENIED', 'denied'), call('HUMILIATION', 'humiliation'),
75 call('TAKEN THE LEAD', 'takenlead'), call('YOU WIN', 'youwin'),
76 ],
77 },
78}
79hooks/actions.ts 59 lines1// What Claude did, as the actions the packs sound (sounds/<pack>/<action>.wav, listed in scripts/packs.json).
2export const ACTIONS = [
3 'question', 'permission', 'subagent', 'edit', 'big-refactor', 'tests-fail', 'green-after-red', 'tool-error',
4 'commit', 'push', 'force', 'destructive', 'install', 'compacted', 'background-done',
5 'task-done', 'interrupted',
6] as const
7export type Action = (typeof ACTIONS)[number]
8
9// When each fires, as `/announcer list` tells it; the README's table says the same.
10export const WHEN: Record<Action, string> = {
11 question: 'Claude asks you (AskUserQuestion, a plan to approve)',
12 permission: 'a permission dialog opens',
13 subagent: 'Claude spawns a subagent (with the subagents setting on)',
14 edit: "the turn's first file edit",
15 'big-refactor': 'the tenth file edited in one turn',
16 'tests-fail': 'a test/build/lint run fails, by its exit code or its summary (once a turn)',
17 'green-after-red': 'a test/build/lint run passes after one failed',
18 'tool-error': 'an edit or an MCP call errors (once a turn)',
19 commit: 'git commit succeeds (git prints the new commit)',
20 push: 'git push starts',
21 force: 'a command with --force or --no-verify starts',
22 destructive: 'rm -rf, git reset --hard, git clean -f, a force push or DROP TABLE starts',
23 install: 'a package install starts (npm, pip, uv, cargo, winget …)',
24 compacted: 'the conversation is compacted',
25 'background-done': "a background task's notification arrives (a subagent's only with the subagents setting on)",
26 'task-done': "a win, when the win call's own sound is off",
27 interrupted: 'you interrupt a turn',
28}
29
30// A test/build/lint/typecheck run: a runner then a check word, or a checker by name.
31const CHECK = /\b(npm|pnpm|yarn|bun|npx|node|deno|python3?|py|uv|poetry|cargo|go|dotnet|make|gradle|mvn|claude plugin)\b.*\b(test|tests|lint|typecheck|build|check|vet|clippy)\b|\b(pytest|vitest|jest|mocha|tsc|eslint|ruff|mypy|pyright)\b/
32// Quoted text is what a command says, not what it runs: `git commit -m "fix npm test"` runs no tests.
33const QUOTED = /(["'])(?:\\[\s\S]|(?!\1)[\s\S])*\1/g
34const unquoted = (command: string) => command.replace(QUOTED, '""')
35export const isCheck = (command: string) => CHECK.test(unquoted(command))
36// A piped check (`npm test 2>&1 | tail`) exits with the pipe's last command, 0, so its own summary tells a failure:
37// vitest/jest/pytest/mocha counts, jest/go/pytest FAIL lines, tsc, ruff/mypy, eslint errors (not warnings), cargo, vite.
38const FAILED = /\b[1-9]\d* (failed|failing)\b|^\s*FAIL(ED)?\b|\berror TS\d+|\bFound [1-9]\d* errors?\b|✖ \d+ problems? \([1-9]|\btest result: FAILED\b|\berror during build\b/m
39export const failedOutput = (output: string) => FAILED.test(output)
40
41const DESTRUCTIVE = /\brm\s+-[a-z]*(r[a-z]*f|f[a-z]*r)|\bRemove-Item\b.*-Recurse|\bgit\s+reset\s+--hard|\bgit\s+clean\s+-[a-z]*f|\bgit\s+push\b.*\s(--force(-with-lease)?|-f)\b|\bdrop\s+(table|database|schema)\b/i
42const FORCE = /\s(--no-verify|--force)\b/
43const PUSH = /\bgit\s+push\b/
44const INSTALL = /\b(npm|pnpm|yarn|bun)\s+(i|install|add)\b|\bpip3?\s+install\b|\buv\s+(add|pip\s+install)\b|\bpoetry\s+add\b|\bcargo\s+(add|install)\b|\b(winget|choco|scoop|brew|apt(-get)?)\s+install\b|\bgo\s+(get|install)\b|\bgem\s+install\b/
45const COMMIT = /\bgit\s+commit\b/
46
47// The sound as a command starts, at most one: a force push is destructive, not a push.
48export const beforeRun = (command: string): Action | undefined => {
49 const runs = unquoted(command)
50 return DESTRUCTIVE.test(runs) ? 'destructive' : FORCE.test(runs) ? 'force' : PUSH.test(runs) ? 'push'
51 : INSTALL.test(runs) ? 'install' : undefined
52}
53
54// The sound once it has run, from its result. A piped commit (`git commit … | tail`) exits 0 even when a hook
55// rejects it, so it counts only once git prints the new commit, `[main 1a2b3c4] …`, or nothing at all (`-q`).
56const LANDED = /^\[[^\]\n]+ [0-9a-f]{4,}\] /m
57export const afterRun = (command: string, isOk: boolean, output: string): Action | undefined =>
58 isOk && COMMIT.test(unquoted(command)) && (LANDED.test(output) || output.trim() === '') ? 'commit' : undefined
59hooks/cues.ts 41 lines1import type { Action } from './actions'
2
3// The visual cue each action sound gets: a few-frame motion five cells wide, in its family's theme colour.
4// Families share a colour; checks and turn ends take their outcome's (success / error) instead.
5export type Cue = { color: string; frames: readonly string[] }
6
7const SESSION = 'ide'
8const ASKS = 'permission'
9const WORK = 'claude'
10const GIT = 'merged'
11
12export const CUES: Record<Action, Cue> = {
13 compacted: { color: SESSION, frames: ['◀ ▶', ' ◀ ▶ ', ' ◆ ', ' ◆ '] }, // walls close in
14 'background-done': { color: SESSION, frames: [' ●', ' ● ', ' ● ', ' ● ', '● '] }, // comes back to you
15 question: { color: ASKS, frames: [' ? ', ' ‹?› ', '‹ ? ›', ' ‹?› '] }, // pulses at you
16 permission: { color: ASKS, frames: ['▐███▌', '▐█ █▌', '▐ ▌', '▐█ █▌'] }, // a gate, waiting to open
17 subagent: { color: WORK, frames: [' ● ', ' ●○ ', ' ● ○ ', '● ○', '● '] }, // sends one out
18 edit: { color: WORK, frames: ['▏ ', '━▏ ', '━━▏ ', '━━━▏ ', '━━━━▏'] }, // a pen writes a line
19 'big-refactor': { color: WORK, frames: ['▚▞▚▞▚', '▞▚▞▚▞', '▙▟▙▟▙', '▛▜▛▜▛'] }, // blocks reshuffle
20 install: { color: WORK, frames: ['▱▱▱▱▱', '▰▱▱▱▱', '▰▰▱▱▱', '▰▰▰▱▱', '▰▰▰▰▱', '▰▰▰▰▰'] }, // a bar fills
21 'tests-fail': { color: 'error', frames: [' ╳ ', ' ╳ ╳ ', '╳ ╳ ╳', ' ╳ ╳ '] }, // crosses flash
22 'green-after-red': { color: 'success', frames: ['╳ ', '╳ › ', '╳ › ›', ' ✓', ' ✓ '] }, // red, then through
23 'tool-error': { color: 'error', frames: ['░▒▓▒░', '▒▓░▓▒', '▓░▒░▓'] }, // static
24 commit: { color: GIT, frames: ['──○──', '──◎──', '──●──', '──●──'] }, // a node fills on the line
25 push: { color: GIT, frames: ['» ', ' »» ', ' »»»', ' »»', ' »'] }, // goes out the right edge
26 force: { color: 'warning', frames: ['▶ ', '▶▶ ', '▶▶▶ ', '▶▶▶▶ ', '▶▶▶▶▶'] }, // shoves through
27 destructive: { color: 'error', frames: ['█████', '█▓█▓█', '▓▒▓▒▓', '▒░▒░▒', '░ ░ ░'] }, // crumbles away
28 'task-done': { color: 'success', frames: [' ✦ ', ' ·✦· ', '· ✦ ·', ' ·✦· '] }, // a star bursts
29 interrupted: { color: 'inactive', frames: ['━━━━━', '━━━╸ ', '━━╸ ', '━╸ ', '╸ '] }, // a line cut short
30}
31
32export const TICK_MS = 130 // one frame
33export const CUE_MS = 6000 // how long a cue stays by default: `cue_duration`'s default, and the audition page's
34
35// What triggered it, one line and short: a command, a file, a tool.
36export const cut = (text: string, max = 48) => {
37 const line = text.replace(/\s+/g, ' ').trim()
38 return line.length > max ? `${line.slice(0, max - 1)}…` : line
39}
40export const base = (path: string) => path.split(/[\\/]/).pop() ?? path
41types/index.d.ts 12 lines1export type Streak = number
2// The cue on screen: which action, what triggered it, its frame; `id` tells a newer cue from the one it replaced.
3export type ShownCue = { id: number; action: string; detail: string; frame: number }
4// The last win's line, `HEADSHOT · streak 3`, kept under the prompt.
5export type WinLine = string
6
7declare module 'claude-code' {
8 interface PluginState {
9 announcer: { streak: Streak; cue: ShownCue | null; win: WinLine | null }
10 }
11}
12