@trygocode/notify 0.4.0 → 0.6.0

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
@@ -128,14 +128,14 @@ The installed rule/skill tells the agent **not** to call the MCP tool for
128
128
  done/idle/error pings — those are owned by the deterministic hook (A), so you
129
129
  never get double-pinged.
130
130
 
131
- > **Cursor `stop.status` → kind mapping (since 0.3.0).** Cursor's `stop` hook
132
- > carries a `status` field in its stdin JSON. The `on-stop` dispatcher maps it
133
- > to the right notification kind automatically:
131
+ > **Cursor `stop.status` → kind mapping (since 0.3.0; `aborted` corrected 0.4.0).**
132
+ > Cursor's `stop` hook carries a `status` field in its stdin JSON. The `on-stop`
133
+ > dispatcher maps it to the right notification kind automatically:
134
134
  >
135
135
  > | Cursor `stop.status` | Notification kind | Meaning |
136
136
  > |---|---|---|
137
137
  > | `completed` | `finished` | Agent turned cleanly (default — same as before 0.3.0) |
138
- > | `aborted` | `awaiting_input` | Agent yielded back to youmay need your input |
138
+ > | `aborted` | _(suppressed — no notification)_ | You pressed **Stop** to interrupt the turn a manual stop sends nothing |
139
139
  > | `error` | `error` | Agent hit an error |
140
140
  > | absent / unrecognised | `finished` | Back-compatible fallback |
141
141
  >
@@ -143,6 +143,28 @@ never get double-pinged.
143
143
  > did. The status mapping requires no hook re-installation — run
144
144
  > `gocode-notify setup --force` once to apply the updated hook command.
145
145
 
146
+ > **Question detection — "Agent needs you" (since 0.5.0).** When the agent
147
+ > **pauses to ask you a question** (as opposed to finishing its work), the
148
+ > package fires an `awaiting_input` ping titled **"Agent needs you"** instead of
149
+ > a misleading `finished`. The available signal differs per runtime:
150
+ >
151
+ > | Runtime | Question signal | Status |
152
+ > |---|---|---|
153
+ > | **Claude Code** | dedicated `Notification` hook → `awaiting_input` | ✅ Live — questions show as questions |
154
+ > | **OpenCode** | `permission.asked` plugin event → `awaiting_input` | ✅ Live — OpenCode genuinely exposes a "needs you" signal |
155
+ > | **Cursor** | `postToolUse` hook scoped to the `AskQuestion` tool → `awaiting_input` | ⏳ Installed but **dormant** — see note |
156
+ >
157
+ > **Cursor caveat:** Cursor currently exposes **no** "agent needs input" event
158
+ > (its docs list `Notification` as unsupported), and there is a
159
+ > [confirmed-open Cursor bug](https://forum.cursor.com/t/askquestion-tool-does-not-trigger-cursor-hooks/152230)
160
+ > where the `AskQuestion` tool fires **zero** hooks. So a Cursor question is
161
+ > indistinguishable from a completion at the only signal we get (the `stop`
162
+ > hook), and you'll receive `finished` until Cursor ships their fix. We install
163
+ > the `postToolUse(AskQuestion)` hook **now** so that the moment Cursor fixes
164
+ > the bug, question detection **auto-activates with no new install** for everyone
165
+ > already running `@trygocode/notify@latest`. Run `gocode-notify setup --force`
166
+ > once to wire it in.
167
+
146
168
  > **OpenCode's hook (A)** is a small `session.idle` plugin written to
147
169
  > `~/.config/opencode/plugin/gocode-notify.js`; on each `session.idle` event it
148
170
  > fire-and-forgets `gocode-notify on-stop --source opencode` (the same
@@ -507,6 +529,61 @@ npm publish, real-device E2E), see
507
529
 
508
530
  ## Changelog
509
531
 
532
+ ### 0.6.0
533
+
534
+ - **Real native macOS "Allow Notifications" dialog (signed helper).** GoCode now
535
+ ships a tiny **Developer-ID-signed** macOS app bundle (`GoCodeNotifier.app`,
536
+ Team `88DC7KFY7M`) inside the package. On macOS, `gocode-notify permissions`
537
+ (and the post-`setup` nudge) now pop the **real system Allow/Don't-Allow
538
+ permission modal** — the thing a plain `npx`/`osascript` helper can't do —
539
+ instead of only opening System Settings and asking you to hunt for the toggle.
540
+ Once allowed, desktop banners post as a **first-class, authorized** app
541
+ (proper attribution + a real click-to-open-your-IDE action).
542
+ - **How it ships:** the bundle is **pre-signed at build time** by the
543
+ maintainer (`native/macos/build_and_sign.sh`) and committed to `assets/`, so
544
+ end-users need **no Xcode, no Developer ID, no compile step** — the one-line
545
+ install is unchanged. The signature survives the npm pack/extract round-trip.
546
+ - **Two macOS rules it honors (both required for the dialog to appear):** the
547
+ helper is copied to a **stable** location (`~/.gocode/desktop/`) — never
548
+ `/tmp` — and is always launched via **LaunchServices** (`open`), never by
549
+ exec'ing the inner binary (direct-exec auto-denies and the denial sticks).
550
+ - **Hot-path stays fast:** fire-and-forget banners read a cached auth status
551
+ (`~/.gocode/desktop/.auth-status`), so a notification never does a slow live
552
+ permission round-trip. The signed banner is used only once you've granted;
553
+ otherwise it transparently falls back to the legacy applet banner.
554
+ - **Own identity:** the signed helper uses bundle id `com.gocode.notify.helper`
555
+ (distinct from the legacy applet's `com.gocode.notify.desktop`) so it owns
556
+ its own row in System Settings → Notifications.
557
+ - **Linux/Windows unchanged.** This is a macOS-only enhancement; other
558
+ platforms keep their existing branded-banner paths.
559
+
560
+ ### 0.5.0
561
+
562
+ - **Question detection across ALL IDEs — "Agent needs you" vs "finished".** When
563
+ the agent **pauses to ask you a question**, you now get an `awaiting_input`
564
+ ("Agent needs you") notification instead of a misleading `finished`, on every
565
+ supported runtime that exposes a question signal:
566
+ - **Claude Code** — already wired (`Notification` hook); unchanged.
567
+ - **OpenCode** — NEW: the plugin now listens to `permission.asked` (the moment
568
+ the agent raises a request) and fires `awaiting_input`. OpenCode genuinely
569
+ exposes a "the agent needs the user" signal via permission events. (We listen
570
+ to `permission.asked` only, not `permission.updated`/`replied`, which also
571
+ fire after the user answers.)
572
+ - **Cursor** — NEW (dormant): installs a `postToolUse` hook scoped to the
573
+ `AskQuestion` / `AskUserQuestion` tool. Cursor has a
574
+ [confirmed-open bug](https://forum.cursor.com/t/askquestion-tool-does-not-trigger-cursor-hooks/152230)
575
+ where that tool fires zero hooks, and Cursor exposes no `Notification` event,
576
+ so the hook is dormant today — but it **auto-activates with no new install**
577
+ the moment Cursor ships their fix. Until then, Cursor keeps its `finished`
578
+ fallback.
579
+ - **Corrected the documented Cursor `aborted` mapping.** The README table
580
+ previously (incorrectly) showed `aborted → awaiting_input`; the actual
581
+ behaviour (since the 0.4.0 fix) is that a manual **Stop** press (`aborted`)
582
+ sends **no notification at all**. A user force-stop is now clearly
583
+ distinguished from a genuine IDE question.
584
+ - Run `gocode-notify setup --force` once to wire the new hooks into Cursor +
585
+ OpenCode.
586
+
510
587
  ### 0.4.0
511
588
 
512
589
  - **New: branded desktop notifications (on by default).** Every event that pings
@@ -0,0 +1,37 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <!-- CFBundleIdentifier MUST stay stable across rebuilds: macOS keys the user's
6
+ notification-authorization grant off it, so changing it would forget the
7
+ Allow. This is DISTINCT from the legacy applet's `com.gocode.notify.desktop`
8
+ on purpose: the signed helper owns its own identity in System Settings →
9
+ Notifications so a poisoned/denied state on the old shared id can't block
10
+ the signed app's grant. Mirrors HELPER_BUNDLE_ID in mac_helper.ts. -->
11
+ <key>CFBundleIdentifier</key>
12
+ <string>com.gocode.notify.helper</string>
13
+ <key>CFBundleName</key>
14
+ <string>GoCode</string>
15
+ <key>CFBundleDisplayName</key>
16
+ <string>GoCode</string>
17
+ <key>CFBundleExecutable</key>
18
+ <string>GoCodeNotifier</string>
19
+ <key>CFBundlePackageType</key>
20
+ <string>APPL</string>
21
+ <key>CFBundleInfoDictionaryVersion</key>
22
+ <string>6.0</string>
23
+ <key>CFBundleShortVersionString</key>
24
+ <string>1.0.0</string>
25
+ <key>CFBundleVersion</key>
26
+ <string>1</string>
27
+ <!-- LSUIElement: a faceless background helper — no Dock icon, no menu bar. -->
28
+ <key>LSUIElement</key>
29
+ <true/>
30
+ <key>LSMinimumSystemVersion</key>
31
+ <string>11.0</string>
32
+ <key>CFBundleIconFile</key>
33
+ <string>AppIcon</string>
34
+ <key>NSHumanReadableCopyright</key>
35
+ <string>© BIGSMS Pty Ltd</string>
36
+ </dict>
37
+ </plist>
@@ -0,0 +1,128 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>files</key>
6
+ <dict>
7
+ <key>Resources/AppIcon.icns</key>
8
+ <data>
9
+ GbhU1RfeSzs8A7x841vrx8S9NpM=
10
+ </data>
11
+ </dict>
12
+ <key>files2</key>
13
+ <dict>
14
+ <key>Resources/AppIcon.icns</key>
15
+ <dict>
16
+ <key>hash2</key>
17
+ <data>
18
+ 80/V6KfdsY5g+nVZNtqimRwukW+CXDnk+bV0NlYa75w=
19
+ </data>
20
+ </dict>
21
+ </dict>
22
+ <key>rules</key>
23
+ <dict>
24
+ <key>^Resources/</key>
25
+ <true/>
26
+ <key>^Resources/.*\.lproj/</key>
27
+ <dict>
28
+ <key>optional</key>
29
+ <true/>
30
+ <key>weight</key>
31
+ <real>1000</real>
32
+ </dict>
33
+ <key>^Resources/.*\.lproj/locversion.plist$</key>
34
+ <dict>
35
+ <key>omit</key>
36
+ <true/>
37
+ <key>weight</key>
38
+ <real>1100</real>
39
+ </dict>
40
+ <key>^Resources/Base\.lproj/</key>
41
+ <dict>
42
+ <key>weight</key>
43
+ <real>1010</real>
44
+ </dict>
45
+ <key>^version.plist$</key>
46
+ <true/>
47
+ </dict>
48
+ <key>rules2</key>
49
+ <dict>
50
+ <key>.*\.dSYM($|/)</key>
51
+ <dict>
52
+ <key>weight</key>
53
+ <real>11</real>
54
+ </dict>
55
+ <key>^(.*/)?\.DS_Store$</key>
56
+ <dict>
57
+ <key>omit</key>
58
+ <true/>
59
+ <key>weight</key>
60
+ <real>2000</real>
61
+ </dict>
62
+ <key>^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/</key>
63
+ <dict>
64
+ <key>nested</key>
65
+ <true/>
66
+ <key>weight</key>
67
+ <real>10</real>
68
+ </dict>
69
+ <key>^.*</key>
70
+ <true/>
71
+ <key>^Info\.plist$</key>
72
+ <dict>
73
+ <key>omit</key>
74
+ <true/>
75
+ <key>weight</key>
76
+ <real>20</real>
77
+ </dict>
78
+ <key>^PkgInfo$</key>
79
+ <dict>
80
+ <key>omit</key>
81
+ <true/>
82
+ <key>weight</key>
83
+ <real>20</real>
84
+ </dict>
85
+ <key>^Resources/</key>
86
+ <dict>
87
+ <key>weight</key>
88
+ <real>20</real>
89
+ </dict>
90
+ <key>^Resources/.*\.lproj/</key>
91
+ <dict>
92
+ <key>optional</key>
93
+ <true/>
94
+ <key>weight</key>
95
+ <real>1000</real>
96
+ </dict>
97
+ <key>^Resources/.*\.lproj/locversion.plist$</key>
98
+ <dict>
99
+ <key>omit</key>
100
+ <true/>
101
+ <key>weight</key>
102
+ <real>1100</real>
103
+ </dict>
104
+ <key>^Resources/Base\.lproj/</key>
105
+ <dict>
106
+ <key>weight</key>
107
+ <real>1010</real>
108
+ </dict>
109
+ <key>^[^/]+$</key>
110
+ <dict>
111
+ <key>nested</key>
112
+ <true/>
113
+ <key>weight</key>
114
+ <real>10</real>
115
+ </dict>
116
+ <key>^embedded\.provisionprofile$</key>
117
+ <dict>
118
+ <key>weight</key>
119
+ <real>20</real>
120
+ </dict>
121
+ <key>^version\.plist$</key>
122
+ <dict>
123
+ <key>weight</key>
124
+ <real>20</real>
125
+ </dict>
126
+ </dict>
127
+ </dict>
128
+ </plist>
package/dist/src/cli.js CHANGED
@@ -21,7 +21,7 @@ import { setup } from "./setup.js";
21
21
  import { uninstall } from "./uninstall.js";
22
22
  import { cmdConfig } from "./config.js";
23
23
  import { onStop } from "./on_stop.js";
24
- import { notifyDesktop } from "./desktop_notify.js";
24
+ import { notifyDesktop, requestDesktopPermission } from "./desktop_notify.js";
25
25
  import { gatherDoctor, formatDoctor } from "./doctor.js";
26
26
  /** Subcommands the finished CLI will expose (see PRD §4.1). */
27
27
  export const COMMANDS = [
@@ -31,6 +31,7 @@ export const COMMANDS = [
31
31
  "setup",
32
32
  "status",
33
33
  "doctor",
34
+ "permissions",
34
35
  "mcp",
35
36
  "uninstall",
36
37
  "config",
@@ -93,6 +94,7 @@ export function printHelp() {
93
94
  " setup Pair + detect runtimes + write configs",
94
95
  " status Report credentials / server / detected runtimes",
95
96
  " doctor Self-diagnostic checklist with exact fix commands",
97
+ " permissions Request macOS desktop-notification permission (opens Settings)",
96
98
  " mcp Run as an MCP server over stdio",
97
99
  " uninstall Remove entries this tool added",
98
100
  " config Get/set Notify settings (get | set <key> <value> | pull)",
@@ -366,6 +368,48 @@ export async function cmdTest(args, deps = {}) {
366
368
  console.error(`gocode-notify test: ${detail}.`);
367
369
  return 1;
368
370
  }
371
+ /**
372
+ * Handle `gocode-notify permissions` (alias `perms`). Makes the OS
373
+ * notification-permission UI appear so the user grants GoCode desktop banners
374
+ * with a click instead of hunting through Settings.
375
+ *
376
+ * On macOS this now pops the REAL native Allow/Deny modal via the Developer-ID-
377
+ * signed helper bundled in the package (see {@link requestDesktopPermission}); if
378
+ * the signed helper is unavailable or you previously denied, it falls back to
379
+ * registering the branded bundle, opening System Settings → Notifications, and
380
+ * firing a priming banner. Always exits 0 — best-effort convenience, never a gate.
381
+ */
382
+ export async function cmdPermissions(args, deps = {}) {
383
+ const flags = parseFlags(args);
384
+ const agent = isAgentDriven(flags);
385
+ const sink = deps.sink ?? stdoutSink;
386
+ const res = await requestDesktopPermission({
387
+ home: deps.home,
388
+ timeoutMs: deps.timeoutMs,
389
+ timestamp: deps.timestamp,
390
+ });
391
+ if (agent) {
392
+ // `ok` reflects whether the NOTIFICATION path actually worked (the priming
393
+ // banner fired) — NOT merely that we opened Settings. On macOS, opening
394
+ // Settings is only a partial remediation; reporting ok:true off it alone
395
+ // would be false-success telemetry for automation consuming the sink.
396
+ sink({
397
+ step: "permissions",
398
+ ok: res.primed,
399
+ detail: res.message,
400
+ });
401
+ }
402
+ else {
403
+ console.log(`🔔 ${res.message}`);
404
+ if (res.platform === "darwin" && !res.primed) {
405
+ // Only show the Settings-hunt hint when we did NOT manage a real grant
406
+ // (e.g. previously denied, or the signed helper wasn't available).
407
+ console.log(" (If no dialog appeared, enable “Allow Notifications” for GoCode in\n" +
408
+ " System Settings → Notifications — it’s now one tap away.)");
409
+ }
410
+ }
411
+ return 0;
412
+ }
369
413
  /**
370
414
  * Handle `gocode-notify status [--server URL]`. Prints a coherent report of
371
415
  * credentials / server reachability / detected runtimes (PRD §4.1, §8). Purely
@@ -462,6 +506,28 @@ export async function cmdSetup(args, deps = {}) {
462
506
  console.log(` ${step.ok ? "✓" : "✗"} ${step.step}: ${step.detail}`);
463
507
  }
464
508
  }
509
+ // On macOS, proactively trigger the notification-permission flow so the user
510
+ // doesn't have to hunt for the GoCode toggle: register the branded bundle,
511
+ // open System Settings → Notifications, and fire a priming banner. Skipped in
512
+ // agent-driven mode (no human to grant it) and when setup didn't succeed.
513
+ // Best-effort — never flips the setup exit code (a permission nudge failing
514
+ // must not make `setup` report failure).
515
+ if (!agent && result.ok && process.platform === "darwin") {
516
+ try {
517
+ const perm = await requestDesktopPermission({
518
+ home: deps.home,
519
+ timeoutMs: deps.timeoutMs,
520
+ });
521
+ console.log("");
522
+ console.log(` 🔔 Desktop notifications: ${perm.message}`);
523
+ }
524
+ catch (err) {
525
+ // Never let a permission nudge fail the setup — but DO leave a breadcrumb
526
+ // so an unexpected throw here isn't completely invisible to the user.
527
+ console.log("");
528
+ console.log(` 🔔 Desktop notifications: couldn't run the permission nudge (${err instanceof Error ? err.message : String(err)}). Run \`gocode-notify permissions\` manually.`);
529
+ }
530
+ }
465
531
  return result.ok ? 0 : 1;
466
532
  }
467
533
  /**
@@ -689,6 +755,8 @@ export async function runAsync(argv) {
689
755
  return cmdStatus(argv.slice(1));
690
756
  if (cmd === "doctor")
691
757
  return cmdDoctor(argv.slice(1));
758
+ if (cmd === "permissions" || cmd === "perms")
759
+ return cmdPermissions(argv.slice(1));
692
760
  if (cmd === "setup")
693
761
  return cmdSetup(argv.slice(1));
694
762
  if (cmd === "mcp")
@@ -2,11 +2,15 @@
2
2
  // Cursor. It does three things, all idempotently and without clobbering the
3
3
  // user's existing config:
4
4
  //
5
- // 1. MERGE a fire-and-forget `stop` hook into `~/.cursor/hooks.json`:
6
- // stop → "finished" (the agent completed a turn).
7
- // The command shells out to `gocode-notify send … || true` so a failed push
5
+ // 1. MERGE two fire-and-forget hooks into `~/.cursor/hooks.json`:
6
+ // stop → "finished" (the agent completed a turn)
7
+ // postToolUse(AskQuestion) → "awaiting_input" (the agent asked a question)
8
+ // The commands shell out to `gocode-notify … || true` so a failed push
8
9
  // NEVER blocks the agent's turn (PRD §4.4, §5.4). The file's `version` is
9
- // preserved (or set to 1 when creating it fresh).
10
+ // preserved (or set to 1 when creating it fresh). The postToolUse question
11
+ // hook is DORMANT until Cursor fixes their AskQuestion tool-hook-skip bug
12
+ // (forum 152230/161836) — installing it now means it auto-activates with no
13
+ // new install once upstream lands. See CURSOR_ASK_QUESTION_COMMAND.
10
14
  // 2. MERGE an `mcpServers` entry into `~/.cursor/mcp.json` pointing at
11
15
  // `npx -y @trygocode/notify mcp`.
12
16
  // 3. WRITE the on-demand rule to `~/.cursor/rules/gocode-notify.md` (the
@@ -57,6 +61,48 @@ export const MCP_SERVER_ENTRY = {
57
61
  // version on every turn, so users self-update seamlessly. (Matchers below key on
58
62
  // the `@trygocode/notify` substring, which `@latest` preserves.)
59
63
  export const CURSOR_STOP_COMMAND = "npx -y @trygocode/notify@latest on-stop --source cursor --dedupe-key cursor-stop || true";
64
+ /**
65
+ * The Cursor `postToolUse` question hook (T-CUR3) — the ONLY available signal for
66
+ * "the Cursor agent asked the user a question and is now waiting." It fires a
67
+ * plain `awaiting_input` ping ("Agent needs you"), the SAME kind Claude Code's
68
+ * dedicated `Notification` hook fires, so a real question surfaces as a QUESTION
69
+ * notification on the phone/desktop instead of a misleading `finished`.
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.
80
+ *
81
+ * Cursor question detection has no other path: Cursor exposes NO `Notification`
82
+ * event (docs: `Notification | - | No`), and the `stop` hook's
83
+ * `status: completed|aborted|error` cannot distinguish "asked a question" from
84
+ * "finished the work." This dormant tool hook is the best available approximation
85
+ * and becomes a real signal once upstream lands.
86
+ *
87
+ * Ends in `|| true` so a failed push NEVER blocks the turn; carries a distinct
88
+ * `--dedupe-key` so it coalesces with itself but not with the `stop` ping.
89
+ */
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';
91
+ /**
92
+ * Tool names that mean "the agent is asking the user a question." Used as the
93
+ * `postToolUse` matcher (a regex alternation). Cursor maps Claude Code tool
94
+ * names to Cursor names, and the question tool has shipped under several names
95
+ * across versions/runtimes (`AskQuestion` in the IDE, `AskUserQuestion` in some
96
+ * builds, `ask_question` in the CLI). We match ALL of them so the hook works the
97
+ * instant Cursor wires any of them to `postToolUse`.
98
+ */
99
+ export const CURSOR_ASK_QUESTION_TOOLS = [
100
+ "AskQuestion",
101
+ "AskUserQuestion",
102
+ "ask_question",
103
+ ];
104
+ /** The `matcher` regex string written into the `postToolUse` entry. */
105
+ export const CURSOR_ASK_QUESTION_MATCHER = `^(${CURSOR_ASK_QUESTION_TOOLS.join("|")})$`;
60
106
  /**
61
107
  * Substrings that together identify a `stop` hook entry as OURS. Used for
62
108
  * idempotent merge (replace, don't duplicate) and for surgical uninstall (remove
@@ -143,12 +189,34 @@ async function writeJsonFile(file, value) {
143
189
  await fs.mkdir(path.dirname(file), { recursive: true });
144
190
  await fs.writeFile(file, JSON.stringify(value, null, 2) + "\n");
145
191
  }
192
+ /** True when a command string is one of ours (BOTH a name token AND the source). */
193
+ function isOurCommand(cmd) {
194
+ return cmd.includes(HOOK_SOURCE_TOKEN) && HOOK_NAME_TOKENS.some((t) => cmd.includes(t));
195
+ }
146
196
  /** True when a single `stop` hook entry (`{ command }`) is one we wrote. */
147
197
  function isOurStopHook(h) {
148
198
  if (!isRecord(h) || typeof h.command !== "string")
149
199
  return false;
150
- const cmd = h.command;
151
- return cmd.includes(HOOK_SOURCE_TOKEN) && HOOK_NAME_TOKENS.some((t) => cmd.includes(t));
200
+ return isOurCommand(h.command);
201
+ }
202
+ /**
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`.
207
+ */
208
+ function isOurToolHookGroup(g) {
209
+ if (!isRecord(g) || !Array.isArray(g.hooks))
210
+ return false;
211
+ return g.hooks.some((h) => isRecord(h) && typeof h.command === "string" && isOurCommand(h.command));
212
+ }
213
+ /**
214
+ * Strip OUR groups out of a `postToolUse` array. Returns the cleaned array plus
215
+ * whether anything of ours was removed. Never mutates the input.
216
+ */
217
+ function stripOurToolHooks(entries) {
218
+ const kept = entries.filter((g) => !isOurToolHookGroup(g));
219
+ return { entries: kept, removed: kept.length !== entries.length };
152
220
  }
153
221
  /**
154
222
  * Strip OUR entries out of the `stop` array. Returns the cleaned array plus
@@ -175,6 +243,30 @@ function mergeStopHook(config) {
175
243
  hooks.stop = preserved;
176
244
  config.hooks = hooks;
177
245
  }
246
+ /**
247
+ * Merge our `postToolUse` AskQuestion hook into the hooks config (T-CUR3),
248
+ * 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
+ * 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
+ *
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.
256
+ */
257
+ function mergeAskQuestionHook(config) {
258
+ if (typeof config.version !== "number")
259
+ config.version = 1;
260
+ const hooks = isRecord(config.hooks) ? config.hooks : {};
261
+ const existing = Array.isArray(hooks.postToolUse) ? hooks.postToolUse : [];
262
+ const preserved = stripOurToolHooks(existing).entries;
263
+ preserved.push({
264
+ matcher: CURSOR_ASK_QUESTION_MATCHER,
265
+ hooks: [{ type: "command", command: CURSOR_ASK_QUESTION_COMMAND }],
266
+ });
267
+ hooks.postToolUse = preserved;
268
+ config.hooks = hooks;
269
+ }
178
270
  /** Merge our MCP server entry into `mcp.mcpServers`. Mutates in place. */
179
271
  function mergeMcp(mcp) {
180
272
  const servers = isRecord(mcp.mcpServers) ? mcp.mcpServers : {};
@@ -197,6 +289,7 @@ export async function writeCursorConfig(runtime, opts) {
197
289
  const hooksPath = cursorHooksPath(opts);
198
290
  const hooksConfig = (await readJsonObject(hooksPath)) ?? {};
199
291
  mergeStopHook(hooksConfig);
292
+ mergeAskQuestionHook(hooksConfig);
200
293
  await writeJsonFile(hooksPath, hooksConfig);
201
294
  written.push(hooksPath);
202
295
  const mcpPath = cursorMcpPath(opts);
@@ -212,7 +305,10 @@ export async function writeCursorConfig(runtime, opts) {
212
305
  runtime: name,
213
306
  written,
214
307
  skipped: false,
215
- detail: "merged stop hook + MCP entry; wrote rule",
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).",
216
312
  };
217
313
  }
218
314
  catch (err) {
@@ -250,6 +346,16 @@ export async function uninstallCursorConfig(opts) {
250
346
  delete hooks.stop;
251
347
  }
252
348
  }
349
+ if (Array.isArray(hooks.postToolUse)) {
350
+ const { entries: kept, removed: r } = stripOurToolHooks(hooks.postToolUse);
351
+ if (r) {
352
+ changed = true;
353
+ if (kept.length > 0)
354
+ hooks.postToolUse = kept;
355
+ else
356
+ delete hooks.postToolUse;
357
+ }
358
+ }
253
359
  if (Object.keys(hooks).length === 0)
254
360
  delete hooksConfig.hooks;
255
361
  if (changed) {