SLOPSHOPPER

pager

Plays a sound when Claude finishes, needs you or hits an error.

newcommandtoastnetworkaudio
★ 1v0.1.1MITupdated 2026-10-13modmyclaude/claude-code-mods/mods/pager
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pager
› fix the failing auth test and add an audit log call ╭──────────────────╮ │ pager │ ⏺ Read(src/auth.ts) │ Done - app (42s) │ ⎿ Read 6 lines ╰──────────────────╯ ⏺ Update(src/auth.ts) ╭────────────────────────────────────────────╮ ⎿ Added 2 lines, removed 1 line │ pager │ ⏺ Bash(bun test) │ classic pack, sounds on. Done pages after │ ⎿ 3 pass, 1 fail │ turns of 20s or more. Quiet hours: off. │ │ Phone push: off. Commands: /pager pack │ ● Done. refresh now rejects expired claims and logs an audit event. ╰────────────────────────────────────────────╯ ✻ Worked for 42s · done 4:20 PM › /pager ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

<img src="../../brand/pixel-icons/svg/pager.svg" width="64" height="64" alt="">

Pager

Plays a sound when Claude finishes, needs you or hits an error.

Look away while Claude works. Pager plays one short sound, with a toast naming the session, when:

  • done - a turn finished and it ran at least minDuration seconds (quick back-and-forth stays quiet),
  • needs you - Claude is waiting on a permission prompt, a question, a plan approval or an MCP input request,
  • error - a turn ended on an API error or a refusal.

Install

/plugin install pager --marketplace modmyclaude/claude-code-mods

Use it

CommandWhat it does
/pagerShows the pack, whether sounds are on, and your settings.
`/pager pack <classic\arcade\zen\butler\coach>`Switches the sound pack. /pager pack alone lists them.
/pager testPlays all three sounds of the current pack, one after another (even while muted).
/pager mute <duration>Silences sounds and phone pushes for 30m, 2h, 1h 30m, 45s or 1d (up to 7 days; a bare number is minutes; no duration means 1 hour).
/pager unmuteEnds a mute early.

/pager runs at once, even while a turn is running, so you can mute mid-turn. Its answers show as a toast and stay out of the conversation, so Claude never reads them and they cost no tokens. Only you can change Pager: a /pager run that comes from a peer session, a channel relay, a background task or another plugin can read the status but cannot mute, unmute, switch packs or play the test (typed at the terminal, in the desktop app or through Remote Control all count as you).

Session titles and folder names are cleaned before a toast shows them: terminal escape codes are removed and other control characters become a replacement mark.

The five packs, all made for this mod. Three are synthesized tones:

  • classic - soft bell tones
  • arcade - 8-bit blips
  • zen - gentle chimes

Two are spoken, one short original line per event in a generated voice (no game lines, no imitation of anyone), under a second and a half each, at the same loudness as the tones:

  • butler - dry and formal: "Done." / "It needs you." / "Something went wrong."
  • coach - upbeat: "That one's done." / "Your turn." / "We hit a snag."

Guardrails: one sound per event at most, and never more than one sound every 5 seconds in a session. Alerts inside that gap, while muted, or in quiet hours still show their toast; only the sound and the phone push are skipped.

Settings

Every setting has a default, so the notice at install about options not yet set is expected. Set them in /config (or when you enable the plugin).

FieldDefaultMeaning
minDuration20Seconds a turn must run before its done sound plays. Errors and needs-you always page.
quietHoursempty (off)A local time window with no sound or push, such as 22:00-07:00. It may wrap midnight.
ntfyTopicempty (off)An ntfy.sh topic for phone pushes. Stored as a secret. A pasted https://ntfy.sh/<topic> works too.

The pack and a mute are kept in the plugin's store, so they last across sessions and apply to every session at once.

What it can touch

  • Sound: plays its own nine WAV files from sounds/ through Claude Code's audio player.
  • Files: reads and writes none of your files. The pack and mute end time live in the plugin's own store, a small JSON file Claude Code keeps for it in your Claude Code configuration folder.
  • Processes: none of its own (Claude Code runs afplay to play a clip on macOS).
  • Network: only when you set ntfyTopic. Each alert that makes a sound also sends one HTTPS POST to https://ntfy.sh/<topic> with a fixed title (Pager: done, Pager: needs you, Pager: error) and a short fixed text (for done, the turn's length, such as "A turn finished after 2m 5s."). It never sends your prompts, Claude's answers, the session title or the project name. This is the only network call Pager makes. Anyone who knows an ntfy.sh topic can read it, so pick a long, unguessable one.
  • Model calls: none.

How "needs you" is detected

Pager listens for Claude Code's own Notification event and pages on the types that mean a dialog is waiting on you: permission_prompt, worker_permission_prompt, elicitation_dialog, elicitation_url_dialog and agent_needs_input. Claude Code raises these once a dialog has waited about 6 seconds without a keypress, so a prompt you answer right away makes no sound. idle_prompt is left out because it follows a finished turn: a long one already paged, and a short one means you were there.

Limits

  • Sound plays on macOS only. Claude Code plays clips with afplay; on Linux and Windows it has no player, so $.audio.play is silent there. Toasts and phone pushes still work. /pager test says so.
  • An interrupted turn (Esc, or a plugin cutting the turn for you) makes no sound: you were there.
  • Pager stays silent (no sound, push or toast) when nothing draws the session: a plain claude -p run, or a desktop session with no window attached.
  • Each session keeps its own 5 second gap, so two sessions ending together can both sound.
  • If CLAUDE_CODE_DISABLE_PERMISSION_PROMPT_NOTIFY_HOOKS is set, Claude Code raises no permission notification in the desktop app or SDK, so needs-you stays silent there.
  • Subagent turns never page on their own; the main turn that ran them does.
  • minDuration counts the whole turn, including time a permission prompt waited on you.
  • Quiet hours use the clock of the machine running Claude Code.
  • A hot reload of the plugin forgets the 5 second gap and the session title until the next prompt (at most one early sound, or a toast showing the folder name). A /clear forgets the title on purpose, so the old conversation's name does not linger.
  • If ntfy.sh is down or offline, the push is dropped (noted in the debug log); the sound still plays.

The sounds are original

Every clip is computed from sine and pulse waves by tools/synth.py, using only the Python standard library. Regenerate them, byte for byte, with:

python3 -I mods/pager/tools/synth.py

Built from a prompt

This mod was built from ../../prompts/pager.md.

More mods: the full catalogue.

Source 2 files
hooks/register.tsx 275 lines
1// Pager: a sound (and a toast, and optionally a phone push) when a turn
2// finishes, when Claude waits on you, or when a turn ends on an error.
3//
4// Every function that takes `$` is declared at the top of this file: the
5// engine reads the module's source to see which nouns it calls.
6
7import type { EngineInterface, Register } from 'claude-code'
8
9import {
10  DEFAULT_MUTE_MS,
11  DEFAULT_PACK,
12  GAP_MS,
13  KINDS,
14  LABEL,
15  PACKS,
16  assetFor,
17  clean,
18  formatDuration,
19  formatWindow,
20  isPack,
21  isPerson,
22  isQuiet,
23  minuteOfDay,
24  notificationAlert,
25  parseDuration,
26  parseQuietHours,
27  parseTopic,
28  pushFor,
29  sessionName,
30  turnAlert,
31  untilText,
32  type Kind,
33  type Pack,
34  type QuietHours,
35} from './logic'
36
37// Kept in $.store, so a pack choice and a mute last across sessions and apply
38// to every session at once.
39const PACK_KEY = 'pack'
40const MUTED_KEY = 'mutedUntil'
41
42const COMMANDS = '/pager pack <classic|arcade|zen|butler|coach>, /pager test, /pager mute <30m>, /pager unmute'
43const NO_PLAYER =
44  'Heard nothing? Sounds play through macOS afplay; on Linux and Windows Claude Code has no player, so Pager shows toasts (and pushes) only.'
45
46/** The settings, read once per load, and the little memory one load keeps. */
47type Pager = {
48  minMs: number
49  /** quietHours as shown: `22:00-07:00` when it parses, else the setting cleaned. */
50  quietText: string
51  quiet: QuietHours | null | undefined
52  topic: string | null | undefined
53  // A hot reload forgets these two, which costs at most one early sound or a
54  // toast naming the folder until the next prompt.
55  lastSoundAt: number
56  title: string | undefined
57}
58
59// The engine's errors lead with the plugin's own name; drop it in Pager's own lines.
60const reason = (err: unknown) => (err instanceof Error ? err.message : String(err)).replace(/^pager: /, '')
61
62function debug($: EngineInterface, line: string) {
63  $.ui.log(`pager: ${line}`, { to: 'debug' })
64}
65
66async function currentPack($: EngineInterface): Promise<Pack> {
67  const stored = await $.store.get(PACK_KEY)
68  return isPack(stored) ? stored : DEFAULT_PACK
69}
70
71/** Why alerts are silent right now, or undefined when they are not. */
72async function silence($: EngineInterface, pager: Pager, now: number): Promise<string | undefined> {
73  const until = await $.store.get(MUTED_KEY)
74  if (typeof until === 'number' && now < until) return `muted until ${untilText(now, until)}`
75  if (isQuiet(pager.quiet, minuteOfDay(now))) return `quiet hours (${pager.quietText})`
76  return undefined
77}
78
79async function nameOf($: EngineInterface, pager: Pager): Promise<string> {
80  if (pager.title) return sessionName(pager.title, undefined)
81  const root = await $.session.root().catch(() => undefined)
82  return sessionName(undefined, root)
83}
84
85/** The one network call Pager makes, and only when ntfyTopic is set. */
86function push($: EngineInterface, topic: string, kind: Kind, durationMs: number | undefined) {
87  // Fixed words today; cleaned anyway, since a header or body holding a
88  // control character would be refused.
89  const { title, body } = pushFor(kind, durationMs)
90  // Not awaited: a slow push never holds up the turn; a failed one only
91  // reaches the debug log.
92  void $.http
93    .fetch(`https://ntfy.sh/${topic}`, { method: 'POST', headers: { Title: clean(title) }, body: clean(body) })
94    .then(res => (res.ok ? undefined : debug($, `ntfy.sh answered ${res.status}`)))
95    .catch(err => debug($, `ntfy.sh push failed: ${reason(err)}`))
96}
97
98/** One alert: always a toast; a sound and a push unless silenced or too soon. */
99async function alert($: EngineInterface, pager: Pager, kind: Kind, durationMs?: number) {
100  // A plain `claude -p` run draws nowhere: nobody to page, so no sound and no push.
101  if ((await $.session.surfaces()).length === 0) return
102  const now = await $.clock.now()
103  const took = durationMs === undefined ? '' : ` (${formatDuration(durationMs)})`
104  $.ui.toast(`${LABEL[kind]} - ${await nameOf($, pager)}${took}`)
105
106  if ((await silence($, pager, now)) !== undefined) return
107  // No await between this check and the set: two alerts at once rely on it.
108  if (now - pager.lastSoundAt < GAP_MS) return
109  pager.lastSoundAt = now
110
111  const pack = await currentPack($)
112  // Not awaited: the clip plays while the turn's end is drawn.
113  void $.audio.play({ asset: assetFor(pack, kind) }).catch(err => debug($, `could not play ${kind}: ${reason(err)}`))
114  if (typeof pager.topic === 'string') push($, pager.topic, kind, durationMs)
115}
116
117/** A pager must never break the session: an alert that fails is logged and dropped. */
118async function safeAlert($: EngineInterface, pager: Pager, kind: Kind, durationMs?: number) {
119  try {
120    await alert($, pager, kind, durationMs)
121  } catch (err) {
122    debug($, `alert failed: ${reason(err)}`)
123  }
124}
125
126async function status($: EngineInterface, pager: Pager, now: number): Promise<string> {
127  const silent = await silence($, pager, now)
128  const phone =
129    pager.topic === null ? 'off' : pager.topic === undefined ? 'off (the topic setting is not valid)' : 'on'
130  return [
131    `${await currentPack($)} pack, ${silent ? `silent (${silent})` : 'sounds on'}.`,
132    `Done pages after turns of ${formatDuration(pager.minMs)} or more. Quiet hours: ${pager.quiet ? pager.quietText : 'off'}. Phone push: ${phone}.`,
133    `Commands: ${COMMANDS}.`,
134  ].join(' ')
135}
136
137async function choosePack($: EngineInterface, arg: string): Promise<string> {
138  if (arg === '') return `Pack: ${await currentPack($)}. Packs: ${PACKS.join(', ')}.`
139  const name = arg.toLowerCase()
140  if (!isPack(name)) return `No pack called "${clean(arg.slice(0, 40))}". Packs: ${PACKS.join(', ')}.`
141  await $.store.set(PACK_KEY, name)
142  return `Pack set to ${name}. Run /pager test to hear it.`
143}
144
145/** You asked for it, so it plays even while muted or in quiet hours, one clip after another. */
146async function testPack($: EngineInterface, pager: Pager, now: number): Promise<string> {
147  const pack = await currentPack($)
148  const failed: string[] = []
149  for (const kind of KINDS) {
150    await $.audio.play({ asset: assetFor(pack, kind) }).catch(err => {
151      failed.push(`${LABEL[kind]}: ${clean(reason(err))}`)
152    })
153  }
154  const silent = await silence($, pager, now)
155  return [
156    failed.length === 0
157      ? `Played the ${pack} pack: done, needs you, error.`
158      : `Played the ${pack} pack, but ${failed.length} of 3 failed: ${failed.join('; ')}.`,
159    silent ? `Real alerts are silent now (${silent}).` : '',
160    NO_PLAYER,
161  ]
162    .filter(Boolean)
163    .join(' ')
164}
165
166async function mute($: EngineInterface, arg: string, now: number): Promise<string> {
167  const ms = arg === '' ? DEFAULT_MUTE_MS : parseDuration(arg)
168  if (ms === undefined) {
169    return `Could not read "${clean(arg.slice(0, 40))}" as a duration. Try /pager mute 30m, 2h or 1h30m (up to 7d).`
170  }
171  await $.store.set(MUTED_KEY, now + ms)
172  return `Muted for ${formatDuration(ms)}, until ${untilText(now, now + ms)}. Toasts still show. /pager unmute ends it early.`
173}
174
175async function unmute($: EngineInterface, now: number): Promise<string> {
176  const until = await $.store.get(MUTED_KEY)
177  await $.store.delete(MUTED_KEY)
178  return typeof until === 'number' && now < until ? 'Unmuted. Sounds are back on.' : 'Not muted: sounds are on.'
179}
180
181async function runCommand($: EngineInterface, pager: Pager, args: string, from: { kind: string }): Promise<string> {
182  const [verb = '', ...rest] = args.trim().split(/\s+/).filter(Boolean)
183  const arg = rest.join(' ')
184  const now = await $.clock.now()
185  const name = verb.toLowerCase()
186  // Status is read-only and open to anyone. Everything else changes what you
187  // hear, so a peer session, a channel relay, a task or another plugin cannot
188  // mute your alerts or switch them: only the person can.
189  if (name !== '' && name !== 'status' && !isPerson(from)) {
190    return `/pager ${clean(name.slice(0, 40))} only runs when you type it (this run came from ${clean(from.kind)}); nothing changed.`
191  }
192  switch (name) {
193    case '':
194    case 'status':
195      return status($, pager, now)
196    case 'pack':
197      return choosePack($, arg)
198    case 'test':
199      return testPack($, pager, now)
200    case 'mute':
201      return mute($, arg, now)
202    case 'unmute':
203      return unmute($, now)
204    default:
205      return `Unknown: "${clean(verb.slice(0, 40))}". ${COMMANDS}.`
206  }
207}
208
209export const register: Register = (on, options) => {
210  const minSeconds = Number(options.minDuration)
211  const quietRaw = typeof options.quietHours === 'string' ? options.quietHours.trim() : ''
212  const quiet = parseQuietHours(quietRaw)
213  const pager: Pager = {
214    minMs: (Number.isFinite(minSeconds) && minSeconds >= 0 ? minSeconds : 20) * 1000,
215    quietText: quiet ? formatWindow(quiet) : clean(quietRaw.slice(0, 40)),
216    quiet,
217    topic: parseTopic(options.ntfyTopic),
218    lastSoundAt: -Infinity,
219    title: undefined,
220  }
221
222  on('session.start', async ($, e, next) => {
223    await $.command.register({
224      name: 'pager',
225      description: 'Sound alerts: switch pack, test, mute or unmute',
226      argumentHint: '[pack <name> | test | mute <30m> | unmute]',
227      immediate: true,
228    })
229    // Say a bad setting once, here, instead of failing quietly at every alert.
230    if (pager.quiet === undefined) {
231      $.ui.log(`pager: quietHours "${pager.quietText}" is not like 22:00-07:00, so quiet hours are off.`)
232    }
233    if (pager.topic === undefined) {
234      $.ui.log('pager: ntfyTopic must be 1-64 letters, digits, - or _, so phone push is off.')
235    }
236    return next(e)
237  })
238
239  // These hooks only observe. Should one fail, its .catch passes the event on
240  // untouched, so Pager can never block a prompt, a session or a notification.
241
242  // The session's title rides on these two settings-hook events; read it, change nothing.
243  on('classic.SessionStart', ($, e, next) => {
244    // Set or cleared: after a /clear the old conversation's title must not linger.
245    pager.title = e.session_title?.trim() ? e.session_title : undefined
246    return next(e)
247  }).catch(($, e, next) => next(e))
248  on('classic.UserPromptSubmit', ($, e, next) => {
249    if (e.session_title?.trim()) pager.title = e.session_title
250    return next(e)
251  }).catch(($, e, next) => next(e))
252
253  // Done and error: the main loop's turn ending, timed by the engine's own
254  // durationMs (measured from the turn's start).
255  on('turn.complete', async ($, e, next) => {
256    const kind = turnAlert(e, pager.minMs)
257    if (kind !== undefined) await safeAlert($, pager, kind, kind === 'done' ? e.durationMs : undefined)
258    return next(e)
259  })
260
261  // Needs you: Claude Code's own "waiting on you" notification (see notificationAlert).
262  on('classic.Notification', async ($, e, next) => {
263    const kind = notificationAlert(e.notification_type)
264    if (kind !== undefined) await safeAlert($, pager, kind)
265    return next(e)
266  }).catch(($, e, next) => next(e))
267
268  // The answer is for the person, not the model: shown as a toast, and the
269  // command answers {} so nothing lands in the transcript the model reads.
270  on('command.run', { command: 'pager' }, async ($, e) => {
271    $.ui.toast(await runCommand($, pager, e.args, e.origin), { timeoutMs: 8_000 })
272    return {}
273  })
274}
275
hooks/logic.ts 200 lines
1// Pure rules for Pager: what pages, when it stays quiet, and what a push says.
2// No `$` here, so every rule is tested on plain values.
3
4import type { TurnCompleteInput } from 'claude-code'
5
6/** Three synthesized tone packs, then two spoken packs: original lines in generated voices. */
7export const PACKS = ['classic', 'arcade', 'zen', 'butler', 'coach'] as const
8export type Pack = (typeof PACKS)[number]
9export const DEFAULT_PACK: Pack = 'classic'
10
11export const KINDS = ['done', 'needs-you', 'error'] as const
12export type Kind = (typeof KINDS)[number]
13
14export const LABEL: Record<Kind, string> = {
15  done: 'Done',
16  'needs-you': 'Needs you',
17  error: 'Error',
18}
19
20/** At most one sound (and one push) every five seconds, whatever the events. */
21export const GAP_MS = 5_000
22export const DEFAULT_MUTE_MS = 60 * 60_000
23export const MAX_MUTE_MS = 7 * 24 * 60 * 60_000
24
25export const isPack = (value: unknown): value is Pack =>
26  typeof value === 'string' && (PACKS as readonly string[]).includes(value)
27
28/** The clip for one alert, relative to the plugin folder as `$.audio.play` takes it. */
29export const assetFor = (pack: Pack, kind: Kind) => `sounds/${pack}/${kind}.wav`
30
31/**
32 * Which turn endings page you. A subagent's turn never does (its parent's turn
33 * will), and neither does an interrupt: you, or a plugin acting on your input,
34 * stopped it, so you are already there.
35 */
36export function turnAlert(
37  e: Pick<TurnCompleteInput, 'reason' | 'durationMs' | 'agentId'>,
38  minMs: number,
39): Kind | undefined {
40  if (e.agentId !== undefined) return undefined
41  if (e.reason === 'error' || e.reason === 'refusal') return 'error'
42  if (e.reason === 'answer' && e.durationMs >= minMs) return 'done'
43  return undefined
44}
45
46/**
47 * The engine's own Notification types that mean "a dialog is waiting on you".
48 * Claude Code raises them once a permission prompt, question, plan approval or
49 * MCP input request has waited about six seconds without a keypress. Left out
50 * on purpose: idle_prompt (it follows a finished turn: a long one already paged,
51 * and a short one means you were there),
52 * agent_completed, auth and the rest.
53 */
54const NEEDS_YOU = new Set([
55  'permission_prompt',
56  'worker_permission_prompt',
57  'elicitation_dialog',
58  'elicitation_url_dialog',
59  'agent_needs_input',
60])
61
62export const notificationAlert = (type: string): Kind | undefined =>
63  NEEDS_YOU.has(type) ? 'needs-you' : undefined
64
65const UNIT_MS: Record<string, number> = { d: 86_400_000, h: 3_600_000, m: 60_000, s: 1_000 }
66
67/**
68 * A mute length: `30m`, `2h`, `1h30m` (or `1h 30m`), `45s`, `1d`; a bare number is minutes.
69 * Undefined for anything else, for zero, and past seven days.
70 */
71export function parseDuration(text: string): number | undefined {
72  const t = text.replace(/\s+/g, '').toLowerCase() // `1h 30m` and `30 m` read as typed
73  let ms: number
74  if (/^\d+$/.test(t)) {
75    ms = Number(t) * UNIT_MS.m!
76  } else if (/^(\d+[dhms])+$/.test(t)) {
77    ms = 0
78    for (const [, n, unit] of t.matchAll(/(\d+)([dhms])/g)) ms += Number(n) * UNIT_MS[unit!]!
79  } else {
80    return undefined
81  }
82  return ms > 0 && ms <= MAX_MUTE_MS ? ms : undefined
83}
84
85/** A daily window in minutes since local midnight; `end` before `start` wraps midnight. */
86export type QuietHours = { start: number; end: number }
87
88/**
89 * `22:00-07:00` (or `22-7`). `null` when the setting is empty (off),
90 * `undefined` when it cannot be read (also off, and said once at start).
91 */
92export function parseQuietHours(text: string): QuietHours | null | undefined {
93  if (text.trim() === '') return null
94  const m = /^(\d{1,2})(?::(\d{2}))?\s*-\s*(\d{1,2})(?::(\d{2}))?$/.exec(text.trim())
95  if (!m) return undefined
96  const minute = (h: string, mm = '0') =>
97    Number(h) <= 23 && Number(mm) <= 59 ? Number(h) * 60 + Number(mm) : NaN
98  const start = minute(m[1]!, m[2])
99  const end = minute(m[3]!, m[4])
100  if (Number.isNaN(start) || Number.isNaN(end) || start === end) return undefined
101  return { start, end }
102}
103
104/** A window as `22:00-07:00`, however it was typed. */
105export function formatWindow(window: QuietHours): string {
106  const hhmm = (m: number) => `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`
107  return `${hhmm(window.start)}-${hhmm(window.end)}`
108}
109
110export function isQuiet(window: QuietHours | null | undefined, minuteOfDay: number): boolean {
111  if (!window) return false
112  return window.start < window.end
113    ? minuteOfDay >= window.start && minuteOfDay < window.end
114    : minuteOfDay >= window.start || minuteOfDay < window.end
115}
116
117/** Minutes since local midnight for an epoch time. */
118export const minuteOfDay = (ms: number) => {
119  const d = new Date(ms)
120  return d.getHours() * 60 + d.getMinutes()
121}
122
123const pad = (n: number) => String(n).padStart(2, '0')
124const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
125
126/** `14:05`, `tomorrow 07:30` or `Oct 12 14:05`, in local time. */
127export function untilText(now: number, until: number): string {
128  const a = new Date(now)
129  const b = new Date(until)
130  const time = `${pad(b.getHours())}:${pad(b.getMinutes())}`
131  const dayOf = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
132  const days = Math.round((dayOf(b) - dayOf(a)) / 86_400_000)
133  if (days === 0) return time
134  if (days === 1) return `tomorrow ${time}`
135  return `${MONTHS[b.getMonth()]} ${b.getDate()} ${time}`
136}
137
138/** `20s`, `2m 5s`, `1h 30m`, `7d`: the two largest units that are not zero. */
139export function formatDuration(ms: number): string {
140  let s = Math.max(0, Math.round(ms / 1000))
141  const parts: string[] = []
142  for (const [unit, size] of [['d', 86_400], ['h', 3_600], ['m', 60], ['s', 1]] as const) {
143    const n = Math.floor(s / size)
144    s -= n * size
145    if (n > 0) parts.push(`${n}${unit}`)
146  }
147  return parts.slice(0, 2).join(' ') || '0s'
148}
149
150/**
151 * The ntfy topic setting: `null` when unset (push off), `undefined` when it is
152 * not a topic ntfy.sh accepts (push off, said once at start). A pasted
153 * `https://ntfy.sh/<topic>` URL is taken as its topic.
154 */
155export function parseTopic(raw: unknown): string | null | undefined {
156  if (typeof raw !== 'string' || raw.trim() === '') return null
157  const topic = raw.trim().replace(/^https:\/\/ntfy\.sh\//, '')
158  return /^[A-Za-z0-9_-]{1,64}$/.test(topic) ? topic : undefined
159}
160
161/** What reaches ntfy.sh: fixed words and a duration, never conversation text. */
162export function pushFor(kind: Kind, durationMs?: number): { title: string; body: string } {
163  switch (kind) {
164    case 'done':
165      return {
166        title: 'Pager: done',
167        body: durationMs === undefined ? 'A turn finished.' : `A turn finished after ${formatDuration(durationMs)}.`,
168      }
169    case 'needs-you':
170      return { title: 'Pager: needs you', body: 'Claude is waiting on a permission or a question.' }
171    case 'error':
172      return { title: 'Pager: error', body: 'A turn ended on an error.' }
173  }
174}
175
176/**
177 * Text safe to draw or send: ANSI escape sequences removed, and every other
178 * C0 control, DEL and C1 control (U+0080..U+009F) replaced with U+FFFD. The
179 * engine can refuse drawn text holding them, and a session title or folder
180 * name is not ours to trust.
181 */
182export function clean(text: string): string {
183  return text
184    .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '') // CSI: colors, cursor moves
185    .replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g, '') // OSC: titles, links
186    .replace(/[\u0000-\u001f\u007f-\u009f]/g, '\ufffd')
187}
188
189/** Who may change Pager with /pager: the person, typing in a terminal, the desktop app or Remote Control. */
190const PERSON = new Set(['composer', 'sdk', 'bridge'])
191export const isPerson = (origin: { kind: string }) => PERSON.has(origin.kind)
192
193/** The name a toast shows: the session's title, else its project folder, cleaned. */
194export function sessionName(title: string | undefined, root: string | undefined): string {
195  const clip = (s: string) => (s.length > 60 ? `${s.slice(0, 59)}...` : s)
196  if (title?.trim()) return clip(clean(title.trim()))
197  const folder = root?.split(/[\\/]/).filter(Boolean).at(-1)
198  return folder ? clip(clean(folder)) : 'this session'
199}
200