SLOPSHOPPER

notify

Notifications that say what happened: a long turn finished (with its answer), a permission prompt or a question waiting, an Actions budget running low; on the…

newguardcommandpromptprocesstimer
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: Usage: /notify test ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

notify

Notifications that say what happened, for when you switch away during a long turn: on the desktop of the machine Claude Code runs on, and on your phone through Claude Code's own push:

WhenNotification
A turn longer than minSeconds finishedClaude Code · my-app: Done in 2m 3s: All 42 tests pass.
A permission prompt is still waiting after permissionDelaySecondsClaude Code · my-app: Needs permission: Claude needs your permission to use Bash
Claude asked a question (AskUserQuestion) and it is still open after the same delayClaude Code · my-app: Question: Which database should the migration target?
The repo owner's Actions usage reached budgetPercent of its included minutes, and again at 100% (needs ci-budget)Claude Code · my-app: Actions budget: acme has used 85% of its included minutes this month (1700 of 2000 minutes)
/plugin install notify@claude-mods

Each notification has its own switch, all on by default. The title names the project folder, so two sessions are told apart. A prompt you answer within the delay sends nothing, and neither does a turn you interrupted (you were there) or a subagent's turn.

On your phone

Each notification also goes through Claude Code's own push (its PushNotification tool), so it reaches the Claude app on your phone when you are away: Claude Code decides delivery itself. It needs nothing beyond Claude Code:

  • Remote Control connected for the session, and the Claude app (iOS or Android) signed in to the same claude.ai account; open the app once so it registers for push
  • "Push when Claude decides" on in /config (agentPushNotifEnabled, on unless you turned it off)

While you are typing in or looking at the connected terminal, Claude Code holds the push: nothing pings you twice. When it does deliver, Claude Code also shows its own desktop notification, and notify then skips its own, so the desktop gets one. Without Remote Control the phone gets nothing and the desktop notification works as before. /notify test says what each channel did and, for the phone, why it got nothing and what to do. push: false turns the phone off, desktop: false the desktop.

Budget notifications

With ci-budget installed, notify watches the measurement ci-budget keeps and sends one notification when the owner of the current repo (organisation or account) reaches budgetPercent of its included Actions minutes, and one more at 100%. Each is sent once per owner and month, remembered across sessions. It needs exact numbers, so it works where ci-budget reads GitHub billing (see its setup); an estimate has no percentage and sends nothing. Without ci-budget nothing happens: notify does not depend on it.

How it notifies

No dependencies, and the text never passes through a shell or into a script:

  • Windows: a WinRT toast through Windows PowerShell; the title and body arrive on stdin as ASCII-only JSON, so any language reads right whatever the console code page
  • macOS: osascript, the texts passed as AppleScript arguments
  • Linux: notify-send from libnotify (apt install libnotify-bin, dnf install libnotify, pacman -S libnotify), the texts after --
  • WSL: the Windows toast through powershell.exe, since WSL draws on the Windows desktop

/notify test sends a sample, and says why if it could not (a missing notifier comes with how to install it). Windows shows one toast at a time: one sent while another is on screen goes straight to the notification centre.

Options

OptionDefault
desktoptrueNative desktop notification on the machine Claude Code runs on
pushtrueAlso through Claude Code push: the Claude app on your phone when you are away
donetrueA turn longer than minSeconds finished
permissiontrueA permission prompt is still waiting
questiontrueA question is still waiting
budgettrueAn Actions budget reached budgetPercent (with ci-budget)
budgetPercent80Share of the included minutes that triggers the budget notification
minSeconds30Shortest finished turn to notify about
permissionDelaySeconds10How long a prompt or question may wait before it notifies
speakfalseAlso read the notification aloud with the system voice
Source 2 files
hooks/register.ts 207 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { budgetLevel, budgetToast, doneToast, permissionToast, pushMessage, pushReason, questionToast, title, toastCommand } from './toast'
4import type { BudgetSnapshot, Platform, Toast } from './toast'
5
6let platform: Platform | undefined
7
8/**
9 * Bumps whenever the person or the turn moves on (a prompt, a tool call
10 * starting or ending, a turn starting or ending). A delayed toast is shown
11 * only if nothing moved in the meantime: an answered prompt sends none.
12 */
13let activity = 0
14
15async function detectPlatform($: EngineInterface): Promise<Platform> {
16  if (platform) return platform
17  if ((await $.env.get('OS')) === 'Windows_NT') {
18    platform = 'windows'
19  } else if ((await $.env.get('WSL_DISTRO_NAME')) !== undefined) {
20    // Linux under WSL draws on the Windows desktop, where notify-send shows nothing
21    platform = 'wsl'
22  } else {
23    const uname = await $.process.run(['uname', '-s'], { timeoutMs: 5_000 }).catch(() => undefined)
24    platform = uname?.stdout.trim() === 'Darwin' ? 'macos' : 'linux'
25  }
26
27  return platform
28}
29
30/** What to do when a platform's notifier is missing. */
31const INSTALL_HINT: Record<Platform, string> = {
32  windows: 'Windows PowerShell ships with Windows 10 and 11; check that powershell is on PATH.',
33  wsl: 'Check that WSL interop is on (powershell.exe must run from WSL).',
34  macos: 'osascript ships with macOS; check that /usr/bin is on PATH.',
35  linux: 'Install libnotify (apt install libnotify-bin, dnf install libnotify, pacman -S libnotify).',
36}
37
38/** Where a notification goes. */
39type Channels = { isDesktop: boolean; isPush: boolean; isSpoken: boolean }
40
41/** What each channel did: undefined is sent, a string is why not. */
42type Sent = { desktop?: string; push?: string }
43
44/** The desktop toast; resolves to why it failed, or undefined. */
45async function showDesktop($: EngineInterface, toast: Toast): Promise<string | undefined> {
46  const command = toastCommand(await detectPlatform($), toast)
47  const ran = await $.process
48    .run(command.argv, { ...(command.stdin === undefined ? {} : { stdin: command.stdin }), timeoutMs: 15_000 })
49    .catch(() => undefined)
50
51  if (ran === undefined) return `${command.argv[0]} could not start. ${INSTALL_HINT[platform ?? 'linux']}`
52  return ran.exitCode === 0 ? undefined : ran.stderr.trim() || `exit code ${ran.exitCode}`
53}
54
55/**
56 * Claude Code's own push: it reaches the Claude app on your phone when you are
57 * away, and sends nothing while you are at the terminal. Resolves to why
58 * nothing was sent, or undefined.
59 */
60async function sendPush($: EngineInterface, toast: Toast): Promise<{ failure?: string; isLocalShown: boolean }> {
61  const ran = await $.tool
62    .call({ tool: 'PushNotification', message: pushMessage(toast), status: 'proactive' } as never)
63    .catch(() => undefined)
64  const result = (ran as { result?: { pushSent?: boolean; localSent?: boolean; disabledReason?: string } } | undefined)?.result
65  if (!result) return { failure: 'Claude Code has no push notifications in this version', isLocalShown: false }
66  const isLocalShown = result.localSent === true
67  if (result.pushSent) return { isLocalShown }
68
69  return { failure: isLocalShown ? 'shown on this desktop by Claude Code; no phone is connected' : pushReason(result.disabledReason), isLocalShown }
70}
71
72/**
73 * Sends a notification on every channel that is on. Push goes first: Claude
74 * Code shows its own desktop notification with it when you are away, and then
75 * the mod's desktop toast would be the same thing twice.
76 */
77async function show($: EngineInterface, toast: Toast, channels: Channels): Promise<Sent> {
78  const sent: Sent = {}
79  let isLocalShown = false
80  if (channels.isPush) {
81    const pushed = await sendPush($, toast)
82    isLocalShown = pushed.isLocalShown
83    if (pushed.failure !== undefined) sent.push = pushed.failure
84  }
85  if (channels.isDesktop && !isLocalShown) {
86    const failure = await showDesktop($, toast)
87    if (failure !== undefined) sent.desktop = failure
88  }
89  if (channels.isSpoken) await $.audio.speak(toast.body).catch(() => undefined)
90
91  return sent
92}
93
94/** Shows the notification after `delayMs`, unless something happened by then. */
95function showUnlessAnswered($: EngineInterface, toast: Toast, delayMs: number, channels: Channels) {
96  const mark = activity
97  $.clock.after(delayMs, () => {
98    if (activity === mark) void show($, toast, channels)
99  })
100}
101
102/**
103 * Sends the budget notification a ci-budget measurement is worth, once per
104 * owner, month and level (threshold, then 100%), across sessions.
105 */
106async function notifyBudget($: EngineInterface, snapshot: BudgetSnapshot, threshold: number, channels: Channels) {
107  const level = budgetLevel(snapshot, threshold)
108  if (level === 0) return
109
110  const key = `budget:${String(snapshot.owner)}:${String(snapshot.period)}`
111  const sent = Number((await $.store.get(key)) ?? 0)
112  if (level <= sent) return
113
114  await $.store.set(key, level)
115  await show($, budgetToast(await $.session.cwd(), snapshot), channels)
116}
117
118export const register: Register = (on, options) => {
119  const isOn = (name: string) => options[name] !== false
120  const budgetPercent = Math.min(100, Math.max(1, Number(options.budgetPercent ?? 80)))
121  const minMs = Math.max(0, Number(options.minSeconds ?? 30)) * 1000
122  const delayMs = Math.max(0, Number(options.permissionDelaySeconds ?? 10)) * 1000
123  const channels: Channels = { isDesktop: options.desktop !== false, isPush: options.push !== false, isSpoken: options.speak === true }
124
125  on('session.start', async ($, e, next) => {
126    await $.command.register({
127      name: 'notify',
128      description: 'Send a sample desktop notification (/notify test)',
129      argumentHint: 'test',
130    })
131
132    return next(e)
133  })
134
135  on('prompt.submit', ($, e, next) => {
136    activity += 1
137    return next(e)
138  })
139
140  on('turn.start', ($, e, next) => {
141    activity += 1
142    return next(e)
143  })
144
145  on('tool.call', async ($, e, next) => {
146    activity += 1
147    // AskUserQuestion holds the call until the person answers
148    if (isOn('question') && String(e.tool) === 'AskUserQuestion') {
149      const { questions } = e as { questions?: { question?: unknown }[] }
150      const question = questions?.[0]?.question
151      showUnlessAnswered($, questionToast(await $.session.cwd(), typeof question === 'string' ? question : ''), delayMs, channels)
152    }
153    const ran = await next(e)
154    activity += 1
155
156    return ran
157  })
158
159  on('classic.Notification', async ($, e, next) => {
160    if (isOn('permission') && e.notification_type === 'permission_prompt') {
161      showUnlessAnswered($, permissionToast(await $.session.cwd(), e.message), delayMs, channels)
162    }
163
164    return next(e)
165  })
166
167  on('turn.complete', async ($, e, next) => {
168    const done = await next(e)
169    activity += 1
170    // An interrupted turn was stopped by the person, who is there to see it
171    if (isOn('done') && e.agentId === undefined && !e.isAborted && e.durationMs >= minMs) {
172      await show($, doneToast(await $.session.cwd(), e.durationMs, e.answer), channels)
173    }
174
175    return done
176  })
177
178  // ci-budget, when it is installed, writes its measurement to its own state;
179  // watching that write needs no dependency on it.
180  on('state.set', async ($, e, next) => {
181    const written = await next(e)
182    const write = e as unknown as { plugin?: string; key?: string; value?: unknown }
183    if (isOn('budget') && write.plugin === 'ci-budget' && write.key === 'snapshot' && write.value !== null && typeof write.value === 'object') {
184      await notifyBudget($, write.value as BudgetSnapshot, budgetPercent, channels)
185    }
186
187    return written
188  })
189
190  on('command.run', { command: 'notify' }, async ($, e) => {
191    if (e.args.trim() !== 'test') return { text: 'Usage: /notify test' }
192
193    const cwd = await $.session.cwd()
194    const sent = await show($, { title: title(cwd), body: 'Notifications work.' }, channels)
195    const lines = [
196      channels.isDesktop
197        ? sent.desktop === undefined
198          ? `Desktop: sent (${await detectPlatform($)}).`
199          : `Desktop: failed: ${sent.desktop}`
200        : 'Desktop: off (desktop: false).',
201      channels.isPush ? (sent.push === undefined ? 'Phone: sent through Claude Code push.' : `Phone: not sent: ${sent.push}`) : 'Phone: off (push: false).',
202    ]
203
204    return { text: lines.join('\n') }
205  })
206}
207
hooks/toast.ts 132 lines
1// How a toast is shown on each platform, and the texts it carries, as pure
2// functions: no `$`, so tests call them directly.
3
4export type Platform = 'windows' | 'wsl' | 'macos' | 'linux'
5
6export type Toast = { title: string; body: string }
7
8/** A command to run with no shell: the text travels as argv or stdin, never inside a script. */
9export type Command = { argv: string[]; stdin?: string }
10
11// WinRT toast through Windows PowerShell, which every Windows 10/11 has. The
12// title and body arrive as JSON on stdin; the AppUserModelID is PowerShell's
13// own, so no shortcut or registration is needed.
14//
15// PowerShell decodes stdin with the console code page (CP866, CP1251, ...), so
16// the JSON is sent as ASCII alone, see asciiJson.
17const WINDOWS_SCRIPT = [
18  '$toast = [Console]::In.ReadToEnd() | ConvertFrom-Json',
19  '[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null',
20  '$xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)',
21  "$text = $xml.GetElementsByTagName('text')",
22  '$text.Item(0).AppendChild($xml.CreateTextNode($toast.title)) > $null',
23  '$text.Item(1).AppendChild($xml.CreateTextNode($toast.body)) > $null',
24  "$app = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe'",
25  '[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($app).Show([Windows.UI.Notifications.ToastNotification]::new($xml))',
26].join('; ')
27
28// AppleScript reads the texts from its argv, so they are never parsed as code.
29const MACOS_SCRIPT = ['on run argv', 'display notification (item 2 of argv) with title (item 1 of argv)', 'end run']
30
31/** JSON with every non-ASCII character as a `\uXXXX` escape: the same text in any code page. */
32export const asciiJson = (value: unknown) =>
33  JSON.stringify(value).replace(/[\u0080-￿]/g, char => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`)
34
35export function toastCommand(platform: Platform, toast: Toast): Command {
36  if (platform === 'windows' || platform === 'wsl') {
37    return {
38      argv: [platform === 'wsl' ? 'powershell.exe' : 'powershell', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', WINDOWS_SCRIPT],
39      stdin: asciiJson(toast),
40    }
41  }
42  if (platform === 'macos') {
43    return { argv: ['osascript', ...MACOS_SCRIPT.flatMap(line => ['-e', line]), toast.title, toast.body] }
44  }
45  return { argv: ['notify-send', '--app-name=Claude Code', '--', toast.title, toast.body] }
46}
47
48/** `42s`, `2m 3s`, `1h 5m`, as Claude Code writes durations. */
49export function formatDuration(ms: number): string {
50  const seconds = Math.round(ms / 1000)
51  if (seconds < 60) return `${seconds}s`
52  const minutes = Math.floor(seconds / 60)
53  if (minutes < 60) return `${minutes}m ${seconds % 60}s`
54  return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
55}
56
57/** The first non-empty line, Markdown marks dropped, cut to `max` characters. */
58export function firstLine(text: string, max = 140): string {
59  const line =
60    text
61      .split('\n')
62      .map(part => part.replace(/^[#>*\-\s]+/, '').replace(/[*_`]/g, '').trim())
63      .find(part => part.length > 0) ?? ''
64
65  return line.length > max ? `${line.slice(0, max - 1).trimEnd()}…` : line
66}
67
68/** `Claude Code · claude-mods`: the project folder tells two sessions apart. */
69export function title(cwd: string): string {
70  const folder = cwd.replace(/[\\/]+$/, '').split(/[\\/]/).pop()
71  return folder ? `Claude Code · ${folder}` : 'Claude Code'
72}
73
74export const doneToast = (cwd: string, durationMs: number, answer: string): Toast => ({
75  title: title(cwd),
76  body: [`Done in ${formatDuration(durationMs)}`, firstLine(answer)].filter(Boolean).join(': '),
77})
78
79export const permissionToast = (cwd: string, message: string): Toast => ({
80  title: title(cwd),
81  body: `Needs permission: ${firstLine(message) || 'a tool call is waiting'}`,
82})
83
84export const questionToast = (cwd: string, question: string): Toast => ({
85  title: title(cwd),
86  body: `Question: ${firstLine(question) || 'Claude is asking you something'}`,
87})
88
89/** What ci-budget keeps in its `snapshot` state, as far as a budget notification reads it. */
90export type BudgetSnapshot = {
91  owner?: unknown
92  period?: unknown
93  source?: unknown
94  percent?: unknown
95  quotaMinutes?: unknown
96  includedMinutes?: unknown
97}
98
99/**
100 * Which budget notification a measurement is worth: 2 at 100% or more, 1 from
101 * the threshold, 0 below it or without exact billing numbers.
102 */
103export function budgetLevel(snapshot: BudgetSnapshot, threshold: number): 0 | 1 | 2 {
104  if (snapshot.source !== 'billing' || typeof snapshot.percent !== 'number') return 0
105  if (snapshot.percent >= 100) return 2
106  return snapshot.percent >= threshold ? 1 : 0
107}
108
109export const budgetToast = (cwd: string, snapshot: BudgetSnapshot): Toast => {
110  const used = typeof snapshot.quotaMinutes === 'number' ? Math.round(snapshot.quotaMinutes) : undefined
111  const included = typeof snapshot.includedMinutes === 'number' ? snapshot.includedMinutes : undefined
112  const minutes = used !== undefined && included !== undefined ? ` (${used} of ${included} minutes)` : ''
113  return {
114    title: title(cwd),
115    body: `Actions budget: ${String(snapshot.owner)} has used ${String(snapshot.percent)}% of its included minutes this month${minutes}`,
116  }
117}
118
119/** The push text: title and body in one line, under the 200 characters a phone shows. */
120export function pushMessage(toast: Toast, max = 200): string {
121  const text = `${toast.title}: ${toast.body}`
122  return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text
123}
124
125/** Why Claude Code sent no push, said so the person knows what, if anything, to do. */
126export function pushReason(reason: string | undefined): string {
127  if (reason === 'user_present') return 'you are at this terminal, so Claude Code holds it; it reaches your phone when you are away.'
128  if (reason === 'config_off') return 'push is off in Claude Code: turn on "Push when Claude decides" in /config (agentPushNotifEnabled).'
129  if (reason === 'no_transport') return 'no phone is connected: start Remote Control and sign in to the Claude app with the same account.'
130  return `Claude Code did not send it${reason ? ` (${reason})` : ''}.`
131}
132