SLOPSHOPPER

done-ding

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.

newcommandtoastprocessnetworktimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · done-ding
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /ding ⎿ done-ding: On: it pings after turns of 30s or longer, and when Claude is waiting on you. Sound: chime. Try /ding test, /d ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

🔔 done-ding

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.

Features

  • Done pings: a turn of at least 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.
  • Waiting pings: permission prompts, the "waiting for your input" reminder and MCP input dialogs ping too, at most once a minute. The idle reminder right after a done ping is skipped.
  • Errors and refusals say so: An error ended the turn after 3m 2s.
  • Sound: a short two-note chime synthesized in code, so the mod ships no audio files. say speaks "Claude is done" instead, and none stays quiet.
  • Works where it can: osascript on macOS, notify-send on Linux, otherwise a toast inside Claude Code. A missing notifier never errors.
  • Stays out of the way: interrupted turns, subagent turns and headless 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.
  • Phone pushes (optional). Set 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.

Install

/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.

Configuration

Set these in /config, or under pluginConfigs in settings.json.

OptionDefaultWhat it does
minSeconds30Only turns at least this long ping you.
soundchimechime, say (speaks a line), or none.
desktoptrueShow a desktop notification. Off: a toast inside Claude Code.
notifyOnWaitingtrueAlso ping when Claude needs a permission or your input.
inHeadlessfalseAlso ping for claude -p and SDK runs.
ntfyTopicemptyYour ntfy topic for phone pushes (letters, digits, -, _). Empty: no pushes.
ntfyServerhttps://ntfy.shThe ntfy server to publish to.

How it works

Event / APIWhy
turn.completeMain-thread turns that weren't interrupted and ran at least minSeconds
classic.NotificationThe settings Notification event: permission_prompt, idle_prompt, elicitation_dialog
session.startRegisters /ding and notes whether a person is at the prompt
$.process.runosascript (argv, no shell quoting) or notify-send
$.audio.play / $.audio.speakThe synthesized WAV chime (hooks/chime.ts), or speech
$.clock.afterSends the ping after the turn has finished, so it never delays the answer
$.http.fetchOnly with ntfyTopic set: one JSON POST to your ntfy server per ping

Test it

claude plugin test mods/notifications/done-ding   # 35 tests

Limitations

  • It can't tell whether you're looking at the terminal, so minSeconds is the only filter for done pings.
  • Windows has no desktop notifier here yet, so it falls back to a toast.
  • If you already have a settings Notification hook that notifies you, turn notifyOnWaiting off to avoid double pings.
  • A phone push sends the ping's text (the turn's length, the first line of the answer, the project name) to your ntfy server. On the public ntfy.sh, anyone who guesses your topic can read it, so pick a long random one, or self-host.
Source 3 files
hooks/register.ts 185 lines
1import 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}
185
hooks/chime.ts 80 lines
1// 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}
80
hooks/text.ts 60 lines
1// 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