Play a sound and post a desktop notification when Claude finishes a turn or needs you to answer a question or a permission prompt.

A sound and a desktop notification when Claude finishes a turn or needs you. Two different sounds, so from across the room you know whether to come back now (Claude is asking a question or waiting on a permission prompt) or whenever (the turn is done).
Claude Code's own preferredNotifChannel setting already gives you a bell or a desktop notification when a turn completes. What it doesn't do is tell you that Claude is waiting on an answer or a permission prompt, which is the case that costs you twenty minutes. ding adds that, with a different sound so you can tell the two apart, and puts the first line of the answer or the question in the notification.
/plugin marketplace add MDmubarak786/claude-mods
/plugin install ding@modhub
Try it for one session without installing:
claude --plugin-dir ./mods/ding
| Command | What it does |
|---|---|
/ding | Show whether it's on, and which OS it detected. |
/ding off | Quiet, on this machine, until /ding on. Saved across sessions. |
/ding test | Play both sounds now. |
| Moment | Sound (macOS) | Notification |
|---|---|---|
| The main turn ends | Glass | "Claude finished" and the first line of the answer |
| The turn was interrupted | Glass | "Claude stopped" |
| Claude asks you a question | Ping | "Claude has a question" and the question |
| A tool call is about to show a permission prompt | Ping | "Claude needs permission" and the tool name |
Subagents are silent. On an OS it doesn't recognize, it shows a toast inside Claude Code instead.
From claude plugin validate ./mods/ding:
hooks: session.start, command.run{command=ding}, turn.complete, tool.call{tool=AskUserQuestion}, tool.check
calls: $.command.register, $.process.run (via loadSettings, notify), $.store.get (via loadSettings), $.store.set, $.ui.log, $.ui.toast (via notify)
$.process.run starts uname once at session start, then afplay and osascript on macOS or paplay and notify-send on Linux. The title and body are passed as arguments, never spliced into a script.tool.check is observe-only. The hook awaits the decision Claude Code reached, dings if it's ask, and returns that decision unchanged. It never approves or refuses anything.claude plugin validate --strict and claude plugin test pass. The Linux path is covered by tests only.paplay) and a notification daemon (notify-send). If either is missing, the call fails quietly and nothing plays./ding off while you're pairing with it.MIT, see the repository root.
hooks/register.ts 119 lines1// ding: a sound and a desktop notification when Claude finishes a turn or needs you.
2//
3// /ding show whether it's on
4// /ding off quiet for this machine until /ding on
5// /ding on turn it back on
6// /ding test play both sounds now
7//
8// "Done" fires at the end of a main-conversation turn. "Needs you" fires when
9// Claude asks a question (AskUserQuestion) or a tool call is about to show you a
10// permission prompt. Subagent turns and calls are ignored. Sounds and
11// notifications use the system's own tools, so there are no audio files here.
12
13type Kind = 'done' | 'needs-you'
14
15let os = '' // 'Darwin', 'Linux', or '' when unknown
16let enabled = true
17
18async function loadSettings($) {
19 try {
20 const saved = await $.store.get('enabled')
21 enabled = saved !== false
22 } catch {
23 enabled = true
24 }
25 try {
26 const uname = await $.process.run(['uname'])
27 os = uname.stdout.trim()
28 } catch {
29 os = ''
30 }
31}
32
33// Fire and forget: nothing here is awaited, so a slow player never delays Claude.
34function notify($, kind: Kind, title: string, body: string) {
35 if (!enabled) return
36 if (os === 'Darwin') {
37 const sound = kind === 'done' ? '/System/Library/Sounds/Glass.aiff' : '/System/Library/Sounds/Ping.aiff'
38 $.process.run(['afplay', sound]).catch(() => {})
39 // Title and body go in as arguments, never spliced into the script.
40 $.process.run([
41 'osascript',
42 '-e', 'on run argv',
43 '-e', 'display notification (item 2 of argv) with title (item 1 of argv)',
44 '-e', 'end run',
45 title, body,
46 ]).catch(() => {})
47 } else if (os === 'Linux') {
48 const sound = kind === 'done' ? '/usr/share/sounds/freedesktop/stereo/complete.oga' : '/usr/share/sounds/freedesktop/stereo/dialog-information.oga'
49 $.process.run(['paplay', sound]).catch(() => {})
50 $.process.run(['notify-send', '--app-name=Claude Code', title, body]).catch(() => {})
51 } else {
52 $.ui.toast(title + ': ' + body)
53 }
54}
55
56function excerpt(text: string): string {
57 const line = text.replace(/\s+/g, ' ').trim()
58 return line.length > 100 ? line.slice(0, 99) + '…' : line || 'Turn finished'
59}
60
61export function register(on) {
62 on('session.start', async ($, e, next) => {
63 await loadSettings($)
64 try {
65 await $.command.register({
66 name: 'ding',
67 description: 'Sound and notification when Claude finishes or needs you',
68 argumentHint: '[on | off | test]',
69 immediate: true,
70 })
71 } catch (error) {
72 $.ui.log('could not register /ding: ' + error)
73 }
74 return next(e)
75 })
76
77 on('command.run', { command: 'ding' }, async ($, e) => {
78 const args = e.args.trim()
79 if (args === 'off' || args === 'on') {
80 enabled = args === 'on'
81 await $.store.set('enabled', enabled)
82 return { text: enabled ? 'ding on.' : 'ding off. Run /ding on to turn it back on.' }
83 }
84 if (args === 'test') {
85 notify($, 'done', 'Claude finished', 'This is the done sound.')
86 notify($, 'needs-you', 'Claude needs you', 'This is the needs-you sound.')
87 return { text: enabled ? 'Played both.' : 'ding is off, so nothing played. Run /ding on first.' }
88 }
89 return { text: (enabled ? 'ding on' : 'ding off') + (os ? ' (' + os + ')' : ' (unknown OS: toasts only)') + '. /ding off, /ding on, /ding test' }
90 }).catch(async () => ({ text: 'ding: the command failed, so nothing changed.' }))
91
92 // Done: the main conversation's turn ended. Subagent turns carry agentId.
93 on('turn.complete', async ($, e, next) => {
94 if (typeof e.agentId !== 'string') {
95 notify($, 'done', e.isAborted ? 'Claude stopped' : 'Claude finished', excerpt(e.answer))
96 }
97 return next(e)
98 })
99
100 // Needs you: Claude is asking a question.
101 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
102 if (typeof e.agentId !== 'string') {
103 const first = Array.isArray(e.questions) && e.questions[0] ? String(e.questions[0].question) : 'Claude has a question'
104 notify($, 'needs-you', 'Claude has a question', excerpt(first))
105 }
106 return next(e)
107 }).catch(async ($, e, next) => next(e))
108
109 // Needs you: a tool call is about to show a permission prompt. Observe only:
110 // the decision is returned exactly as it came.
111 on('tool.check', async ($, e, next) => {
112 const decided = await next(e)
113 if (decided.decision === 'ask' && typeof e.agentId !== 'string') {
114 notify($, 'needs-you', 'Claude needs permission', e.tool)
115 }
116 return decided
117 }).catch(async ($, e, next) => next(e))
118}
119