SLOPSHOPPER

Done & Waiting Ping

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…

guardcommandprocess
v0.1.0MITupdated 2026-10-16AMoncade/done-waiting-ping-claudecode
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · done-waiting-ping
› 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 › /ping ⎿ done-waiting-ping: done-waiting-ping: on ⎿ done-waiting-ping: Events: done + waiting · Delivery: popup + sound ⎿ done-waiting-ping: Done after: 1m 00s without interaction · Sounds: done = default, waiting = reminder ⎿ done-waiting-ping: Platform: unknown (terminal notification only) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

done-waiting-ping

A Claude Code mod that tells you when Claude needs you, so you can work in another window:

  • Done: a long turn has finished (or stopped on an error) while you were away.
  • Waiting: Claude is waiting on you: a permission prompt, a question, a plan to approve, an MCP server or another Claude Code dialog asking for input.

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                          │
└──────────────────────────────────────┘

Install

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.

Update

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

Systems

SystemPopupSoundTested
Windows 10 / 11Windows notification (toast) through the built-in Windows PowerShell 5.1Windows' notification sounds, or your .wavYes, on Windows 11
WSLThe same Windows notification, through Windows' powershell.exeSame as WindowsYes, Ubuntu 24.04 on Windows 11
macOSosascript (display notification)afplay with a macOS system sound or your fileUnit tests only (no Mac to test on yet)
Linuxnotify-send (package libnotify-bin / libnotify)your sound theme through canberra-gtk-play, else paplay or pw-play; a custom .wav can also go through aplayUnit 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.

Choose what you get

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.

OptionChoicesDefaultWhat it does
Notifications on (enabled)on / offonEvery ping on or off. /ping on, /ping off
What to ping for (events)both, done, waitingbothFinished turns, waiting on you, or both. /ping done, /ping waiting, /ping both
How to ping (delivery)popup+sound, popup, soundpopup+soundNotification with sound, silent notification, or just the sound. /ping popup, /ping sound, /ping popup+sound
Done: minimum time without you (doneAfterSeconds)seconds60A 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, nonedefaultSee the sound table below
Sound when waiting (waitingSound)samereminderA different sound, so you can tell them apart
Custom sound file (doneSoundFile, waitingSoundFile)a file pathemptyUsed 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 / offoffAlso ping when Claude Code says it has been waiting for your next message for a while
Show details (showDetails)on / offonShow 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 / offoffSkip pings while one of the listed apps has the keyboard focus. Windows and macOS only
Apps that count as looking (focusApps)comma-separated listWindowsTerminal, Code, CursorWindows: process names. macOS: bundle ids (the terminal Claude Code runs in always counts)
Language (language)en, frenLanguage of the notifications and of /ping

Sounds

ChoiceWindowsmacOSLinux (sound theme)
defaultNotification.DefaultGlasscomplete
messageNotification.IMPingmessage-new-instant
mailNotification.MailPopmessage
reminderNotification.ReminderHerobell
customyour .wavyour fileyour file
nonesilentsilentsilent

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.

The /ping command

CommandDoes
/ping or /ping statusShows the current settings, the system, and the last ping's result
/ping on, /ping offTurns every ping on or off
/ping done, /ping waiting, /ping bothChooses the events
/ping popup, /ping sound, /ping popup+soundChooses how to ping
/ping 90, /ping 2m, /ping 1hMinimum time without you for the done ping
/ping test, /ping test waitingSends a sample now and says how it went (ignores "off" and "quiet while looking")
/ping resetForgets the choices made with /ping, back to /config
/ping helpLists 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.

When it pings

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.

What it runs, reads, sends and stores

  • It sends nothing over the network, and reads no file of yours (except your custom sound file, to play it).
  • A notification shows the project folder's name and what happened, a duration, a tool name, and (with "Show details") the first line of Claude's question as Claude wrote it, code blocks and inline code left out (it can still mention a file, a value or a command written in plain text). Never Claude's reply, a tool's input, or the text of Claude Code's own notices.
  • It hooks these Claude Code events: session start, turn start and end, AskUserQuestion and ExitPlanMode tool calls (to know when a question or plan dialog is open, and when you answered it), permission requests (for the tool's name; it never changes the decision), notifications, and its /ping command.
  • It reads these environment variables to know where it runs: CLAUDE_CODE_ENTRYPOINT (to tell a headless run), OS, SystemRoot, WSL_DISTRO_NAME, WSLENV, HOME, __CFBundleIdentifier (macOS), SSH_CONNECTION and SSH_TTY.
  • It runs these programs, each as an argument list with no shell, the text never placed inside a script:
  • Windows: %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:
  • shows the toast through the Windows notification API under the Windows PowerShell app id, then reads that app's notification history to check the toast was kept;
  • with a popup and a named sound, lets the toast play the sound itself;
  • otherwise plays the sound with 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;
  • only when "Quiet while you are looking" is on: asks user32.dll (GetForegroundWindow, GetWindowThreadProcessId) which process has the keyboard focus, through a P/Invoke declared at run time, and reads that process's name;
  • parses the request with the JSON parser that ships with .NET Framework (System.Web.Extensions).
  • WSL: 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.
  • macOS: uname -sr, osascript, afplay, and lsappinfo when "Quiet while you are looking" is on.
  • Linux: uname -sr, notify-send, then canberra-gtk-play, paplay, pw-play or aplay.
  • It stores only the choices you make with /ping, in the plugin's own storage in your Claude Code config folder.

Troubleshooting

  • Nothing shows on Windows. Run /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.
  • Nothing shows on macOS. macOS credits osascript notifications to Script Editor: allow notifications for Script Editor in System Settings > Notifications. The sound plays through afplay either way.
  • No sound. Check the volume, and that the chosen sound is not 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.
  • On a work computer where PowerShell runs in Constrained Language mode, the Windows notification cannot be shown; the mod falls back to the terminal notification.
  • Organizations that manage Claude Code: Claude Code does not pass its notices and permission requests to plugins a person installed themselves there, so only done pings work.
  • Details: run Claude Code with claude --debug; each ping writes a done-waiting-ping: line with where it went, why, and how long it took.

Uninstall

/plugin uninstall done-waiting-ping

Develop

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

Credits

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

License

MIT, see LICENSE.

Source 4 files
hooks/register.ts 420 lines
1import 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}
420
hooks/config.ts 152 lines
1import 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}
152
hooks/deliver.ts 427 lines
1import 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') : 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}
427
hooks/text.ts 336 lines
1import 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