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…

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:
| When | Notification |
|---|---|
A turn longer than minSeconds finished | Claude Code · my-app: Done in 2m 3s: All 42 tests pass. |
A permission prompt is still waiting after permissionDelaySeconds | Claude 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 delay | Claude 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.
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:
/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.
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.
No dependencies, and the text never passes through a shell or into a script:
osascript, the texts passed as AppleScript argumentsnotify-send from libnotify (apt install libnotify-bin, dnf install libnotify, pacman -S libnotify), the texts after --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.
| Option | Default | |
|---|---|---|
desktop | true | Native desktop notification on the machine Claude Code runs on |
push | true | Also through Claude Code push: the Claude app on your phone when you are away |
done | true | A turn longer than minSeconds finished |
permission | true | A permission prompt is still waiting |
question | true | A question is still waiting |
budget | true | An Actions budget reached budgetPercent (with ci-budget) |
budgetPercent | 80 | Share of the included minutes that triggers the budget notification |
minSeconds | 30 | Shortest finished turn to notify about |
permissionDelaySeconds | 10 | How long a prompt or question may wait before it notifies |
speak | false | Also read the notification aloud with the system voice |
hooks/register.ts 207 lines1import 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}
207hooks/toast.ts 132 lines1// 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