Desktop notifications, plus optional phone push via ntfy, when Claude needs your input, finishes a long turn, hits an error, or a subagent finishes.

Native desktop notifications from Claude Code, so you can look away while it works. You get one when Claude needs you, when a long turn finishes, when something fails, and when a subagent finishes. Optionally, each one is also pushed to your phone through ntfy, which works from cloud sessions too, where there is no desktop.
/plugin install notify --marketplace mrjk05/modemon
Answer y to add the marketplace, then pick a scope. Run /notify test to check that notifications reach you.
| Trigger | Source | Body |
|---|---|---|
| Needs input | classic.Notification (permission prompt, idle prompt, elicitation; not auth_success) and every AskUserQuestion call | The prompt's message, or Question: <first question> |
| Turn finished | turn.complete for a main-loop turn that ran longer than minTurnSeconds (timed from turn.start) | Done in 1m 12s: <first line of the answer> |
| Error | a turn that ended on an API error or a refusal, and classic.StopFailure | Error: rate limited, Error: API server error (…) |
| Subagent done | classic.SubagentStop, or a foreground Agent call's result | Agent done: <the Agent call's description> |
The title is Claude Code · <repo-name>.
Interrupted turns (Esc) and subagent turns never send a "done" notification. To avoid spamming you, it sends at most one notification per kind every 5 seconds. That's why a failed turn and the StopFailure that follows it produce only one error notification.
/notify test: sends a sample right away to the desktop and, when ntfyTopic is set, to your phone. It ignores mute, throttle and focus, and reports for each side what delivered it or why it failed./notify off / /notify on: mutes or unmutes this session./notify status (or just /notify): shows mute state, platform, the backend in use, the last delivery failure, the ntfy target (topic masked, e.g. https://ntfy.sh/cl••••sT), the last push result and the current config. It draws as a small card in the terminal and the desktop app, and as a compact headline plus Markdown list on the Claude mobile app.Set these in /config (the plugin's rows) or in the install screen. ntfyTopic is marked sensitive, so it is kept in secure storage and is not a /config row (see Phone push).
| Field | Default | Meaning |
|---|---|---|
onNeedsInput | true | Permission and idle prompts, AskUserQuestion |
onTurnDone | true | Long turns finishing |
minTurnSeconds | 30 | A turn must run longer than this to send a "done" notification |
onError | true | API errors and failed turns |
onSubagentDone | true | Subagents finishing |
sound | Glass | macOS sound name (Ping, Hero, Submarine, ...). Leave it empty for silent notifications |
onlyWhenUnfocused | true | macOS: skip desktop notifications while your terminal app is the frontmost app |
ntfyTopic | empty (off) | ntfy topic to also push every notification to. Sensitive |
ntfyServer | https://ntfy.sh | ntfy server base URL; a self-hosted one may have a path prefix |
ntfyOnlyWhenAway | true | Skip the phone push while your terminal is the frontmost app (macOS only; see below) |
Notifications are sent with $.process.run (no shell), after the hook returns, so a notification never holds up the turn or a tool call. A failed delivery is swallowed and never breaks the session. Once a backend works, the mod caches it for the rest of the session.
terminal-notifier if it is installed. Clicking the notification brings your terminal to the front. Notifications from one session replace each other (-group claude-code-<session>). brew install terminal-notifier
The terminal is detected from TERM_PROGRAM: Terminal, iTerm2, Ghostty, VS Code and WezTerm are recognised. Otherwise it falls back to macOS's __CFBundleIdentifier.
osascript -e 'display notification "…" with title "…" sound name "Glass"'. Clicking it opens Script Editor, not your terminal.Permission: macOS shows a notification only if the sending app may post them. Open System Settings → Notifications. Allow your terminal app for osascript, or terminal-notifier once it has posted its first notification. If /notify test says it sent but nothing appears, this setting or Focus / Do Not Disturb is usually the cause.
notify-send -a "Claude Code" -- <title> <body> (from libnotify-bin / libnotify). The body is markup-escaped, because most notification daemons parse it as markup. Linux notifications have no sound.
There is no desktop backend on other platforms (Windows included), so nothing is shown there. /notify status says so. Phone push still works.
ntfy is a simple pub/sub notification service: you subscribe to a topic in its phone app, and anything POSTed to https://ntfy.sh/<topic> buzzes your phone. notify POSTs every notification it sends there too.
echo "claude-$(openssl rand -hex 12)"
Topic names are 1-64 letters, digits, - or _.
ntfyTopic in notify's config:/plugin, pick notify, and set it on its config screen (the same screen as at install). Because the field is sensitive it is stored in secure storage and does not show as a /config row.pluginConfigs in ~/.claude/settings.json, keyed by the plugin name (notify, or notify@inline for a --plugin-dir load): {
"pluginConfigs": {
"notify": { "options": { "ntfyTopic": "claude-3f9c1e…", "ntfyServer": "https://ntfy.sh" } }
}
}
/notify test. It reports phone push sent via ntfy (HTTP 200) and your phone buzzes.For a cloud session, the topic has to be in settings that session reads (for example your user settings). Never put it in a committed project file such as .claude/settings.json: anyone with the repo could then read your notifications. A value written into a settings file by hand is plain text there; only the config screen puts it in secure storage. The mod needs nothing installed in the container, because the push goes through the session's own $.http.fetch.
POST <ntfyServer>/<ntfyTopic> with:
| Header | Value |
|---|---|
Title | Claude Code · <repo>. HTTP headers must be ASCII, so a title with any non-ASCII character (the · included) is sent as RFC 2047 encoded words (=?UTF-8?B?…?=), which ntfy decodes |
Priority | high for needs-input and errors, default otherwise |
Tags | question (needs input), white_check_mark (turn done), warning (error), robot (subagent), bell (/notify test); the app draws them as emoji |
The body is the notification text (UTF-8, flattened to one line, up to 1000 characters). Control characters are removed before anything goes into a header, so a message cannot inject headers.
Mute (/notify off), the per-trigger toggles and the 5-second throttle apply to both the desktop and the phone. Focus is handled separately:
onlyWhenUnfocused only gates the desktop notification.ntfyOnlyWhenAway (default true) skips the phone push only when there is proof you are at the computer: your terminal is the frontmost app (readable on macOS only) and the desktop side did not fail. If the desktop notification failed while you were focused, the phone still gets it.ntfyOnlyWhenAway to false to push everything to the phone, focused or not.Desktop and phone are delivered one after the other, each in its own error handling: a missing notify-send or a blocked osascript never stops the push, and an unreachable ntfy server never stops the desktop notification. /notify status shows the last push result.
claude, mytopic) are guessed in practice. Use a long random topic; /notify status flags topics shorter than 20 characters./notify status and is stored as a sensitive config value, but it travels in the URL of every push, so it is visible to the ntfy server (and to anything that logs your outgoing URLs, e.g. a corporate proxy).ntfyServer at it (e.g. https://ntfy.example.com). Authenticated topics (tokens) are not supported by this mod yet.$.http.fetch; an organization web-fetch policy that blocks the host makes every push fail (shown in /notify status). There is no retry and no auth token support.lsappinfo). If your terminal is frontmost but you are looking at another tab or tmux pane, you still won't get a notification. On Linux, and in any terminal whose app can't be identified, onlyWhenUnfocused has no effect and every notification is sent.classic.PermissionRequest is not hooked. Permission prompts are caught through classic.Notification, which avoids notifying for requests that a policy or auto mode answers without asking you.$.state and survives a reload.hooks/register.tsx 416 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import {
4 HELP,
5 Throttle,
6 argvFor,
7 askBody,
8 backendsFor,
9 baseName,
10 doneBody,
11 errorBody,
12 isNeedsInput,
13 isStatusText,
14 needsInputBody,
15 ntfyRequest,
16 ntfyTarget,
17 parseBundleId,
18 parseCommand,
19 parseFrontAsn,
20 platformFromUname,
21 readConfig,
22 shouldPush,
23 statusMarkdown,
24 subagentBody,
25 terminalBundle,
26 titleFor,
27} from './lib'
28import type { Backend, Config, Kind, Note, Platform } from './lib'
29
30type $ = EngineInterface
31
32const muted = { plugin: 'notify', key: 'muted' } as const
33
34const RUN_TIMEOUT_MS = 5000
35
36// Per load: lost on a hot reload, which only means probing again.
37let config: Config = readConfig(undefined)
38const throttle = new Throttle(5000)
39const turnStarts = new Map<string, number>()
40const agentDescriptions = new Map<string, string>()
41const agentsNotified = new Set<string>()
42let platform: Platform | undefined
43let backend: Backend | undefined
44let lastError: string | undefined
45let lastNtfy: string | undefined
46let bundle: { id: string | undefined } | undefined
47
48async function getPlatform($: $): Promise<Platform> {
49 if (platform !== undefined) return platform
50 try {
51 const r = await $.process.run(['uname', '-s'], { timeoutMs: RUN_TIMEOUT_MS })
52 platform = r.exitCode === 0 ? platformFromUname(r.stdout) : 'other'
53 } catch {
54 platform = 'other' // no uname: Windows or a locked-down host
55 }
56 return platform
57}
58
59async function getBundle($: $): Promise<string | undefined> {
60 if (bundle !== undefined) return bundle.id
61 const termProgram = await $.env.get('TERM_PROGRAM')
62 const cfBundle = await $.env.get('__CFBundleIdentifier')
63 bundle = { id: terminalBundle(termProgram, cfBundle) }
64 return bundle.id
65}
66
67/** macOS only: is the app the session runs in the frontmost app? */
68async function isTerminalFrontmost($: $): Promise<boolean> {
69 if ((await getPlatform($)) !== 'darwin') return false
70 const mine = await getBundle($)
71 if (mine === undefined) return false
72 try {
73 const front = await $.process.run(['lsappinfo', 'front'], { timeoutMs: RUN_TIMEOUT_MS })
74 const asn = parseFrontAsn(front.stdout)
75 if (asn === undefined) return false
76 const info = await $.process.run(['lsappinfo', 'info', '-only', 'bundleid', asn], {
77 timeoutMs: RUN_TIMEOUT_MS,
78 })
79 return parseBundleId(info.stdout) === mine
80 } catch {
81 return false
82 }
83}
84
85async function buildNote($: $, body: string): Promise<Note> {
86 let repoName: string | undefined
87 try {
88 const repo = await $.session.repo()
89 repoName = baseName(repo !== null ? repo.root : await $.session.cwd())
90 } catch {
91 repoName = undefined
92 }
93 let sessionId = 'session'
94 try {
95 sessionId = await $.session.id()
96 } catch {
97 // keep the generic group
98 }
99 const activate = (await getPlatform($)) === 'darwin' ? await getBundle($) : undefined
100 return {
101 title: titleFor(repoName),
102 body,
103 sound: config.sound,
104 group: `claude-code-${sessionId}`,
105 ...(activate !== undefined ? { activate } : {}),
106 }
107}
108
109type Sent = { isSent: true; backend: Backend } | { isSent: false; reason: string }
110
111/** Tries the cached backend first, then the platform's others, caching the one that works. */
112async function send($: $, note: Note): Promise<Sent> {
113 const all = backendsFor(await getPlatform($))
114 if (all.length === 0) return { isSent: false, reason: `no notifier for this platform (${platform})` }
115 const order = backend !== undefined ? [backend, ...all.filter(b => b !== backend)] : all
116 for (const b of order) {
117 try {
118 const r = await $.process.run(argvFor(b, note), { timeoutMs: RUN_TIMEOUT_MS })
119 if (r.exitCode === 0) {
120 backend = b
121 lastError = undefined
122 return { isSent: true, backend: b }
123 }
124 lastError = `${b} exited ${r.exitCode}${r.stderr.trim() ? `: ${r.stderr.trim().slice(0, 200)}` : ''}`
125 } catch (err) {
126 lastError = `${b} could not run: ${err instanceof Error ? err.message : String(err)}`
127 }
128 if (backend === b) backend = undefined
129 }
130 return { isSent: false, reason: lastError ?? 'no notifier worked' }
131}
132
133type Pushed = { isSent: true; status: number } | { isSent: false; reason: string }
134
135/** POSTs the note to the configured ntfy topic through `$.http.fetch`. Never throws. */
136async function push($: $, kind: Kind, note: Note): Promise<Pushed> {
137 const target = ntfyTarget(config.ntfyServer, config.ntfyTopic)
138 if ('error' in target) return { isSent: false, reason: target.error }
139 const req = ntfyRequest(target.url, kind, note)
140 try {
141 const r = await $.http.fetch(req.url, req.init)
142 if (r.ok) {
143 lastNtfy = `delivered (HTTP ${r.status})`
144 return { isSent: true, status: r.status }
145 }
146 const why = `HTTP ${r.status}${r.text.trim() ? `: ${r.text.trim().slice(0, 160)}` : ''}`
147 lastNtfy = `failed, ${why}`
148 return { isSent: false, reason: why }
149 } catch (err) {
150 const why = err instanceof Error ? err.message : String(err)
151 lastNtfy = `failed, ${why}`
152 return { isSent: false, reason: why }
153 }
154}
155
156const hasNtfy = (): boolean => config.ntfyTopic !== ''
157
158async function isMuted($: $): Promise<boolean> {
159 try {
160 const { value } = await $.state.get(muted)
161 return value === true
162 } catch {
163 return false
164 }
165}
166
167/**
168 * Decides now (mute, throttle) and delivers off the hook's path, on a timer,
169 * so neither the turn nor a gating site waits on a child process.
170 */
171async function notify($: $, kind: Kind, body: string): Promise<void> {
172 if (await isMuted($)) return
173 if (!throttle.allow(kind, await $.clock.now())) return
174 $.clock.after(0, () => {
175 void deliver($, kind, body).catch(() => undefined)
176 })
177}
178
179/**
180 * Desktop first, then the phone; each in its own try, so one failing never
181 * stops the other. See `shouldPush` for when the phone push is skipped.
182 */
183async function deliver($: $, kind: Kind, body: string): Promise<void> {
184 const note = await buildNote($, body)
185 const needsFocus = config.onlyWhenUnfocused || (hasNtfy() && config.ntfyOnlyWhenAway)
186 let isFocused = false
187 if (needsFocus) {
188 try {
189 isFocused = await isTerminalFrontmost($)
190 } catch {
191 isFocused = false
192 }
193 }
194 let desktop: 'sent' | 'skipped' | 'failed'
195 if (config.onlyWhenUnfocused && isFocused) {
196 desktop = 'skipped'
197 } else {
198 try {
199 desktop = (await send($, note)).isSent ? 'sent' : 'failed'
200 } catch {
201 desktop = 'failed'
202 }
203 }
204 if (hasNtfy() && shouldPush(config.ntfyOnlyWhenAway, isFocused, desktop)) await push($, kind, note)
205}
206
207/** Runs `fn`, swallowing anything it throws: a notification never breaks a hook. */
208async function quietly(fn: () => Promise<void>): Promise<void> {
209 try {
210 await fn()
211 } catch {
212 // a failed notification is not the session's problem
213 }
214}
215
216async function subagentDone($: $, agentId: string, agentType: string | undefined): Promise<void> {
217 if (!config.onSubagentDone || agentsNotified.has(agentId)) return
218 agentsNotified.add(agentId)
219 let description = agentDescriptions.get(agentId)
220 if (description === undefined) {
221 try {
222 description = (await $.agent.list()).find(a => a.id === agentId)?.description
223 } catch {
224 description = undefined
225 }
226 }
227 await notify($, 'subagent', subagentBody(description, agentType))
228}
229
230export const register: Register = (on, options) => {
231 config = readConfig(options)
232 throttle.reset()
233 turnStarts.clear()
234 agentDescriptions.clear()
235 agentsNotified.clear()
236 platform = undefined
237 backend = undefined
238 lastError = undefined
239 lastNtfy = undefined
240 bundle = undefined
241
242 // --- commands -----------------------------------------------------------
243
244 on('session.start', async ($, e, next) => {
245 await quietly(async () => {
246 await $.command.register({
247 name: 'notify',
248 description: 'Desktop and phone (ntfy) notifications: test, on, off, status.',
249 argumentHint: '[test|on|off|status]',
250 immediate: true,
251 })
252 })
253 return next(e)
254 })
255
256 on('command.run', { command: 'notify' }, async ($, e) => {
257 const cmd = parseCommand(e.args)
258 if (cmd === 'help') return { text: HELP }
259 if (cmd === 'on' || cmd === 'off') {
260 await $.state.set(muted, cmd === 'off')
261 return { text: cmd === 'off' ? 'notify: muted for this session.' : 'notify: on.' }
262 }
263 if (cmd === 'test') {
264 const note = await buildNote($, 'Test notification: notify is working.')
265 let desktop: string
266 try {
267 const sent = await send($, note)
268 desktop = sent.isSent ? `sent via ${sent.backend}` : `could not send (${sent.reason})`
269 } catch (err) {
270 desktop = `could not send (${err instanceof Error ? err.message : String(err)})`
271 }
272 const lines = [`notify: desktop ${desktop}.`]
273 if (hasNtfy()) {
274 const pushed = await push($, 'test', note)
275 lines.push(
276 pushed.isSent
277 ? `notify: phone push sent via ntfy (HTTP ${pushed.status}).`
278 : `notify: phone push failed (${pushed.reason}).`,
279 )
280 } else {
281 lines.push('notify: phone push off (set ntfyTopic to turn it on).')
282 }
283 return { text: lines.join('\n') }
284 }
285 const p = await getPlatform($)
286 const front = p === 'darwin' ? await getBundle($) : undefined
287 const focus =
288 p === 'darwin'
289 ? front !== undefined
290 ? `terminal ${front}`
291 : 'terminal app unknown, always notifies'
292 : 'focus not readable here, always notifies'
293 const text = statusMarkdown({
294 isMuted: await isMuted($),
295 platform: p,
296 backend,
297 lastError,
298 focus,
299 lastNtfy,
300 config,
301 })
302 return { text }
303 })
304
305 // Status drawn as a card: a coloured headline over the Markdown bullets.
306 // Mobile and narrow rows get no border, so the text keeps the full width.
307 on('ui.render', { component: 'CommandOutput', props: { command: 'notify' } }, async ($, e, next) => {
308 if (e.props.isErrored || !isStatusText(e.props.text)) return next(e)
309 const { Box, Text, Markdown } = $.ui.resolve(e)
310 const [headline = '', ...rest] = e.props.text.split('\n')
311 const isMutedRow = headline.includes('muted')
312 const columns = e.viewport?.columns ?? 80
313 const isCompact = e.surface === 'mobile' || columns < 60
314 const title = headline.replace(/\*\*/g, '')
315 const body = rest.join('\n').trim()
316 return isCompact ? (
317 <Box flexDirection="column">
318 <Text bold color={isMutedRow ? 'yellow' : 'green'}>
319 {`🔔 ${title}`}
320 </Text>
321 <Markdown text={body} />
322 </Box>
323 ) : (
324 <Box
325 flexDirection="column"
326 borderStyle="round"
327 borderColor={isMutedRow ? 'yellow' : 'green'}
328 paddingX={1}
329 width={Math.min(columns, 100)}
330 >
331 <Text bold color={isMutedRow ? 'yellow' : 'green'}>
332 {`🔔 ${title}`}
333 </Text>
334 <Markdown text={body} />
335 </Box>
336 )
337 })
338
339 // --- needs input --------------------------------------------------------
340
341 on('classic.Notification', async ($, e, next) => {
342 if (config.onNeedsInput && e.agent_id === undefined && isNeedsInput(e.notification_type)) {
343 await quietly(() => notify($, 'input', needsInputBody(e.notification_type, e.message)))
344 }
345 return next(e)
346 }).catch(($, e, next) => next(e))
347
348 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
349 if (config.onNeedsInput) {
350 await quietly(() => notify($, 'input', askBody(e.questions)))
351 }
352 return next(e)
353 }).catch(($, e, next) => next(e))
354
355 // --- turn done / failed -------------------------------------------------
356
357 on('turn.start', async ($, e, next) => {
358 await quietly(async () => {
359 turnStarts.set(e.turnId, await $.clock.now())
360 })
361 return next(e)
362 })
363
364 on('turn.complete', async ($, e, next) => {
365 await quietly(async () => {
366 const started = turnStarts.get(e.turnId)
367 turnStarts.delete(e.turnId)
368 if (e.agentId !== undefined || e.isAborted) return
369 if (e.reason === 'error' || e.reason === 'refusal') {
370 if (!config.onError) return
371 const body =
372 e.reason === 'refusal'
373 ? `Turn ended: the model declined${e.refusal.explanation ? ` (${e.refusal.explanation})` : ''}`
374 : 'Error: the turn failed on an API error'
375 await notify($, 'error', body)
376 return
377 }
378 if (!config.onTurnDone) return
379 const ms = started !== undefined ? (await $.clock.now()) - started : e.durationMs
380 if (ms > config.minTurnSeconds * 1000) await notify($, 'done', doneBody(ms, e.answer))
381 })
382 return next(e)
383 })
384
385 on('classic.StopFailure', async ($, e, next) => {
386 if (config.onError && e.agent_id === undefined) {
387 await quietly(() => notify($, 'error', errorBody(e.error, e.error_details)))
388 }
389 return next(e)
390 }).catch(($, e, next) => next(e))
391
392 // --- subagents ----------------------------------------------------------
393
394 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
395 const ran = await next(e)
396 await quietly(async () => {
397 if (ran.deny !== undefined || ran.isError === true) return
398 const result: unknown = ran.result
399 if (typeof result !== 'object' || result === null) return
400 const r = result as { agentId?: unknown; totalDurationMs?: unknown; agentType?: unknown }
401 if (typeof r.agentId !== 'string') return
402 agentDescriptions.set(r.agentId, e.description)
403 // A foreground agent's result arrives when it has finished.
404 if (typeof r.totalDurationMs === 'number') {
405 await subagentDone($, r.agentId, typeof r.agentType === 'string' ? r.agentType : undefined)
406 }
407 })
408 return ran
409 }).catch(($, e, next) => next(e))
410
411 on('classic.SubagentStop', async ($, e, next) => {
412 await quietly(() => subagentDone($, e.agent_id, e.agent_type))
413 return next(e)
414 }).catch(($, e, next) => next(e))
415}
416hooks/lib.ts 503 lines1// Pure helpers for notify: no `$`, so every one is unit-testable.
2
3export type Kind = 'input' | 'done' | 'error' | 'subagent' | 'test'
4export type Platform = 'darwin' | 'linux' | 'other'
5export type Backend = 'terminal-notifier' | 'osascript' | 'notify-send'
6
7export type Note = {
8 title: string
9 body: string
10 /** macOS sound name; '' for silent. */
11 sound: string
12 /** terminal-notifier group: a newer note of the same group replaces the older. */
13 group: string
14 /** Bundle id to bring forward when the note is clicked (terminal-notifier). */
15 activate?: string
16}
17
18export type Config = {
19 onNeedsInput: boolean
20 onTurnDone: boolean
21 minTurnSeconds: number
22 onError: boolean
23 onSubagentDone: boolean
24 sound: string
25 onlyWhenUnfocused: boolean
26 /** ntfy topic to push to; '' is off. */
27 ntfyTopic: string
28 /** ntfy server base URL. */
29 ntfyServer: string
30 /** Skip the phone push while you are evidently at the computer (terminal frontmost). */
31 ntfyOnlyWhenAway: boolean
32}
33
34export const DEFAULT_NTFY_SERVER = 'https://ntfy.sh'
35
36export const DEFAULTS: Config = {
37 onNeedsInput: true,
38 onTurnDone: true,
39 minTurnSeconds: 30,
40 onError: true,
41 onSubagentDone: true,
42 sound: 'Glass',
43 onlyWhenUnfocused: true,
44 ntfyTopic: '',
45 ntfyServer: DEFAULT_NTFY_SERVER,
46 ntfyOnlyWhenAway: true,
47}
48
49type Options = Readonly<Record<string, string | number | boolean | readonly string[]>>
50
51const bool = (v: unknown, d: boolean): boolean => (typeof v === 'boolean' ? v : d)
52
53export function readConfig(options: Options | undefined): Config {
54 const o = options ?? {}
55 const min = o['minTurnSeconds']
56 const sound = o['sound']
57 const topic = o['ntfyTopic']
58 const server = o['ntfyServer']
59 return {
60 onNeedsInput: bool(o['onNeedsInput'], DEFAULTS.onNeedsInput),
61 onTurnDone: bool(o['onTurnDone'], DEFAULTS.onTurnDone),
62 minTurnSeconds:
63 typeof min === 'number' && Number.isFinite(min) && min >= 0 ? min : DEFAULTS.minTurnSeconds,
64 onError: bool(o['onError'], DEFAULTS.onError),
65 onSubagentDone: bool(o['onSubagentDone'], DEFAULTS.onSubagentDone),
66 sound: typeof sound === 'string' ? sound.trim() : DEFAULTS.sound,
67 onlyWhenUnfocused: bool(o['onlyWhenUnfocused'], DEFAULTS.onlyWhenUnfocused),
68 ntfyTopic: typeof topic === 'string' ? topic.trim() : DEFAULTS.ntfyTopic,
69 ntfyServer:
70 typeof server === 'string' && server.trim() !== '' ? server.trim() : DEFAULTS.ntfyServer,
71 ntfyOnlyWhenAway: bool(o['ntfyOnlyWhenAway'], DEFAULTS.ntfyOnlyWhenAway),
72 }
73}
74
75/** `uname -s` output to a platform. */
76export function platformFromUname(stdout: string): Platform {
77 const s = stdout.trim().toLowerCase()
78 if (s.startsWith('darwin')) return 'darwin'
79 if (s.startsWith('linux')) return 'linux'
80 return 'other'
81}
82
83/** The backends to try, in order, on a platform. */
84export function backendsFor(platform: Platform): Backend[] {
85 if (platform === 'darwin') return ['terminal-notifier', 'osascript']
86 if (platform === 'linux') return ['notify-send']
87 return []
88}
89
90/** One line of plain text: control characters and runs of whitespace become one space, cut to `max`. */
91export function clean(text: string, max = 180): string {
92 // eslint-disable-next-line no-control-regex
93 const flat = text.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim()
94 if (flat.length <= max) return flat
95 return flat.slice(0, Math.max(0, max - 1)).trimEnd() + '…'
96}
97
98/** Escapes text for the inside of an AppleScript "string literal". */
99export function escapeAppleScript(text: string): string {
100 return text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
101}
102
103/** Escapes the markup notify-send bodies are parsed as (most daemons read a subset of HTML). */
104export function escapeMarkup(text: string): string {
105 return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
106}
107
108/**
109 * terminal-notifier reads a value that starts with `-` or `[` as something
110 * else; a zero-width space in front keeps it a plain value and draws nothing.
111 */
112function tnValue(text: string): string {
113 return /^[-[]/.test(text) ? '' + text : text
114}
115
116export function terminalNotifierArgv(n: Note): string[] {
117 const argv = [
118 'terminal-notifier',
119 '-title', tnValue(clean(n.title, 80)),
120 '-message', tnValue(clean(n.body) || ' '),
121 '-group', n.group,
122 ]
123 if (n.sound !== '') argv.push('-sound', n.sound)
124 if (n.activate !== undefined && n.activate !== '') argv.push('-activate', n.activate)
125 return argv
126}
127
128export function appleScriptFor(n: Note): string {
129 const body = escapeAppleScript(clean(n.body))
130 const title = escapeAppleScript(clean(n.title, 80))
131 let script = `display notification "${body}" with title "${title}"`
132 if (n.sound !== '') script += ` sound name "${escapeAppleScript(n.sound)}"`
133 return script
134}
135
136export function osascriptArgv(n: Note): string[] {
137 return ['osascript', '-e', appleScriptFor(n)]
138}
139
140export function notifySendArgv(n: Note): string[] {
141 return [
142 'notify-send',
143 '-a', 'Claude Code',
144 '--',
145 clean(n.title, 80),
146 escapeMarkup(clean(n.body)),
147 ]
148}
149
150export function argvFor(backend: Backend, n: Note): string[] {
151 switch (backend) {
152 case 'terminal-notifier':
153 return terminalNotifierArgv(n)
154 case 'osascript':
155 return osascriptArgv(n)
156 case 'notify-send':
157 return notifySendArgv(n)
158 }
159}
160
161/** TERM_PROGRAM values to the bundle id of the app that sets them. */
162export const TERMINAL_BUNDLES: Readonly<Record<string, string>> = {
163 Apple_Terminal: 'com.apple.Terminal',
164 'iTerm.app': 'com.googlecode.iterm2',
165 ghostty: 'com.mitchellh.ghostty',
166 vscode: 'com.microsoft.VSCode',
167 WezTerm: 'com.github.wez.wezterm',
168}
169
170/**
171 * The bundle id of the app the session runs in: TERM_PROGRAM's when known,
172 * else macOS's own `__CFBundleIdentifier` (set for apps started from the Dock).
173 */
174export function terminalBundle(
175 termProgram: string | undefined,
176 cfBundle: string | undefined,
177): string | undefined {
178 if (termProgram !== undefined) {
179 const known = TERMINAL_BUNDLES[termProgram]
180 if (known !== undefined) return known
181 }
182 const b = cfBundle?.trim()
183 return b !== undefined && /^[A-Za-z0-9.-]+$/.test(b) ? b : undefined
184}
185
186/** The ASN `lsappinfo front` prints, if any. */
187export function parseFrontAsn(stdout: string): string | undefined {
188 const m = /ASN:[0-9a-fx-]+:?/i.exec(stdout)
189 return m?.[0]
190}
191
192/** The bundle id in `lsappinfo info -only bundleid <asn>` output. */
193export function parseBundleId(stdout: string): string | undefined {
194 const m = /"CFBundleIdentifier"\s*=\s*"([^"]+)"/.exec(stdout)
195 return m?.[1]
196}
197
198/** Last path segment of a directory. */
199export function baseName(path: string): string {
200 const parts = path.replace(/[\\/]+$/, '').split(/[\\/]/)
201 return parts[parts.length - 1] ?? ''
202}
203
204export function titleFor(repoName: string | undefined): string {
205 const name = repoName?.trim()
206 return name ? `Claude Code · ${name}` : 'Claude Code'
207}
208
209export function formatDuration(ms: number): string {
210 const s = Math.max(0, Math.round(ms / 1000))
211 if (s < 60) return `${s}s`
212 const m = Math.floor(s / 60)
213 const rest = s % 60
214 if (m < 60) return rest ? `${m}m ${rest}s` : `${m}m`
215 const h = Math.floor(m / 60)
216 const mm = m % 60
217 return mm ? `${h}h ${mm}m` : `${h}h`
218}
219
220/** First non-empty line of a text, flattened (markdown markers dropped). */
221export function firstLine(text: string | undefined): string {
222 if (text === undefined) return ''
223 for (const raw of text.split('\n')) {
224 const line = raw.replace(/^[\s#>*-]+/, '').replace(/[`*_]/g, '').trim()
225 if (line !== '') return line
226 }
227 return ''
228}
229
230export function needsInputBody(notificationType: string, message: string): string {
231 const m = message.trim()
232 if (m !== '') return m
233 if (notificationType === 'permission_prompt') return 'Claude needs your permission'
234 if (notificationType === 'idle_prompt') return 'Claude is waiting for your input'
235 return 'Claude needs your attention'
236}
237
238/** The classic Notification types that mean the person is needed. */
239export function isNeedsInput(notificationType: string): boolean {
240 return notificationType !== 'auth_success'
241}
242
243export function askBody(questions: readonly { question?: unknown }[] | undefined): string {
244 const q = questions?.[0]?.question
245 return typeof q === 'string' && q.trim() !== '' ? `Question: ${q.trim()}` : 'Claude has a question for you'
246}
247
248export function doneBody(durationMs: number, answer: string | undefined): string {
249 const head = `Done in ${formatDuration(durationMs)}`
250 const line = firstLine(answer)
251 return line ? `${head}: ${line}` : head
252}
253
254const ERROR_TEXT: Readonly<Record<string, string>> = {
255 authentication_failed: 'authentication failed',
256 oauth_org_not_allowed: 'organization not allowed',
257 account_on_hold: 'account on hold',
258 verification_required: 'verification required',
259 billing_error: 'billing error',
260 rate_limit: 'rate limited',
261 overloaded: 'API overloaded',
262 invalid_request: 'invalid request',
263 model_not_found: 'model not found',
264 server_error: 'API server error',
265 max_output_tokens: 'hit the output token limit',
266 cloud_credential_error: 'cloud credentials error',
267 unknown: 'unknown error',
268}
269
270export function errorBody(error: string | undefined, details?: string): string {
271 const what = (error !== undefined && ERROR_TEXT[error]) || error || 'the turn failed'
272 const d = details?.trim()
273 return d ? `Error: ${what} (${d})` : `Error: ${what}`
274}
275
276export function subagentBody(description: string | undefined, agentType: string | undefined): string {
277 const d = description?.trim()
278 if (d) return `Agent done: ${d}`
279 const t = agentType?.trim()
280 return t ? `Agent done: ${t}` : 'A subagent finished'
281}
282
283/** At most one notification per key per window. */
284export class Throttle {
285 private readonly last = new Map<string, number>()
286 constructor(readonly windowMs = 5000) {}
287
288 /** True (and records `now`) when `key` last passed more than the window ago. */
289 allow(key: string, now: number): boolean {
290 const prev = this.last.get(key)
291 if (prev !== undefined && now - prev < this.windowMs) return false
292 this.last.set(key, now)
293 return true
294 }
295
296 reset(): void {
297 this.last.clear()
298 }
299}
300
301export type NotifyCommand = 'test' | 'on' | 'off' | 'status' | 'help'
302
303export function parseCommand(args: string): NotifyCommand {
304 const word = args.trim().split(/\s+/)[0]?.toLowerCase() ?? ''
305 if (word === '' || word === 'status') return 'status'
306 if (word === 'test' || word === 'on' || word === 'off') return word
307 return 'help'
308}
309
310export const HELP = 'Usage: /notify [test | on | off | status]'
311
312// --- ntfy (phone push) ------------------------------------------------------
313
314/** ntfy's own topic rule: 1 to 64 of letters, digits, `-` and `_`. */
315const TOPIC = /^[-_A-Za-z0-9]{1,64}$/
316/** An http(s) origin with an optional path (a self-hosted server under a prefix). */
317const SERVER = /^https?:\/\/[^\s/?#@]+(\/[^\s?#]*)?$/i
318
319/** Topics shorter than this are flagged as guessable in `/notify status`. */
320export const SHORT_TOPIC = 20
321
322export type NtfyTarget = { url: string; server: string; topic: string } | { error: string }
323
324/** The URL a notification is POSTed to, or why the config cannot make one. */
325export function ntfyTarget(server: string, topic: string): NtfyTarget {
326 const t = topic.trim()
327 if (t === '') return { error: 'off (no ntfyTopic set)' }
328 if (!TOPIC.test(t)) return { error: 'ntfyTopic must be 1-64 letters, digits, - or _' }
329 const s = (server.trim() || DEFAULT_NTFY_SERVER).replace(/\/+$/, '')
330 if (!SERVER.test(s)) return { error: `ntfyServer is not an http(s) URL: ${clean(s, 80)}` }
331 return { url: `${s}/${t}`, server: s, topic: t }
332}
333
334/** `abcdefghij…` → `ab••••ij`: enough to recognise, not enough to subscribe. */
335export function maskTopic(topic: string): string {
336 const t = topic.trim()
337 if (t.length < 8) return '•'.repeat(Math.max(4, t.length))
338 return `${t.slice(0, 2)}••••${t.slice(-2)}`
339}
340
341export type NtfyPriority = 'high' | 'default'
342
343export function ntfyPriority(kind: Kind): NtfyPriority {
344 return kind === 'input' || kind === 'error' ? 'high' : 'default'
345}
346
347/** ntfy emoji short codes (drawn as emoji in the app) per kind. */
348export const NTFY_TAGS: Readonly<Record<Kind, readonly string[]>> = {
349 input: ['question'],
350 done: ['white_check_mark'],
351 error: ['warning'],
352 subagent: ['robot'],
353 test: ['bell'],
354}
355
356function utf8(text: string): number[] {
357 const out: number[] = []
358 for (const ch of text) {
359 const cp = ch.codePointAt(0) ?? 0xfffd
360 if (cp < 0x80) out.push(cp)
361 else if (cp < 0x800) out.push(0xc0 | (cp >> 6), 0x80 | (cp & 63))
362 else if (cp < 0x10000) out.push(0xe0 | (cp >> 12), 0x80 | ((cp >> 6) & 63), 0x80 | (cp & 63))
363 else out.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 63), 0x80 | ((cp >> 6) & 63), 0x80 | (cp & 63))
364 }
365 return out
366}
367
368const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
369
370export function base64(bytes: readonly number[]): string {
371 let out = ''
372 for (let i = 0; i < bytes.length; i += 3) {
373 const a = bytes[i] ?? 0
374 const b = bytes[i + 1]
375 const c = bytes[i + 2]
376 const n = (a << 16) | ((b ?? 0) << 8) | (c ?? 0)
377 out += B64[(n >> 18) & 63]
378 out += B64[(n >> 12) & 63]
379 out += b === undefined ? '=' : B64[(n >> 6) & 63]
380 out += c === undefined ? '=' : B64[n & 63]
381 }
382 return out
383}
384
385/** Longest UTF-8 run per encoded word: 45 bytes is 60 base64 chars, 72 with the wrapper (RFC 2047 caps a word at 75). */
386const WORD_BYTES = 45
387
388/**
389 * A header value safe to send: printable ASCII as is, anything else as RFC 2047
390 * `=?UTF-8?B?…?=` encoded words (split on character boundaries, space
391 * separated), which ntfy decodes. Control characters are flattened first, so
392 * no value can smuggle a CR/LF into the request.
393 */
394export function headerValue(text: string, max = 80): string {
395 const v = clean(text, max)
396 if (/^[\x20-\x7e]*$/.test(v) && !v.includes('=?')) return v
397 const words: string[] = []
398 let run: number[] = []
399 for (const ch of v) {
400 const bytes = utf8(ch)
401 if (run.length + bytes.length > WORD_BYTES) {
402 words.push(`=?UTF-8?B?${base64(run)}?=`)
403 run = []
404 }
405 run.push(...bytes)
406 }
407 if (run.length > 0) words.push(`=?UTF-8?B?${base64(run)}?=`)
408 return words.join(' ')
409}
410
411export type NtfyRequest = {
412 url: string
413 init: { method: 'POST'; headers: Record<string, string>; body: string }
414}
415
416/** The POST that publishes `note` to the ntfy topic at `url`. */
417export function ntfyRequest(url: string, kind: Kind, note: Pick<Note, 'title' | 'body'>): NtfyRequest {
418 return {
419 url,
420 init: {
421 method: 'POST',
422 headers: {
423 Title: headerValue(note.title, 80),
424 Priority: ntfyPriority(kind),
425 Tags: NTFY_TAGS[kind].join(','),
426 'Content-Type': 'text/plain; charset=utf-8',
427 },
428 body: clean(note.body, 1000) || ' ',
429 },
430 }
431}
432
433/**
434 * Whether the phone push goes out, given what the desktop side did.
435 * With `onlyWhenAway`, it is skipped only while the terminal is the frontmost
436 * app (proof that you are at the computer, readable on macOS alone) and the
437 * desktop side did not fail. Anywhere focus cannot be read (Linux, a cloud
438 * container, an unknown terminal) or no desktop notifier exists, it always goes.
439 */
440export function shouldPush(
441 onlyWhenAway: boolean,
442 isFocused: boolean,
443 desktop: 'sent' | 'skipped' | 'failed',
444): boolean {
445 if (!onlyWhenAway) return true
446 return !(isFocused && desktop !== 'failed')
447}
448
449export type StatusInfo = {
450 isMuted: boolean
451 platform: Platform
452 backend: Backend | undefined
453 lastError: string | undefined
454 /** Where focus is read from, or why it is not (only shown with onlyWhenUnfocused). */
455 focus: string
456 lastNtfy: string | undefined
457 config: Config
458}
459
460const yesNo = (b: boolean): string => (b ? 'on' : 'off')
461
462/**
463 * `/notify status` as Markdown: a headline, then one short bullet per fact,
464 * which reads the same in a terminal row, the desktop and the phone.
465 */
466export function statusMarkdown(s: StatusInfo): string {
467 const c = s.config
468 const tries = backendsFor(s.platform)
469 const ntfy = ntfyTarget(c.ntfyServer, c.ntfyTopic)
470 const lines = [
471 `**notify** · ${s.isMuted ? 'muted for this session' : 'on'}`,
472 '',
473 `- **Desktop:** ${s.platform}, backend: ${
474 s.backend ?? (tries.length > 0 ? `not chosen yet (tries ${tries.join(', ')})` : 'none on this host')
475 }`,
476 ]
477 if (s.lastError !== undefined) lines.push(`- **Last desktop failure:** ${clean(s.lastError, 200)}`)
478 if ('error' in ntfy) {
479 lines.push(`- **Phone (ntfy):** ${ntfy.error}`)
480 } else {
481 const short = ntfy.topic.length < SHORT_TOPIC ? ' (short topic: easy to guess, use a longer random one)' : ''
482 lines.push(
483 `- **Phone (ntfy):** \`${ntfy.server}/${maskTopic(ntfy.topic)}\`${short}`,
484 `- **Phone only when away:** ${yesNo(c.ntfyOnlyWhenAway)}`,
485 )
486 if (s.lastNtfy !== undefined) lines.push(`- **Last push:** ${clean(s.lastNtfy, 200)}`)
487 }
488 lines.push(
489 `- **Triggers:** needs input ${yesNo(c.onNeedsInput)} · turn done (> ${c.minTurnSeconds}s) ${yesNo(
490 c.onTurnDone,
491 )} · errors ${yesNo(c.onError)} · subagents ${yesNo(c.onSubagentDone)}`,
492 `- **Sound:** ${c.sound || '(none)'} · **only when unfocused:** ${yesNo(c.onlyWhenUnfocused)}${
493 c.onlyWhenUnfocused ? ` (${s.focus})` : ''
494 }`,
495 )
496 return lines.join('\n')
497}
498
499/** True for the text `statusMarkdown` builds (its headline). */
500export function isStatusText(text: string): boolean {
501 return text.startsWith('**notify** · ')
502}
503types/index.d.ts 11 lines1/**
2 * notify's session state: `muted` is true while `/notify off` holds.
3 */
4export type NotifyMuted = boolean
5
6declare module 'claude-code' {
7 interface PluginState {
8 notify: { muted: boolean }
9 }
10}
11