Forward Claude Code task completion, repeated Bash failures, and AskUserQuestion prompts to the macOS Notification Center so you hear about them when you are…

Forward Claude Code lifecycle events to the macOS Notification Center so you hear about them when you are away from the terminal. Three event sources, three notification levels:
| Event | Level | Subtitle | Sound | Body |
|---|---|---|---|---|
turn.complete (not aborted) | info | [Task Complete] | Glass | first line of Claude's answer |
Bash failed N times in a row | warn | [Bash failed N×] | Basso | first line of the error output |
AskUserQuestion rendered | important | [Claude is asking] | Frog | the question Claude is asking |
For install / marketplace / hot-reload setup, see the parent marketplace README.
Three user-configurable knobs; everything else lives as code constants in register.mjs.
enabled (boolean, default true)Master switch. When false, no system notifications are sent at all.
{
"pluginConfigs": {
"cc-notify-mod": { "enabled": false }
}
}
suppressWhenFocused (boolean, default true)Skip notifications when Claude Code is the frontmost app. The check is by foreground application name via osascript against a built-in allowlist (Terminal / iTerm2 / Warp / Alacritty / kitty / WezTerm / Ghostty / Hyper / Claude). Set to false to notify regardless of focus.
stickyOnError (boolean, default false)Reserved for warning notifications. macOS's display notification does not have a sticky option; this knob is honored by future code that may route through terminal-notifier or BurntToast. Today it is accepted silently — warnings still auto-dismiss after a few seconds.
{
"pluginConfigs": {
"cc-notify-mod": {
"enabled": true,
"suppressWhenFocused": true,
"stickyOnError": false
}
}
}
register.mjs to change)| Constant | Default | Meaning |
|---|---|---|
SUPPRESS_IF_UNDER_MS | 5000 | Skip turn.complete notifications for sub-5s turns |
RETRY_COUNT | 3 | Trigger a warn after this many consecutive failures of the same command |
RETRY_RESET_MS | 60000 | A successful run of the same command clears its counter |
FOCUSED_NAMES | 10 apps | Apps that count as "the user is watching the terminal" |
FOCUSED_NAMES is the allowlist the foreground probe checks against. If you run Claude Code from a terminal not on the list (Tabby / Rio / Contour / VS Code integrated terminal / etc.), add its osascript name to the set in register.mjs. To find the exact string, run:
osascript -e 'tell application "System Events" to get name of first application process whose frontmost is true'
while your terminal is focused — paste whatever it prints.
Claude Code [Task Complete]
Done: implemented user auth in src/api/users.ts.
Claude Code — repeated failure [Bash failed 3×]
tsc: src/api/users.ts:42:5 — error TS2322: Type 'string' is not assignable to type 'number'.
Claude Code [Claude is asking]
Which auth provider should we wire up: Google, GitHub, or email magic-link?
Clicking a notification does nothing for now (the display notification AppleScript verb does not support click handlers). Tapping anywhere on the desktop dismisses; the answer remains visible in the terminal.
turn.complete and turn took < SUPPRESS_IF_UNDER_MS — skipped (sub-5s turns are usually typing aids, not finished work).turn.complete with isAborted: true) — skipped (you pressed it).tool.check deny / permission prompt — not currently notified.osascript rejects, the call is swallowed silently. Re-authorize in System Settings → Notifications → Script Editor.| Hook | What it does | ||
|---|---|---|---|
session.start | Resets per-session state and re-probes focus. | ||
| `classic.SessionStart { source: 'clear'\ | 'resume'\ | 'fork' }` | Same reset on /clear, /resume, /branch (the mod-native session.start does not fire on these). |
turn.start | Stamps lastTurnStartedAt so turn.complete can compute duration. | ||
turn.complete | Notifies info when the main-loop turn (no agentId) finishes cleanly and is long enough. | ||
tool.call { tool: 'Bash' } (post) | Tracks per-command failure counts in a Map. After RETRY_COUNT consecutive failures of the same command, notifies warn. | ||
ui.render { component: 'AskUserQuestion' } | Notifies important when Claude renders a question dialog. |
The foreground check is one osascript call:
tell application "System Events" to get name of first application process whose frontmost is true
The notification is one osascript call:
display notification "<body>" with title "<title>" subtitle "<subtitle>" sound name "<sound>"
Both can throw if osascript is missing or if Notification Center permission was revoked — both paths are caught and the mod falls back to silent (no notification) rather than failing the hook.
Bash retries are keyed by <cwd>::<command> so:
RETRY_GC_MS (5 minutes) of inactivity, capped at 64 entries.All state lives in module-level variables (lets and one Map) and resets on hot reload. After /clear, /resume, /branch the same reset runs via classic.SessionStart, so retries drop and the focus cache is re-probed before the next turn.
| Platform | Status | Notes |
|---|---|---|
| macOS | Full | The only platform targeted in v1. Built-in osascript. |
| Linux | Not supported | osascript is unavailable. A future version could fall back to notify-send — open an issue if you want this. |
| Windows | Not supported | Same — osascript is unavailable. BurntToast integration is a possible follow-up. |
The mod still loads on Linux/Windows: hooks register, the foreground-probe returns "unknown" (the osascript probe at session.start fails, so osascriptOk = false and notify short-circuits). No real notification fires — silently.
display notification cannot route a click back to the terminal; you have to switch focus manually.System Events requires Accessibility permission for some apps; in that case the probe returns unknown and notifications still fire (better to over-notify than miss).osascript notifications silently dropped on a fresh macOS Sequoia (15.x) installSymptom. osascript -e 'display notification "x" with title "x"' returns exit 0 but no banner appears and no entry shows up in System Settings → Notifications. After applying the fix below, notifications work as expected.
Quick check. Run this and look at the flags field of the com.apple.ScriptEditor2 entry:
defaults read com.apple.ncprefs | grep -A 6 'ScriptEditor2'
A stuck fresh install shows flags = 8206; a working install has a different value. The usernoted daemon will also log:
usernoted: com.apple.ScriptEditor2 needs a valid url to ask permissions, none found
usernoted: Presenting <NotificationRecord app:"com.apple.ScriptEditor2" ...> as none
Fix. Open Script Editor.app (at /System/Applications/Utilities/Script Editor.app) and run the same display notification line from inside it:
display notification "test" with title "test" sound name "Glass"
macOS will surface the permission prompt it refused to surface when called from the bare osascript binary. Click Allow, then quit Script Editor. From that point on, every osascript notification (including this mod's) works as expected.
Why. osascript is hard-wired (via its notificationcenter-identifiers entitlement) to attribute its notifications to bundle id com.apple.ScriptEditor2. On a fresh Sequoia install, that bundle's ncprefs record lands in a state where usernoted accepts the notification but presents it as "none" and the system refuses to surface the app in System Settings → Notifications — leaving you no UI to change it. Running the same line from Script Editor.app itself takes a code path macOS trusts, so it can prompt for the permission and rewrite the flags field. Because osascript shares that bundle id, all subsequent osascript notifications work too.
.claude-plugin/types/claude-code/index.d.ts after first load).token-weather — AbovePrompt band layout pattern, and the hot-reload state-reset rationale (which this mod also follows).blast-radius — tool.call post-call interception pattern (observation + short-circuit on error).display notification and System Events — documented in osascript Standard Additions.hooks/register.mjs 331 lines1// Copyright 2026
2// SPDX-License-Identifier: Apache-2.0
3//
4// cc-notify-mod: forward Claude Code lifecycle events to the macOS
5// Notification Center so the user hears about them when they are away
6// from the terminal.
7//
8// Three event sources, three notification levels:
9//
10// turn.complete (not aborted) -> info
11// subtitle "[Task Complete]" body "<first sentence of answer>"
12// Bash failed retryCount times -> warn
13// subtitle "[Bash failed N×]" body "<short error>"
14// AskUserQuestion rendered -> important
15// subtitle "[Claude is asking]" body "<question>"
16//
17// All three are suppressed when the terminal is the frontmost app
18// (suppressWhenFocused) and when the turn finished too quickly
19// (suppressIfUnderMs). The full configuration is documented in README.md.
20
21const SUPPRESS_IF_UNDER_MS = 5_000;
22const RETRY_COUNT = 3;
23const RETRY_RESET_MS = 60_000;
24const RETRY_GC_MS = 5 * RETRY_RESET_MS;
25const MAX_RETRY_ENTRIES = 64;
26
27// macOS apps that mean "the user is looking at the terminal running Claude".
28// Comparison is case-sensitive against the exact `name of first application
29// process whose frontmost is true` string from System Events. Add your own
30// terminal to this list by editing register.mjs.
31const FOCUSED_NAMES = new Set([
32 "Terminal",
33 "iTerm2",
34 "iTerm",
35 "Warp",
36 "Alacritty",
37 "kitty",
38 "Kitty",
39 "Hyper",
40 "WezTerm",
41 "Ghostty",
42 "Claude",
43]);
44
45// Built-in macOS notification sounds. See `man say` or System Settings >
46// Sound > Sound Effects for the full list.
47const DEFAULT_SOUNDS = {
48 info: "Glass",
49 warn: "Basso",
50 important: "Frog",
51};
52
53// State — resets on module reload.
54let enabled = true;
55let suppressWhenFocused = true;
56let stickyOnError = false;
57let sounds = { ...DEFAULT_SOUNDS };
58let lastTurnStartedAt = 0;
59// Cached result of the last osascript foreground-app probe. Shared between
60// hook calls within a single turn (turn.complete + tool.call +
61// AskUserQuestion may all probe in the same turn) so we don't run the
62// osascript round trip more than once per 2s.
63let lastFocus = null; // "focused" | "away" | "unknown"
64let lastFocusCheckedAt = 0;
65// sig -> { count, lastAt }
66const retries = new Map();
67// Probe result for whether osascript is on PATH and runnable. null until
68// the first call to osascriptAvailable. The mod has no Node.js APIs (no
69// process.platform), so we can't detect the host OS at the JS level — we
70// run a one-shot `osascript -e 1` instead.
71let osascriptOk = null;
72
73export function register(on, options) {
74 // userConfig wiring — only top-level toggles in v1, the rest (sounds,
75 // thresholds) live as code constants and can be promoted to userConfig
76 // in a later version.
77 if (typeof options?.enabled === "boolean") enabled = options.enabled;
78 if (typeof options?.suppressWhenFocused === "boolean") {
79 suppressWhenFocused = options.suppressWhenFocused;
80 }
81 if (typeof options?.stickyOnError === "boolean") {
82 stickyOnError = options.stickyOnError;
83 }
84
85 on("session.start", async ($, e, next) => {
86 await resetSessionState($);
87 return next(e);
88 });
89
90 // `session.start` only fires at the very beginning. /clear, /resume and
91 // /branch fire `classic.SessionStart` instead (the setting-hook namespaced
92 // event), with `source` set to one of "clear" / "resume" / "fork". Reset
93 // the same state on those triggers so the focus cache doesn't carry
94 // stale data across a clear and osascriptOk is re-probed cleanly.
95 on(
96 "classic.SessionStart",
97 { source: ["clear", "resume", "fork"] },
98 async ($, e, next) => {
99 await resetSessionState($);
100 return next(e);
101 }
102 );
103
104 on("turn.start", ($, e, next) => {
105 lastTurnStartedAt = Date.now();
106 return next(e);
107 });
108
109 on("turn.complete", async ($, e, next) => {
110 const result = await next(e);
111 // Subagent turns: skip — they fire on the same Notifier and would
112 // double up with the parent's notification.
113 if (e.agentId) return result;
114 // Aborted (user interrupted): no notification — they already know.
115 if (e.isAborted) return result;
116
117 const durationMs = Date.now() - (lastTurnStartedAt || Date.now());
118 const tooShort = durationMs >= 0 && durationMs < SUPPRESS_IF_UNDER_MS;
119
120 if (!tooShort) {
121 const focus = await getFocus($);
122 if (focus !== "focused") {
123 await notify($, {
124 level: "info",
125 title: "Claude Code",
126 subtitle: "[Task Complete]",
127 body: summary(e),
128 });
129 }
130 }
131
132 return result;
133 });
134
135 on("tool.call", { tool: "Bash" }, async ($, e, next) => {
136 const result = await next(e);
137 if (!result) return result;
138
139 // success: clear any retry counter for this signature
140 if (!result.isError && !result.deny) {
141 retries.delete(commandSignature(e));
142 return result;
143 }
144
145 const sig = commandSignature(e);
146 const now = Date.now();
147 const existing = retries.get(sig);
148 const count = (existing?.count ?? 0) + 1;
149 retries.set(sig, { count, lastAt: now });
150
151 // Periodically GC stale entries so the Map doesn't grow unbounded.
152 if (retries.size > MAX_RETRY_ENTRIES) {
153 for (const [k, v] of retries.entries()) {
154 if (now - v.lastAt > RETRY_GC_MS) retries.delete(k);
155 }
156 }
157
158 if (count < RETRY_COUNT) return result;
159
160 const focus = await getFocus($);
161 if (focus === "focused") return result;
162
163 await notify($, {
164 level: "warn",
165 title: "Claude Code — repeated failure",
166 subtitle: `[Bash failed ${count}×]`,
167 body: shortError(result),
168 sticky: stickyOnError,
169 });
170 return result;
171 });
172
173 on("ui.render", { component: "AskUserQuestion" }, async ($, e, next) => {
174 const result = await next(e);
175 const focus = await getFocus($);
176 if (focus === "focused") return result;
177 const question = askQuestionText(e);
178 await notify($, {
179 level: "important",
180 title: "Claude Code",
181 subtitle: "[Claude is asking]",
182 body: question,
183 });
184 return result;
185 });
186}
187
188// --- session lifecycle ----------------------------------------------------
189
190// Reset all per-session state and re-probe the foreground app. Called from
191// `session.start` (real session boot) and `classic.SessionStart { source:
192// ['clear','resume','fork'] }` (after /clear, /resume, /branch).
193async function resetSessionState($) {
194 retries.clear();
195 lastTurnStartedAt = Date.now();
196 lastFocus = null;
197 lastFocusCheckedAt = 0;
198 osascriptOk = null;
199 // Eagerly probe focus so the first turn.complete after startup or
200 // /clear has a fresh result rather than a cached one from before the
201 // reset.
202 await getFocus($);
203}
204
205// --- focus detection ------------------------------------------------------
206
207// One-shot probe: runs `osascript -e 1` once and caches whether it succeeded.
208// The mod runtime has no Node.js globals (no `process.platform`), so we use
209// the probe itself as the host-OS signal: macOS exits 0, every other host
210// either rejects (command not found) or exits non-zero. After the first
211// call the answer is cached for the lifetime of this module instance.
212async function osascriptAvailable($) {
213 if (osascriptOk !== null) return osascriptOk;
214 try {
215 const { exitCode } = await $.process.run(["osascript", "-e", "1"]);
216 osascriptOk = exitCode === 0;
217 } catch {
218 osascriptOk = false;
219 }
220 return osascriptOk;
221}
222
223// Returns "focused" if Claude Code is the frontmost app, "away" otherwise,
224// "unknown" if System Events cannot answer. Cached for 2s because the
225// osascript round trip is ~50ms and we may call this several times in a
226// single turn.
227async function getFocus($) {
228 if (!suppressWhenFocused) return "away";
229 const now = Date.now();
230 if (lastFocus !== null && now - lastFocusCheckedAt < 2_000) {
231 return lastFocus;
232 }
233 if (!(await osascriptAvailable($))) {
234 lastFocus = "unknown";
235 lastFocusCheckedAt = now;
236 return lastFocus;
237 }
238 try {
239 const { stdout, exitCode } = await $.process.run([
240 "osascript",
241 "-e",
242 'tell application "System Events" to get name of first application process whose frontmost is true',
243 ]);
244 if (exitCode !== 0) {
245 lastFocus = "unknown";
246 } else {
247 const name = stdout.trim();
248 lastFocus = FOCUSED_NAMES.has(name) ? "focused" : "away";
249 }
250 } catch {
251 lastFocus = "unknown";
252 }
253 lastFocusCheckedAt = now;
254 return lastFocus;
255}
256
257// --- system notification --------------------------------------------------
258
259async function notify($, opts) {
260 if (!enabled) return;
261 if (!(await osascriptAvailable($))) return;
262 const { level = "info", title, subtitle, body, sticky } = opts;
263 const sound = sounds[level] ?? sounds.info;
264 const script = [
265 "display notification",
266 appleString(body || ""),
267 "with title",
268 appleString(title),
269 "subtitle",
270 appleString(subtitle || ""),
271 "sound name",
272 appleString(sound),
273 ].join(" ");
274 try {
275 await $.process.run(["osascript", "-e", script]);
276 } catch {
277 // Most common failure: the user has not granted Notification Center
278 // permission to osascript. macOS will surface a one-time permission
279 // dialog; until they accept, the call rejects silently.
280 }
281 // stickyOnError is a future feature — osascript's `display notification`
282 // has no sticky parameter, and BurntToast / terminal-notifier integration
283 // is deferred. Accept the option silently so the config knob is honored
284 // by code that already calls notify($, { sticky: true }).
285 void sticky;
286}
287
288// Escape a string for embedding inside a double-quoted AppleScript literal.
289// We only need to handle backslashes and double quotes because we control
290// every other character (ASCII body, short titles).
291function appleString(s) {
292 return '"' + String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"';
293}
294
295// --- summaries ------------------------------------------------------------
296
297function summary(e) {
298 const text = (e.answer || "").trim();
299 if (!text) return "task complete";
300 // First line, capped so the notification body stays readable.
301 const firstLine = text.split(/\r?\n/, 1)[0].trim();
302 const cap = 140;
303 return firstLine.length > cap ? firstLine.slice(0, cap - 1) + "…" : firstLine;
304}
305
306function shortError(result) {
307 if (result.deny) return `denied: ${result.deny}`;
308 const text = (result.output || result.error || "").toString().trim();
309 if (!text) return "no output";
310 const firstLine = text.split(/\r?\n/, 1)[0].trim();
311 const cap = 140;
312 return firstLine.length > cap ? firstLine.slice(0, cap - 1) + "…" : firstLine;
313}
314
315function askQuestionText(e) {
316 // AskUserQuestion props vary by surface; pick whichever is present.
317 const props = e.props || {};
318 return (
319 props.question ||
320 props.header ||
321 (Array.isArray(props.questions) && props.questions[0]?.question) ||
322 "Claude needs your input"
323 );
324}
325
326// sig = command + cwd so identical commands in different worktrees don't
327// share a counter (and accidentally trip the warn threshold).
328function commandSignature(e) {
329 return `${e.cwd || ""}::${e.command || ""}`;
330}
331