@trygocode/notify 0.6.4 → 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 +27 -0
- package/dist/src/claude.js +5 -4
- package/dist/src/cli.js +48 -7
- package/dist/src/cursor.js +27 -29
- package/dist/src/doctor.js +11 -5
- package/dist/src/on_stop.js +97 -4
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
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
|
package/dist/src/claude.js
CHANGED
|
@@ -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`
|
|
54
|
-
*
|
|
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
|
|
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
|
-
:
|
|
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);
|
package/dist/src/cursor.js
CHANGED
|
@@ -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
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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`
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
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
|
|
209
|
-
if (!isRecord(
|
|
205
|
+
function isOurToolHookEntry(entry) {
|
|
206
|
+
if (!isRecord(entry))
|
|
210
207
|
return false;
|
|
211
|
-
|
|
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((
|
|
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
|
-
*
|
|
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
|
|
252
|
+
* place. Cursor's current hook schema requires `command` DIRECTLY on every
|
|
253
|
+
* event entry: `{ command, matcher }`.
|
|
252
254
|
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
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) {
|
package/dist/src/doctor.js
CHANGED
|
@@ -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
|
|
32
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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?",
|
package/dist/src/on_stop.js
CHANGED
|
@@ -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
|
-
//
|
|
678
|
-
//
|
|
679
|
-
//
|
|
680
|
-
//
|
|
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);
|
package/dist/src/version.js
CHANGED
|
@@ -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.
|
|
2
|
+
export const VERSION = "0.6.5";
|
package/package.json
CHANGED