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

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.
Nothing goes anywhere. Here is each thing, plainly.
What it sends, and where.
ownSound), if you set one.$.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.
powershell.exe -NoProfile -Command "(New-Object Media.SoundPlayer $env:RADIO_FILE).PlaySync()"RADIO_FILE, never inside the command./chime test, and once on /chime sound <file> so you hear it.What it puts in the prompts it submits.
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.
See every Rostech mod, live, at rostech.app.
hooks/register.ts 91 lines1import { 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}
91types/index.d.ts 6 lines1declare module 'claude-code' {
2 interface PluginState {
3 'radio': { isOff: boolean; startedAt: number; ownSound: string }
4 }
5}
6