Plays a chime and shows a toast when a long turn finishes (or Claude is waiting on your approval), so you can walk away.

Plays a chime and shows a toast when a long Claude Code turn finishes, so you can walk away.
/plugin marketplace add troyjlorents-gh/mod-squad
/plugin install ping@mod-squad
minSeconds plays a two-note chime and toasts ✔ Done in 2m 14s.✖ Turn ended: error after 1m 3s.minSeconds, a soft single tone and ⏳ Waiting for your approval (Bash) after 45s. (In auto mode the classifier may answer the ask before you do.)/ping shows settings, /ping on, /ping off (remembered across sessions), /ping test plays the done chime.minSeconds (default 30), sound (default on; off = toast only).Sound depends on the surface: it plays with afplay on macOS; Linux/Windows terminals and remote/mobile sessions have no player, so you get the toast only.
Chimes live in sounds/ (44.1 kHz mono 16-bit WAV); regenerate them with python3 scripts/make-sounds.py.
Test: claude plugin test mods/ping. Validate: claude plugin validate mods/ping.
hooks/register.ts 113 lines1import type { EngineInterface, Register } from 'claude-code'
2
3type Options = { minSeconds?: number; sound?: boolean }
4
5export const SOUNDS = {
6 done: 'sounds/done.wav',
7 error: 'sounds/error.wav',
8 attention: 'sounds/attention.wav',
9} as const
10
11/** 45s, 2m 14s, 1h 3m. */
12export function formatDuration(ms: number): string {
13 const s = Math.max(0, Math.round(ms / 1000))
14 if (s < 60) return `${s}s`
15 const m = Math.floor(s / 60)
16 if (m < 60) return `${m}m ${s % 60}s`
17 return `${Math.floor(m / 60)}h ${m % 60}m`
18}
19
20async function isOn($: EngineInterface): Promise<boolean> {
21 try {
22 return (await $.store.get('enabled')) !== false
23 } catch {
24 return true
25 }
26}
27
28/** Toast, then the chime when sound is on; a surface with no audio (or any failure) leaves the toast alone. */
29async function ping($: EngineInterface, text: string, asset: string | undefined, sound: boolean) {
30 try {
31 $.ui.toast(text)
32 } catch {}
33 if (!sound || asset === undefined) return
34 try {
35 await $.audio.play({ asset })
36 } catch {}
37}
38
39async function now($: EngineInterface): Promise<number | undefined> {
40 try {
41 return await $.clock.now()
42 } catch {
43 return undefined
44 }
45}
46
47export const register: Register = (on, options) => {
48 const opts = (options ?? {}) as Options
49 const minMs = Math.max(0, Number(opts.minSeconds ?? 30)) * 1000
50 const sound = opts.sound !== false
51
52 // When the current main-loop turn started (module state; a reload resetting it is harmless).
53 let turnStartedAt: number | undefined
54 const asked = new Set<string>()
55
56 on('session.start', async ($, e, next) => {
57 await $.command.register({
58 name: 'ping',
59 description: 'Ping when a long turn finishes: on | off | test, or bare to show settings',
60 argumentHint: '[on | off | test]',
61 immediate: true,
62 })
63 return next(e)
64 })
65
66 on('turn.start', async ($, e, next) => {
67 turnStartedAt = await now($)
68 asked.clear()
69 return next(e)
70 })
71
72 on('turn.complete', async ($, e, next) => {
73 const result = await next(e)
74 if (e.agentId !== undefined || e.durationMs < minMs || e.reason === 'aborted') return result
75 if (!(await isOn($))) return result
76 const took = formatDuration(e.durationMs)
77 if (e.reason === 'answer') await ping($, `✔ Done in ${took}`, SOUNDS.done, sound)
78 else await ping($, `✖ Turn ended: ${e.reason} after ${took}`, SOUNDS.error, sound)
79 return result
80 })
81
82 // A real call whose verdict is `ask` goes to the person (or, in auto mode, the classifier).
83 on('tool.check', async ($, e, next) => {
84 const verdict = await next(e)
85 try {
86 if (verdict.decision !== 'ask' || e.tool_use_id === undefined || asked.has(e.tool_use_id)) return verdict
87 asked.add(e.tool_use_id)
88 const t = await now($)
89 if (turnStartedAt === undefined || t === undefined || t - turnStartedAt < minMs) return verdict
90 if (!(await isOn($))) return verdict
91 await ping($, `⏳ Waiting for your approval (${e.tool}) after ${formatDuration(t - turnStartedAt)}`, SOUNDS.attention, sound)
92 } catch {}
93 return verdict
94 }).catch(($, e, next) => next(e)) // we never gate: on any failure, the verdict beneath stands
95
96 on('command.run', { command: 'ping' }, async ($, e) => {
97 const verb = e.args.trim().toLowerCase()
98 if (verb === 'on' || verb === 'off') {
99 await $.store.set('enabled', verb === 'on')
100 return { text: `Ping: ${verb}.` }
101 }
102 if (verb === 'test') {
103 await ping($, '✔ Ping test', SOUNDS.done, sound)
104 return { text: sound ? 'Ping: played done.wav (if this surface can play audio).' : 'Ping: sound is off in settings; toast only.' }
105 }
106 if (verb !== '') return { text: 'Usage: /ping [on | off | test]' }
107 const enabled = await isOn($)
108 return {
109 text: `Ping: ${enabled ? 'on' : 'off'} · turns ≥ ${minMs / 1000}s · sound ${sound ? 'on' : 'off (toast only)'}`,
110 }
111 })
112}
113