A desktop notification and/or a sound when a long Claude Code turn is done or when Claude is waiting on you (permission, question, plan). Native on Windows…

A Claude Code mod that tells you when Claude needs you, so you can work in another window:
Each ping can be a desktop notification, a sound, or both, and you choose which events ping. It is native on Windows (a real Windows notification with Windows' own sounds, nothing to install) and also works on WSL, macOS and Linux.
┌──────────────────────────────────────┐
│ Windows PowerShell │
│ Claude Code is done │
│ shop · finished in 4m 12s │
│ Claude Code │
└──────────────────────────────────────┘
At the prompt of a Claude Code terminal session:
/plugin install done-waiting-ping --marketplace AMoncade/done-waiting-ping-claudecode
Answer y to add the marketplace, then pick a scope (user scope = every project). The install screen lets you set the options below; the mod is active right away in that session.
From a shell (the mod loads in your next session, or after /reload-plugins), optionally setting options:
claude plugin install done-waiting-ping --marketplace AMoncade/done-waiting-ping-claudecode --config delivery=popup
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with AMoncade/done-waiting-ping-claudecode, then Add plugin. (The desktop app's WSL sessions load no plugins: there, use the claude CLI inside WSL.)
Then check it works:
/ping test
/ping test waiting
Requires Claude Code 2.1.295 or later (the one-line shell install needs 2.1.292 or later); developed and tested on Claude Code 2.1.301. Works in the terminal, the VS Code extension and the desktop app.
Third-party marketplaces do not update by themselves unless you turn auto-update on (/plugin, Marketplaces, done-waiting-ping, Enable auto-update). To update now:
claude plugin update done-waiting-ping@done-waiting-ping
| System | Popup | Sound | Tested |
|---|---|---|---|
| Windows 10 / 11 | Windows notification (toast) through the built-in Windows PowerShell 5.1 | Windows' notification sounds, or your .wav | Yes, on Windows 11 |
| WSL | The same Windows notification, through Windows' powershell.exe | Same as Windows | Yes, Ubuntu 24.04 on Windows 11 |
| macOS | osascript (display notification) | afplay with a macOS system sound or your file | Unit tests only (no Mac to test on yet) |
| Linux | notify-send (package libnotify-bin / libnotify) | your sound theme through canberra-gtk-play, else paplay or pw-play; a custom .wav can also go through aplay | Unit tests only |
If the system notification cannot be shown, the mod falls back to Claude Code's own terminal notification (a terminal bell, or your terminal's notification), and /ping test tells you why. On macOS a notification that macOS keeps hidden cannot be detected (see Troubleshooting). Over SSH on macOS or Linux, the terminal notification is used directly, since the remote desktop is not where you are. Headless runs (claude -p, and apps built on the Agent SDK) never ping.
Set the options in /config (search for "done-waiting-ping"), on the install screen, with --config key=value at install, or from a shell:
echo '{"delivery":"sound","waitingSound":"mail"}' | claude plugin configure done-waiting-ping@done-waiting-ping --values-stdin
The common choices also have a /ping command.
| Option | Choices | Default | What it does |
|---|---|---|---|
Notifications on (enabled) | on / off | on | Every ping on or off. /ping on, /ping off |
What to ping for (events) | both, done, waiting | both | Finished turns, waiting on you, or both. /ping done, /ping waiting, /ping both |
How to ping (delivery) | popup+sound, popup, sound | popup+sound | Notification with sound, silent notification, or just the sound. /ping popup, /ping sound, /ping popup+sound |
Done: minimum time without you (doneAfterSeconds) | seconds | 60 | A finished turn pings only when at least this long has passed since you last interacted: the start of the turn, or the last question or plan you answered. 0 = every turn. /ping 90, /ping 2m |
Sound when done (doneSound) | default, message, mail, reminder, custom, none | default | See the sound table below |
Sound when waiting (waitingSound) | same | reminder | A different sound, so you can tell them apart |
Custom sound file (doneSoundFile, waitingSoundFile) | a file path | empty | Used when the sound is custom. On Windows: a PCM .wav (if the file is missing or Windows cannot play it, the default sound plays instead). A leading ~/ is your home folder. Sounds are cut at 20 seconds (30 on Linux) |
Idle reminder (idleReminder) | on / off | off | Also ping when Claude Code says it has been waiting for your next message for a while |
Show details (showDetails) | on / off | on | Show the tool of a permission prompt and the first line of Claude's question, as Claude wrote it (it can include file names or values; code is left out). Off: only the project and what happened |
Quiet while you are looking (quietWhenFocused) | on / off | off | Skip pings while one of the listed apps has the keyboard focus. Windows and macOS only |
Apps that count as looking (focusApps) | comma-separated list | WindowsTerminal, Code, Cursor | Windows: process names. macOS: bundle ids (the terminal Claude Code runs in always counts) |
Language (language) | en, fr | en | Language of the notifications and of /ping |
| Choice | Windows | macOS | Linux (sound theme) |
|---|---|---|---|
default | Notification.Default | Glass | complete |
message | Notification.IM | Ping | message-new-instant |
mail | Notification.Mail | Pop | message |
reminder | Notification.Reminder | Hero | bell |
custom | your .wav | your file | your file |
none | silent | silent | silent |
On Windows the four named sounds follow your sound scheme (Control Panel > Sound > Sounds), so they sound like your other notifications; a sound set to (None) there stays silent.
| Command | Does |
|---|---|
/ping or /ping status | Shows the current settings, the system, and the last ping's result |
/ping on, /ping off | Turns every ping on or off |
/ping done, /ping waiting, /ping both | Chooses the events |
/ping popup, /ping sound, /ping popup+sound | Chooses how to ping |
/ping 90, /ping 2m, /ping 1h | Minimum time without you for the done ping |
/ping test, /ping test waiting | Sends a sample now and says how it went (ignores "off" and "quiet while looking") |
/ping reset | Forgets the choices made with /ping, back to /config |
/ping help | Lists the commands |
/ping works even while Claude is busy. A choice made with /ping applies to every open session and is kept across sessions. If you later change the same option in /config, /config wins.
Waiting. Claude Code raises its own "needs your input" notice when a dialog has stayed open about 6 seconds without a keypress: a permission prompt, a question (AskUserQuestion), a plan to approve, an MCP server's request, a background agent that needs you. The mod pings on that notice. So nothing pings while you are answering, nothing pings for a dialog you answer within a few seconds, and in auto mode the calls the classifier approves never ping (no dialog opens). Each dialog pings once.
Done. When a turn of the main conversation ends on its own and at least the minimum time has passed since you last interacted: the start of the turn, or the last question or plan you answered. Claude Code does not tell plugins when you answer a permission prompt, so time spent waiting on one counts as time away: approving a prompt you left for ten minutes can be followed by a done ping soon after. Turns you interrupt (Esc) and subagents' turns never ping. A turn that ended on an error or a refusal says "Claude Code stopped". The time shown is the whole turn's.
Idle (off by default). When Claude Code says it has been waiting for your next message.
/ping command.CLAUDE_CODE_ENTRYPOINT (to tell a headless run), OS, SystemRoot, WSL_DISTRO_NAME, WSLENV, HOME, __CFBundleIdentifier (macOS), SSH_CONNECTION and SSH_TTY.%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File <plugin>\scripts\notify-windows.ps1, with the notification's text in the DWP_PAYLOAD environment variable (base64 of JSON). -ExecutionPolicy Bypass applies to that one process only. The script, scripts/notify-windows.ps1, is short; it:System.Media.SoundPlayer: your .wav (quotes, %VARIABLES% and a leading ~ resolved), or the .wav your sound scheme maps the event to (it reads HKCU\AppEvents\Schemes\Apps\.Default\Notification.<event>), falling back to %WINDIR%\Media\Windows Notify System Generic.wav, for at most 20 seconds;user32.dll (GetForegroundWindow, GetWindowThreadProcessId) which process has the keyboard focus, through a P/Invoke declared at run time, and reads that process's name;System.Web.Extensions).uname -sr, wslpath (to find powershell.exe and the script's Windows path, and your custom sound's), then the same powershell.exe with DWP_PAYLOAD added to WSLENV for it.uname -sr, osascript, afplay, and lsappinfo when "Quiet while you are looking" is on.uname -sr, notify-send, then canberra-gtk-play, paplay, pw-play or aplay./ping, in the plugin's own storage in your Claude Code config folder./ping test; it says why. Notifications can be off for Windows PowerShell or for all apps (Settings > System > Notifications), or blocked by your organization. The toast carries the Windows PowerShell name and icon, as it is shown through it. Do Not Disturb hides banners and mutes the sound a toast plays itself; they still land in the notification center. A sound the mod plays itself ("sound only", or your custom .wav) plays even during Do Not Disturb.osascript notifications to Script Editor: allow notifications for Script Editor in System Settings > Notifications. The sound plays through afplay either way.none. With "sound only" and a sound set to none, nothing is sent. On Linux, install a sound theme (sound-theme-freedesktop) and canberra-gtk-play or paplay.claude --debug; each ping writes a done-waiting-ping: line with where it went, why, and how long it took./plugin uninstall done-waiting-ping
claude plugin validate .
claude plugin test .
hooks/register.ts wires the events and the /ping command, hooks/config.ts reads the options, hooks/text.ts holds every word (English and French), hooks/deliver.ts runs the system's notifier, and scripts/notify-windows.ps1 is the Windows side. Tests are in tests/.
Inspired by partalemre/MODZ done-ping and jrlprost/claude-code-mods jr-done-ping. The macOS notifier follows MODZ's approach (see THIRD_PARTY_NOTICES.md).
MIT, see LICENSE.
hooks/register.ts 420 lines1import type { EngineInterface, Register } from 'claude-code'
2import {
3 applyOverrides,
4 DEFAULTS,
5 DELIVERIES,
6 EVENTS,
7 fromOptions,
8 parseDuration,
9 readOverrides,
10 type Config,
11 type Delivery,
12 type Events,
13 type OverrideField,
14 type Overrides,
15} from './config'
16import { deliver, detectPlatform, PLATFORM_LABEL, type Outcome, type Platform, type Request } from './deliver'
17import {
18 deliveryLabel,
19 describeOutcome,
20 donePing,
21 duration,
22 eventsLabel,
23 helpText,
24 idlePing,
25 projectName,
26 statusText,
27 toolFromMessage,
28 waitingPing,
29 type DoneReason,
30 type Ping,
31 type WaitingWhy,
32} from './text'
33
34// done-waiting-ping: a notification (popup, sound, or both) when a long turn is done and when
35// Claude Code is waiting on you.
36//
37// waiting Claude Code's own "needs your input" notice (classic.Notification). It raises one
38// per dialog, and only once the dialog has stayed open about 6 s with no keypress, so
39// nothing pings while you are answering. While a question or plan dialog is open
40// (tool.call AskUserQuestion / ExitPlanMode) the notice is worded as that question
41// or plan; a permission prompt's tool comes from classic.PermissionRequest, only when
42// exactly one request is pending (never a wrong name, sometimes none).
43// done turn.complete of the main conversation, not interrupted, when at least
44// doneAfterSeconds have passed since you last interacted: the turn's start, or the
45// moment you answered a question or a plan. Claude Code does not tell plugins when you
46// answer a permission prompt, so that wait counts as time away.
47// idle optional: Claude Code's idle notice ("waiting for your input").
48// Headless runs (claude -p and SDK hosts: CLAUDE_CODE_ENTRYPOINT sdk-*) never ping.
49
50const COMMAND = 'ping'
51const STORE_KEY = 'overrides'
52const SAME_NOTICE_MS = 2_000
53const PENDING_REQUEST_MS = 60_000
54const WAITING_TYPES = new Set(['permission_prompt', 'worker_permission_prompt', 'elicitation_dialog', 'elicitation_url_dialog', 'agent_needs_input'])
55
56type OpenDialog = { why: 'question' | 'plan'; question?: string }
57
58type Session = {
59 base: Config
60 config: Config
61 overrides: Overrides
62 started: Promise<void> | null
63 platform: Promise<Platform> | null
64 project: string
65 isInteractive: boolean | null
66 // The latest moment you answered a question or plan in the current main turn (null: none).
67 turnInteractionAt: number | null
68 lastNotice: { key: string; at: number } | null
69 openDialog: OpenDialog | null
70 requests: Array<{ tool: string; at: number }>
71 last: Outcome | null
72}
73
74function freshSession(base: Config): Session {
75 return {
76 base,
77 config: base,
78 overrides: {},
79 started: null,
80 platform: null,
81 project: 'Claude Code',
82 isInteractive: null,
83 turnInteractionAt: null,
84 lastNotice: null,
85 openDialog: null,
86 requests: [],
87 last: null,
88 }
89}
90
91let session: Session = freshSession(DEFAULTS)
92
93function errorText(error: unknown): string {
94 return error instanceof Error ? error.message : String(error)
95}
96
97function debug($: EngineInterface, text: string): void {
98 $.ui.log(`done-waiting-ping: ${text}`, { to: 'debug' })
99}
100
101// Choices made with /ping live in the plugin's store, shared by every session: read them again
102// before each decision, so `/ping off` in one terminal also quiets the others.
103async function refreshOverrides($: EngineInterface): Promise<void> {
104 try {
105 const stored = readOverrides(await $.store.get(STORE_KEY))
106 const { config, kept } = applyOverrides(session.base, stored)
107 session.config = config
108 session.overrides = kept
109 if (Object.keys(kept).length !== Object.keys(stored).length) await $.store.set(STORE_KEY, kept)
110 } catch (error) {
111 debug($, `could not read saved /ping choices (${errorText(error)})`)
112 }
113}
114
115async function startSession($: EngineInterface, cwd: string | undefined): Promise<void> {
116 try {
117 await $.command.register({
118 name: COMMAND,
119 description: 'done-waiting-ping: status, on/off, done/waiting/both, popup/sound, threshold, test',
120 argumentHint: '[status|on|off|done|waiting|both|popup|sound|popup+sound|90|2m|test|reset|help]',
121 immediate: true,
122 })
123 } catch (error) {
124 debug($, `/${COMMAND} not registered (${errorText(error)})`)
125 }
126 await refreshOverrides($)
127 try {
128 session.project = projectName(await $.session.root())
129 } catch {
130 session.project = projectName(cwd)
131 }
132 void platformOf($)
133}
134
135// session.start registers the command; a plugin installed mid-session may never hear it, so
136// every hook makes sure the start ran once.
137async function ensureStarted($: EngineInterface, cwd?: string): Promise<void> {
138 if (session.started === null) session.started = startSession($, cwd ?? (await $.session.cwd()))
139 await session.started
140}
141
142async function platformOf($: EngineInterface): Promise<Platform> {
143 if (session.platform === null) session.platform = detectPlatform($).catch(() => 'unknown' as const)
144 return session.platform
145}
146
147// Nobody is watching a `claude -p` run or an SDK host: Claude Code marks them with an
148// sdk-* entrypoint. VS Code's extension also draws no surface, yet a person is there.
149async function headless($: EngineInterface): Promise<boolean> {
150 const entrypoint = await $.env.get('CLAUDE_CODE_ENTRYPOINT')
151 if (entrypoint !== undefined) return entrypoint.startsWith('sdk-')
152 if (session.isInteractive !== null) return !session.isInteractive
153 try {
154 return (await $.session.surfaces()).length === 0
155 } catch {
156 return false
157 }
158}
159
160async function send($: EngineInterface, ping: Ping, forced: boolean): Promise<Outcome> {
161 const c = session.config
162 if (!forced && (await headless($))) {
163 debug($, `${ping.kind} skipped (headless)`)
164 return { status: 'skipped', via: 'none', reason: 'headless', detail: 'headless run', ms: 0 }
165 }
166 const isDone = ping.kind === 'done'
167 const request: Request = {
168 ping,
169 popup: c.delivery !== 'sound',
170 sound: c.delivery === 'popup' ? 'none' : isDone ? c.doneSound : c.waitingSound,
171 soundFile: isDone ? c.doneSoundFile : c.waitingSoundFile,
172 quietWhenFocused: c.quietWhenFocused && !forced,
173 quietApps: c.focusApps,
174 }
175 const outcome = await deliver($, await platformOf($), request)
176 session.last = outcome
177 debug($, `${ping.kind} ${outcome.status} via ${outcome.via} in ${outcome.ms} ms (${outcome.reason}: ${outcome.detail})`)
178 return outcome
179}
180
181async function onDone($: EngineInterface, durationMs: number, isAborted: boolean, reason: string, interactionAt: number | null): Promise<void> {
182 try {
183 await ensureStarted($)
184 await refreshOverrides($)
185 const c = session.config
186 if (!c.enabled || c.events === 'waiting') return
187 if (isAborted || reason === 'aborted') return
188 const sinceInteraction = interactionAt === null ? durationMs : (await $.clock.now()) - interactionAt
189 if (Math.min(durationMs, sinceInteraction) < c.doneAfterSeconds * 1000) return
190 const why: DoneReason = reason === 'error' ? 'error' : reason === 'refusal' ? 'refusal' : 'answer'
191 await send($, donePing(c.language, session.project, why, durationMs), false)
192 } catch (error) {
193 debug($, `done ping failed (${errorText(error)})`)
194 }
195}
196
197async function onWaiting($: EngineInterface, what: WaitingWhy): Promise<void> {
198 try {
199 await ensureStarted($)
200 await refreshOverrides($)
201 const c = session.config
202 if (!c.enabled || c.events === 'done') return
203 await send($, waitingPing(c.language, session.project, what, c.showDetails), false)
204 } catch (error) {
205 debug($, `waiting ping failed (${errorText(error)})`)
206 }
207}
208
209async function onIdle($: EngineInterface): Promise<void> {
210 try {
211 await ensureStarted($)
212 await refreshOverrides($)
213 const c = session.config
214 if (!c.enabled || !c.idleReminder) return
215 await send($, idlePing(c.language, session.project), false)
216 } catch (error) {
217 debug($, `idle ping failed (${errorText(error)})`)
218 }
219}
220
221// The tool of the one permission request pending: with none, or several (queued, parallel, or
222// one already answered without a notice), the name is left out rather than guessed.
223function pendingTool(now: number): string | null {
224 const recent = session.requests.filter(request => now - request.at <= PENDING_REQUEST_MS)
225 session.requests = []
226 return recent.length === 1 ? (recent[0]?.tool ?? null) : null
227}
228
229// What a "needs your input" notice is about.
230function waitingFor(type: string, message: string, now: number): WaitingWhy {
231 if (type === 'agent_needs_input') return { why: 'agent' }
232 if (type.startsWith('elicitation')) return { why: 'mcp' }
233 if (type === 'worker_permission_prompt') return { why: 'permission' }
234 if (session.openDialog !== null) return { ...session.openDialog }
235 const tool = toolFromMessage(message) ?? (/permission/i.test(message) ? pendingTool(now) : null)
236 if (tool === 'AskUserQuestion') return { why: 'question' }
237 if (tool === 'ExitPlanMode' || /approval for the plan/i.test(message)) return { why: 'plan' }
238 if (tool !== null) return { why: 'permission', tool }
239 if (/permission/i.test(message)) return { why: 'permission' }
240 return { why: 'other' }
241}
242
243async function onNotification($: EngineInterface, type: string, message: string): Promise<void> {
244 // $.ui.notify (this mod's fallback, or any plugin's) comes back here as plugin_notification.
245 if (type === 'plugin_notification') return
246 if (type === 'idle_prompt') return onIdle($)
247 if (!WAITING_TYPES.has(type)) return
248 try {
249 const now = await $.clock.now()
250 const key = `${type}\n${message}`
251 // Claude Code raises one notice per dialog; this only drops an exact repeat of the same one.
252 if (session.lastNotice !== null && session.lastNotice.key === key && now - session.lastNotice.at < SAME_NOTICE_MS) return
253 session.lastNotice = { key, at: now }
254 await onWaiting($, waitingFor(type, message, now))
255 } catch (error) {
256 debug($, `notice not handled (${errorText(error)})`)
257 }
258}
259
260function questionOf(e: unknown): string | undefined {
261 const questions = (e as { questions?: unknown }).questions
262 if (!Array.isArray(questions) || questions.length === 0) return undefined
263 const first = questions[0] as { question?: unknown } | undefined
264 return typeof first?.question === 'string' ? first.question : undefined
265}
266
267// A question or plan dialog is open while its tool call runs, and the call ends when you answer:
268// that moment is an interaction.
269async function watchDialog<R>($: EngineInterface, dialog: OpenDialog, run: () => Promise<R>): Promise<R> {
270 session.openDialog = dialog
271 try {
272 return await run()
273 } finally {
274 if (session.openDialog === dialog) session.openDialog = null
275 try {
276 session.turnInteractionAt = await $.clock.now()
277 } catch {
278 // the done ping keeps measuring from the turn's start
279 }
280 }
281}
282
283async function setOverride($: EngineInterface, field: OverrideField, value: unknown): Promise<void> {
284 // Read, change one field, write: another session's choices stay.
285 const stored = readOverrides(await $.store.get(STORE_KEY))
286 if (value === session.base[field]) delete stored[field]
287 else stored[field] = { value, base: session.base[field] }
288 const { config, kept } = applyOverrides(session.base, stored)
289 session.config = config
290 session.overrides = kept
291 await $.store.set(STORE_KEY, kept)
292}
293
294const EVENT_WORDS: Record<string, Events> = {
295 done: 'done',
296 'terminé': 'done',
297 termine: 'done',
298 waiting: 'waiting',
299 attente: 'waiting',
300 both: 'both',
301 tout: 'both',
302 'les deux': 'both',
303}
304
305function deliveryOf(words: string): Delivery | null {
306 const joined = words.replace(/\bson\b/g, 'sound').replace(/\s*(\+|and|et|&)\s*|\s+/g, '+')
307 if (joined === 'sound+popup') return 'popup+sound'
308 return (DELIVERIES as readonly string[]).includes(joined) ? (joined as Delivery) : null
309}
310
311async function runCommand($: EngineInterface, args: string): Promise<{ text: string }> {
312 await ensureStarted($)
313 await refreshOverrides($)
314 const joined = args.trim().toLowerCase().replace(/\s+/g, ' ')
315 const words = joined === '' ? [] : joined.split(' ')
316 const c = () => session.config
317 const fr = () => session.config.language === 'fr'
318 const platform = await platformOf($)
319
320 if (words.length === 0 || joined === 'status') {
321 const last = session.last === null ? null : describeOutcome(session.last, c(), platform)
322 return { text: statusText(c(), session.overrides, platform, PLATFORM_LABEL[platform], last) }
323 }
324 if (joined === 'help' || joined === 'aide') return { text: helpText(c().language) }
325 if (joined === 'on' || joined === 'off') {
326 await setOverride($, 'enabled', joined === 'on')
327 return {
328 text: c().enabled
329 ? fr() ? 'done-waiting-ping est activé.' : 'done-waiting-ping is on.'
330 : fr() ? 'done-waiting-ping est coupé (/ping on pour le réactiver).' : 'done-waiting-ping is off (/ping on turns it back on).',
331 }
332 }
333 const events = EVENT_WORDS[joined]
334 if (events !== undefined && (EVENTS as readonly string[]).includes(events)) {
335 await setOverride($, 'events', events)
336 return { text: fr() ? `Notifie maintenant : ${eventsLabel(c())}.` : `Now pinging: ${eventsLabel(c())}.` }
337 }
338 const delivery = deliveryOf(joined)
339 if (delivery !== null) {
340 await setOverride($, 'delivery', delivery)
341 return { text: fr() ? `Envoi : ${deliveryLabel(c())}.` : `Delivery: ${deliveryLabel(c())}.` }
342 }
343 if (words[0] === 'test') {
344 const kind = words[1] === 'waiting' || words[1] === 'attente' ? 'waiting' : 'done'
345 const ping =
346 kind === 'done'
347 ? donePing(c().language, session.project, 'answer', 252_000)
348 : waitingPing(c().language, session.project, { why: 'permission', tool: 'Bash' }, c().showDetails)
349 const outcome = await send($, ping, true)
350 return { text: describeOutcome(outcome, c(), platform) }
351 }
352 if (joined === 'reset') {
353 await $.store.set(STORE_KEY, {})
354 session.overrides = {}
355 session.config = applyOverrides(session.base, {}).config
356 return { text: fr() ? 'Retour aux réglages de /config.' : 'Back to your /config settings.' }
357 }
358 const secondsValue = parseDuration(joined)
359 if (secondsValue !== null) {
360 await setOverride($, 'doneAfterSeconds', secondsValue)
361 const shown = duration(c().doneAfterSeconds * 1000, c().language)
362 return {
363 text: fr()
364 ? `Ping « terminé » quand un tour finit après ${shown} sans interaction.`
365 : `Done ping when a turn ends ${shown} or more after you last interacted.`,
366 }
367 }
368 return { text: `${fr() ? `Commande inconnue : ${joined}` : `Unknown command: ${joined}`}\n${helpText(c().language)}` }
369}
370
371export const register: Register = (on, options) => {
372 session = freshSession(fromOptions(options))
373
374 on('session.start', async ($, e, next) => {
375 const result = await next(e)
376 session.isInteractive = e.isInteractive
377 await ensureStarted($, e.cwd)
378 return result
379 }).catch(($, e, next) => next(e))
380
381 on('turn.start', async ($, e, next) => {
382 if (e.agentId === undefined) session.turnInteractionAt = null
383 return next(e)
384 }).catch(($, e, next) => next(e))
385
386 on('turn.complete', async ($, e, next) => {
387 // Read now: a next turn may start (and reset it) before the done ping is decided.
388 const interactionAt = session.turnInteractionAt
389 const result = await next(e)
390 if (e.agentId === undefined) void onDone($, e.durationMs, e.isAborted, e.reason, interactionAt)
391 return result
392 }).catch(($, e, next) => next(e))
393
394 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) =>
395 watchDialog($, { why: 'question', question: questionOf(e) }, () => next(e)),
396 ).catch(($, e, next) => next(e))
397
398 on('tool.call', { tool: 'ExitPlanMode' }, async ($, e, next) => watchDialog($, { why: 'plan' }, () => next(e))).catch(($, e, next) =>
399 next(e),
400 )
401
402 on('classic.PermissionRequest', async ($, e, next) => {
403 if (typeof e.tool_name === 'string') {
404 const now = await $.clock.now()
405 session.requests = [...session.requests.filter(request => now - request.at <= PENDING_REQUEST_MS), { tool: e.tool_name, at: now }]
406 }
407 return next(e)
408 }).catch(($, e, next) => next(e))
409
410 on('classic.Notification', async ($, e, next) => {
411 // Handled before the hooks beneath run: a slow settings Notification hook must not delay it.
412 void onNotification($, String(e.notification_type ?? ''), String(e.message ?? ''))
413 return next(e)
414 }).catch(($, e, next) => next(e))
415
416 on('command.run', { command: COMMAND }, async ($, e) => runCommand($, e.args ?? '')).catch(() => ({
417 text: 'done-waiting-ping: the command failed (see claude --debug).',
418 }))
419}
420hooks/config.ts 152 lines1import type { PluginOptions } from 'claude-code'
2
3// What the person chose, from the plugin's userConfig (set at install time or in /config),
4// possibly overridden for a field by the /ping command.
5
6export type Events = 'both' | 'done' | 'waiting'
7export type Delivery = 'popup+sound' | 'popup' | 'sound'
8export type SoundChoice = 'default' | 'message' | 'mail' | 'reminder' | 'custom' | 'none'
9export type Language = 'en' | 'fr'
10
11export type Config = {
12 enabled: boolean
13 events: Events
14 delivery: Delivery
15 doneAfterSeconds: number
16 doneSound: SoundChoice
17 waitingSound: SoundChoice
18 doneSoundFile: string
19 waitingSoundFile: string
20 idleReminder: boolean
21 showDetails: boolean
22 quietWhenFocused: boolean
23 focusApps: string[]
24 language: Language
25}
26
27export const EVENTS: readonly Events[] = ['both', 'done', 'waiting']
28export const DELIVERIES: readonly Delivery[] = ['popup+sound', 'popup', 'sound']
29export const SOUNDS: readonly SoundChoice[] = ['default', 'message', 'mail', 'reminder', 'custom', 'none']
30export const LANGUAGES: readonly Language[] = ['en', 'fr']
31
32export const MAX_DONE_AFTER_SECONDS = 24 * 60 * 60
33
34export const DEFAULTS: Config = {
35 enabled: true,
36 events: 'both',
37 delivery: 'popup+sound',
38 doneAfterSeconds: 60,
39 doneSound: 'default',
40 waitingSound: 'reminder',
41 doneSoundFile: '',
42 waitingSoundFile: '',
43 idleReminder: false,
44 showDetails: true,
45 quietWhenFocused: false,
46 focusApps: ['WindowsTerminal', 'Code', 'Cursor'],
47 language: 'en',
48}
49
50function pick<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
51 return typeof value === 'string' && (allowed as readonly string[]).includes(value) ? (value as T) : fallback
52}
53
54function flag(value: unknown, fallback: boolean): boolean {
55 return typeof value === 'boolean' ? value : fallback
56}
57
58function seconds(value: unknown, fallback: number): number {
59 const n = typeof value === 'number' ? value : typeof value === 'string' && value.trim() !== '' ? Number(value) : NaN
60 return Number.isFinite(n) && n >= 0 ? Math.min(n, MAX_DONE_AFTER_SECONDS) : fallback
61}
62
63// A file path as typed or pasted: Explorer's "Copy as path" wraps it in double quotes.
64function path(value: unknown): string {
65 if (typeof value !== 'string') return ''
66 const trimmed = value.trim()
67 return /^(["']).*\1$/.test(trimmed) ? trimmed.slice(1, -1).trim() : trimmed
68}
69
70export function appList(value: unknown, fallback: string[]): string[] {
71 if (typeof value !== 'string') return fallback
72 return value
73 .split(/[,;]/)
74 .map(part => part.trim())
75 .filter(part => part !== '')
76}
77
78export function fromOptions(options: PluginOptions): Config {
79 return {
80 enabled: flag(options.enabled, DEFAULTS.enabled),
81 events: pick(options.events, EVENTS, DEFAULTS.events),
82 delivery: pick(options.delivery, DELIVERIES, DEFAULTS.delivery),
83 doneAfterSeconds: seconds(options.doneAfterSeconds, DEFAULTS.doneAfterSeconds),
84 doneSound: pick(options.doneSound, SOUNDS, DEFAULTS.doneSound),
85 waitingSound: pick(options.waitingSound, SOUNDS, DEFAULTS.waitingSound),
86 doneSoundFile: path(options.doneSoundFile),
87 waitingSoundFile: path(options.waitingSoundFile),
88 idleReminder: flag(options.idleReminder, DEFAULTS.idleReminder),
89 showDetails: flag(options.showDetails, DEFAULTS.showDetails),
90 quietWhenFocused: flag(options.quietWhenFocused, DEFAULTS.quietWhenFocused),
91 focusApps: appList(options.focusApps, DEFAULTS.focusApps),
92 language: pick(options.language, LANGUAGES, DEFAULTS.language),
93 }
94}
95
96// The fields /ping can change. Each override remembers the /config value it was made over
97// (`base`): when that value has changed since, the person edited /config, and /config wins.
98export type OverrideField = 'enabled' | 'events' | 'delivery' | 'doneAfterSeconds'
99export const OVERRIDE_FIELDS: readonly OverrideField[] = ['enabled', 'events', 'delivery', 'doneAfterSeconds']
100export type Override = { value: unknown; base: unknown }
101export type Overrides = Partial<Record<OverrideField, Override>>
102
103export function readOverrides(stored: unknown): Overrides {
104 const out: Overrides = {}
105 if (stored === null || typeof stored !== 'object') return out
106 for (const field of OVERRIDE_FIELDS) {
107 const entry = (stored as Record<string, unknown>)[field]
108 if (entry !== null && typeof entry === 'object' && 'value' in entry && 'base' in entry) {
109 out[field] = { value: (entry as Override).value, base: (entry as Override).base }
110 }
111 }
112 return out
113}
114
115function validFor(field: OverrideField, value: unknown): boolean {
116 switch (field) {
117 case 'enabled':
118 return typeof value === 'boolean'
119 case 'events':
120 return typeof value === 'string' && (EVENTS as readonly string[]).includes(value)
121 case 'delivery':
122 return typeof value === 'string' && (DELIVERIES as readonly string[]).includes(value)
123 case 'doneAfterSeconds':
124 return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= MAX_DONE_AFTER_SECONDS
125 }
126}
127
128// The config in force, and the overrides still standing (stale or invalid ones are dropped).
129export function applyOverrides(base: Config, overrides: Overrides): { config: Config; kept: Overrides } {
130 const config: Config = { ...base, focusApps: [...base.focusApps] }
131 const kept: Overrides = {}
132 for (const field of OVERRIDE_FIELDS) {
133 const entry = overrides[field]
134 if (entry === undefined) continue
135 if (entry.base !== base[field] || !validFor(field, entry.value)) continue
136 ;(config as Record<OverrideField, unknown>)[field] = entry.value
137 kept[field] = entry
138 }
139 return { config, kept }
140}
141
142// `90`, `90s`, `2m`, `2 min`, `1.5m`, `1h`: seconds, or null when it is not a duration.
143export function parseDuration(input: string): number | null {
144 const match = /^(\d+(?:[.,]\d+)?)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours)?$/i.exec(input.trim())
145 if (match === null) return null
146 const amount = Number((match[1] ?? '').replace(',', '.'))
147 const unit = (match[2] ?? 's').toLowerCase()
148 const factor = unit.startsWith('h') ? 3600 : unit.startsWith('m') ? 60 : 1
149 const total = Math.round(amount * factor)
150 return Number.isFinite(total) && total <= MAX_DONE_AFTER_SECONDS ? total : null
151}
152hooks/deliver.ts 427 lines1import type { EngineInterface, ProcessRunResult } from 'claude-code'
2import type { SoundChoice } from './config'
3import type { Ping } from './text'
4
5// How a ping reaches the person, per operating system. Every program runs as an argument
6// vector (no shell), and the text never becomes part of a script:
7// Windows (and WSL): Windows PowerShell 5.1 runs the bundled scripts/notify-windows.ps1,
8// the request passed in the DWP_PAYLOAD environment variable.
9// macOS: osascript `display notification`, the text passed as argv items; afplay.
10// Linux: notify-send; canberra-gtk-play, paplay, pw-play or aplay.
11// Over SSH (macOS, Linux) the remote desktop is not where the person is, so Claude Code's own
12// terminal notification ($.ui.notify) is used; it is also the fallback when the system fails.
13
14export type Platform = 'windows' | 'wsl' | 'macos' | 'linux' | 'unknown'
15
16export type Request = {
17 ping: Ping
18 popup: boolean
19 sound: SoundChoice
20 soundFile: string
21 // Stay quiet while the terminal (macOS: the app Claude Code runs in) or a listed app has the
22 // focus. Windows matches process names (WindowsTerminal, Code), macOS bundle ids; not Linux.
23 quietWhenFocused: boolean
24 quietApps: readonly string[]
25}
26
27// Why an outcome is what it is; text.ts words each one for the person.
28export type Reason =
29 | 'ok'
30 | 'ok-sound-fallback'
31 | 'ok-sound-failed'
32 | 'ok-unconfirmed'
33 | 'ok-scheme-silent'
34 | 'nothing-to-do'
35 | 'focus'
36 | 'headless'
37 | 'ssh'
38 | 'toast-off-app'
39 | 'toast-off-all'
40 | 'toast-off-policy'
41 | 'toast-dropped'
42 | 'cannot-start'
43 | 'script-error'
44 | 'popup-failed'
45 | 'sound-failed'
46 | 'unsupported-system'
47
48export type Outcome = {
49 status: 'sent' | 'skipped' | 'fallback' | 'failed'
50 via: string
51 reason: Reason
52 detail: string
53 ms: number
54}
55
56type Attempt = { ok: boolean; skipped?: boolean; via: string; reason: Reason; detail: string }
57
58// Generous: delivery runs after the hook has returned, and a busy machine can stall a cold
59// PowerShell start; killing it after the toast showed would add a duplicate fallback.
60const POPUP_TIMEOUT_MS = 60_000
61const SOUND_TIMEOUT_MS = 30_000
62const SOUND_MAX_SECONDS = '20'
63const PROBE_TIMEOUT_MS = 5_000
64const TOAST_EXPIRE_MINUTES = 120
65const ATTRIBUTION = 'Claude Code'
66const GROUP = 'done-waiting-ping'
67
68const WINDOWS_EVENT = { default: 'Default', message: 'IM', mail: 'Mail', reminder: 'Reminder' } as const
69const MAC_SOUND = { default: 'Glass', message: 'Ping', mail: 'Pop', reminder: 'Hero' } as const
70const LINUX_SOUND = { default: 'complete', message: 'message-new-instant', mail: 'message', reminder: 'bell' } as const
71const LINUX_SOUND_DIR = '/usr/share/sounds/freedesktop/stereo'
72const WINDOWS_POWERSHELL = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
73const OK_WORDS = new Set(['toast-ok', 'toast-unconfirmed', 'sound-ok', 'sound-fallback', 'sound-none'])
74
75export const PLATFORM_LABEL: Record<Platform, string> = {
76 windows: 'Windows (PowerShell toast)',
77 wsl: 'WSL (Windows toast through powershell.exe)',
78 macos: 'macOS (osascript)',
79 linux: 'Linux (notify-send)',
80 unknown: 'unknown (terminal notification only)',
81}
82
83function errorText(error: unknown): string {
84 return error instanceof Error ? error.message : String(error)
85}
86
87// `uname -sr`: "Darwin 23.5.0", "Linux 6.8.0-45-generic", "Linux 5.15.167.4-microsoft-standard-WSL2".
88export function platformFromUname(text: string): Platform {
89 const [name, release = ''] = text.trim().split(/\s+/)
90 if (name === 'Darwin') return 'macos'
91 if (name === 'Linux') return /microsoft/i.test(release) ? 'wsl' : 'linux'
92 return 'unknown'
93}
94
95export async function detectPlatform($: EngineInterface): Promise<Platform> {
96 if ((await $.env.get('OS')) === 'Windows_NT') return 'windows'
97 if ((await $.env.get('WSL_DISTRO_NAME')) !== undefined) return 'wsl'
98 try {
99 const ran = await $.process.run(['uname', '-sr'], { timeoutMs: PROBE_TIMEOUT_MS })
100 return platformFromUname(ran.stdout)
101 } catch {
102 return 'unknown'
103 }
104}
105
106// ---- Windows ----
107
108// The request as the script reads it: JSON with every non-ASCII character escaped (\uXXXX,
109// which the script's JSON parser decodes, surrogate pairs included), then base64.
110export function encodePayload(value: unknown): string {
111 const json = JSON.stringify(value).replace(/[\u007f-\uffff]/g, ch => `\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`)
112 return btoa(json)
113}
114
115export function decodePayload(base64: string): unknown {
116 return JSON.parse(atob(base64))
117}
118
119// '' silent, an event name, or a file path. A custom file that is not a .wav falls back to the
120// default event (Windows plays .wav files only); the script handles a missing or unusable .wav.
121export function windowsSound(req: Request, soundFile: string): { sound: string; fallback: boolean } {
122 if (req.sound === 'none') return { sound: '', fallback: false }
123 if (req.sound === 'custom') {
124 if (soundFile === '' || !/\.wav["']?$/i.test(soundFile)) return { sound: WINDOWS_EVENT.default, fallback: true }
125 return { sound: soundFile, fallback: false }
126 }
127 return { sound: WINDOWS_EVENT[req.sound], fallback: false }
128}
129
130// U+FFFE and U+FFFF are not valid in XML, and Windows PowerShell's JSON parser mangles them.
131const noNonCharacters = (text: string) => text.replace(/[]/g, '')
132
133export function windowsPayload(req: Request, sound: string): Record<string, unknown> {
134 return {
135 title: noNonCharacters(req.ping.title),
136 body: noNonCharacters(req.ping.body),
137 attribution: ATTRIBUTION,
138 popup: req.popup,
139 sound,
140 tag: req.ping.tag,
141 group: GROUP,
142 expireMinutes: TOAST_EXPIRE_MINUTES,
143 quietWhenForeground: req.quietWhenFocused ? [...req.quietApps] : [],
144 }
145}
146
147export function wslEnv(inherited: string | undefined): string {
148 const kept = (inherited ?? '').split(':').filter(entry => entry !== '' && entry.split('/')[0] !== 'DWP_PAYLOAD')
149 return [...kept, 'DWP_PAYLOAD'].join(':')
150}
151
152function droppedReason(setting: string): Reason {
153 if (setting === 'DisabledForApplication') return 'toast-off-app'
154 if (setting === 'DisabledForUser') return 'toast-off-all'
155 if (setting === 'DisabledByGroupPolicy' || setting === 'DisabledByManifest') return 'toast-off-policy'
156 return 'toast-dropped'
157}
158
159// The script's first word says what happened. Exit 0 without a known word is not a success:
160// under WSL a PowerShell that could not run the script can still exit 0.
161export function readWindowsResult(ran: ProcessRunResult): Attempt {
162 const out = ran.stdout.trim()
163 const words = out.split(/\s+/)
164 const first = words[0] ?? ''
165 if (first === 'skipped-foreground') return { ok: false, skipped: true, via: 'windows-toast', reason: 'focus', detail: out }
166 if (first === 'toast-dropped') {
167 const setting = /setting=(\S*)/.exec(out)?.[1] ?? ''
168 return { ok: false, via: 'windows-toast', reason: droppedReason(setting), detail: out }
169 }
170 if (ran.exitCode === 0 && OK_WORDS.has(first)) {
171 const reason: Reason = words.includes('sound-failed')
172 ? 'ok-sound-failed'
173 : words.includes('sound-fallback')
174 ? 'ok-sound-fallback'
175 : first === 'toast-unconfirmed'
176 ? 'ok-unconfirmed'
177 : words.includes('sound-none')
178 ? 'ok-scheme-silent'
179 : 'ok'
180 return { ok: true, via: 'windows-toast', reason, detail: out }
181 }
182 if (first === 'sound-failed') return { ok: false, via: 'windows-toast', reason: 'sound-failed', detail: out }
183 const why = out || ran.stderr.trim().slice(0, 200)
184 return { ok: false, via: 'windows-toast', reason: 'script-error', detail: `exit ${ran.exitCode}: ${why}` }
185}
186
187// Under WSL, powershell.exe is wherever the automount root puts drive C, else on the PATH.
188async function wslPowershells($: EngineInterface): Promise<string[]> {
189 const found: string[] = []
190 try {
191 const ran = await $.process.run(['wslpath', '-u', WINDOWS_POWERSHELL], { timeoutMs: PROBE_TIMEOUT_MS })
192 const path = ran.stdout.trim()
193 if (ran.exitCode === 0 && path !== '') found.push(path)
194 } catch {
195 // no wslpath: try the PATH
196 }
197 return [...found, 'powershell.exe']
198}
199
200async function wslWindowsPath($: EngineInterface, path: string): Promise<string | null> {
201 try {
202 const ran = await $.process.run(['wslpath', '-w', path], { timeoutMs: PROBE_TIMEOUT_MS })
203 const converted = ran.stdout.trim()
204 return ran.exitCode === 0 && converted !== '' ? converted : null
205 } catch {
206 return null
207 }
208}
209
210async function windowsDelivery($: EngineInterface, req: Request, wsl: boolean): Promise<Attempt> {
211 let powershells: string[]
212 let script: string
213 let soundFile = req.soundFile
214 const env: Record<string, string> = {}
215 if (wsl) {
216 const converted = await wslWindowsPath($, `${$.plugin.root}/scripts/notify-windows.ps1`)
217 if (converted === null) return { ok: false, via: 'windows-toast', reason: 'cannot-start', detail: 'wslpath could not convert the script path' }
218 script = converted
219 powershells = await wslPowershells($)
220 // A Linux path, or ~/..., becomes the Windows path the script can open.
221 if (req.sound === 'custom' && soundFile !== '' && !/^[a-z]:[\\/]/i.test(soundFile) && !soundFile.startsWith('\\\\')) {
222 const home = (await $.env.get('HOME')) ?? ''
223 const linuxPath = soundFile.startsWith('~/') && home !== '' ? `${home}${soundFile.slice(1)}` : soundFile
224 soundFile = (await wslWindowsPath($, linuxPath)) ?? soundFile
225 }
226 // WSL passes a variable to a Windows program only when WSLENV names it, with no flag
227 // (`/u` is the other direction, Windows to WSL: with it the variable does not cross).
228 env.WSLENV = wslEnv(await $.env.get('WSLENV'))
229 } else {
230 const systemRoot = (await $.env.get('SystemRoot')) ?? 'C:\\Windows'
231 powershells = [`${systemRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`]
232 script = `${$.plugin.root}\\scripts\\notify-windows.ps1`
233 }
234 const { sound } = windowsSound(req, soundFile)
235 env.DWP_PAYLOAD = encodePayload(windowsPayload(req, sound))
236 let lastError = ''
237 for (const powershell of powershells) {
238 const argv = [powershell, '-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', script]
239 try {
240 const attempt = readWindowsResult(await $.process.run(argv, { env, timeoutMs: POPUP_TIMEOUT_MS }))
241 return req.sound === 'custom' && windowsSound(req, soundFile).fallback && attempt.reason === 'ok'
242 ? { ...attempt, reason: 'ok-sound-fallback' }
243 : attempt
244 } catch (error) {
245 lastError = errorText(error)
246 }
247 }
248 return { ok: false, via: 'windows-toast', reason: 'cannot-start', detail: lastError }
249}
250
251// ---- macOS ----
252
253export function osascriptArgv(title: string, body: string): string[] {
254 return ['osascript', '-e', 'on run argv', '-e', 'display notification (item 1 of argv) with title (item 2 of argv)', '-e', 'end run', body, title]
255}
256
257// `lsappinfo front` prints the front app's ASN; `lsappinfo info -only bundleid <asn>` its id.
258export function asnOf(text: string): string | null {
259 return /ASN:[0-9a-fx-]+:?/i.exec(text)?.[0] ?? null
260}
261
262export function bundleOf(text: string): string | null {
263 return /(?:"CFBundleIdentifier"|bundleID)\s*=\s*"([^"]+)"/i.exec(text)?.[1] ?? null
264}
265
266async function macFrontBundle($: EngineInterface): Promise<string | null> {
267 try {
268 const front = await $.process.run(['lsappinfo', 'front'], { timeoutMs: PROBE_TIMEOUT_MS })
269 const asn = asnOf(front.stdout)
270 if (front.exitCode !== 0 || asn === null) return null
271 const info = await $.process.run(['lsappinfo', 'info', '-only', 'bundleid', asn], { timeoutMs: PROBE_TIMEOUT_MS })
272 return info.exitCode === 0 ? bundleOf(info.stdout) : null
273 } catch {
274 return null
275 }
276}
277
278// Tries each player in turn until one works. A player killed at the timeout did play (a long
279// sound), so the next one is not started to play it again.
280async function playFirst($: EngineInterface, players: readonly string[][]): Promise<boolean> {
281 for (const argv of players) {
282 const started = await $.clock.now()
283 try {
284 const ran = await $.process.run(argv, { timeoutMs: SOUND_TIMEOUT_MS })
285 if (ran.exitCode === 0) return true
286 } catch {
287 if ((await $.clock.now()) - started >= SOUND_TIMEOUT_MS - 1_000) return true
288 }
289 }
290 return false
291}
292
293export function unquote(path: string): string {
294 const trimmed = path.trim()
295 return /^(["']).*\1$/.test(trimmed) ? trimmed.slice(1, -1) : trimmed
296}
297
298// No shell expands `~/` in an argument vector: do it here (macOS, Linux).
299async function homePath($: EngineInterface, path: string): Promise<string> {
300 const file = unquote(path)
301 if (!file.startsWith('~/')) return file
302 const home = await $.env.get('HOME')
303 return home === undefined || home === '' ? file : `${home}${file.slice(1)}`
304}
305
306async function macDelivery($: EngineInterface, req: Request): Promise<Attempt> {
307 if (req.quietWhenFocused) {
308 const front = await macFrontBundle($)
309 const host = await $.env.get('__CFBundleIdentifier')
310 if (front !== null && (front === host || req.quietApps.includes(front))) {
311 return { ok: false, skipped: true, via: 'osascript', reason: 'focus', detail: `skipped-foreground ${front}` }
312 }
313 }
314 // The sound plays through afplay, not the notification, so it is heard even when macOS
315 // keeps notifications from Script Editor (osascript's sender) hidden.
316 const soundPath =
317 req.sound === 'none'
318 ? null
319 : req.sound === 'custom'
320 ? (await homePath($, req.soundFile)) || `/System/Library/Sounds/${MAC_SOUND.default}.aiff`
321 : `/System/Library/Sounds/${MAC_SOUND[req.sound]}.aiff`
322 let shown = false
323 if (req.popup) {
324 try {
325 shown = (await $.process.run(osascriptArgv(req.ping.title, req.ping.body), { timeoutMs: POPUP_TIMEOUT_MS })).exitCode === 0
326 } catch {
327 shown = false
328 }
329 }
330 const played = soundPath === null ? false : await playFirst($, [['afplay', '-t', SOUND_MAX_SECONDS, soundPath]])
331 if (req.popup) {
332 if (!shown) return { ok: false, via: 'osascript', reason: 'popup-failed', detail: 'osascript failed' }
333 return { ok: true, via: 'osascript', reason: soundPath !== null && !played ? 'ok-sound-failed' : 'ok', detail: 'ok' }
334 }
335 return played ? { ok: true, via: 'afplay', reason: 'ok', detail: 'sound ok' } : { ok: false, via: 'afplay', reason: 'sound-failed', detail: 'afplay failed' }
336}
337
338// ---- Linux ----
339
340// notify-send reads C escapes (\n, \t) in its arguments, and some daemons read the body as markup.
341export function forNotifySend(text: string, markup: boolean): string {
342 const escaped = markup ? text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') : text
343 return escaped.replace(/\\/g, '\\\\')
344}
345
346export function notifySendArgv(title: string, body: string): string[] {
347 return ['notify-send', '-a', ATTRIBUTION, '-u', 'normal', '--', forNotifySend(title, false), forNotifySend(body, true)]
348}
349
350// A theme sound by id (canberra resolves the user's theme), then the freedesktop file; a custom
351// file through each player that can read it.
352export function linuxPlayers(sound: SoundChoice, soundFile: string): string[][] {
353 if (sound === 'none') return []
354 const file = sound === 'custom' ? unquote(soundFile) : ''
355 if (file !== '') {
356 const players = [['paplay', file], ['pw-play', file], ['canberra-gtk-play', '-f', file]]
357 return /\.wav$/i.test(file) ? [...players, ['aplay', '-q', file]] : players
358 }
359 const id = sound === 'custom' ? LINUX_SOUND.default : LINUX_SOUND[sound]
360 const themed = `${LINUX_SOUND_DIR}/${id}.oga`
361 return [['canberra-gtk-play', '-i', id], ['paplay', themed], ['pw-play', themed]]
362}
363
364async function linuxDelivery($: EngineInterface, req: Request): Promise<Attempt> {
365 let shown = false
366 if (req.popup) {
367 try {
368 shown = (await $.process.run(notifySendArgv(req.ping.title, req.ping.body), { timeoutMs: POPUP_TIMEOUT_MS })).exitCode === 0
369 } catch {
370 shown = false
371 }
372 }
373 const players = linuxPlayers(req.sound, req.sound === 'custom' ? await homePath($, req.soundFile) : '')
374 const played = players.length > 0 && (await playFirst($, players))
375 if (req.popup) {
376 if (!shown) return { ok: false, via: 'notify-send', reason: 'popup-failed', detail: 'notify-send failed' }
377 return { ok: true, via: 'notify-send', reason: players.length > 0 && !played ? 'ok-sound-failed' : 'ok', detail: 'ok' }
378 }
379 return played ? { ok: true, via: 'sound', reason: 'ok', detail: 'sound ok' } : { ok: false, via: 'sound', reason: 'sound-failed', detail: 'no sound player worked' }
380}
381
382// ---- all platforms ----
383
384async function terminal($: EngineInterface, ping: Ping): Promise<{ sent: boolean; via: string; why: string }> {
385 try {
386 const result = await $.ui.notify(ping.body, { title: ping.title })
387 if (result.isSent) return { sent: true, via: `terminal (${result.channel})`, why: '' }
388 return { sent: false, via: 'terminal', why: `terminal notification not sent (${result.reason})` }
389 } catch (error) {
390 return { sent: false, via: 'terminal', why: errorText(error) }
391 }
392}
393
394async function onSsh($: EngineInterface): Promise<boolean> {
395 return (await $.env.get('SSH_CONNECTION')) !== undefined || (await $.env.get('SSH_TTY')) !== undefined
396}
397
398export async function deliver($: EngineInterface, platform: Platform, req: Request): Promise<Outcome> {
399 const started = await $.clock.now()
400 const done = async (status: Outcome['status'], via: string, reason: Reason, detail: string): Promise<Outcome> => ({
401 status,
402 via,
403 reason,
404 detail,
405 ms: (await $.clock.now()) - started,
406 })
407 if (!req.popup && req.sound === 'none') return done('skipped', 'none', 'nothing-to-do', 'sound only, and the sound is none')
408 if ((platform === 'macos' || platform === 'linux') && (await onSsh($))) {
409 const sent = await terminal($, req.ping)
410 return done(sent.sent ? 'sent' : 'failed', sent.via, 'ssh', sent.why || 'over SSH: terminal notification')
411 }
412 let attempt: Attempt
413 try {
414 if (platform === 'windows' || platform === 'wsl') attempt = await windowsDelivery($, req, platform === 'wsl')
415 else if (platform === 'macos') attempt = await macDelivery($, req)
416 else if (platform === 'linux') attempt = await linuxDelivery($, req)
417 else attempt = { ok: false, via: 'none', reason: 'unsupported-system', detail: 'no notifier for this system' }
418 } catch (error) {
419 attempt = { ok: false, via: platform, reason: 'cannot-start', detail: errorText(error) }
420 }
421 if (attempt.skipped === true) return done('skipped', attempt.via, attempt.reason, attempt.detail)
422 if (attempt.ok) return done('sent', attempt.via, attempt.reason, attempt.detail)
423 const sent = await terminal($, req.ping)
424 const detail = sent.why === '' ? `${attempt.via}: ${attempt.detail}` : `${attempt.via}: ${attempt.detail}; ${sent.why}`
425 return done(sent.sent ? 'fallback' : 'failed', sent.sent ? sent.via : attempt.via, attempt.reason, detail)
426}
427hooks/text.ts 336 lines1import type { Config, Language, OverrideField, Overrides } from './config'
2import type { Outcome, Platform, Reason } from './deliver'
3
4// Every word a notification or the /ping command shows, in English and French.
5// A notification carries the project folder's name, what happened, a duration, a tool name and
6// (with showDetails) the first line of Claude's question as written, code blocks and inline code
7// left out. Never Claude's reply, a tool's input, or Claude Code's own notice text.
8
9export type PingKind = 'done' | 'waiting' | 'idle'
10export type Ping = { kind: PingKind; title: string; body: string; tag: string }
11export type DoneReason = 'answer' | 'error' | 'refusal'
12
13const FALLBACK_PROJECT = 'Claude Code'
14
15// The last segment of a path, on Windows (`C:\work\shop`) as on macOS/Linux (`/work/shop`).
16export function projectName(path: string | undefined | null): string {
17 if (typeof path !== 'string') return FALLBACK_PROJECT
18 const parts = path.split(/[\\/]+/).filter(part => part !== '')
19 return parts[parts.length - 1] ?? FALLBACK_PROJECT
20}
21
22// `45s`, `4m 12s`, `1h 05m` in English; `45 s`, `4 min 12 s`, `1 h 05` in French.
23export function duration(ms: number, lang: Language): string {
24 const total = Math.max(0, Math.round(ms / 1000))
25 const h = Math.floor(total / 3600)
26 const m = Math.floor((total % 3600) / 60)
27 const s = total % 60
28 const two = (n: number) => String(n).padStart(2, '0')
29 if (lang === 'fr') {
30 if (h > 0) return `${h} h ${two(m)}`
31 if (m > 0) return `${m} min ${two(s)} s`
32 return `${s} s`
33 }
34 if (h > 0) return `${h}h ${two(m)}m`
35 if (m > 0) return `${m}m ${two(s)}s`
36 return `${s}s`
37}
38
39// The first line of Claude's markdown question as plain text: code blocks and inline code left
40// out, links reduced to their text, emphasis marks removed (underscores in names are kept).
41export function snippet(markdown: unknown, max = 100): string {
42 if (typeof markdown !== 'string') return ''
43 const withoutBlocks = markdown.replace(/[]/g, '').replace(/```[\s\S]*?(```|$)/g, '\n')
44 const first = withoutBlocks.split(/\r?\n/).find(lineText => lineText.trim() !== '') ?? ''
45 const plain = first
46 .replace(/`[^`]*`/g, ' ')
47 .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
48 .replace(/^\s{0,3}(#{1,6}|>|[-*+]|\d+[.)])\s+/, '')
49 .replace(/[*~`]/g, '')
50 .replace(/\s+/g, ' ')
51 .trim()
52 if (plain.length <= max) return plain
53 return `${plain.slice(0, max - 1).trimEnd()}\u2026`
54}
55
56// From an SDK or desktop host's notice ("Claude needs your permission to use Bash"), the tool.
57// The terminal's notice never names it ("Claude needs your permission").
58export function toolFromMessage(message: unknown): string | null {
59 if (typeof message !== 'string') return null
60 const match = /permission to use (.+?)\.?\s*$/i.exec(message)
61 if (match === null) return null
62 const tool = (match[1] ?? '').trim()
63 return tool === '' || tool.length > 60 ? null : tool
64}
65
66function tagFor(kind: PingKind, project: string): string {
67 const slug = project.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40)
68 return `${kind}-${slug || 'claude'}`
69}
70
71const WORDS = {
72 en: {
73 doneTitle: 'Claude Code is done',
74 stoppedTitle: 'Claude Code stopped',
75 waitingTitle: 'Claude Code needs you',
76 idleTitle: 'Claude Code is waiting',
77 finished: (took: string) => `finished in ${took}`,
78 error: (took: string) => `stopped on an error after ${took}`,
79 refusal: (took: string) => `declined the request after ${took}`,
80 permissionFor: (tool: string) => `permission needed: ${tool}`,
81 permission: 'needs your permission',
82 question: 'has a question',
83 questionFor: (q: string) => `question: ${q}`,
84 plan: 'plan ready for your approval',
85 mcp: 'an MCP server needs your input',
86 agent: 'needs your input',
87 other: 'needs your attention',
88 idle: 'waiting for your next message',
89 },
90 fr: {
91 doneTitle: 'Claude Code a terminé',
92 stoppedTitle: "Claude Code s'est arrêté",
93 waitingTitle: 'Claude Code a besoin de toi',
94 idleTitle: "Claude Code t'attend",
95 finished: (took: string) => `terminé en ${took}`,
96 error: (took: string) => `arrêté par une erreur après ${took}`,
97 refusal: (took: string) => `a refusé la demande après ${took}`,
98 permissionFor: (tool: string) => `permission requise : ${tool}`,
99 permission: 'demande ta permission',
100 question: 'a une question',
101 questionFor: (q: string) => `question : ${q}`,
102 plan: 'plan prêt, à approuver',
103 mcp: 'un serveur MCP attend ta réponse',
104 agent: 'attend ta réponse',
105 other: 'attend ta décision',
106 idle: 'attend ton prochain message',
107 },
108} as const
109
110const line = (project: string, detail: string) => `${project} \u00b7 ${detail}`
111
112export function donePing(lang: Language, project: string, reason: DoneReason, ms: number): Ping {
113 const w = WORDS[lang]
114 const took = duration(ms, lang)
115 const detail = reason === 'error' ? w.error(took) : reason === 'refusal' ? w.refusal(took) : w.finished(took)
116 return { kind: 'done', title: reason === 'answer' ? w.doneTitle : w.stoppedTitle, body: line(project, detail), tag: tagFor('done', project) }
117}
118
119export type WaitingWhy =
120 | { why: 'permission'; tool?: string }
121 | { why: 'question'; question?: string }
122 | { why: 'plan' }
123 | { why: 'mcp' }
124 | { why: 'agent' }
125 | { why: 'other' }
126
127export function waitingPing(lang: Language, project: string, what: WaitingWhy, showDetails: boolean): Ping {
128 const w = WORDS[lang]
129 let detail: string
130 switch (what.why) {
131 case 'permission':
132 detail = showDetails && what.tool ? w.permissionFor(what.tool) : w.permission
133 break
134 case 'question': {
135 const q = showDetails ? snippet(what.question) : ''
136 detail = q === '' ? w.question : w.questionFor(q)
137 break
138 }
139 case 'plan':
140 detail = w.plan
141 break
142 case 'mcp':
143 detail = w.mcp
144 break
145 case 'agent':
146 detail = w.agent
147 break
148 case 'other':
149 detail = w.other
150 break
151 default: {
152 const never: never = what
153 return never
154 }
155 }
156 return { kind: 'waiting', title: w.waitingTitle, body: line(project, detail), tag: tagFor('waiting', project) }
157}
158
159export function idlePing(lang: Language, project: string): Ping {
160 const w = WORDS[lang]
161 return { kind: 'idle', title: w.idleTitle, body: line(project, w.idle), tag: tagFor('idle', project) }
162}
163
164// ---- the /ping command's own text ----
165
166const EVENT_LABEL = {
167 en: { both: 'done + waiting', done: 'done only', waiting: 'waiting only' },
168 fr: { both: 'terminé + attente', done: 'terminé seulement', waiting: 'attente seulement' },
169} as const
170
171const DELIVERY_LABEL = {
172 en: { 'popup+sound': 'popup + sound', popup: 'popup only', sound: 'sound only' },
173 fr: { 'popup+sound': 'popup + son', popup: 'popup seulement', sound: 'son seulement' },
174} as const
175
176const FIELD_LABEL: Record<Language, Record<OverrideField, string>> = {
177 en: { enabled: 'Notifications on', events: 'What to ping for', delivery: 'How to ping', doneAfterSeconds: 'Done: minimum turn length' },
178 fr: { enabled: 'Notifications', events: 'Quoi notifier', delivery: 'Comment notifier', doneAfterSeconds: 'Durée minimale d’un tour' },
179}
180
181const REASON_TEXT: Record<Language, Record<Reason, string>> = {
182 en: {
183 ok: '',
184 'ok-sound-fallback': 'your custom sound could not be used (missing, not a .wav, or a format Windows cannot play); the default sound played instead',
185 'ok-sound-failed': 'no sound could be played',
186 'ok-unconfirmed': 'the banner was shown but Windows did not keep it in the notification center',
187 'ok-scheme-silent': 'your Windows sound scheme sets this sound to (None)',
188 'nothing-to-do': 'delivery is sound only and the sound is none',
189 focus: 'one of your "looking" apps had the focus',
190 headless: 'headless run (claude -p): nothing is shown',
191 ssh: 'over SSH: the terminal notification is used instead of the remote desktop',
192 'toast-off-app': 'notifications are turned off for Windows PowerShell (Settings > System > Notifications)',
193 'toast-off-all': 'notifications are turned off for all apps (Settings > System > Notifications)',
194 'toast-off-policy': 'your organization blocks notifications on this computer',
195 'toast-dropped': 'Windows did not show the notification',
196 'cannot-start': 'the notifier could not start',
197 'script-error': 'the notifier failed',
198 'popup-failed': 'the popup could not be shown',
199 'sound-failed': 'no sound could be played',
200 'unsupported-system': 'this system has no notifier this mod knows',
201 },
202 fr: {
203 ok: '',
204 'ok-sound-fallback': 'ton son perso est inutilisable (introuvable, pas un .wav, ou un format que Windows ne lit pas) ; le son par défaut a joué à la place',
205 'ok-sound-failed': 'aucun son n’a pu jouer',
206 'ok-unconfirmed': 'la bannière s’est affichée mais Windows ne l’a pas gardée dans le centre de notifications',
207 'ok-scheme-silent': 'ton jeu de sons Windows règle ce son sur (Aucun)',
208 'nothing-to-do': 'envoi en son seulement, et le son est « none »',
209 focus: 'une de tes applis « je regarde » était au premier plan',
210 headless: 'exécution sans interface (claude -p) : rien n’est affiché',
211 ssh: 'en SSH : la notification du terminal remplace celle du bureau distant',
212 'toast-off-app': 'les notifications sont coupées pour Windows PowerShell (Paramètres > Système > Notifications)',
213 'toast-off-all': 'les notifications sont coupées pour toutes les applis (Paramètres > Système > Notifications)',
214 'toast-off-policy': 'ton organisation bloque les notifications sur cet ordinateur',
215 'toast-dropped': 'Windows n’a pas affiché la notification',
216 'cannot-start': 'le notificateur n’a pas pu démarrer',
217 'script-error': 'le notificateur a échoué',
218 'popup-failed': 'la popup n’a pas pu s’afficher',
219 'sound-failed': 'aucun son n’a pu jouer',
220 'unsupported-system': 'ce système n’a pas de notificateur connu du mod',
221 },
222}
223
224export function reasonText(reason: Reason, lang: Language): string {
225 return REASON_TEXT[lang][reason]
226}
227
228export function eventsLabel(config: Config): string {
229 return EVENT_LABEL[config.language][config.events]
230}
231
232export function deliveryLabel(config: Config): string {
233 return DELIVERY_LABEL[config.language][config.delivery]
234}
235
236function seconds(ms: number, lang: Language): string {
237 const text = (ms / 1000).toFixed(1)
238 return lang === 'fr' ? `${text.replace('.', ',')} s` : `${text} s`
239}
240
241export function describeOutcome(outcome: Outcome, config: Config, platform: Platform): string {
242 const lang = config.language
243 const fr = lang === 'fr'
244 const why = reasonText(outcome.reason, lang)
245 const took = seconds(outcome.ms, lang)
246 let text: string
247 switch (outcome.status) {
248 case 'sent':
249 text = fr
250 ? `Envoyé (${deliveryLabel(config)}, via ${outcome.via}, ${took})${why ? ` ; ${why}` : ''}.`
251 : `Sent (${deliveryLabel(config)}, via ${outcome.via}, ${took})${why ? `; ${why}` : ''}.`
252 break
253 case 'skipped':
254 text = fr ? `Rien envoyé : ${why}.` : `Nothing sent: ${why}.`
255 break
256 case 'fallback':
257 text = fr
258 ? `La notification du système a échoué (${why}) ; la notification du terminal a été envoyée à la place. Détail : ${outcome.detail}`
259 : `The system notification failed (${why}); the terminal notification was sent instead. Detail: ${outcome.detail}`
260 break
261 case 'failed':
262 text = fr ? `Impossible de notifier (${why}). Détail : ${outcome.detail}` : `Could not notify (${why}). Detail: ${outcome.detail}`
263 break
264 default: {
265 const never: never = outcome.status
266 return never
267 }
268 }
269 if (platform === 'macos' && outcome.status === 'sent' && outcome.via === 'osascript') {
270 text += fr
271 ? ' Si rien ne s’est affiché, autorise les notifications de « Éditeur de script » dans Réglages Système > Notifications.'
272 : ' If nothing appeared, allow notifications for Script Editor in System Settings > Notifications.'
273 }
274 return text
275}
276
277export function statusText(config: Config, overrides: Overrides, platform: Platform, platformLabel: string, last: string | null): string {
278 const lang = config.language
279 const fr = lang === 'fr'
280 const lines = [
281 fr ? `done-waiting-ping : ${config.enabled ? 'activé' : 'coupé'}` : `done-waiting-ping: ${config.enabled ? 'on' : 'off'}`,
282 fr
283 ? `Événements : ${eventsLabel(config)} · Envoi : ${deliveryLabel(config)}`
284 : `Events: ${eventsLabel(config)} · Delivery: ${deliveryLabel(config)}`,
285 fr
286 ? `Ping « terminé » après : ${duration(config.doneAfterSeconds * 1000, 'fr')} sans interaction · Sons : terminé = ${config.doneSound}, attente = ${config.waitingSound}`
287 : `Done after: ${duration(config.doneAfterSeconds * 1000, 'en')} without interaction · Sounds: done = ${config.doneSound}, waiting = ${config.waitingSound}`,
288 fr ? `Système : ${platformLabel}` : `Platform: ${platformLabel}`,
289 ]
290 if (config.idleReminder) lines.push(fr ? 'Rappel quand Claude reste inactif : oui' : 'Idle reminder: on')
291 if (config.quietWhenFocused) {
292 if (platform === 'linux' || platform === 'unknown') {
293 lines.push(fr ? 'Silence quand tu regardes : non disponible sur ce système' : 'Quiet while looking: not available on this system')
294 } else {
295 lines.push(fr ? `Silence quand tu regardes : ${config.focusApps.join(', ')}` : `Quiet while looking: ${config.focusApps.join(', ')}`)
296 }
297 }
298 if (last !== null) lines.push(fr ? `Dernier envoi : ${last}` : `Last ping: ${last}`)
299 const overridden = (Object.keys(overrides) as OverrideField[]).map(field => FIELD_LABEL[lang][field])
300 if (overridden.length > 0) {
301 lines.push(
302 fr
303 ? `Changé avec /ping : ${overridden.join(', ')} (/ping reset pour revenir à /config)`
304 : `Set with /ping: ${overridden.join(', ')} (/ping reset goes back to /config)`,
305 )
306 }
307 return lines.join('\n')
308}
309
310export function helpText(lang: Language): string {
311 if (lang === 'fr') {
312 return [
313 'Usage : /ping [commande]',
314 ' (rien) ou status état actuel',
315 ' on | off active ou coupe tout',
316 ' done | waiting | both quoi notifier (aussi : terminé, attente, tout)',
317 ' popup | sound | popup+sound comment notifier (aussi : son, popup+son)',
318 ' 90 | 2m | 1h durée sans interaction avant le ping « terminé »',
319 ' test [done|waiting] envoie une notification d’essai (aussi : test attente)',
320 ' reset oublie les choix faits avec /ping (retour à /config)',
321 'Le reste (sons, fichier son perso, langue, détails, rappel inactif) : /config.',
322 ].join('\n')
323 }
324 return [
325 'Usage: /ping [command]',
326 ' (nothing) or status current settings',
327 ' on | off turn every ping on or off',
328 ' done | waiting | both which events ping',
329 ' popup | sound | popup+sound popup, sound, or both',
330 ' 90 | 2m | 1h time without interaction before the done ping',
331 ' test [done|waiting] send a sample notification now',
332 ' reset forget choices made with /ping (back to /config)',
333 'Everything else (sounds, custom sound file, language, details, idle reminder): /config.',
334 ].join('\n')
335}
336