Plays a macOS system sound when Claude finishes a turn

Plays a sound when Claude finishes a reply, so you can switch to another window during a long task and hear when it's done.
The sound plays in the background, so it never delays the end of a turn.
| Command | Does |
|---|---|
/sound | Mutes or unmutes |
/sound off | Mutes |
/sound on | Unmutes |
/sound test | Plays the done sound now |
The command runs at once, even while Claude is working. Muting is remembered across sessions, and the status line shows sound off while muted.
| Setting | Default | Does |
|---|---|---|
sound | Glass | The sound for a finished reply |
errorSound | Basso | The sound for an error or a refusal |
minSeconds | 0 | Stays silent for turns shorter than this many seconds. Set it to around 15 to hear only long tasks. |
sound and errorSound take any macOS system sound: Basso, Blow, Bottle, Frog, Funk, Glass, Hero, Morse, Ping, Pop, Purr, Sosumi, Submarine or Tink.
The settings appear in Claude Code's config menu. To see or set them from the command line:
claude plugin configure done-sound@claude-mods
| Hook | What it does |
|---|---|
session.start | Registers /sound, and shows sound off in the status line if muted. |
command.run for sound | Handles /sound. |
turn.complete | After a turn of the main conversation ends, plays the sound with afplay from /System/Library/Sounds, from a timer so the turn doesn't wait for playback. |
If afplay fails, a toast says so.
afplay and the macOS system sounds.hooks/register.ts 79 lines1import type { EngineInterface, Register } from 'claude-code'
2
3const SOUNDS_DIR = '/System/Library/Sounds'
4const MUTED_KEY = 'isMuted'
5
6const play = ($: EngineInterface, name: string) =>
7 $.process
8 .run(['afplay', `${SOUNDS_DIR}/${name}.aiff`], { timeoutMs: 10_000 })
9 .then(({ exitCode, stderr }) => {
10 if (exitCode !== 0) {
11 $.ui.toast(`done-sound: afplay failed: ${stderr.trim().slice(0, 80)}`)
12 }
13 })
14 .catch(() => $.ui.toast('done-sound: could not run afplay'))
15
16const isMuted = async ($: EngineInterface) => (await $.store.get(MUTED_KEY)) === true
17
18const setMuted = async ($: EngineInterface, value: boolean) => {
19 await $.store.set(MUTED_KEY, value)
20 $.ui.status(value ? 'sound off' : undefined)
21}
22
23export const register: Register = (on, options) => {
24 const sound = String(options.sound ?? 'Glass')
25 const errorSound = String(options.errorSound ?? 'Basso')
26 const minMs = Number(options.minSeconds ?? 0) * 1000
27
28 on('session.start', async ($, e, next) => {
29 await $.command.register({
30 name: 'sound',
31 description: 'Toggle the turn-finished sound (on, off, test)',
32 argumentHint: '[on|off|test]',
33 immediate: true,
34 })
35 $.ui.status((await isMuted($)) ? 'sound off' : undefined)
36
37 return next(e)
38 })
39
40 on('command.run', { command: 'sound' }, async ($, e) => {
41 const arg = e.args.trim().toLowerCase()
42
43 if (arg === 'test') {
44 await play($, sound)
45 return { text: `Played ${sound}.` }
46 }
47
48 const wasMuted = await isMuted($)
49 const nextMuted = arg === 'on' ? false : arg === 'off' ? true : !wasMuted
50
51 if (arg !== '' && arg !== 'on' && arg !== 'off') {
52 return { text: 'Usage: /sound [on|off|test]' }
53 }
54
55 await setMuted($, nextMuted)
56
57 return { text: nextMuted ? 'Done sound off.' : 'Done sound on.' }
58 })
59
60 on('turn.complete', async ($, e, next) => {
61 const result = await next(e)
62
63 // Main conversation only: subagent turns end mid-task, and an interrupted
64 // turn means the person is already at the keyboard.
65 const isSilent =
66 e.agentId !== undefined || e.isAborted || e.durationMs < minMs || (await isMuted($))
67 if (isSilent) {
68 return result
69 }
70
71 const name = e.reason === 'answer' ? sound : errorSound
72
73 // Played from a timer so the turn's completion never waits on playback.
74 $.clock.after(0, () => void play($, name))
75
76 return result
77 })
78}
79