SLOPSHOPPER

Radio

A chime when Claude finishes a long turn, so you can look away.

commandprocessaudio
v0.3.0MITupdated 2026-10-11DJPalefaceSD/rostech-mods/plugins/radio
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · 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 › /chime ⎿ radio: Radio is on. A chime plays when a turn longer than 20 seconds finishes. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Radio

A chime when Claude finishes a long turn, so you can look away while it works and come back when it is done.

The chime plays only when a turn took longer than 20 seconds, so quick answers stay quiet.

  • /chime sound C:/path/to/chime.wav makes it play your own sound. It plays the file once so you hear it, then keeps it across sessions.
  • /chime sound default goes back to the bundled chime.
  • /chime test plays the done chime, then the whistle.
  • /chime off and /chime on switch it.
  • /chime says whether it is on, and which sound it plays.

The command was /radio until 0.3.0. Claude Code now has a /radio of its own, so the mod answers to /chime. Your saved sound carries over.

Your own sound has to be a .wav file. On Windows it is played with Windows' own SoundPlayer, through PowerShell, because Claude Code's player does not play in a Windows terminal. The path is passed as a setting, never inside the command. Elsewhere Claude Code plays the bundled chime. It reads only the sound file you name and sends nothing anywhere.

What it runs and sends

Nothing goes anywhere. Here is each thing, plainly.

What it sends, and where.

  • Nothing leaves your machine. The only thing it sends is a sound file's path, to the player program below.
  • It keeps one value on this machine, in Claude Code's plugin store: the path of your own sound (ownSound), if you set one.
  • The ways out are two Claude Code calls. $.process.run starts the player program below on your machine. $.audio.play is Claude Code's own sound player, which plays a bundled sound.

Which programs it runs, and why.

  • One program, on Windows, to play the sound, because Claude Code's own player does not play in a Windows terminal:
  • powershell.exe -NoProfile -Command "(New-Object Media.SoundPlayer $env:RADIO_FILE).PlaySync()"
  • The command is fixed text. The sound file's path goes in as the environment value RADIO_FILE, never inside the command.
  • It runs when a turn longer than 20 seconds finishes, unless Radio is off. It also runs on /chime test, and once on /chime sound <file> so you hear it.
  • Where PowerShell does not run, Claude Code's own player plays the bundled chime instead.

What it puts in the prompts it submits.

  • It submits no prompts, and changes none of yours.

What each hook does.

  • session.start: adds the /chime command and loads your own sound's path.
  • turn.start: notes when the turn began.
  • turn.complete: plays the chime if the turn took 20 seconds or more.
  • command.run for chime: switches it on or off, sets your sound, plays the test, or says how it is set.

Tested with Claude Code 2.1.287 on Windows.

More mods

See every Rostech mod, live, at rostech.app.

Source 2 files
hooks/register.ts 91 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4// A chime when Claude finishes a turn long enough that you looked away.
5const isOff = atom({ plugin: 'radio', key: 'isOff' } as const, false)
6const startedAt = atom({ plugin: 'radio', key: 'startedAt' } as const, 0)
7// Your own chime, by absolute path; empty plays the bundled one. $.store keeps it.
8const ownSound = atom({ plugin: 'radio', key: 'ownSound' } as const, '')
9const LONG_MS = 20_000
10const DONE = 'sounds/done.wav'
11const WHISTLE = 'sounds/whistle.wav'
12
13// Claude Code's own player skips a Windows terminal, so Windows plays the file
14// with its built-in SoundPlayer; everywhere else the engine plays it. The path
15// goes in as an environment value, never inside the command.
16async function playFile($: EngineInterface, file: string): Promise<boolean> {
17  const win = await $.process
18    .run(['powershell.exe', '-NoProfile', '-Command', '(New-Object Media.SoundPlayer $env:RADIO_FILE).PlaySync()'], {
19      env: { RADIO_FILE: file.split('/').join(String.fromCharCode(92)) },
20    })
21    .catch(() => null)
22  return !!win && win.exitCode === 0
23}
24
25async function play($: EngineInterface, clip: string) {
26  if (!(await playFile($, `${$.plugin.root}/${clip}`))) await $.audio.play({ asset: clip }).catch(() => undefined)
27}
28
29// The chime for a finished turn: yours when you set one, else the bundled one.
30async function chime($: EngineInterface) {
31  const own = await read($, ownSound)
32  if (own && (await playFile($, own))) return
33  await play($, DONE)
34}
35
36export const register: Register = on => {
37  on('session.start', async ($, e, next) => {
38    // /chime is Claude Code's own command now, so the mod answers to /chime. A
39    // refused name must never stop the rest of the start, or your sound is lost.
40    await $.command
41      .register({ name: 'chime', description: 'A chime when a long turn finishes. /chime sound <a .wav>, /chime off, /chime on, /chime test.' })
42      .catch(() => undefined)
43    const kept = await $.store.get('ownSound')
44    if (typeof kept === 'string') await update($, ownSound, () => kept)
45    return next(e)
46  })
47
48  on('turn.start', async ($, e, next) => {
49    const now = await $.clock.now()
50    await update($, startedAt, () => now)
51    return next(e)
52  })
53
54  on('turn.complete', async ($, e, next) => {
55    const began = await read($, startedAt)
56    const took = (await $.clock.now()) - began
57    if (began && took >= LONG_MS && !(await read($, isOff))) void chime($)
58    return next(e)
59  })
60
61  on('command.run', { command: 'chime' }, async ($, e) => {
62    const [verb, ...rest] = e.args.trim().split(/\s+/)
63    const arg = verb.toLowerCase()
64    if (arg === 'off' || arg === 'on') {
65      await update($, isOff, () => arg === 'off')
66      return { text: arg === 'off' ? 'Radio off.' : 'Radio on. A chime plays when a turn longer than 20 seconds finishes.' }
67    }
68    if (arg === 'sound') {
69      const path = rest.join(' ').replace(/^"|"$/g, '')
70      if (!path || path.toLowerCase() === 'default') {
71        await update($, ownSound, () => '')
72        await $.store.set('ownSound', '')
73        return { text: 'Radio plays its own chime again.' }
74      }
75      if (!/\.wav$/i.test(path)) return { text: 'The sound has to be a .wav file. /chime sound C:/path/to/chime.wav' }
76      if (!(await playFile($, path))) return { text: `Could not play ${path}. Check the path, and that it is a .wav.` }
77      await update($, ownSound, () => path)
78      await $.store.set('ownSound', path)
79      return { text: `Radio now plays ${path}. That was it. /chime sound default goes back.` }
80    }
81    if (arg === 'test') {
82      // Your chime (or the bundled one), then the whistle, one after the other.
83      void chime($).then(() => play($, WHISTLE))
84      return { text: 'Playing the done chime, then the whistle.' }
85    }
86    const own = await read($, ownSound)
87    const which = own ? ` It plays ${own}.` : ''
88    return { text: (await read($, isOff)) ? 'Radio is off. /chime on turns it on.' : `Radio is on. A chime plays when a turn longer than 20 seconds finishes.${which}` }
89  })
90}
91
types/index.d.ts 6 lines
1declare module 'claude-code' {
2  interface PluginState {
3    'radio': { isOff: boolean; startedAt: number; ownSound: string }
4  }
5}
6