Counter-Strike 1.6 radio calls for Claude Code: 'Fire in the hole' on deploys, 'Bomb has been defused' when a long turn lands. /radio to toggle.

Counter-Strike 1.6 radio calls for Claude Code.
| When | You hear |
|---|---|
| Session starts | "Locked and loaded" |
| A long turn finishes (45s+) | "Bomb has been defused" |
| A turn errors | "Negative" |
| You interrupt a turn | "Fall back" |
| A deploy starts | "Fire in the hole" |
| The deploy succeeds | "Counter-Terrorists win" |
| The deploy fails | "Terrorists win" |
A deploy is any command Claude runs that looks like one: pnpm/npm/yarn/bun run deploy, wrangler deploy, vercel --prod, fly deploy, scripts/deploy.sh.
In Claude Code:
/plugin marketplace add ben-rogerson/claude-counter-strike
/plugin install cs-radio@cs-radio
Or from your terminal:
claude plugin marketplace add ben-rogerson/claude-counter-strike
claude plugin install cs-radio@cs-radio
Function-hook plugins (mods) are early access in Claude Code, so you need a recent build.
Claude Counter-Strike looks for sounds in this order:
~/Library/Application Support/Steam/steamapps/common/Half-Life/cstrike/sound/radio~/.steam/steam/... or ~/.local/share/Steam/... (same path from steamapps on)If yours is somewhere else, set CS 1.6 radio folder in /config to the cstrike/sound/radio folder.
sounds/, used when no install is found. They were made for this repo with Kokoro (Apache-2.0) text-to-speech plus a radio filter, so they're free to redistribute. Regenerate them with uv run scripts/make-sounds.py (see the script header).Clip playback uses afplay, so wavs only play on macOS for now.
/radio - toggle on/off (remembered across sessions)/radio on / /radio off/radio test - plays "Counter-Terrorists win" and tells you where the sounds come from/config)cstrike/sound/radioclaude --plugin-dir . # load it from this checkout
claude plugin validate .
claude plugin test .
MIT for the code and the bundled soundalikes. Counter-Strike and its original sounds are Valve's.
hooks/register.ts 130 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// Counter-Strike 1.6 radio calls for Claude Code events. /radio [on|off|test] toggles,
4// kept across sessions.
5//
6// session start "Locked and loaded" locknload.wav
7// long turn finishes "Bomb has been defused" bombdef.wav
8// turn errors "Negative" negative.wav
9// turn interrupted "Fall back" fallback.wav
10// deploy starts "Fire in the hole" ct_fireinhole.wav
11// deploy ok / failed "Counter-Terrorists win" ctwin.wav / terwin.wav
12//
13// Valve's wavs can't ship here, so they're read from your own CS 1.6 install (soundDir
14// option, else the usual Steam folders). Without them it plays the soundalikes in sounds/
15// (scripts/make-sounds.py), and speaks the call if those can't play.
16
17const CALLS = {
18 locknload: 'Locked and loaded',
19 bombdef: 'Bomb has been defused',
20 negative: 'Negative',
21 fallback: 'Fall back',
22 ct_fireinhole: 'Fire in the hole',
23 ctwin: 'Counter-Terrorists win',
24 terwin: 'Terrorists win',
25} as const
26type Call = keyof typeof CALLS
27
28const STEAM_RADIO = [
29 'Library/Application Support/Steam/steamapps/common/Half-Life/cstrike/sound/radio',
30 '.steam/steam/steamapps/common/Half-Life/cstrike/sound/radio',
31 '.local/share/Steam/steamapps/common/Half-Life/cstrike/sound/radio',
32]
33
34const DEPLOY = /\b(?:pnpm|npm|yarn|bun)\s+(?:run\s+)?deploy\b|\bwrangler\s+deploy\b|\bvercel\s+(?:--prod|deploy)\b|\bfly\s+deploy\b|scripts\/deploy\.sh/
35
36let soundDir: string | undefined
37const clips = new Map<Call, string>()
38
39async function findSoundDir($: EngineInterface, configured: string) {
40 const home = (await $.env.get('HOME')) ?? ''
41 const candidates = configured
42 ? [configured.replace(/^~(?=\/)/, home)]
43 : STEAM_RADIO.map(p => `${home}/${p}`)
44 for (const dir of candidates) {
45 if (await $.fs.exists(`${dir}/ctwin.wav`)) return dir
46 }
47 return undefined
48}
49
50async function isOn($: EngineInterface) {
51 return (await $.store.get('enabled')) !== false
52}
53
54async function play($: EngineInterface, call: Call) {
55 if (!(await isOn($))) return
56 const fail = (err: unknown) => $.ui.log(`cs-radio: ${err}`, { to: 'debug' })
57 const speak = () => void $.audio.speak(CALLS[call]).catch(fail)
58 if (!soundDir) {
59 return void $.audio.play({ asset: `sounds/${call}.wav` }).catch(err => (fail(err), speak()))
60 }
61 try {
62 let base64 = clips.get(call)
63 if (!base64) {
64 const read = await $.fs.read(`${soundDir}/${call}.wav`, { as: 'bytes' })
65 base64 = read.base64
66 clips.set(call, base64)
67 }
68 void $.audio.play({ base64, mime: 'audio/wav' }).catch(fail)
69 } catch (err) {
70 fail(err)
71 speak()
72 }
73}
74
75export const register: Register = (on, options) => {
76 const longTurnMs = Number(options.longTurnSeconds ?? 45) * 1000
77
78 on('session.start', async ($, e, next) => {
79 soundDir = await findSoundDir($, String(options.soundDir ?? ''))
80 await $.command.register({
81 name: 'radio',
82 description: 'Toggle Counter-Strike radio calls',
83 argumentHint: '[on|off|test]',
84 immediate: true,
85 })
86 await play($, 'locknload')
87 return next(e)
88 })
89
90 on('command.run', { command: 'radio' }, async ($, e) => {
91 const arg = e.args.trim().toLowerCase()
92 const source = soundDir ? `wavs from ${soundDir}` : 'the bundled soundalikes (no CS 1.6 radio folder found, set soundDir in /config)'
93 if (arg === 'test') {
94 if (!(await isOn($))) return { text: 'Radio is off - /radio on first.' }
95 await play($, 'ctwin')
96 return { text: `Radio check: Counter-Terrorists win. Using ${source}.` }
97 }
98 if (arg && arg !== 'on' && arg !== 'off') return { text: 'Usage: /radio [on|off|test]' }
99 const now = arg ? arg === 'on' : !(await isOn($))
100 await $.store.set('enabled', now)
101 if (now) await play($, 'locknload')
102 return { text: `Radio ${now ? 'on' : 'off'}.` }
103 })
104
105 on('turn.complete', async ($, e, next) => {
106 if (e.reason === 'aborted') await play($, 'fallback')
107 else if (e.reason === 'error') await play($, 'negative')
108 else if (e.reason === 'answer' && e.durationMs >= longTurnMs) await play($, 'bombdef')
109 return next(e)
110 })
111
112 // deploys Claude runs through Bash
113 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
114 if (!DEPLOY.test(e.command) || e.run_in_background) return next(e)
115 await play($, 'ct_fireinhole')
116 const ran = await next(e)
117 if (ran.deny === undefined) await play($, ran.isError ? 'terwin' : 'ctwin')
118 return ran
119 })
120
121 // deploys other plugins spawn
122 on('process.spawn', async function* ($, e, next) {
123 if (!DEPLOY.test(e.argv.join(' '))) return yield* next(e)
124 await play($, 'ct_fireinhole')
125 const result = yield* next(e)
126 if (result.deny === undefined) await play($, result.value.code === 0 ? 'ctwin' : 'terwin')
127 return result
128 })
129}
130