SLOPSHOPPER

cc-notify-mod

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

newrowsguardprocess
v0.1.0Apache-2.0updated 2026-10-08kukaka/cc-mods/cc-notify-mod
A shopper browsing a rack in a slop shop
README

cc-notify-mod

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:

EventLevelSubtitleSoundBody
turn.complete (not aborted)info[Task Complete]Glassfirst line of Claude's answer
Bash failed N times in a rowwarn[Bash failed N×]Bassofirst line of the error output
AskUserQuestion renderedimportant[Claude is asking]Frogthe question Claude is asking

For install / marketplace / hot-reload setup, see the parent marketplace README.


Configure

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
    }
  }
}

Code-level defaults (edit register.mjs to change)

ConstantDefaultMeaning
SUPPRESS_IF_UNDER_MS5000Skip turn.complete notifications for sub-5s turns
RETRY_COUNT3Trigger a warn after this many consecutive failures of the same command
RETRY_RESET_MS60000A successful run of the same command clears its counter
FOCUSED_NAMES10 appsApps 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.


What you see

Notification Center

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.

Suppression rules (apply to all three notification kinds)

  • Master switch off — nothing is sent.
  • suppressWhenFocused on AND terminal is frontmost — skipped.
  • turn.complete and turn took < SUPPRESS_IF_UNDER_MS — skipped (sub-5s turns are usually typing aids, not finished work).
  • Aborted turn (turn.complete with isAborted: true) — skipped (you pressed it).
  • tool.check deny / permission prompt — not currently notified.
  • Notification Center permission denied — osascript rejects, the call is swallowed silently. Re-authorize in System Settings → Notifications → Script Editor.

How it works

HookWhat it does
session.startResets 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.startStamps lastTurnStartedAt so turn.complete can compute duration.
turn.completeNotifies 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.

Failure signature

Bash retries are keyed by <cwd>::<command> so:

  • The same command run from two worktrees has two counters — neither trip the other.
  • A successful re-run clears the counter for that signature.
  • Stale entries are GC'd after RETRY_GC_MS (5 minutes) of inactivity, capped at 64 entries.

State

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 support

PlatformStatusNotes
macOSFullThe only platform targeted in v1. Built-in osascript.
LinuxNot supportedosascript is unavailable. A future version could fall back to notify-send — open an issue if you want this.
WindowsNot supportedSame — 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.


Limitations

  • macOS only for v1. Linux and Windows paths are deferred.
  • No click handlers. display notification cannot route a click back to the terminal; you have to switch focus manually.
  • Foreground probe is approximate. 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).
  • No quiet hours yet. No way to silence during specific windows.
  • AskUserQuestion probe may fire on dialog opens that don't actually need an answer. The hook triggers when the dialog renders; if you dismiss it instantly, the notification is still sent.

Troubleshooting

osascript notifications silently dropped on a fresh macOS Sequoia (15.x) install

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


Inspiration

  • Claude Code docs — Mods overview and the in-process API declaration (.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).
  • Apple display notification and System Events — documented in osascript Standard Additions.
Source 1 files
hooks/register.mjs 331 lines
1// 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