SLOPSHOPPER

cs-radio

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.

newguardcommandaudio
v0.1.0MITupdated 2026-10-04ben-rogerson/claude-counter-strike
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cs-radio
› 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 › /radio ⎿ cs-radio: Radio off. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Claude Counter-Strike

Counter-Strike 1.6 radio calls for Claude Code.

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

Install

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.

Sounds

Claude Counter-Strike looks for sounds in this order:

  1. Valve's originals from your own Counter-Strike 1.6 install (they're not in this repo). It checks the usual Steam folders:
  2. macOS: ~/Library/Application Support/Steam/steamapps/common/Half-Life/cstrike/sound/radio
  3. Linux: ~/.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.

  1. Bundled soundalikes in 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).
  2. Your system voice, if a clip can't play.

Clip playback uses afplay, so wavs only play on macOS for now.

Commands

  • /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

Options (/config)

  • CS 1.6 radio folder - path to cstrike/sound/radio
  • Long turn (seconds) - how long a turn runs before "Bomb has been defused" plays. Default 45.

Develop

claude --plugin-dir .          # load it from this checkout
claude plugin validate .
claude plugin test .

License

MIT for the code and the bundled soundalikes. Counter-Strike and its original sounds are Valve's.

Source 1 files
hooks/register.ts 130 lines
1import 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