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

<img src="../../brand/pixel-icons/svg/pager.svg" width="64" height="64" alt="">
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:
minDuration seconds (quick back-and-forth stays quiet),/plugin install pager --marketplace modmyclaude/claude-code-mods
| Command | What it does | ||||
|---|---|---|---|---|---|
/pager | Shows 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 test | Plays 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 unmute | Ends 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:
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:
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.
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).
| Field | Default | Meaning |
|---|---|---|
minDuration | 20 | Seconds a turn must run before its done sound plays. Errors and needs-you always page. |
quietHours | empty (off) | A local time window with no sound or push, such as 22:00-07:00. It may wrap midnight. |
ntfyTopic | empty (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.
sounds/ through Claude Code's audio player.afplay to play a clip on macOS).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.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.
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.claude -p run, or a desktop session with no window attached.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.minDuration counts the whole turn, including time a permission prompt waited on you.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
This mod was built from ../../prompts/pager.md.
More mods: the full catalogue.
hooks/register.tsx 275 lines1// 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}
275hooks/logic.ts 200 lines1// 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