SLOPSHOPPER

notify

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

newrowsguardcommandprocessnetwork
v0.2.0MITupdated 2026-10-06mrjk05/modemon/mods/notify
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · notify
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /notify ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 🔔 notify · on │ │ • Desktop: other, backend: none on this host │ │ • Phone (ntfy): off (no ntfyTopic set) │ │ • Triggers: needs input on · turn done (> 30s) on · errors on · subagents on │ │ • Sound: Glass · only when unfocused: on (focus not readable here, always notifies) │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Command output
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 🔔 notify · on │ │ • Desktop: other, backend: none on this host │ │ • Phone (ntfy): off (no ntfyTopic set) │ │ • Triggers: needs input on · turn done (> 30s) on · errors on · subagents on │ │ • Sound: Glass · only when unfocused: on (focus not readable here, always notifies) │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

notify

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.

Install

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

When it notifies

TriggerSourceBody
Needs inputclassic.Notification (permission prompt, idle prompt, elicitation; not auth_success) and every AskUserQuestion callThe prompt's message, or Question: <first question>
Turn finishedturn.complete for a main-loop turn that ran longer than minTurnSeconds (timed from turn.start)Done in 1m 12s: <first line of the answer>
Errora turn that ended on an API error or a refusal, and classic.StopFailureError: rate limited, Error: API server error (…)
Subagent doneclassic.SubagentStop, or a foreground Agent call's resultAgent 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.

Commands

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

Config

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

FieldDefaultMeaning
onNeedsInputtruePermission and idle prompts, AskUserQuestion
onTurnDonetrueLong turns finishing
minTurnSeconds30A turn must run longer than this to send a "done" notification
onErrortrueAPI errors and failed turns
onSubagentDonetrueSubagents finishing
soundGlassmacOS sound name (Ping, Hero, Submarine, ...). Leave it empty for silent notifications
onlyWhenUnfocusedtruemacOS: skip desktop notifications while your terminal app is the frontmost app
ntfyTopicempty (off)ntfy topic to also push every notification to. Sensitive
ntfyServerhttps://ntfy.shntfy server base URL; a self-hosted one may have a path prefix
ntfyOnlyWhenAwaytrueSkip the phone push while your terminal is the frontmost app (macOS only; see below)

Delivery

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.

macOS

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

  1. Otherwise 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.

Linux

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.

Other platforms

There is no desktop backend on other platforms (Windows included), so nothing is shown there. /notify status says so. Phone push still works.

Phone push (ntfy)

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.

Setup

  1. Install the ntfy app (Android, iOS, or F-Droid).
  2. Make up a long random topic name. Anyone who knows it can read and send to it, so treat it as a password. For example:
   echo "claude-$(openssl rand -hex 12)"

Topic names are 1-64 letters, digits, - or _.

  1. In the app, tap + and subscribe to that topic (on the default server, or yours).
  2. Set ntfyTopic in notify's config:
  3. Installed plugin: /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.
  4. Settings file: under 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" } }
       }
     }
  1. Run /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.

What is sent

POST <ntfyServer>/<ntfyTopic> with:

HeaderValue
TitleClaude 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
Priorityhigh for needs-input and errors, default otherwise
Tagsquestion (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.

When the phone gets it

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.
  • Wherever focus cannot be read (Linux, a cloud container, a terminal that cannot be identified) or there is no desktop notifier at all, every notification goes to the phone.
  • Set 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.

Privacy

  • ntfy.sh is a public server. Anyone who knows or guesses your topic can subscribe and read your notifications (repo names, the first line of Claude's answers, questions it asks you). Short or common topics (claude, mytopic) are guessed in practice. Use a long random topic; /notify status flags topics shorter than 20 characters.
  • The topic is kept masked in /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).
  • Notification text passes through ntfy.sh's servers (and the platform push services the app uses), which cache messages for a while so offline phones can catch up.
  • To keep it all on your own infrastructure, self-host ntfy (docs), optionally with access control, and point ntfyServer at it (e.g. https://ntfy.example.com). Authenticated topics (tokens) are not supported by this mod yet.

Limitations

  • Phone push needs the session to reach the ntfy server through $.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.
  • Focus can be read only on macOS, and only at the app level (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.
  • The throttle is per kind. If two subagents finish within 5 seconds, you get one notification.
  • 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.
  • The per-session state (backend cache, turn timers, throttle) resets on a hot reload of the mod. Mute is kept in $.state and survives a reload.
Source 3 files
hooks/register.tsx 416 lines
1import 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}
416
hooks/lib.ts 503 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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}
503
types/index.d.ts 11 lines
1/**
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