Go make coffee: a desktop notification, a chime, and optionally a phone push when a long Claude turn finishes or Claude is waiting on you.

Go make coffee. done-ding pings you with a desktop notification and a chime when a long Claude turn finishes, or when Claude is waiting on you.
┌──────────────────────────────────────────────┐
│ 🔔 Claude Code │
│ api │
│ Done in 4m 12s · Refactored the auth module… │
└──────────────────────────────────────────────┘
♪ ding-dong
┌──────────────────────────────────────────────┐
│ 🔔 Claude Code needs you │
│ api │
│ Claude needs your permission to use Bash │
└──────────────────────────────────────────────┘
Long turns are when you switch windows, and they're also when you miss the moment Claude finishes or stops to ask a question. done-ding only pings for turns that took a while. A 3-second answer you were watching stays silent.
minSeconds (30 by default) on the main thread gets a desktop notification with how long it took and the answer's first line, e.g. Done in 2m 14s · Fixed the flaky test. The subtitle is the project name.An error ended the turn after 3m 2s.say speaks "Claude is done" instead, and none stays quiet.osascript on macOS, notify-send on Linux, otherwise a toast inside Claude Code. A missing notifier never errors.claude -p runs don't ping. Headless runs can opt in./ding test sends a sample, /ding mute and /ding unmute silence it for the session, and /ding shows the settings.ntfyTopic and every ping also lands on your phone through ntfy: install the ntfy app, subscribe to a hard-to-guess topic, done. Works with a self-hosted ntfy server too./plugin marketplace add Singh-AP/awesome-claude-mods
/plugin install done-ding@awesome-claude-mods
Requires Claude Code 2.1.287 or later. On macOS, the first notification may ask you to allow notifications for Script Editor (osascript) in System Settings → Notifications.
Set these in /config, or under pluginConfigs in settings.json.
| Option | Default | What it does |
|---|---|---|
minSeconds | 30 | Only turns at least this long ping you. |
sound | chime | chime, say (speaks a line), or none. |
desktop | true | Show a desktop notification. Off: a toast inside Claude Code. |
notifyOnWaiting | true | Also ping when Claude needs a permission or your input. |
inHeadless | false | Also ping for claude -p and SDK runs. |
ntfyTopic | empty | Your ntfy topic for phone pushes (letters, digits, -, _). Empty: no pushes. |
ntfyServer | https://ntfy.sh | The ntfy server to publish to. |
| Event / API | Why |
|---|---|
turn.complete | Main-thread turns that weren't interrupted and ran at least minSeconds |
classic.Notification | The settings Notification event: permission_prompt, idle_prompt, elicitation_dialog |
session.start | Registers /ding and notes whether a person is at the prompt |
$.process.run | osascript (argv, no shell quoting) or notify-send |
$.audio.play / $.audio.speak | The synthesized WAV chime (hooks/chime.ts), or speech |
$.clock.after | Sends the ping after the turn has finished, so it never delays the answer |
$.http.fetch | Only with ntfyTopic set: one JSON POST to your ntfy server per ping |
claude plugin test mods/notifications/done-ding # 35 tests
minSeconds is the only filter for done pings.Notification hook that notifies you, turn notifyOnWaiting off to avoid double pings.hooks/register.ts 185 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { chimeBase64 } from './chime'
4import { baseName, donePing, waitingPing, type Ping } from './text'
5
6// Two pings closer together than this are one too many.
7const COOLDOWN_MS = 60_000
8
9type Push = { server: string; topic: string }
10
11let chime = ''
12let isMuted = false
13let isInteractive = true
14let lastPingAt = Number.NEGATIVE_INFINITY
15let project: string | undefined
16// Which desktop notifier this machine has, once a ping has found out.
17let notifier: 'osascript' | 'notify-send' | 'none' | undefined
18
19async function projectName($: EngineInterface): Promise<string> {
20 if (project !== undefined) return project
21 try {
22 const repo = await $.session.repo()
23 project = baseName(repo?.root ?? (await $.session.root()))
24 } catch {
25 project = ''
26 }
27 return project
28}
29
30/** Shows a desktop notification; says which notifier took it, or `none`. */
31async function desktop($: EngineInterface, ping: Ping): Promise<'osascript' | 'notify-send' | 'none'> {
32 if (notifier === undefined || notifier === 'osascript') {
33 try {
34 const shown = await $.process.run(
35 [
36 'osascript',
37 '-e', 'on run argv',
38 '-e', 'display notification (item 1 of argv) with title (item 2 of argv) subtitle (item 3 of argv)',
39 '-e', 'end run',
40 ping.body, ping.title, ping.subtitle,
41 ],
42 { timeoutMs: 5000 },
43 )
44 notifier = 'osascript'
45 return shown.exitCode === 0 ? 'osascript' : 'none'
46 } catch {
47 // No osascript: not a Mac. Try the next one.
48 if (notifier === 'osascript') return 'none'
49 }
50 }
51 if (notifier === undefined || notifier === 'notify-send') {
52 try {
53 const summary = ping.subtitle === '' ? ping.title : `${ping.title} · ${ping.subtitle}`
54 const shown = await $.process.run(['notify-send', '--app-name=Claude Code', summary, ping.body], { timeoutMs: 5000 })
55 notifier = 'notify-send'
56 return shown.exitCode === 0 ? 'notify-send' : 'none'
57 } catch {
58 // Nothing to notify with on this machine.
59 }
60 }
61 notifier = 'none'
62 return 'none'
63}
64
65/** Pushes the ping to a phone through an ntfy server; says whether it took. */
66async function pushToPhone($: EngineInterface, ping: Ping, push: Push): Promise<boolean> {
67 if (push.topic === '') return false
68 try {
69 // JSON publishing (POST to the server root) keeps non-ASCII titles out of headers.
70 const sent = await $.http.fetch(push.server, {
71 method: 'POST',
72 headers: { 'Content-Type': 'application/json' },
73 body: JSON.stringify({
74 topic: push.topic,
75 title: ping.subtitle === '' ? ping.title : `${ping.title} · ${ping.subtitle}`,
76 message: ping.body,
77 tags: ['robot'],
78 }),
79 })
80 return sent.ok
81 } catch {
82 return false
83 }
84}
85
86async function playSound($: EngineInterface, sound: string, phrase: string): Promise<void> {
87 try {
88 if (sound === 'chime') {
89 if (chime === '') chime = chimeBase64()
90 await $.audio.play({ base64: chime, mime: 'audio/wav' })
91 } else if (sound === 'say') {
92 await $.audio.speak(phrase)
93 }
94 } catch {
95 // No player or voice here: the notification is enough.
96 }
97}
98
99/** Notifies on every channel the person turned on; says where it showed. */
100async function announce($: EngineInterface, message: Ping, sound: string, shouldNotify: boolean, push: Push): Promise<string> {
101 const [where, isPushed] = await Promise.all([
102 shouldNotify ? desktop($, message) : Promise.resolve('none' as const),
103 pushToPhone($, message, push),
104 ])
105 if (where === 'none') $.ui.toast(message.body)
106 void playSound($, sound, message.phrase)
107 const shown = where === 'none' ? 'a toast' : `a desktop notification (${where})`
108 return isPushed ? `${shown} and a phone push` : shown
109}
110
111export const register: Register = (on, options) => {
112 const minMs = Math.max(0, Number(options.minSeconds ?? 30)) * 1000
113 const sound = String(options.sound ?? 'chime')
114 const shouldNotify = options.desktop !== false
115 const notifyOnWaiting = options.notifyOnWaiting !== false
116 const inHeadless = options.inHeadless === true
117 const topic = String(options.ntfyTopic ?? '').trim()
118 const push: Push = {
119 server: String(options.ntfyServer ?? 'https://ntfy.sh').trim().replace(/\/+$/, ''),
120 // A topic is the only secret on a public ntfy server; anything odd is ignored.
121 topic: /^[A-Za-z0-9_-]{1,64}$/.test(topic) ? topic : '',
122 }
123
124 const isListening = () => !isMuted && (isInteractive || inHeadless)
125
126 on('session.start', async ($, e, next) => {
127 isInteractive = e.isInteractive
128 await $.command.register({
129 name: 'ding',
130 description: 'done-ding: test the notification, or mute it for this session',
131 argumentHint: '[test|mute|unmute]',
132 })
133 return next(e)
134 })
135
136 on('turn.complete', async ($, e, next) => {
137 const out = await next(e)
138 if (e.agentId !== undefined || e.isAborted || e.reason === 'aborted') return out
139 if (!isListening() || e.durationMs < minMs) return out
140
141 const message = donePing(e, await projectName($))
142 lastPingAt = await $.clock.now()
143 $.clock.after(0, () => void announce($, message, sound, shouldNotify, push))
144 return out
145 })
146
147 on('classic.Notification', async ($, e, next) => {
148 const out = await next(e)
149 if (!notifyOnWaiting || !isListening()) return out
150
151 const message = waitingPing(e, await projectName($))
152 if (message === undefined) return out
153 const now = await $.clock.now()
154 if (now - lastPingAt < COOLDOWN_MS) return out
155 lastPingAt = now
156 $.clock.after(0, () => void announce($, message, sound, shouldNotify, push))
157 return out
158 })
159
160 on('command.run', { command: 'ding' }, async ($, e) => {
161 const verb = e.args.trim().toLowerCase()
162 if (verb === 'mute') {
163 isMuted = true
164 return { text: 'Muted for this session. /ding unmute turns it back on.' }
165 }
166 if (verb === 'unmute') {
167 isMuted = false
168 return { text: 'On again.' }
169 }
170 if (verb === 'test') {
171 const message = donePing({ durationMs: 134_000, answer: 'This is what a finished turn looks like.', reason: 'answer' }, await projectName($))
172 const where = await announce($, message, sound, shouldNotify, push)
173 return { text: `Sent a test as ${where}${sound === 'none' ? '' : `, with sound: ${sound}`}.` }
174 }
175 const state = isMuted ? 'Muted for this session' : 'On'
176 const waiting = notifyOnWaiting ? ', and when Claude is waiting on you' : ''
177 const phone = push.topic === '' ? '' : ` Phone pushes go to ntfy topic "${push.topic}".`
178 return {
179 text:
180 `${state}: it pings after turns of ${minMs / 1000}s or longer${waiting}. ` +
181 `Sound: ${sound}.${phone} Try /ding test, /ding mute or /ding unmute.`,
182 }
183 })
184}
185hooks/chime.ts 80 lines1// A two-note chime synthesized as a 16-bit mono PCM WAV, so the mod ships no
2// audio files. Pure: no `$`.
3
4type Note = { hz: number; at: number; seconds: number }
5
6// A5 then E6: a rising fifth, bright and short.
7const NOTES: readonly Note[] = [
8 { hz: 880, at: 0, seconds: 0.18 },
9 { hz: 1318.51, at: 0.12, seconds: 0.33 },
10]
11
12/** The chime's samples in -1..1, `seconds` long at `sampleRate`. */
13export function chimeSamples(sampleRate = 22050, seconds = 0.46): Float32Array {
14 const samples = new Float32Array(Math.ceil(sampleRate * seconds))
15 for (const note of NOTES) {
16 const start = Math.floor(note.at * sampleRate)
17 const length = Math.min(Math.floor(note.seconds * sampleRate), samples.length - start)
18 for (let i = 0; i < length; i++) {
19 const t = i / sampleRate
20 // A 5 ms attack, then an exponential decay to silence by the note's end.
21 const envelope = Math.min(1, t / 0.005) * Math.exp(-t * 9) * (1 - i / length)
22 const tone = Math.sin(2 * Math.PI * note.hz * t) + 0.25 * Math.sin(4 * Math.PI * note.hz * t)
23 samples[start + i] = (samples[start + i] ?? 0) + 0.3 * envelope * tone
24 }
25 }
26 return samples
27}
28
29/** Encodes samples as a 16-bit mono PCM WAV file. */
30export function encodeWav(samples: Float32Array, sampleRate: number): Uint8Array {
31 const dataBytes = samples.length * 2
32 const bytes = new Uint8Array(44 + dataBytes)
33 const view = new DataView(bytes.buffer)
34 const ascii = (at: number, text: string) => {
35 for (let i = 0; i < text.length; i++) bytes[at + i] = text.charCodeAt(i)
36 }
37
38 ascii(0, 'RIFF')
39 view.setUint32(4, 36 + dataBytes, true)
40 ascii(8, 'WAVE')
41 ascii(12, 'fmt ')
42 view.setUint32(16, 16, true) // fmt chunk size
43 view.setUint16(20, 1, true) // PCM
44 view.setUint16(22, 1, true) // mono
45 view.setUint32(24, sampleRate, true)
46 view.setUint32(28, sampleRate * 2, true) // byte rate
47 view.setUint16(32, 2, true) // block align
48 view.setUint16(34, 16, true) // bits per sample
49 ascii(36, 'data')
50 view.setUint32(40, dataBytes, true)
51 for (let i = 0; i < samples.length; i++) {
52 const clamped = Math.max(-1, Math.min(1, samples[i] ?? 0))
53 view.setInt16(44 + i * 2, Math.round(clamped * 32767), true)
54 }
55 return bytes
56}
57
58const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
59
60/** Standard base64 with padding. */
61export function toBase64(bytes: Uint8Array): string {
62 let out = ''
63 for (let i = 0; i < bytes.length; i += 3) {
64 const a = bytes[i] ?? 0
65 const b = bytes[i + 1] ?? 0
66 const c = bytes[i + 2] ?? 0
67 const n = (a << 16) | (b << 8) | c
68 out += ALPHABET[(n >> 18) & 63]! + ALPHABET[(n >> 12) & 63]!
69 out += i + 1 < bytes.length ? ALPHABET[(n >> 6) & 63]! : '='
70 out += i + 2 < bytes.length ? ALPHABET[n & 63]! : '='
71 }
72 return out
73}
74
75/** The chime as a base64 WAV, ready for `$.audio.play({ base64, mime })`. */
76export function chimeBase64(): string {
77 const rate = 22050
78 return toBase64(encodeWav(chimeSamples(rate), rate))
79}
80hooks/text.ts 60 lines1// What the notifications say. Pure: no `$`.
2
3/** `45s`, `2m 14s`, `1h 3m`. */
4export function formatDuration(ms: number): string {
5 const total = Math.max(0, Math.round(ms / 1000))
6 const h = Math.floor(total / 3600)
7 const m = Math.floor((total % 3600) / 60)
8 const s = total % 60
9 if (h > 0) return `${h}h ${m}m`
10 if (m > 0) return `${m}m ${s}s`
11 return `${s}s`
12}
13
14/** The answer's opening words as one plain line: markdown dropped, cut at `max`. */
15export function firstLine(answer: string, max = 90): string {
16 const plain = answer
17 .replace(/```[\s\S]*?(```|$)/g, ' ')
18 .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
19 .replace(/^\s{0,3}(#{1,6}|>|[-*+]|\d+\.)\s+/gm, '')
20 .replace(/[*_`~|]/g, '')
21 .split('\n')
22 .map(line => line.trim())
23 .find(line => line !== '') ?? ''
24 const line = plain.replace(/\s+/g, ' ')
25 return line.length <= max ? line : `${line.slice(0, max - 1).trimEnd()}…`
26}
27
28export type Ping = { title: string; subtitle: string; body: string; phrase: string }
29
30export type TurnEnd = { durationMs: number; answer: string; reason: string }
31
32/** The notification for a finished turn. */
33export function donePing(turn: TurnEnd, project: string): Ping {
34 const took = formatDuration(turn.durationMs)
35 const subtitle = project
36 if (turn.reason === 'error') {
37 return { title: 'Claude Code stopped', subtitle, body: `An error ended the turn after ${took}`, phrase: 'Claude stopped on an error' }
38 }
39 if (turn.reason === 'refusal') {
40 return { title: 'Claude Code stopped', subtitle, body: `The model declined after ${took}`, phrase: 'Claude stopped' }
41 }
42 const gist = firstLine(turn.answer)
43 return { title: 'Claude Code', subtitle, body: gist === '' ? `Done in ${took}` : `Done in ${took} · ${gist}`, phrase: 'Claude is done' }
44}
45
46// The settings Notification event's types that mean "a person is needed".
47export const WAITING_TYPES: ReadonlySet<string> = new Set(['permission_prompt', 'idle_prompt', 'elicitation_dialog'])
48
49/** The notification for Claude waiting on the person, or undefined when it isn't. */
50export function waitingPing(event: { message: string; notification_type: string }, project: string): Ping | undefined {
51 if (!WAITING_TYPES.has(event.notification_type)) return undefined
52 const body = event.message.trim() === '' ? 'Claude needs your input' : event.message.trim()
53 return { title: 'Claude Code needs you', subtitle: project, body, phrase: 'Claude needs your input' }
54}
55
56/** The last path segment, for a folder name used as the project's name. */
57export function baseName(path: string): string {
58 return path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? ''
59}
60