@trygocode/notify 0.6.3 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -513,6 +513,7 @@ been written. Most issues below are diagnosable from that output.
513
513
  | **No push arrives** even though `test` exits 0 | The send is fire-and-forget and exits 0 even on failure — check `~/.gocode/notify.log` for the real error. Also confirm push permissions are granted in the GoCode app and the device token is registered (re-open the app once after signing in). |
514
514
  | **Double pings** (two notifications per event) | The agent is calling the `gocode_notify` MCP tool *and* the runtime hook is firing. Re-run `setup` so the anti-double-ping rule/skill is installed; it tells the agent not to notify for automatic done/idle/error events. |
515
515
  | **Hook doesn't fire** in Cursor / Claude Code / OpenCode | Re-run `setup` and check `status` shows "config written" for that runtime. Restart the agent app so it reloads `~/.cursor/hooks.json` / `~/.claude/settings.json` / `~/.config/opencode/plugin/gocode-notify.js`. The hooks are merged, never clobbered — your existing hooks are preserved. |
516
+ | `doctor` says Claude hooks are missing but GoCode Sync is installed | Expected and accurate: Sync's `Stop` hook uploads transcripts; it is not a Notify hook and cannot deliver completion pushes. Run `gocode-notify setup` to install Notify's separate `Stop` + `Notification` hooks. |
516
517
  | **Pushes queue up while offline** then arrive later | Expected. Sends made while the server is unreachable are enqueued to `~/.gocode/outbox/` (size-capped, drop-oldest) and flushed best-effort on the next `send`. A missed "done" ping is acceptable; a blocked agent is not. |
517
518
  | **`npx @trygocode/notify` can't find the package** | Until it's published to npm, run from a local tarball: `npm pack` in `tools/gocode-notify/`, then `npx ./gocode-notify-*.tgz <command>`. See [`docs/GOCODE_NOTIFY_MANUAL_STEPS.md`](../../docs/GOCODE_NOTIFY_MANUAL_STEPS.md). |
518
519
  | **Want it gone** | `gocode-notify uninstall` removes exactly the hook/MCP/rule entries this tool added (nothing else). Delete `~/.gocode/` to also drop the stored credentials, and revoke the key from the app's **"Connected agents"** screen. |
@@ -529,6 +530,32 @@ npm publish, real-device E2E), see
529
530
 
530
531
  ## Changelog
531
532
 
533
+ ### 0.6.5
534
+
535
+ - **Fixed Cursor lifecycle notifications on current Cursor releases.** The
536
+ installer now writes `postToolUse` using Cursor's direct
537
+ `{ "command": "...", "matcher": "..." }` schema. Versions 0.5.0–0.6.4 wrote
538
+ a Claude-style nested `hooks` array, causing Cursor to reject the entire user
539
+ hooks file—silencing completion, error, and question notifications. Re-running
540
+ setup migrates the malformed legacy entry in place while preserving user hooks.
541
+ - **Restored standalone Claude Code completion + question notifications without
542
+ duplicating Cursor.** Claude `Stop` and `Notification` hooks are installed
543
+ normally. When Cursor's third-party compatibility layer imports the Claude
544
+ `Stop` hook, the dispatcher detects Cursor's documented `cursor_version` stdin
545
+ field and suppresses only that embedded duplicate. A real standalone Claude
546
+ payload has no `cursor_version` and continues to send.
547
+ - **No silent completion when auto-push has nothing to do.** If auto-push returns
548
+ `clean-tree` (or another non-notifying no-op), `on-stop` now sends the normal
549
+ completion fallback. A non-notifying commit failure sends an error fallback.
550
+ - **Doctor no longer confuses GoCode Sync with GoCode Notify.** A valid hook must
551
+ carry both the runtime source marker and a Notify package/command marker.
552
+ - **Hook commands stay silent for Cursor's JSON protocol.** Installed Cursor and
553
+ Claude lifecycle hooks use `--quiet`, so a successful human-readable
554
+ `✓ on-stop …` line is never misparsed by Cursor as an invalid JSON hook
555
+ response.
556
+ - Run `npx -y @trygocode/notify@latest setup` after upgrading, then restart
557
+ Cursor and any standalone Claude Code process so both reload their hooks.
558
+
532
559
  ### 0.6.1
533
560
 
534
561
  - **Banner now reliably POPS top-right (not just history).** The signed helper
@@ -50,8 +50,9 @@ export const MCP_SERVER_ENTRY = {
50
50
  * plain `finished` ping — exactly one notification per turn. The pre-T-C6 form
51
51
  * was `send --kind finished --source claude_code …`; both old and new commands
52
52
  * contain the {@link HOOK_MARKERS} tokens, so old installs upgrade idempotently
53
- * and uninstall cleanly. `Notification` stays on the plain `send` (it is not a
54
- * turn-completion event and never auto-pushes).
53
+ * and uninstall cleanly. `Notification` uses the small `on-notification`
54
+ * dispatcher: standalone Claude questions forward to `awaiting_input`, while a
55
+ * Cursor-imported Claude hook is suppressed using Cursor's stdin marker.
55
56
  *
56
57
  * We intentionally register ONLY `Stop` + `Notification`. `SubagentStop` is NOT
57
58
  * here on purpose — see {@link SUBAGENT_STOP_EVENT}.
@@ -62,8 +63,8 @@ export const MCP_SERVER_ENTRY = {
62
63
  // fixes without ever clearing their npx cache by hand. Matchers below key on the
63
64
  // `@trygocode/notify` substring, which `@latest` preserves.
64
65
  export const CLAUDE_HOOK_COMMANDS = {
65
- Stop: 'npx -y @trygocode/notify@latest on-stop --source claude_code --dedupe-key "$CLAUDE_SESSION_ID-stop" || true',
66
- Notification: 'npx -y @trygocode/notify@latest send --kind awaiting_input --source claude_code --title "Agent needs you" --dedupe-key "$CLAUDE_SESSION_ID-notify" || true',
66
+ Stop: 'npx -y @trygocode/notify@latest on-stop --source claude_code --dedupe-key "$CLAUDE_SESSION_ID-stop" --quiet || true',
67
+ Notification: 'npx -y @trygocode/notify@latest on-notification --source claude_code --dedupe-key "$CLAUDE_SESSION_ID-notify" --quiet || true',
67
68
  };
68
69
  /**
69
70
  * The Claude event we DELIBERATELY do not register and actively scrub on every
package/dist/src/cli.js CHANGED
@@ -20,7 +20,7 @@ import { serveStdio } from "./mcp.js";
20
20
  import { setup } from "./setup.js";
21
21
  import { uninstall } from "./uninstall.js";
22
22
  import { cmdConfig } from "./config.js";
23
- import { onStop, clickTarget, projectLabel } from "./on_stop.js";
23
+ import { onStop, isClaudeHookImportedByCursor, parseCursorStopStatus, cursorStopStatusToKind, clickTarget, projectLabel, } from "./on_stop.js";
24
24
  import { notifyDesktop, requestDesktopPermission, desktopDisabledByEnv, } from "./desktop_notify.js";
25
25
  import { resolveNotifySettings } from "./config.js";
26
26
  import { deriveRepoIdentity } from "./repo_key.js";
@@ -39,6 +39,7 @@ export const COMMANDS = [
39
39
  "uninstall",
40
40
  "config",
41
41
  "on-stop",
42
+ "on-notification",
42
43
  "launch",
43
44
  ];
44
45
  /** Aliases that route to a canonical command in {@link runAsync}. */
@@ -102,6 +103,7 @@ export function printHelp() {
102
103
  " uninstall Remove entries this tool added",
103
104
  " config Get/set Notify settings (get | set <key> <value> | pull)",
104
105
  " on-stop End-of-turn dispatcher (auto-push or finished ping)",
106
+ " on-notification Needs-input hook dispatcher (embedded-Cursor aware)",
105
107
  " launch Hand a big task off to your GoCode server (alias: autopilot)",
106
108
  "",
107
109
  " -h, --help Show this help",
@@ -323,13 +325,14 @@ export async function fireSendDesktop(payload, deps) {
323
325
  export async function cmdSend(args, deps = {}) {
324
326
  const flags = parseFlags(args);
325
327
  const agent = isAgentDriven(flags);
328
+ const quiet = flagBool(flags, "quiet");
326
329
  const sink = deps.sink ?? stdoutSink;
327
330
  const kind = flagString(flags, "kind");
328
331
  if (kind === undefined) {
329
332
  const detail = `--kind is required (one of: ${NOTIFY_KINDS.join(", ")})`;
330
333
  if (agent)
331
334
  sink({ step: "validate", ok: false, detail });
332
- else
335
+ else if (!quiet)
333
336
  console.error(`gocode-notify send: ${detail}`);
334
337
  return 2;
335
338
  }
@@ -337,7 +340,7 @@ export async function cmdSend(args, deps = {}) {
337
340
  const detail = `invalid --kind "${kind}" (expected one of: ${NOTIFY_KINDS.join(", ")})`;
338
341
  if (agent)
339
342
  sink({ step: "validate", ok: false, detail });
340
- else
343
+ else if (!quiet)
341
344
  console.error(`gocode-notify send: ${detail}`);
342
345
  return 2;
343
346
  }
@@ -390,7 +393,7 @@ export async function cmdSend(args, deps = {}) {
390
393
  : "";
391
394
  if (agent)
392
395
  sink({ step: "send", ok: true, detail: `sent ${payload.kind}${where}` });
393
- else
396
+ else if (!quiet)
394
397
  console.log(`✓ Sent ${payload.kind}${where}.`);
395
398
  }
396
399
  else {
@@ -399,7 +402,7 @@ export async function cmdSend(args, deps = {}) {
399
402
  const detail = `${payload.kind} not delivered (${result.error}); queued for retry`;
400
403
  if (agent)
401
404
  sink({ step: "send", ok: false, detail });
402
- else
405
+ else if (!quiet)
403
406
  console.error(`gocode-notify send: ${detail}.`);
404
407
  }
405
408
  // PRD §4.4: a failed notification must NEVER block a hook — always exit 0.
@@ -692,6 +695,7 @@ export async function cmdMcp(args, deps = {}) {
692
695
  export async function cmdOnStop(args, deps = {}) {
693
696
  const flags = parseFlags(args);
694
697
  const agent = isAgentDriven(flags);
698
+ const quiet = flagBool(flags, "quiet");
695
699
  const sink = deps.sink ?? stdoutSink;
696
700
  // Read the hook's stdin JSON (T-CUR1 / PRD §8.5). The Cursor `stop` hook pipes
697
701
  // its event data to the process stdin; we consume it here so on-stop can map
@@ -723,6 +727,7 @@ export async function cmdOnStop(args, deps = {}) {
723
727
  : result.mode === "send"
724
728
  ? result.send?.ok === true
725
729
  : undefined;
730
+ const fallbackKind = cursorStopStatusToKind(parseCursorStopStatus(hookStdin));
726
731
  const detail = result.mode === "push"
727
732
  ? `auto-push: ${result.push?.outcome ?? "unknown"}${delivered ? " (notified)" : ""}`
728
733
  : result.mode === "dry-run-send"
@@ -731,16 +736,50 @@ export async function cmdOnStop(args, deps = {}) {
731
736
  ? "deduped: another source already notified for this run"
732
737
  : result.mode === "autopilot-suppressed"
733
738
  ? "autopilot-suppressed: an Autopilot loop owns this turn's ping"
734
- : `finished ${delivered ? "delivered" : "not delivered"}`;
739
+ : result.mode === "embedded-claude-suppressed"
740
+ ? "embedded-claude-suppressed: native Cursor hook owns this turn"
741
+ : `${fallbackKind} ${delivered ? "delivered" : "not delivered"}`;
735
742
  if (agent) {
736
743
  sink({ step: "on-stop", ok: true, detail });
737
744
  }
738
- else {
745
+ else if (!quiet) {
739
746
  console.log(`✓ on-stop (${result.mode}): ${detail}`);
740
747
  }
741
748
  // PRD §0.5 / §4.4: a stop hook must NEVER block the turn — always exit 0.
742
749
  return 0;
743
750
  }
751
+ /**
752
+ * Handle Claude Code's `Notification` hook while avoiding Cursor's third-party
753
+ * import duplicate. Standalone Claude payloads are forwarded to the normal
754
+ * awaiting-input send. Cursor-imported payloads carry `cursor_version` and are
755
+ * ignored because Cursor does not support the Claude Notification event and its
756
+ * native question hook is authoritative when available.
757
+ */
758
+ export async function cmdOnNotification(args, deps = {}) {
759
+ const flags = parseFlags(args);
760
+ const source = flagString(flags, "source") ?? "claude_code";
761
+ const hookStdin = deps.hookStdin !== undefined
762
+ ? deps.hookStdin
763
+ : await (deps.readStdin ?? readStdinIfPipe)();
764
+ if (isClaudeHookImportedByCursor(source, hookStdin))
765
+ return 0;
766
+ const forwarded = [
767
+ "--kind",
768
+ "awaiting_input",
769
+ "--source",
770
+ source,
771
+ "--title",
772
+ "Agent needs you",
773
+ "--quiet",
774
+ ];
775
+ const dedupeKey = flagString(flags, "dedupe-key");
776
+ if (dedupeKey)
777
+ forwarded.push("--dedupe-key", dedupeKey);
778
+ const server = flagString(flags, "server");
779
+ if (server)
780
+ forwarded.push("--server", server);
781
+ return (deps.sendCommandImpl ?? cmdSend)(forwarded, deps);
782
+ }
744
783
  /**
745
784
  * Handle `gocode-notify launch "<task>" [--repo owner/repo] [--branch suffix]
746
785
  * [--prd-file path.md] [--model "Profile"] [--runner-kind K] [--server URL]
@@ -878,6 +917,8 @@ export async function runAsync(argv) {
878
917
  return cmdConfig(argv.slice(1));
879
918
  if (cmd === "on-stop")
880
919
  return cmdOnStop(argv.slice(1));
920
+ if (cmd === "on-notification")
921
+ return cmdOnNotification(argv.slice(1));
881
922
  if (cmd === "launch" || cmd === "autopilot")
882
923
  return cmdLaunch(argv.slice(1));
883
924
  return run(argv);
@@ -60,7 +60,7 @@ export const MCP_SERVER_ENTRY = {
60
60
  // ~/.npm/_npx by hand. Pinning `@latest` forces npx to resolve the newest
61
61
  // version on every turn, so users self-update seamlessly. (Matchers below key on
62
62
  // the `@trygocode/notify` substring, which `@latest` preserves.)
63
- export const CURSOR_STOP_COMMAND = "npx -y @trygocode/notify@latest on-stop --source cursor --dedupe-key cursor-stop || true";
63
+ export const CURSOR_STOP_COMMAND = "npx -y @trygocode/notify@latest on-stop --source cursor --dedupe-key cursor-stop --quiet || true";
64
64
  /**
65
65
  * The Cursor `postToolUse` question hook (T-CUR3) — the ONLY available signal for
66
66
  * "the Cursor agent asked the user a question and is now waiting." It fires a
@@ -68,15 +68,10 @@ export const CURSOR_STOP_COMMAND = "npx -y @trygocode/notify@latest on-stop --so
68
68
  * dedicated `Notification` hook fires, so a real question surfaces as a QUESTION
69
69
  * notification on the phone/desktop instead of a misleading `finished`.
70
70
  *
71
- * ⚠️ DORMANT-BY-NECESSITY (2026-06-18): Cursor has a CONFIRMED-OPEN upstream bug
72
- * where the `AskQuestion` / `AskUserQuestion` tool fires ZERO hooks `preToolUse`
73
- * and `postToolUse` are documented to fire "for all tools" but are skipped for
74
- * this one tool specifically (Cursor staff confirmed, no workaround; forum
75
- * threads 152230 + 161836). So today this hook will simply never fire, and the
76
- * `stop`-hook `finished` fallback remains the user's notification. The moment
77
- * Cursor ships their fix this hook AUTO-ACTIVATES with NO new install — every
78
- * user already running `@trygocode/notify@latest` gets correct question
79
- * detection for free. We install it now so we're ahead of the fix.
71
+ * Historically dormant (2026-06-18): Cursor skipped tool hooks for AskQuestion.
72
+ * Keep this hook installed in the current direct schema because newer releases
73
+ * may fire it; on older releases it remains a harmless no-op and `stop` supplies
74
+ * the completion fallback.
80
75
  *
81
76
  * Cursor question detection has no other path: Cursor exposes NO `Notification`
82
77
  * event (docs: `Notification | - | No`), and the `stop` hook's
@@ -87,7 +82,7 @@ export const CURSOR_STOP_COMMAND = "npx -y @trygocode/notify@latest on-stop --so
87
82
  * Ends in `|| true` so a failed push NEVER blocks the turn; carries a distinct
88
83
  * `--dedupe-key` so it coalesces with itself but not with the `stop` ping.
89
84
  */
90
- export const CURSOR_ASK_QUESTION_COMMAND = 'npx -y @trygocode/notify@latest send --kind awaiting_input --source cursor --title "Agent needs you" --dedupe-key cursor-ask-question || true';
85
+ export const CURSOR_ASK_QUESTION_COMMAND = 'npx -y @trygocode/notify@latest send --kind awaiting_input --source cursor --title "Agent needs you" --dedupe-key cursor-ask-question --quiet || true';
91
86
  /**
92
87
  * Tool names that mean "the agent is asking the user a question." Used as the
93
88
  * `postToolUse` matcher (a regex alternation). Cursor maps Claude Code tool
@@ -200,22 +195,28 @@ function isOurStopHook(h) {
200
195
  return isOurCommand(h.command);
201
196
  }
202
197
  /**
203
- * True when a `postToolUse` group (`{ matcher?, hooks: [{ command }] }`) is one
204
- * we wrote its `hooks` array contains a command carrying OUR markers. Cursor's
205
- * tool-hook shape nests commands one level deeper than the flat `stop` array, so
206
- * we look inside `hooks[].command`.
198
+ * True when a `postToolUse` entry is one we wrote. Current Cursor uses the same
199
+ * direct shape as every other Cursor event (`{ command, matcher? }`). Releases
200
+ * through 0.6.4 accidentally wrote Claude Code's nested
201
+ * `{ matcher, hooks: [{ command }] }` shape, which makes Cursor reject the
202
+ * ENTIRE user hooks file. Recognise BOTH shapes so setup/uninstall migrate the
203
+ * malformed legacy entry without touching user hooks.
207
204
  */
208
- function isOurToolHookGroup(g) {
209
- if (!isRecord(g) || !Array.isArray(g.hooks))
205
+ function isOurToolHookEntry(entry) {
206
+ if (!isRecord(entry))
210
207
  return false;
211
- return g.hooks.some((h) => isRecord(h) && typeof h.command === "string" && isOurCommand(h.command));
208
+ if (typeof entry.command === "string" && isOurCommand(entry.command))
209
+ return true;
210
+ if (!Array.isArray(entry.hooks))
211
+ return false;
212
+ return entry.hooks.some((hook) => isRecord(hook) && typeof hook.command === "string" && isOurCommand(hook.command));
212
213
  }
213
214
  /**
214
215
  * Strip OUR groups out of a `postToolUse` array. Returns the cleaned array plus
215
216
  * whether anything of ours was removed. Never mutates the input.
216
217
  */
217
218
  function stripOurToolHooks(entries) {
218
- const kept = entries.filter((g) => !isOurToolHookGroup(g));
219
+ const kept = entries.filter((entry) => !isOurToolHookEntry(entry));
219
220
  return { entries: kept, removed: kept.length !== entries.length };
220
221
  }
221
222
  /**
@@ -246,13 +247,13 @@ function mergeStopHook(config) {
246
247
  /**
247
248
  * Merge our `postToolUse` AskQuestion hook into the hooks config (T-CUR3),
248
249
  * preserving the user's own `postToolUse` groups. Strips any prior copy of OUR
249
- * group (idempotent / version-safe) then appends a single fresh group scoped to
250
+ * entry (idempotent / version-safe) then appends a single fresh entry scoped to
250
251
  * the question tools via {@link CURSOR_ASK_QUESTION_MATCHER}. Mutates `config` in
251
- * place. Cursor's tool-hook shape is `{ matcher, hooks: [{ type, command }] }`.
252
+ * place. Cursor's current hook schema requires `command` DIRECTLY on every
253
+ * event entry: `{ command, matcher }`.
252
254
  *
253
- * This hook is dormant until Cursor fixes the AskQuestion tool-hook-skip bug (see
254
- * {@link CURSOR_ASK_QUESTION_COMMAND}) installing it now means it auto-activates
255
- * with no new install once upstream lands.
255
+ * On Cursor versions that still skip AskQuestion tool hooks, this entry remains
256
+ * a harmless no-op; on versions that fire it, questions notify immediately.
256
257
  */
257
258
  function mergeAskQuestionHook(config) {
258
259
  if (typeof config.version !== "number")
@@ -261,8 +262,8 @@ function mergeAskQuestionHook(config) {
261
262
  const existing = Array.isArray(hooks.postToolUse) ? hooks.postToolUse : [];
262
263
  const preserved = stripOurToolHooks(existing).entries;
263
264
  preserved.push({
265
+ command: CURSOR_ASK_QUESTION_COMMAND,
264
266
  matcher: CURSOR_ASK_QUESTION_MATCHER,
265
- hooks: [{ type: "command", command: CURSOR_ASK_QUESTION_COMMAND }],
266
267
  });
267
268
  hooks.postToolUse = preserved;
268
269
  config.hooks = hooks;
@@ -305,10 +306,7 @@ export async function writeCursorConfig(runtime, opts) {
305
306
  runtime: name,
306
307
  written,
307
308
  skipped: false,
308
- detail: "merged stop + postToolUse(AskQuestion) hooks + MCP entry; wrote rule. " +
309
- "NOTE: the AskQuestion question hook is DORMANT — Cursor currently fires no " +
310
- "hooks for the AskQuestion tool (upstream bug), so question notifications " +
311
- "fall back to 'finished' until Cursor ships their fix (auto-activates then).",
309
+ detail: "merged current-schema stop + postToolUse(AskQuestion) hooks + MCP entry; wrote rule",
312
310
  };
313
311
  }
314
312
  catch (err) {
@@ -28,10 +28,16 @@ export { UNPAIRED_WARNING } from "./status.js";
28
28
  // We detect our hook entries by looking for these stable tokens in the command
29
29
  // strings. The same tokens are used by claude.ts / cursor.ts for idempotent
30
30
  // install / uninstall (kept in sync via the HOOK_SOURCE comments there).
31
- /** Tokens that identify a gocode-notify Claude Code hook in settings.json. */
32
- const CLAUDE_HOOK_TOKEN = "--source claude_code";
31
+ /** Tokens that together identify OUR hook command (not @trygocode/sync). */
32
+ const NOTIFY_HOOK_NAME_TOKENS = ["@trygocode/notify", "gocode-notify", "notify send"];
33
+ const CLAUDE_HOOK_SOURCE_TOKEN = "--source claude_code";
33
34
  /** Token that identifies our Cursor stop hook in hooks.json. */
34
35
  const CURSOR_HOOK_TOKEN = "--source cursor";
36
+ /** True only for a GoCode Notify command for the requested runtime source. */
37
+ function isNotifyHookCommand(command, sourceToken) {
38
+ return (command.includes(sourceToken) &&
39
+ NOTIFY_HOOK_NAME_TOKENS.some((token) => command.includes(token)));
40
+ }
35
41
  // ─── Helpers ───────────────────────────────────────────────────────────────
36
42
  function resolveHomeDir(opts) {
37
43
  return opts.home ?? process.env.HOME ?? process.env.USERPROFILE ?? "~";
@@ -162,7 +168,7 @@ export async function gatherDoctor(opts = {}) {
162
168
  "Stop" in claudeHooks
163
169
  ? claudeHooks.Stop
164
170
  : null);
165
- const claudeStopInstalled = claudeStopCommands.some((c) => c.includes(CLAUDE_HOOK_TOKEN));
171
+ const claudeStopInstalled = claudeStopCommands.some((c) => isNotifyHookCommand(c, CLAUDE_HOOK_SOURCE_TOKEN));
166
172
  checks.push({
167
173
  key: "hook:claude-stop",
168
174
  label: "Claude Code Stop hook installed?",
@@ -180,7 +186,7 @@ export async function gatherDoctor(opts = {}) {
180
186
  "Notification" in claudeHooks
181
187
  ? claudeHooks.Notification
182
188
  : null);
183
- const claudeNotifyInstalled = claudeNotifyCommands.some((c) => c.includes(CLAUDE_HOOK_TOKEN));
189
+ const claudeNotifyInstalled = claudeNotifyCommands.some((c) => isNotifyHookCommand(c, CLAUDE_HOOK_SOURCE_TOKEN));
184
190
  checks.push({
185
191
  key: "hook:claude-notification",
186
192
  label: "Claude Code Notification hook installed?",
@@ -220,7 +226,7 @@ export async function gatherDoctor(opts = {}) {
220
226
  ? flattenHookCommands(cursorRoot.stop)
221
227
  : []),
222
228
  ];
223
- const cursorStopInstalled = cursorStopEntries.some((c) => c.includes(CURSOR_HOOK_TOKEN));
229
+ const cursorStopInstalled = cursorStopEntries.some((c) => isNotifyHookCommand(c, CURSOR_HOOK_TOKEN));
224
230
  checks.push({
225
231
  key: "hook:cursor-stop",
226
232
  label: "Cursor stop hook installed?",
@@ -120,6 +120,33 @@ export function cursorStopStatusToKind(status) {
120
120
  export function shouldSuppressAbortedStop(source, status) {
121
121
  return source === "cursor" && status === "aborted";
122
122
  }
123
+ /**
124
+ * True when Cursor's third-party compatibility layer is executing a Claude Code
125
+ * hook. Cursor imports `~/.claude/settings.json` hooks and maps `Stop` to its own
126
+ * `stop` event, but keeps the Claude command's `--source claude_code`. Without
127
+ * this payload-aware gate, enabling standalone Claude hooks causes both Cursor's
128
+ * native stop hook and the imported Claude Stop hook to notify for one Cursor
129
+ * turn.
130
+ *
131
+ * Cursor documents `cursor_version` on every hook payload; standalone Claude
132
+ * Code does not emit it. That is the narrow, positive discriminator. We do not
133
+ * infer Cursor from paths, environment, or model names, so a real Claude CLI
134
+ * turn keeps notifying even when launched from a terminal inside Cursor.
135
+ */
136
+ export function isClaudeHookImportedByCursor(source, hookStdin) {
137
+ if (source !== "claude_code" || !hookStdin || hookStdin.trim() === "")
138
+ return false;
139
+ try {
140
+ const parsed = JSON.parse(hookStdin);
141
+ if (!parsed || typeof parsed !== "object")
142
+ return false;
143
+ const cursorVersion = parsed.cursor_version;
144
+ return typeof cursorVersion === "string" && cursorVersion.trim() !== "";
145
+ }
146
+ catch {
147
+ return false;
148
+ }
149
+ }
123
150
  /**
124
151
  * Human-readable label for a notification kind, used in the desktop banner body
125
152
  * (PRD §3). Mirrors the phone notification's tone so the computer banner reads
@@ -604,6 +631,20 @@ export async function onStop(opts = {}) {
604
631
  }
605
632
  };
606
633
  try {
634
+ // ── Step -0.5: suppress Claude hooks imported by Cursor. ──
635
+ // Cursor automatically loads Claude Code hooks from ~/.claude/settings.json.
636
+ // Keep those hooks installed for the standalone Claude CLI, but when their
637
+ // stdin carries Cursor's documented `cursor_version`, let the native Cursor
638
+ // hook be the single owner of the turn. This replaces the stale machine-wide
639
+ // GOCODE_NOTIFY_SKIP_RUNTIMES policy without silencing standalone Claude.
640
+ if (isClaudeHookImportedByCursor(source, opts.hookStdin)) {
641
+ await logLine(`Cursor-imported Claude hook → suppressed ${source} duplicate (native Cursor hook is authoritative)`);
642
+ return {
643
+ mode: "embedded-claude-suppressed",
644
+ settingsSource: "default",
645
+ detail: "Cursor imported the Claude hook — native Cursor hook owns the turn",
646
+ };
647
+ }
607
648
  // ── Step 0: Autopilot-owns-turn gate (T-N7 / PRD §3.2). ──
608
649
  // When an Autopilot loop has marked that it owns this turn, it sends its OWN
609
650
  // loop_completed/loop_halted Autopilot ping, so ANY per-turn stop-hook ping
@@ -674,10 +715,62 @@ export async function onStop(opts = {}) {
674
715
  home: opts.home,
675
716
  });
676
717
  await logLine(`auto-push path → ${push.outcome} (source: ${source}, settings: ${resolved.source})`);
677
- // Branded desktop banner mirroring the push notification (PRD §3). Only
678
- // fire when the push flow actually notified the phone (i.e. it pushed,
679
- // hit a conflict, or was rejected) not on no-op outcomes (disabled /
680
- // not-a-repo / clean-tree / protected-skip) where the phone got nothing.
718
+ // A push flow owns the turn only if it actually emitted a notification.
719
+ // `clean-tree` and other no-op/failure outcomes can return notified!==true;
720
+ // in that case send the normal completion/error fallback so enabling
721
+ // auto-push never makes an otherwise valid end-of-turn notification vanish.
722
+ if (push.notified !== true && !opts.dryRun) {
723
+ const fallbackKind = push.outcome === "commit-failed" || push.outcome === "push-rejected" ||
724
+ push.outcome === "conflict-aborted"
725
+ ? "error"
726
+ : cursorStopStatusToKind(earlyHookStatus);
727
+ const fallbackPayload = { kind: fallbackKind, source };
728
+ const fallbackProject = projectLabel(repo, cwd);
729
+ if (fallbackProject)
730
+ fallbackPayload.project = fallbackProject;
731
+ if (opts.dedupeKey)
732
+ fallbackPayload.dedupe_key = opts.dedupeKey;
733
+ const ideChatId = ideChatIdFromHookStdin(source, cwd, opts.hookStdin);
734
+ if (ideChatId)
735
+ fallbackPayload.ide_chat_id = ideChatId;
736
+ const chatTitle = await chatTitleFromHookStdin(opts.hookStdin);
737
+ if (chatTitle)
738
+ fallbackPayload.chat = chatTitle;
739
+ const fallbackBody = fallbackKind === "error" && push.detail
740
+ ? push.detail
741
+ : undefined;
742
+ if (fallbackBody)
743
+ fallbackPayload.body = fallbackBody;
744
+ const desktopBanner = {
745
+ title: decorateTitle({
746
+ kind: fallbackKind,
747
+ source,
748
+ project: fallbackProject,
749
+ }),
750
+ body: decorateBody({
751
+ kind: fallbackKind,
752
+ body: fallbackBody,
753
+ project: fallbackProject,
754
+ chat: chatTitle,
755
+ }),
756
+ kind: fallbackKind,
757
+ };
758
+ const [sent, desktop] = await Promise.all([
759
+ sendImpl(fallbackPayload),
760
+ fireDesktop(settings, desktopBanner, source, ideChatId),
761
+ ]);
762
+ await logLine(`auto-push ${push.outcome} produced no notification → ${fallbackKind} fallback ${sent.ok ? "delivered" : "failed"}`);
763
+ return {
764
+ mode: "send",
765
+ settingsSource: resolved.source,
766
+ push,
767
+ send: sent,
768
+ desktop,
769
+ repo,
770
+ detail: push.detail,
771
+ };
772
+ }
773
+ // Branded desktop banner mirroring the push notification (PRD §3).
681
774
  let desktop;
682
775
  if (push.notified === true) {
683
776
  const project = projectLabel(repo, cwd);
@@ -1,2 +1,2 @@
1
1
  // Single source of truth for the CLI version. Keep in sync with package.json.
2
- export const VERSION = "0.6.3";
2
+ export const VERSION = "0.6.5";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trygocode/notify",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "description": "Free phone + branded desktop notifications for any coding agent (Cursor, Claude Code, OpenCode, Ralph/Homer) via the GoCode app.",
5
5
  "license": "MIT",
6
6
  "type": "module",