SLOPSHOPPER

announcer

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).

newbandspinnerguardcommandtoast
★ 1v0.3.2MITupdated 2026-10-24asfbr/claude-code-vibe-mods/announcer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · announcer
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /announcer ⎿ announcer: /announcer list, or /announcer <action> to hear one: question, permission, subagent, edit, big-refactor, tests █████ destructive rm -rf build && git push --force origin main ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
█████ destructive rm -rf build && git push --force origin main
README

announcer

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.

Setup (once per machine)

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.

Options (/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.
  • One setting per action (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.

Action sounds

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.

ActionWhen
questionClaude asks you (AskUserQuestion, a plan to approve)
permissiona permission dialog opens
subagentClaude spawns a subagent (with subagents on)
edit / big-refactorthe first file edited in a turn / the tenth (once per turn each)
tests-fail / green-after-reda 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-erroran edit or an MCP call errors (once per turn)
commitgit commit succeeds: git prints the new commit (a piped commit a hook rejected still exits 0)
push / force / destructiveas git push, --force/--no-verify, or rm -rf, reset --hard, a force push, DROP TABLE start
installa package install starts (npm, pip, uv, cargo, winget …)
compactedthe conversation is compacted
background-donea background task's notification arrives (a subagent's only with subagents on)
interruptedyou interrupt a turn
task-donea 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.

Sounds

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.

  • UT99 and Xan: Sounds/Announcer.uax, Sounds/BossVoice.uax on the UT GOTY ISO (archive.org/details/ut-goty).
  • UT2003 male, UT2004 female: CD2/Sounds/AnnouncerMain.uax.uz2, announcerfemale2k4.uax.uz2 on the UT2004 DVD (archive.org/details/ut-2004-dvd), which Epic lets OldUnreal host.
  • Quake III: 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.

Source 5 files
hooks/register.tsx 330 lines
1import { 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}
330
themes/index.ts 79 lines
1// 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}
79
hooks/actions.ts 59 lines
1// 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
59
hooks/cues.ts 41 lines
1import 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
41
types/index.d.ts 12 lines
1export 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