Plays a sound when Claude finishes, needs your input, or hits an error. Ten original sounds, your own files, volume, quiet hours and snooze.

Chime plays a sound when Claude Code finishes a turn, stops to wait for you, or hits an error. Start a long task, switch to another window, and come back when you hear it.
/chime command for everything else.Chime is a Claude Code mod: a plugin built on function hooks. In Claude Code, run:
/plugin marketplace add oguz-hd/claude-code-chime
/plugin install chime@claude-code-chime
Restart Claude Code. Type /chime test to hear the current sounds.
Mods use Claude Code's function hooks, which are in early access and may change between releases. If
/chimeis not listed after a restart, update Claude Code (claude update) and check/pluginfor an error next to Chime.
| Event | Default sound | Plays when |
|---|---|---|
| Done | Ta-da | Claude finishes and hands the turn back to you |
| Waiting | Doorbell | Claude needs a permission, asks you a question (AskUserQuestion), or wants a plan approved |
| Error | Sad trombone | The turn ends on an API error, such as a rate limit or an overloaded server |
| Idle | off | The prompt has sat unanswered for a while (Claude Code's idle reminder) |
Chime stays silent:
claude -p, the SDK);/chime ssh on);Type /chime to open the picker. It works from the keyboard like Claude Code's own menus:
| Key | Does |
|---|---|
| ↑ ↓ | move between rows |
| Enter | open a row, or keep the highlighted choice |
| 1–9, 0 | jump straight to a numbered row |
| b | back to the main screen |
| Esc | close the picker |
In a sound list, resting on a sound plays it, so you can browse by ear and press Enter on the one you like. The volume list does the same at each level.
Chime
Sounds for when Claude finishes or needs you.
1: Done Ta-da
2: Waiting Doorbell
3: Error Sad trombone
4: Idle off
5: Volume 60
6: Chime on
7: Snooze off
8: Test play every sound
↑↓ move · Enter open · 1-8 jump · Esc close · /chime help for more
Or use the command directly:
/chime done coin bundled sound
/chime waiting ~/sounds/ping.wav your own file
/chime error off silence one event
/chime volume 40 0-100
/chime snooze 45m also 2h, 1h30m, off
/chime quiet 22:00-08:00 no sounds at night (local time); off to clear
/chime min 20 "done" only after turns of 20 s or more
/chime test play every event's sound
/chime status show the settings
/chime help everything else
Settings are kept per user across sessions and projects.
Give an absolute path or one starting with ~/.
.wav files only (8-, 16-, 24- or 32-bit PCM, or 32-bit float).afplay does: .wav, .mp3, .m4a, .aiff and more..wav works everywhere.Short sounds (under two seconds) work best.
| Platform | Player | Volume |
|---|---|---|
| Windows | PowerShell and System.Media.SoundPlayer, part of every Windows | Chime scales the samples itself and caches the result in %TEMP%\claude-chime |
| macOS | afplay | afplay -v |
| Linux | the first that works of pw-play, paplay, ffplay, aplay | the player's own volume flag (aplay has none) |
| WSL | the Linux players (WSLg provides PulseAudio), else Windows through powershell.exe | as above |
If no player works, Chime shows the reason once as a notice and keeps quiet.
/chime status: Chime may be off, snoozed, in quiet hours or in an SSH session. Then run /chime test.pw-play) or PulseAudio utilities (paplay), for example sudo apt install pulseaudio-utils.ffmpeg -i ping.mp3 ping.wav.Stop or Notification sound hook in ~/.claude/settings.json from an earlier setup. Remove it; Chime covers both./chime min 15 so only longer turns play the done sound.plugins/chime/
.claude-plugin/plugin.json manifest
hooks/hooks.json names the hooks module
hooks/register.tsx hooks, /chime command, picker pane, players
hooks/settings.ts sound catalog, defaults, parsing
hooks/wav.ts WAV scaling and other pure helpers
types/index.d.ts the plugin's state contract
sounds/*.wav the bundled sounds
tools/render_sounds.py renders sounds/*.wav (numpy)
Load your working copy into one session with claude --plugin-dir plugins/chime, or let Claude Code hot-reload it as a mod.
Check your changes:
claude plugin validate plugins/chime # what the engine will load and refuse
claude plugin validate . # the marketplace manifest
To type-check, run /plugin-types plugins/chime/.claude/types in Claude Code once, then npx -p typescript tsc -p plugins/chime.
To change or add a sound, edit tools/render_sounds.py, then pip install numpy && python tools/render_sounds.py. Add the new id to BUNDLED in hooks/settings.ts.
Code: MIT. Sounds: CC0 1.0 (public domain). See LICENSE.
hooks/register.tsx 709 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { ChimeEvent, ChimeSettings, ChimeSound } from '../types'
5import {
6 BUNDLED,
7 DEFAULTS,
8 EVENTS,
9 EVENT_INFO,
10 clampVolume,
11 describeDuration,
12 isBundled,
13 isFilePath,
14 isInQuietHours,
15 normalize,
16 parseDuration,
17 parseQuietHours,
18 soundLabel,
19} from './settings'
20import { POWERSHELL, PS_PLAY, PS_WRITE_AND_PLAY, fnv1a, fromBase64, posixPlayers, scaleWav, toBase64 } from './wav'
21
22type Engine = EngineInterface
23type Platform = 'windows' | 'mac' | 'linux' | 'wsl'
24type PlayResult = { isPlayed: true } | { isPlayed: false; reason: string }
25
26const PANE = 'chime'
27const COMMAND = 'chime'
28/** Two announcements closer than this are one: a permission prompt can raise
29 * both a tool hook and a Notification, and a turn can end with both. */
30const DEBOUNCE_MS = 1500
31
32const settingsAtom = atom({ plugin: 'chime', key: 'settings' } as const, DEFAULTS)
33const snoozeAtom = atom({ plugin: 'chime', key: 'snoozeUntil' } as const, 0)
34/** The picker's screen: `main`, an event's sound list, `volume` or `snooze`. */
35const screenAtom = atom({ plugin: 'chime', key: 'screen' } as const, 'main')
36
37const VOLUME_STEPS = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100] as const
38/** How long the ring must rest on a sound before it plays, so arrowing past is quiet. */
39const PREVIEW_DELAY_MS = 250
40
41// Module state: a hot reload resets these, which is harmless.
42let isInteractive = true
43let turnStartedAt = 0
44let lastPlayedAt = 0
45const reportedFailures = new Set<string>()
46let previewTimer: Timer | undefined
47let lastPreview = { sound: '', volume: -1, at: 0 }
48
49// ------------------------------------------------------------------ settings
50
51async function saveSettings($: Engine, change: (s: ChimeSettings) => ChimeSettings): Promise<ChimeSettings> {
52 const next = await update($, settingsAtom, s => normalize(change(s)))
53 await $.store.set('settings', next)
54 await refreshStatus($)
55 return next
56}
57
58async function setSnooze($: Engine, until: number): Promise<void> {
59 await update($, snoozeAtom, () => until)
60 await $.store.set('snoozeUntil', until)
61 await refreshStatus($)
62 if (until > 0) {
63 const left = until - (await $.clock.now())
64 if (left > 0) $.clock.after(left + 500, () => void refreshStatus($))
65 }
66}
67
68const clockTime = (ms: number) => new Date(ms).toTimeString().slice(0, 5)
69
70/** The status line says something only when Chime is not simply on. */
71async function refreshStatus($: Engine): Promise<void> {
72 const s = await read($, settingsAtom)
73 const snoozeUntil = await read($, snoozeAtom)
74 const now = await $.clock.now()
75 if (!s.isEnabled) $.ui.status('chime off')
76 else if (snoozeUntil > now) $.ui.status(`chime snoozed until ${clockTime(snoozeUntil)}`)
77 else if (isInQuietHours(s.quietHours, new Date(now))) $.ui.status('chime quiet hours')
78 else $.ui.status(undefined)
79}
80
81// ------------------------------------------------------------------ playing
82
83/** Plays the sound set for `event`, unless something says to stay silent. */
84async function announce($: Engine, event: ChimeEvent): Promise<void> {
85 const s = await read($, settingsAtom)
86 const now = await $.clock.now()
87 if (!isInteractive || !s.isEnabled) return
88 if ((await read($, snoozeAtom)) > now) return
89 if (isInQuietHours(s.quietHours, new Date(now))) return
90 if (now - lastPlayedAt < DEBOUNCE_MS) return
91 if (!s.isPlayingOverSsh && (await isOverSsh($))) return
92 lastPlayedAt = now
93 await playSound($, s.sounds[event], s.volume)
94}
95
96async function playSound($: Engine, sound: string, volume: number): Promise<void> {
97 const path = await resolveSound($, sound)
98 if (!path) return
99 const result = await play($, path, volume)
100 if (!result.isPlayed && !reportedFailures.has(result.reason)) {
101 reportedFailures.add(result.reason)
102 $.ui.toast(`Chime could not play a sound: ${result.reason}`)
103 }
104}
105
106/** Fire and forget: a hook never waits for a sound to finish. */
107const later = (work: Promise<unknown>) => void work.catch(() => undefined)
108
109// ------------------------------------------------------------------ platform and players
110
111let platform: Promise<Platform> | undefined
112
113function detectPlatform($: Engine): Promise<Platform> {
114 platform ??= (async (): Promise<Platform> => {
115 if ((await $.env.get('OS')) === 'Windows_NT') return 'windows'
116 if ((await $.env.get('WSL_DISTRO_NAME')) !== undefined) return 'wsl'
117 if (await $.fs.exists('/usr/bin/afplay')) return 'mac'
118 return 'linux'
119 })()
120 return platform
121}
122
123/** True when the session was reached over SSH, so a sound would play on the remote machine. */
124async function isOverSsh($: Engine): Promise<boolean> {
125 return (
126 (await $.env.get('SSH_CONNECTION')) !== undefined ||
127 (await $.env.get('SSH_TTY')) !== undefined ||
128 (await $.env.get('SSH_CLIENT')) !== undefined
129 )
130}
131
132async function homeDir($: Engine): Promise<string> {
133 return (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? ''
134}
135
136async function tempDir($: Engine): Promise<string> {
137 return (await $.env.get('TEMP')) ?? (await $.env.get('TMP')) ?? (await $.env.get('TMPDIR')) ?? '/tmp'
138}
139
140/** The file a sound setting points at, or null for `off`. */
141async function resolveSound($: Engine, sound: ChimeSound): Promise<string | null> {
142 if (sound === 'off') return null
143 if (isBundled(sound)) return `${$.plugin.root}/sounds/${sound}.wav`
144 if (/^~[\\/]/.test(sound)) return `${await homeDir($)}${sound.slice(1)}`
145 return sound
146}
147
148
149/**
150 * Plays `path` at `volume` (0-100) and resolves once the sound has finished.
151 * Never rejects: a failure comes back as `{ isPlayed: false, reason }`.
152 */
153async function play($: Engine, path: string, volume: number): Promise<PlayResult> {
154 const gain = Math.min(1, Math.max(0, volume / 100))
155 if (gain === 0) return { isPlayed: true }
156 try {
157 if (!(await $.fs.exists(path))) return { isPlayed: false, reason: `sound file not found: ${path}` }
158 switch (await detectPlatform($)) {
159 case 'windows':
160 return await playOnWindows($, path, gain)
161 case 'mac':
162 return await runPlayer($, ['afplay', '-v', gain.toFixed(2), path])
163 case 'linux':
164 return await playOnPosix($, path, gain)
165 case 'wsl': {
166 const posix = await playOnPosix($, path, gain)
167 return posix.isPlayed ? posix : await playOnWindowsFromWsl($, path)
168 }
169 }
170 } catch (error) {
171 return { isPlayed: false, reason: error instanceof Error ? error.message : String(error) }
172 }
173}
174
175async function runPlayer($: Engine, argv: string[], stdin?: string): Promise<PlayResult> {
176 const { exitCode, stderr } = await $.process.run(argv, { stdin, timeoutMs: 20_000 })
177 if (exitCode === 0) return { isPlayed: true }
178 const detail = stderr.trim().split(/\r?\n/)[0] ?? ''
179 return { isPlayed: false, reason: `${argv[0]} exited with ${exitCode}${detail ? `: ${detail}` : ''}` }
180}
181
182async function playOnWindows($: Engine, path: string, gain: number): Promise<PlayResult> {
183 if (!/\.wav$/i.test(path)) {
184 return { isPlayed: false, reason: `Windows plays .wav files only; convert ${path} to .wav` }
185 }
186 if (gain >= 0.999) return runPlayer($, [...POWERSHELL, PS_PLAY, path])
187
188 const stat = await $.fs.stat(path)
189 const key = fnv1a(`${path}|${stat.size}|${stat.mtimeMs}|${gain.toFixed(3)}`)
190 const cached = `${await tempDir($)}\\claude-chime\\${key}.wav`
191 if (await $.fs.exists(cached)) return runPlayer($, [...POWERSHELL, PS_PLAY, cached])
192
193 const { base64 } = await $.fs.read(path, { as: 'bytes' })
194 const scaled = scaleWav(fromBase64(base64), gain)
195 if (!scaled) return runPlayer($, [...POWERSHELL, PS_PLAY, path]) // a WAV encoding we cannot scale
196 return runPlayer($, [...POWERSHELL, PS_WRITE_AND_PLAY, cached], toBase64(scaled))
197}
198
199let posixPlayer: number | undefined
200
201async function playOnPosix($: Engine, path: string, gain: number): Promise<PlayResult> {
202 const players = posixPlayers(path, gain)
203 const order = posixPlayer === undefined ? players.map((_, i) => i) : [posixPlayer]
204 const failures: string[] = []
205 for (const i of order) {
206 const argv = players[i]
207 if (!argv) continue
208 try {
209 const result = await runPlayer($, argv)
210 if (result.isPlayed) {
211 posixPlayer = i
212 return result
213 }
214 failures.push(result.reason)
215 } catch {
216 failures.push(`${argv[0]} not found`)
217 }
218 }
219 if (posixPlayer !== undefined) {
220 posixPlayer = undefined // the remembered player broke; search again next time
221 return playOnPosix($, path, gain)
222 }
223 return {
224 isPlayed: false,
225 reason: `no working audio player (tried pw-play, paplay, ffplay, aplay): ${failures.join('; ')}`,
226 }
227}
228
229async function playOnWindowsFromWsl($: Engine, path: string): Promise<PlayResult> {
230 if (!/\.wav$/i.test(path)) return { isPlayed: false, reason: 'no Linux audio player, and Windows plays .wav only' }
231 const { exitCode, stdout } = await $.process.run(['wslpath', '-w', path])
232 if (exitCode !== 0) return { isPlayed: false, reason: 'wslpath could not translate the sound path' }
233 return runPlayer($, [...POWERSHELL, PS_PLAY, stdout.trim()])
234}
235
236// ------------------------------------------------------------------ picker helpers
237
238const pad = (text: string, width: number) => (text.length >= width ? `${text} ` : text.padEnd(width))
239
240const nearestStep = (volume: number) => Math.min(100, Math.max(10, Math.round(volume / 10) * 10))
241
242/** The ring's landing key in a sound list, for the sound an event has now. */
243const focusKeyForSound = (sound: string) =>
244 sound === 'off' ? 'sound:off' : isBundled(sound) ? `sound:${sound}` : `sound:${BUNDLED[0]?.id ?? 'tada'}`
245
246/** What the volume screen plays: the done sound, or the first event's that is not silent. */
247function previewSoundFor(s: ChimeSettings): string {
248 const sound = [s.sounds.done, s.sounds.waiting, s.sounds.error, s.sounds.idle].find(x => x !== 'off')
249 return sound ?? 'tada'
250}
251
252function cancelPreview(): void {
253 previewTimer?.cancel()
254 previewTimer = undefined
255}
256
257function schedulePreview($: Engine, key: string): void {
258 cancelPreview()
259 previewTimer = $.clock.after(PREVIEW_DELAY_MS, () => later(runPreview($, key)))
260}
261
262async function runPreview($: Engine, key: string): Promise<void> {
263 const s = await read($, settingsAtom)
264 let sound: string
265 let volume = s.volume
266 if (key.startsWith('sound:')) sound = key.slice('sound:'.length)
267 else if (key.startsWith('vol:')) {
268 sound = previewSoundFor(s)
269 volume = Number(key.slice('vol:'.length))
270 } else return
271 if (sound === 'off') return
272 lastPreview = { sound, volume, at: Date.now() }
273 await playSound($, sound, volume)
274}
275
276/** True when the browse preview just played this, so Enter need not play it again. */
277const wasJustPreviewed = (sound: string, volume: number) =>
278 lastPreview.sound === sound && lastPreview.volume === volume && Date.now() - lastPreview.at < 3000
279
280async function testSounds($: Engine, only?: ChimeEvent): Promise<void> {
281 const s = await read($, settingsAtom)
282 for (const event of only ? [only] : EVENTS) {
283 if (s.sounds[event] === 'off') continue
284 await playSound($, s.sounds[event], s.volume)
285 await $.clock.sleep(400)
286 }
287}
288
289// ------------------------------------------------------------------ hooks
290
291export const register: Register = on => {
292 on('session.start', async ($, e, next) => {
293 isInteractive = e.isInteractive
294 const stored = await $.store.get('settings')
295 await update($, settingsAtom, () => normalize(stored))
296 const snoozeUntil = Number((await $.store.get('snoozeUntil')) ?? 0)
297 await update($, snoozeAtom, () => (Number.isFinite(snoozeUntil) ? snoozeUntil : 0))
298 await $.command.register({
299 name: COMMAND,
300 description: 'Sounds for when Claude finishes or needs you: pick sounds, volume, snooze',
301 argumentHint: '[status|test|volume N|done SOUND|on|off|snooze 1h|help]',
302 immediate: true,
303 })
304 await refreshStatus($)
305 return next(e)
306 })
307
308 // The turn's start, for the minimum-length rule. The earliest start since
309 // the last stop counts, so a subagent's turn starting mid-way changes nothing.
310 on('turn.start', async ($, e, next) => {
311 if (turnStartedAt === 0) turnStartedAt = await $.clock.now()
312 return next(e)
313 })
314
315 // Done: the main agent stopped and no Stop hook sent it back to work.
316 on('classic.Stop', async ($, e, next) => {
317 const result = await next(e)
318 if (result.block !== undefined) return result
319
320 const startedAt = turnStartedAt
321 turnStartedAt = 0
322 const s = await read($, settingsAtom)
323 const seconds = startedAt ? ((await $.clock.now()) - startedAt) / 1000 : Infinity
324 if (seconds >= s.minTurnSeconds) later(announce($, 'done'))
325 return result
326 })
327
328 // Error: the turn ended on an API error instead of a reply.
329 on('classic.StopFailure', async ($, e, next) => {
330 turnStartedAt = 0
331 later(announce($, 'error'))
332 return next(e)
333 })
334
335 // Waiting / idle: Claude Code's own notifications.
336 on('classic.Notification', async ($, e, next) => {
337 const type = e.notification_type
338 if (type === 'idle_prompt') later(announce($, 'idle'))
339 else if (type !== 'auth_success') later(announce($, 'waiting'))
340 return next(e)
341 })
342
343 // Waiting, also for the dialogs that raise no notification of their own.
344 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
345 later(announce($, 'waiting'))
346 return next(e)
347 })
348 on('tool.call', { tool: 'ExitPlanMode' }, async ($, e, next) => {
349 later(announce($, 'waiting'))
350 return next(e)
351 })
352
353 on('command.run', { command: COMMAND }, async ($, e) => {
354 const text = await runCommand($, e.args)
355 return text ? { text } : {}
356 })
357
358 // Moving the ring onto a sound or a volume previews it, as a theme picker
359 // previews themes: the person browses by ear and presses Enter to keep one.
360 on('ui.focus', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
361 const moved = await next(e)
362 if (moved.deny === undefined && e.origin.kind === 'person' && e.element) {
363 schedulePreview($, e.element)
364 }
365 return moved
366 })
367
368 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
369 const { Box, Button, Text } = $.ui.resolve(e)
370 const s = await read($, settingsAtom)
371 const screen = await read($, screenAtom)
372 const snoozeUntil = await read($, snoozeAtom)
373 const now = await $.clock.now()
374 const isSnoozed = snoozeUntil > now
375
376 /** Switch screens and put the ring on `key` in the new one. */
377 const go = (to: string, key: string) => async () => {
378 cancelPreview()
379 await update($, screenAtom, () => to)
380 // Best effort: the ring cannot move while the pane does not hold the keys.
381 await $.ui.focus({ requestId: PANE, key }).catch(() => undefined)
382 }
383 const footer = (keys: string) => (
384 <Box marginTop={1}>
385 <Text dimColor>{keys}</Text>
386 </Box>
387 )
388
389 // ---- a sound list for one event
390 if (EVENTS.includes(screen as ChimeEvent)) {
391 const event = screen as ChimeEvent
392 const current = s.sounds[event]
393 const keep = (sound: string) => async () => {
394 cancelPreview()
395 await saveSettings($, x => ({ ...x, sounds: { ...x.sounds, [event]: sound } }))
396 if (!wasJustPreviewed(sound, s.volume)) later(playSound($, sound, s.volume))
397 await go('main', `event:${event}`)()
398 }
399 return (
400 <Box flexDirection="column">
401 <Text bold>{EVENT_INFO[event].label} sound</Text>
402 <Text dimColor>Plays when {EVENT_INFO[event].when}.</Text>
403 <Box flexDirection="column" marginTop={1}>
404 {BUNDLED.map((sound, i) => (
405 <Button
406 key={`sound:${sound.id}`}
407 plain
408 hotkey={String((i + 1) % 10)}
409 autoFocus={current === sound.id ? true : undefined}
410 label={`${pad(sound.label + (current === sound.id ? ' ✓' : ''), 16)}${sound.note}`}
411 onPress={keep(sound.id)}
412 />
413 ))}
414 <Button
415 key="sound:off"
416 plain
417 hotkey="s"
418 label={`${pad('Silent' + (current === 'off' ? ' ✓' : ''), 16)}no sound for this event`}
419 onPress={keep('off')}
420 />
421 </Box>
422 {isFilePath(current) && (
423 <Box marginTop={1}>
424 <Text>Using your file: {current}</Text>
425 </Box>
426 )}
427 <Box marginTop={1} flexDirection="column">
428 <Button key="back" plain hotkey="b" label="Back" onPress={go('main', `event:${event}`)} />
429 </Box>
430 {footer(`↑↓ listen · Enter keep · b back · Esc close · own file: /chime ${event} ~/sound.wav`)}
431 </Box>
432 )
433 }
434
435 // ---- volume
436 if (screen === 'volume') {
437 const keep = (volume: number) => async () => {
438 cancelPreview()
439 const saved = await saveSettings($, x => ({ ...x, volume }))
440 const sample = previewSoundFor(saved)
441 if (!wasJustPreviewed(sample, volume)) later(playSound($, sample, volume))
442 await go('main', 'open:volume')()
443 }
444 return (
445 <Box flexDirection="column">
446 <Text bold>Volume</Text>
447 <Text dimColor>Move to hear each level; your done sound plays at it.</Text>
448 <Box flexDirection="column" marginTop={1}>
449 {VOLUME_STEPS.map((v, i) => (
450 <Button
451 key={`vol:${v}`}
452 plain
453 hotkey={String((i + 1) % 10)}
454 autoFocus={nearestStep(s.volume) === v ? true : undefined}
455 label={`${pad(String(v), 5)}${'█'.repeat(v / 10)}${'░'.repeat(10 - v / 10)}${s.volume === v ? ' ✓' : ''}`}
456 onPress={keep(v)}
457 />
458 ))}
459 </Box>
460 <Box marginTop={1} flexDirection="column">
461 <Button key="back" plain hotkey="b" label="Back" onPress={go('main', 'open:volume')} />
462 </Box>
463 {footer('↑↓ listen · Enter keep · b back · Esc close')}
464 </Box>
465 )
466 }
467
468 // ---- snooze
469 if (screen === 'snooze') {
470 const choose = (until: () => number) => async () => {
471 await setSnooze($, until())
472 await go('main', 'open:snooze')()
473 }
474 const tomorrowAt8 = () => {
475 const d = new Date(now)
476 d.setDate(d.getDate() + 1)
477 d.setHours(8, 0, 0, 0)
478 return d.getTime()
479 }
480 const options: { key: string; label: string; until: () => number }[] = [
481 { key: 'snooze:15', label: 'For 15 minutes', until: () => now + 15 * 60_000 },
482 { key: 'snooze:30', label: 'For 30 minutes', until: () => now + 30 * 60_000 },
483 { key: 'snooze:60', label: 'For 1 hour', until: () => now + 60 * 60_000 },
484 { key: 'snooze:120', label: 'For 2 hours', until: () => now + 120 * 60_000 },
485 { key: 'snooze:tomorrow', label: 'Until tomorrow 08:00', until: tomorrowAt8 },
486 ]
487 return (
488 <Box flexDirection="column">
489 <Text bold>Snooze</Text>
490 <Text dimColor>{isSnoozed ? `Silent until ${clockTime(snoozeUntil)}.` : 'Stay silent for a while.'}</Text>
491 <Box flexDirection="column" marginTop={1}>
492 {options.map((o, i) => (
493 <Button
494 key={o.key}
495 plain
496 hotkey={String(i + 1)}
497 autoFocus={i === 2 ? true : undefined}
498 label={o.label}
499 onPress={choose(o.until)}
500 />
501 ))}
502 {isSnoozed && <Button key="snooze:off" plain hotkey="w" label="Wake up now" onPress={choose(() => 0)} />}
503 </Box>
504 <Box marginTop={1} flexDirection="column">
505 <Button key="back" plain hotkey="b" label="Back" onPress={go('main', 'open:snooze')} />
506 </Box>
507 {footer('↑↓ move · Enter choose · b back · Esc close')}
508 </Box>
509 )
510 }
511
512 // ---- main
513 const notes: string[] = []
514 if (s.quietHours) notes.push(`Quiet hours ${s.quietHours}`)
515 if (s.minTurnSeconds) notes.push(`"Done" after turns of ${s.minTurnSeconds} s or more`)
516 if (!s.isPlayingOverSsh && (await isOverSsh($))) notes.push('SSH session: silent (/chime ssh on)')
517 return (
518 <Box flexDirection="column">
519 <Text bold>Chime</Text>
520 <Text dimColor>Sounds for when Claude finishes or needs you.</Text>
521 <Box flexDirection="column" marginTop={1}>
522 {EVENTS.map((event, i) => (
523 <Button
524 key={`event:${event}`}
525 plain
526 hotkey={String(i + 1)}
527 autoFocus={i === 0 ? true : undefined}
528 label={`${pad(EVENT_INFO[event].label, 10)}${soundLabel(s.sounds[event])}`}
529 onPress={go(event, focusKeyForSound(s.sounds[event]))}
530 />
531 ))}
532 <Button
533 key="open:volume"
534 plain
535 hotkey="5"
536 label={`${pad('Volume', 10)}${s.volume}`}
537 onPress={go('volume', `vol:${nearestStep(s.volume)}`)}
538 />
539 <Button
540 key="toggle"
541 plain
542 hotkey="6"
543 label={`${pad('Chime', 10)}${s.isEnabled ? 'on' : 'off'}`}
544 onPress={() => saveSettings($, x => ({ ...x, isEnabled: !x.isEnabled }))}
545 />
546 <Button
547 key="open:snooze"
548 plain
549 hotkey="7"
550 label={`${pad('Snooze', 10)}${isSnoozed ? `until ${clockTime(snoozeUntil)}` : 'off'}`}
551 onPress={go('snooze', 'snooze:60')}
552 />
553 <Button
554 key="test"
555 plain
556 hotkey="8"
557 label={`${pad('Test', 10)}play every sound`}
558 onPress={() => later(testSounds($))}
559 />
560 </Box>
561 {notes.length > 0 && (
562 <Box marginTop={1}>
563 <Text dimColor>{notes.join(' · ')}</Text>
564 </Box>
565 )}
566 {footer('↑↓ move · Enter open · 1-8 jump · Esc close · /chime help for more')}
567 </Box>
568 )
569 })
570}
571
572// ------------------------------------------------------------------ /chime
573
574const HELP = `Chime plays a sound when Claude finishes, needs you, or fails.
575
576 /chime open the sound picker
577 /chime status show the settings
578 /chime test [event] play one event's sound, or all of them
579 /chime list list the bundled sounds
580 /chime play SOUND preview a sound
581 /chime EVENT SOUND set a sound: EVENT is done, waiting, error or idle;
582 SOUND is a bundled name, off, or a file path
583 /chime volume 0-100 set the volume
584 /chime on | off turn Chime on or off
585 /chime snooze 30m | 2h | off stay silent for a while
586 /chime quiet 22:00-08:00 | off no sounds during these hours (local time)
587 /chime min SECONDS only chime "done" after turns at least this long
588 /chime ssh on | off also play in sessions reached over SSH
589 /chime reset back to the defaults`
590
591async function runCommand($: Engine, args: string): Promise<string> {
592 const [verb = '', ...rest] = args.trim().split(/\s+/).filter(Boolean)
593 const value = args.trim().slice(verb.length).trim()
594 const word = verb.toLowerCase()
595
596 if (word === '') {
597 cancelPreview()
598 await update($, screenAtom, () => 'main')
599 await $.ui.open({ id: PANE, title: 'Chime', focus: true, closeOnEscape: true, holdToasts: true, rows: 18 })
600 return ''
601 }
602 if (word === 'help') return HELP
603 if (word === 'status') return statusText($)
604
605 if (word === 'list') {
606 return ['Bundled sounds:', ...BUNDLED.map(b => ` ${b.id.padEnd(14)} ${b.note}`), ' off silence'].join('\n')
607 }
608
609 if (word === 'play') {
610 if (!isBundled(value) && !isFilePath(value)) return `Unknown sound "${value}". Try /chime list.`
611 const s = await read($, settingsAtom)
612 later(playSound($, value, s.volume))
613 return `Playing ${soundLabel(value)} at volume ${s.volume}.`
614 }
615
616 if (word === 'test') {
617 const s = await read($, settingsAtom)
618 const only = EVENTS.includes(value as ChimeEvent) ? (value as ChimeEvent) : undefined
619 const events = only ? [only] : EVENTS
620 later(testSounds($, only))
621 return events.map(ev => `${EVENT_INFO[ev].label}: ${soundLabel(s.sounds[ev])}`).join(', ')
622 }
623
624 if (EVENTS.includes(word as ChimeEvent)) {
625 const event = word as ChimeEvent
626 if (!value) return `${EVENT_INFO[event].label} plays ${soundLabel((await read($, settingsAtom)).sounds[event])}.`
627 const sound = isBundled(value.toLowerCase()) || value.toLowerCase() === 'off' ? value.toLowerCase() : value
628 if (sound !== 'off' && !isBundled(sound)) {
629 if (!isFilePath(sound)) return `Unknown sound "${value}". Use a name from /chime list, off, or an absolute file path.`
630 const path = await resolveSound($, sound)
631 if (!path || !(await $.fs.exists(path))) return `No file at ${sound}.`
632 }
633 const s = await saveSettings($, x => ({ ...x, sounds: { ...x.sounds, [event]: sound } }))
634 later(playSound($, sound, s.volume))
635 return `${EVENT_INFO[event].label} now plays ${soundLabel(sound)}.`
636 }
637
638 if (word === 'volume' || word === 'vol') {
639 const n = Number(value)
640 if (!value || !Number.isFinite(n)) return `Volume is ${(await read($, settingsAtom)).volume}. Set it with /chime volume 0-100.`
641 const s = await saveSettings($, x => ({ ...x, volume: clampVolume(n) }))
642 later(playSound($, s.sounds.done === 'off' ? 'tada' : s.sounds.done, s.volume))
643 return `Volume set to ${s.volume}.`
644 }
645
646 if (word === 'on' || word === 'off') {
647 await saveSettings($, x => ({ ...x, isEnabled: word === 'on' }))
648 return word === 'on' ? 'Chime is on.' : 'Chime is off. Turn it back on with /chime on.'
649 }
650
651 if (word === 'snooze') {
652 if (value === 'off' || value === '0') {
653 await setSnooze($, 0)
654 return 'Snooze cleared.'
655 }
656 const ms = parseDuration(value || '1h')
657 if (!ms) return 'Give a duration such as 30m, 2h or 1h30m.'
658 await setSnooze($, (await $.clock.now()) + ms)
659 return `Snoozed for ${describeDuration(ms)}.`
660 }
661
662 if (word === 'quiet') {
663 if (value === 'off' || value === '') {
664 await saveSettings($, x => ({ ...x, quietHours: '' }))
665 return 'Quiet hours cleared.'
666 }
667 if (!parseQuietHours(value)) return 'Give the hours as HH:MM-HH:MM, for example 22:00-08:00.'
668 await saveSettings($, x => ({ ...x, quietHours: value.replace(/\s+/g, '') }))
669 return `No sounds from ${value.replace(/\s+/g, '').replace('-', ' to ')} (local time).`
670 }
671
672 if (word === 'min') {
673 const n = Number(value)
674 if (!value || !Number.isFinite(n) || n < 0) return 'Give a number of seconds, for example /chime min 20 (0 = every turn).'
675 const s = await saveSettings($, x => ({ ...x, minTurnSeconds: Math.round(n) }))
676 return s.minTurnSeconds ? `"Done" now plays only after turns of ${s.minTurnSeconds} s or more.` : '"Done" plays after every turn.'
677 }
678
679 if (word === 'ssh') {
680 if (value !== 'on' && value !== 'off') return 'Use /chime ssh on or /chime ssh off.'
681 await saveSettings($, x => ({ ...x, isPlayingOverSsh: value === 'on' }))
682 return value === 'on' ? 'Chime also plays in SSH sessions (on the remote machine).' : 'Chime stays silent in SSH sessions.'
683 }
684
685 if (word === 'reset') {
686 await saveSettings($, () => DEFAULTS)
687 await setSnooze($, 0)
688 return 'Chime is back to its defaults.'
689 }
690
691 return `Unknown option "${verb}".\n\n${HELP}`
692}
693
694async function statusText($: Engine): Promise<string> {
695 const s = await read($, settingsAtom)
696 const snoozeUntil = await read($, snoozeAtom)
697 const now = await $.clock.now()
698 const lines = [
699 `Chime is ${s.isEnabled ? 'on' : 'off'}, volume ${s.volume}.`,
700 ...EVENTS.map(ev => ` ${EVENT_INFO[ev].label.padEnd(8)} ${soundLabel(s.sounds[ev])}`),
701 ]
702 if (snoozeUntil > now) lines.push(`Snoozed until ${clockTime(snoozeUntil)}.`)
703 if (s.quietHours) lines.push(`Quiet hours: ${s.quietHours}.`)
704 if (s.minTurnSeconds) lines.push(`"Done" only after turns of ${s.minTurnSeconds} s or more.`)
705 if (await isOverSsh($)) lines.push(s.isPlayingOverSsh ? 'SSH session: playing on the remote machine.' : 'SSH session: silent (/chime ssh on to change).')
706 if (!isInteractive) lines.push('Not an interactive session: silent.')
707 return lines.join('\n')
708}
709hooks/settings.ts 105 lines1import type { ChimeEvent, ChimeSettings, ChimeSound } from '../types'
2
3export const EVENTS: readonly ChimeEvent[] = ['done', 'waiting', 'error', 'idle']
4
5export const EVENT_INFO: Record<ChimeEvent, { label: string; when: string }> = {
6 done: { label: 'Done', when: 'Claude finished and handed the turn back to you' },
7 waiting: { label: 'Waiting', when: 'Claude needs a permission, an answer or a plan approval' },
8 error: { label: 'Error', when: 'the turn ended on an API error (rate limit, overload, auth)' },
9 idle: { label: 'Idle', when: 'the prompt has sat unanswered for a while (a reminder)' },
10}
11
12/** The bundled sounds, in the order the picker lists them. */
13export const BUNDLED: readonly { id: string; label: string; note: string }[] = [
14 { id: 'tada', label: 'Ta-da', note: 'a short pickup note, then a bright chord' },
15 { id: 'coin', label: 'Coin', note: '8-bit coin pickup' },
16 { id: 'level-up', label: 'Level up', note: 'rising 8-bit arpeggio' },
17 { id: 'doorbell', label: 'Doorbell', note: 'ding-dong' },
18 { id: 'microwave', label: 'Microwave', note: 'beep beep beep' },
19 { id: 'boing', label: 'Boing', note: 'cartoon spring' },
20 { id: 'rubber-duck', label: 'Rubber duck', note: 'two squeaks' },
21 { id: 'robot', label: 'Robot', note: 'beep-boop chirps' },
22 { id: 'slide-whistle', label: 'Slide whistle', note: 'whistle sliding up' },
23 { id: 'sad-trombone', label: 'Sad trombone', note: 'wah wah wah waaah' },
24]
25
26export const DEFAULTS: ChimeSettings = {
27 isEnabled: true,
28 volume: 60,
29 sounds: { done: 'tada', waiting: 'doorbell', error: 'sad-trombone', idle: 'off' },
30 minTurnSeconds: 0,
31 quietHours: '',
32 isPlayingOverSsh: false,
33}
34
35export const isBundled = (sound: ChimeSound) => BUNDLED.some(b => b.id === sound)
36
37/** True for `/x`, `~/x`, `C:\x`, `C:/x` and `\\server\share`. */
38export const isFilePath = (sound: ChimeSound) =>
39 /^(\/|~[\\/]|[A-Za-z]:[\\/]|\\\\)/.test(sound)
40
41export const soundLabel = (sound: ChimeSound) =>
42 sound === 'off' ? 'off' : (BUNDLED.find(b => b.id === sound)?.label ?? sound)
43
44export const clampVolume = (n: number) => Math.round(Math.min(100, Math.max(0, n)))
45
46/**
47 * Fills in what a stored value lacks and drops what no longer fits, so a
48 * settings file from an older or newer version still loads.
49 */
50export function normalize(raw: unknown): ChimeSettings {
51 const s = (raw && typeof raw === 'object' ? raw : {}) as Partial<ChimeSettings>
52 const sounds = { ...DEFAULTS.sounds }
53 for (const event of EVENTS) {
54 const value = s.sounds?.[event]
55 if (typeof value === 'string' && (value === 'off' || isBundled(value) || isFilePath(value))) {
56 sounds[event] = value
57 }
58 }
59 return {
60 isEnabled: typeof s.isEnabled === 'boolean' ? s.isEnabled : DEFAULTS.isEnabled,
61 volume: typeof s.volume === 'number' && Number.isFinite(s.volume) ? clampVolume(s.volume) : DEFAULTS.volume,
62 sounds,
63 minTurnSeconds:
64 typeof s.minTurnSeconds === 'number' && s.minTurnSeconds >= 0 ? Math.round(s.minTurnSeconds) : 0,
65 quietHours: typeof s.quietHours === 'string' && parseQuietHours(s.quietHours) ? s.quietHours : '',
66 isPlayingOverSsh: s.isPlayingOverSsh === true,
67 }
68}
69
70/** `22:00-07:30` to minutes after midnight, or null when malformed. */
71export function parseQuietHours(text: string): { from: number; to: number } | null {
72 const m = /^\s*(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})\s*$/.exec(text)
73 if (!m) return null
74 const [h1 = 0, m1 = 0, h2 = 0, m2 = 0] = m.slice(1).map(Number)
75 if (h1 > 23 || h2 > 23 || m1 > 59 || m2 > 59) return null
76 return { from: h1 * 60 + m1, to: h2 * 60 + m2 }
77}
78
79/** Whether `date` (local time) falls inside the range; a range may wrap midnight. */
80export function isInQuietHours(text: string, date: Date): boolean {
81 const range = parseQuietHours(text)
82 if (!range || range.from === range.to) return false
83 const now = date.getHours() * 60 + date.getMinutes()
84 return range.from < range.to
85 ? now >= range.from && now < range.to
86 : now >= range.from || now < range.to
87}
88
89/** `30`, `30m`, `2h`, `1h30m` to milliseconds, or null. A bare number is minutes. */
90export function parseDuration(text: string): number | null {
91 const t = text.trim().toLowerCase()
92 if (/^\d+$/.test(t)) return Number(t) * 60_000
93 const m = /^(?:(\d+)h)?\s*(?:(\d+)m(?:in)?)?$/.exec(t)
94 if (!m || (!m[1] && !m[2])) return null
95 return (Number(m[1] ?? 0) * 60 + Number(m[2] ?? 0)) * 60_000
96}
97
98export function describeDuration(ms: number): string {
99 const minutes = Math.max(1, Math.round(ms / 60_000))
100 if (minutes < 60) return `${minutes} min`
101 const h = Math.floor(minutes / 60)
102 const m = minutes % 60
103 return m ? `${h} h ${m} min` : `${h} h`
104}
105hooks/wav.ts 105 lines1// Pure helpers for playing sounds: no engine calls here (`$` stays in register.tsx).
2
3// Windows: System.Media.SoundPlayer ships with every Windows and needs nothing
4// installed, but it plays .wav only and has no volume control. So the volume is
5// applied to the samples, and the scaled copy is cached in %TEMP%.
6
7export const POWERSHELL = ['powershell.exe', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command']
8export const PS_PLAY = '& { (New-Object Media.SoundPlayer $args[0]).PlaySync() }'
9export const PS_WRITE_AND_PLAY =
10 '& { $o = $args[0]; [void][IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($o)); ' +
11 '[IO.File]::WriteAllBytes($o, [Convert]::FromBase64String([Console]::In.ReadToEnd())); ' +
12 '(New-Object Media.SoundPlayer $o).PlaySync() }'
13
14// Linux (and WSL with WSLg): the first player that is installed and works wins.
15export const posixPlayers = (path: string, gain: number): string[][] => [
16 ['pw-play', `--volume=${gain.toFixed(2)}`, path],
17 ['paplay', `--volume=${Math.round(gain * 65536)}`, path],
18 ['ffplay', '-nodisp', '-autoexit', '-loglevel', 'quiet', '-volume', String(Math.round(gain * 100)), path],
19 ['aplay', '-q', path], // no volume control
20]
21
22// --------------------------------------------------------------------- WAV
23
24/**
25 * Returns a copy of a RIFF/WAVE file with every sample multiplied by `gain`,
26 * or null for an encoding it does not handle (compressed WAV). Handles 8-, 16-,
27 * 24- and 32-bit PCM and 32-bit float, including WAVE_FORMAT_EXTENSIBLE.
28 */
29export function scaleWav(bytes: Uint8Array, gain: number): Uint8Array | null {
30 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
31 const tag = (at: number) => String.fromCharCode(...bytes.subarray(at, at + 4))
32 if (bytes.length < 12 || tag(0) !== 'RIFF' || tag(8) !== 'WAVE') return null
33
34 let format = 0
35 let bits = 0
36 let dataStart = -1
37 let dataEnd = -1
38 for (let at = 12; at + 8 <= bytes.length; ) {
39 const id = tag(at)
40 const size = view.getUint32(at + 4, true)
41 const body = at + 8
42 if (id === 'fmt ') {
43 format = view.getUint16(body, true)
44 bits = view.getUint16(body + 14, true)
45 if (format === 0xfffe && size >= 26) format = view.getUint16(body + 24, true)
46 } else if (id === 'data') {
47 dataStart = body
48 dataEnd = Math.min(bytes.length, body + size)
49 }
50 at = body + size + (size % 2)
51 }
52 if (dataStart < 0 || bits === 0) return null
53
54 const out = bytes.slice()
55 const o = new DataView(out.buffer)
56 const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, Math.round(v)))
57 if (format === 1 && bits === 16) {
58 for (let i = dataStart; i + 1 < dataEnd; i += 2) o.setInt16(i, clamp(o.getInt16(i, true) * gain, -32768, 32767), true)
59 } else if (format === 1 && bits === 8) {
60 for (let i = dataStart; i < dataEnd; i++) o.setUint8(i, clamp((o.getUint8(i) - 128) * gain + 128, 0, 255))
61 } else if (format === 1 && bits === 24) {
62 for (let i = dataStart; i + 2 < dataEnd; i += 3) {
63 let v = o.getUint8(i) | (o.getUint8(i + 1) << 8) | (o.getUint8(i + 2) << 16)
64 if (v & 0x800000) v -= 0x1000000
65 v = clamp(v * gain, -0x800000, 0x7fffff)
66 o.setUint8(i, v & 0xff)
67 o.setUint8(i + 1, (v >> 8) & 0xff)
68 o.setUint8(i + 2, (v >> 16) & 0xff)
69 }
70 } else if (format === 1 && bits === 32) {
71 for (let i = dataStart; i + 3 < dataEnd; i += 4) o.setInt32(i, clamp(o.getInt32(i, true) * gain, -2147483648, 2147483647), true)
72 } else if (format === 3 && bits === 32) {
73 for (let i = dataStart; i + 3 < dataEnd; i += 4) o.setFloat32(i, o.getFloat32(i, true) * gain, true)
74 } else {
75 return null
76 }
77 return out
78}
79
80// --------------------------------------------------------------------- helpers
81
82export function fnv1a(text: string): string {
83 let h = 0x811c9dc5
84 for (let i = 0; i < text.length; i++) {
85 h ^= text.charCodeAt(i)
86 h = Math.imul(h, 0x01000193)
87 }
88 return (h >>> 0).toString(16).padStart(8, '0')
89}
90
91export function fromBase64(base64: string): Uint8Array {
92 const binary = atob(base64)
93 const bytes = new Uint8Array(binary.length)
94 for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
95 return bytes
96}
97
98export function toBase64(bytes: Uint8Array): string {
99 let binary = ''
100 for (let i = 0; i < bytes.length; i += 0x8000) {
101 binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
102 }
103 return btoa(binary)
104}
105types/index.d.ts 35 lines1/** A moment Chime can announce. */
2export type ChimeEvent = 'done' | 'waiting' | 'error' | 'idle'
3
4/**
5 * What plays for one event: a bundled sound's id (`tada`), `off`, or the
6 * absolute path of an audio file of the user's own.
7 */
8export type ChimeSound = string
9
10export type ChimeSettings = {
11 /** Master switch. */
12 isEnabled: boolean
13 /** 0 to 100. */
14 volume: number
15 sounds: Record<ChimeEvent, ChimeSound>
16 /** The done sound plays only for turns at least this long (0 = always). */
17 minTurnSeconds: number
18 /** `HH:MM-HH:MM` in local time, or empty for none. */
19 quietHours: string
20 /** Whether to play in a session reached over SSH (the sound plays on the remote machine). */
21 isPlayingOverSsh: boolean
22}
23
24declare module 'claude-code' {
25 interface PluginState {
26 chime: {
27 settings: ChimeSettings
28 /** The picker's screen: `main`, an event's sound list, `volume` or `snooze`. */
29 screen: string
30 /** Epoch ms until which Chime stays silent; 0 when not snoozed. */
31 snoozeUntil: number
32 }
33 }
34}
35