@trygocode/notify 0.3.4 → 0.5.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
@@ -1,12 +1,14 @@
1
1
  # @trygocode/notify
2
2
 
3
- Free phone notifications for any coding agent — **Cursor, Claude Code, OpenCode,
4
- or any Ralph/Homer loop** — delivered to your phone via the
5
- [GoCode](https://oh.jeltechsolutions.com) app + FCM.
3
+ Free phone **and branded desktop** notifications for any coding agent — **Cursor,
4
+ Claude Code, OpenCode, or any Ralph/Homer loop** — delivered to your phone via the
5
+ [GoCode](https://oh.jeltechsolutions.com) app + FCM, and to your computer as a
6
+ native **"GoCode"** banner.
6
7
 
7
- You get an automatic push when your agent **finishes a turn**, **goes idle
8
+ You get an automatic notification when your agent **finishes a turn**, **goes idle
8
9
  waiting for you**, **errors out**, or when an overnight **loop completes / halts**
9
- so you can walk away and let your phone tell you when it needs you.
10
+ on your phone AND on the computer you're working on (on by default) so you can
11
+ walk away and let either device tell you when it needs you.
10
12
 
11
13
  ## Contents
12
14
 
@@ -126,14 +128,14 @@ The installed rule/skill tells the agent **not** to call the MCP tool for
126
128
  done/idle/error pings — those are owned by the deterministic hook (A), so you
127
129
  never get double-pinged.
128
130
 
129
- > **Cursor `stop.status` → kind mapping (since 0.3.0).** Cursor's `stop` hook
130
- > carries a `status` field in its stdin JSON. The `on-stop` dispatcher maps it
131
- > 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:
132
134
  >
133
135
  > | Cursor `stop.status` | Notification kind | Meaning |
134
136
  > |---|---|---|
135
137
  > | `completed` | `finished` | Agent turned cleanly (default — same as before 0.3.0) |
136
- > | `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 |
137
139
  > | `error` | `error` | Agent hit an error |
138
140
  > | absent / unrecognised | `finished` | Back-compatible fallback |
139
141
  >
@@ -141,6 +143,28 @@ never get double-pinged.
141
143
  > did. The status mapping requires no hook re-installation — run
142
144
  > `gocode-notify setup --force` once to apply the updated hook command.
143
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
+
144
168
  > **OpenCode's hook (A)** is a small `session.idle` plugin written to
145
169
  > `~/.config/opencode/plugin/gocode-notify.js`; on each `session.idle` event it
146
170
  > fire-and-forgets `gocode-notify on-stop --source opencode` (the same
@@ -157,6 +181,69 @@ never get double-pinged.
157
181
  > `setup`/`setup --force`. `send` and `test` remain callable on their own for
158
182
  > scripts and power users.
159
183
 
184
+ ## Desktop notifications (branded, on by default)
185
+
186
+ GoCode Notify also raises a **real, branded "GoCode" banner on the computer you're
187
+ working on** — the same instant your phone gets pinged — so you're told whether
188
+ you're at your desk or away. It's **ON by default** once the package is installed
189
+ and is controlled by the **same server-synced settings** as the phone push, so a
190
+ toggle in the app or the terminal flips the computer banner everywhere.
191
+
192
+ **Word-for-word identical to the phone.** The banner reads exactly the same as the
193
+ push you'd get on your phone — same status emoji, IDE/source label, project, and
194
+ per-kind copy (e.g. `✅ Cursor · openhandapp — Agent finished`). The wording is a
195
+ faithful mirror of the server's notification decoration and the Flutter client's,
196
+ pinned by a cross-check test (`notify_copy.test.ts`) so the three surfaces can't
197
+ drift.
198
+
199
+ **Click to open your IDE (macOS).** Clicking the banner opens the **IDE window for
200
+ that project** — the same editor that raised the turn (Cursor / VS Code), focused
201
+ on the project you were working in. This is best-effort: when the IDE/project
202
+ can't be named the banner is simply display-only (never a dead or mis-routed
203
+ click). It opens the project *window*, not a specific past chat — IDEs don't
204
+ expose reliable per-chat deep links.
205
+
206
+ | OS | How it's shown | Branded as |
207
+ |---|---|---|
208
+ | **macOS** | A one-time `osacompile`d `GoCode.app` helper under `~/.gocode/desktop/` raises the banner via the system | **"GoCode"** + the GoCode icon |
209
+ | **Windows** | A WinRT `ToastGeneric` toast bound to a one-time Start-Menu shortcut carrying the `GoCode.Notify` AppUserModelID | **"GoCode"** |
210
+ | **Linux** | `notify-send -a GoCode -i <icon>` | **"GoCode"** + icon |
211
+
212
+ **Zero new dependencies.** It uses only tools that ship with the OS (`osacompile`/
213
+ `osascript`/`sips` on macOS, PowerShell + WinRT on Windows, `notify-send` on Linux)
214
+ — so the **one-line install does not change at all**. The first banner builds a tiny
215
+ branded helper once and caches it; every banner after that just fires it.
216
+
217
+ ### Control it
218
+
219
+ From the **GoCode app** (Settings → **GoCode Notify** → *Desktop notifications*),
220
+ or from the terminal:
221
+
222
+ ```bash
223
+ gocode-notify config set desktop.enabled false # silence the computer banner
224
+ gocode-notify config set desktop.sound false # keep the banner, drop the sound
225
+ ```
226
+
227
+ The phone push is unaffected — `desktop.*` only governs the local banner.
228
+
229
+ ### Try it right now
230
+
231
+ ```bash
232
+ # Fire a branded test banner on THIS computer (independent of the phone push):
233
+ gocode-notify test --desktop
234
+ ```
235
+
236
+ You should see a **"GoCode"** notification appear on your screen. On a headless
237
+ box / CI / SSH session where a banner makes no sense, set
238
+ `GOCODE_NOTIFY_NO_DESKTOP=1` to hard-disable local banners for that machine.
239
+
240
+ > **macOS branding note.** A bare `osascript` banner is attributed to "Script
241
+ > Editor". To brand it "GoCode", the package compiles a minimal AppleScript app
242
+ > bundle (`~/.gocode/desktop/GoCode.app`) once with `osacompile` (ships with
243
+ > every Mac — no Xcode/Swift) and drops the GoCode icon into it via `sips`. If
244
+ > that one-time setup ever fails, the banner still fires (unbranded) rather than
245
+ > blocking your turn.
246
+
160
247
  ## Hand off to your server — `launch` / `autopilot`
161
248
 
162
249
  > **New in 0.2.0.** Notify can now do more than ping your phone — it can **hand a
@@ -344,6 +431,8 @@ Valid keys (mirror of the canonical schema):
344
431
  | `commit_message.mode` | `auto` \| `ai` \| `deterministic` | Commit-message strategy |
345
432
  | `commit_message.command` | string \| null | Explicit local summariser command |
346
433
  | `commit_message.max_diff_bytes` | int > 0 | Diff byte cap fed to the summariser (default 61440) |
434
+ | `desktop.enabled` | bool | Branded desktop banner on this computer (**ON by default**) |
435
+ | `desktop.sound` | bool | Play a sound with the desktop banner (default true) |
347
436
 
348
437
  ```bash
349
438
  # Examples
@@ -440,6 +529,61 @@ npm publish, real-device E2E), see
440
529
 
441
530
  ## Changelog
442
531
 
532
+ ### 0.5.0
533
+
534
+ - **Question detection across ALL IDEs — "Agent needs you" vs "finished".** When
535
+ the agent **pauses to ask you a question**, you now get an `awaiting_input`
536
+ ("Agent needs you") notification instead of a misleading `finished`, on every
537
+ supported runtime that exposes a question signal:
538
+ - **Claude Code** — already wired (`Notification` hook); unchanged.
539
+ - **OpenCode** — NEW: the plugin now listens to `permission.asked` (the moment
540
+ the agent raises a request) and fires `awaiting_input`. OpenCode genuinely
541
+ exposes a "the agent needs the user" signal via permission events. (We listen
542
+ to `permission.asked` only, not `permission.updated`/`replied`, which also
543
+ fire after the user answers.)
544
+ - **Cursor** — NEW (dormant): installs a `postToolUse` hook scoped to the
545
+ `AskQuestion` / `AskUserQuestion` tool. Cursor has a
546
+ [confirmed-open bug](https://forum.cursor.com/t/askquestion-tool-does-not-trigger-cursor-hooks/152230)
547
+ where that tool fires zero hooks, and Cursor exposes no `Notification` event,
548
+ so the hook is dormant today — but it **auto-activates with no new install**
549
+ the moment Cursor ships their fix. Until then, Cursor keeps its `finished`
550
+ fallback.
551
+ - **Corrected the documented Cursor `aborted` mapping.** The README table
552
+ previously (incorrectly) showed `aborted → awaiting_input`; the actual
553
+ behaviour (since the 0.4.0 fix) is that a manual **Stop** press (`aborted`)
554
+ sends **no notification at all**. A user force-stop is now clearly
555
+ distinguished from a genuine IDE question.
556
+ - Run `gocode-notify setup --force` once to wire the new hooks into Cursor +
557
+ OpenCode.
558
+
559
+ ### 0.4.0
560
+
561
+ - **New: branded desktop notifications (on by default).** Every event that pings
562
+ your phone now also raises a native, **GoCode-branded** banner on the computer
563
+ you're working on — macOS (a one-time `osacompile`d `GoCode.app` helper +
564
+ `sips` icon), Windows (a WinRT `ToastGeneric` toast bound to a one-time
565
+ Start-Menu shortcut with the `GoCode.Notify` AppUserModelID), and Linux
566
+ (`notify-send -a GoCode`). **Zero new dependencies** — OS-native tools only,
567
+ so the one-line install is unchanged. Controlled by the same server-synced
568
+ settings as the phone push via the new `desktop.enabled` / `desktop.sound`
569
+ keys (both default ON); toggle from the app or `gocode-notify config set
570
+ desktop.enabled false`. Try it with `gocode-notify test --desktop`. Hard-
571
+ disable per machine with `GOCODE_NOTIFY_NO_DESKTOP=1` (headless / CI / SSH).
572
+ Best-effort + non-blocking: a failed banner never blocks or fails your turn.
573
+ - **Word-for-word match with the phone push.** The desktop banner now reads
574
+ EXACTLY like the phone notification — same status emoji + IDE/source label +
575
+ project + per-kind title/body (e.g. `✅ Cursor · openhandapp — Agent
576
+ finished`). New `src/notify_copy.ts` mirrors the server's `_decorated_title` /
577
+ `_decorated_body` + `_DEFAULT_TITLES` / `_DEFAULT_BODIES` and the Flutter
578
+ client's `decorateNotificationTitle` / `sourceLabelFor`, pinned by a
579
+ cross-check test so the CLI/server/app surfaces can't drift.
580
+ - **Click to open the IDE window for the project (macOS).** Clicking the banner
581
+ opens the IDE that raised the turn (Cursor / VS Code) focused on the project
582
+ directory. Implemented zero-dependency: the branded AppleScript applet handles
583
+ the click re-run (documented macOS behaviour) and `open -a <ide> <project>`.
584
+ Best-effort — display-only when the IDE/project can't be named. Opens the
585
+ project *window*, not a specific chat (IDEs lack reliable per-chat deep links).
586
+
443
587
  ### 0.3.0
444
588
 
445
589
  - **New: Cursor `stop.status` → kind mapping (T-CUR1/T-CUR4).** The `on-stop`
@@ -510,6 +654,9 @@ Zero runtime dependencies beyond the MCP SDK (Node built-in `fetch`/`fs`/
510
654
  | `src/setup.ts` | Installer orchestration (pair → detect → write configs) |
511
655
  | `src/claude.ts` / `src/cursor.ts` / `src/opencode.ts` | Per-client config writers (hooks + MCP + rule/skill); hooks call the `on-stop` dispatcher. OpenCode uses a `session.idle` plugin instead of a hooks file |
512
656
  | `src/send.ts` / `src/login.ts` / `src/mcp.ts` | Core send, pairing, and MCP server (incl. `gocode_launch_autopilot`) |
657
+ | `src/desktop_notify.ts` | Branded native desktop banners (macOS `GoCode.app` / Windows WinRT toast / Linux `notify-send`); zero-dep, best-effort; macOS click-through opens the IDE window |
658
+ | `src/notify_copy.ts` | Banner wording mirror — reproduces the server/Flutter title/body decoration so the desktop banner reads word-for-word like the phone |
659
+ | `assets/gocode-icon.{png,ico}` | Bundled GoCode icon used to brand desktop banners |
513
660
  | `src/launch.ts` | Shared `launch()` core behind the `launch`/`autopilot` command + the MCP tool |
514
661
  | `src/push.ts` | Auto-push flow behind the `on-stop` dispatcher — git add/commit/push, FF-only |
515
662
  | `src/commit_message.ts` | AI commit-message resolution chain + deterministic fallback |
Binary file
Binary file
package/dist/src/cli.js CHANGED
@@ -21,6 +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
25
  import { gatherDoctor, formatDoctor } from "./doctor.js";
25
26
  /** Subcommands the finished CLI will expose (see PRD §4.1). */
26
27
  export const COMMANDS = [
@@ -88,7 +89,7 @@ export function printHelp() {
88
89
  "Commands:",
89
90
  " login Pair this machine with your GoCode account",
90
91
  " send Send a single push notification",
91
- " test Send a canned test push",
92
+ " test Send a canned test push (add --desktop for a local banner)",
92
93
  " setup Pair + detect runtimes + write configs",
93
94
  " status Report credentials / server / detected runtimes",
94
95
  " doctor Self-diagnostic checklist with exact fix commands",
@@ -313,6 +314,32 @@ export async function cmdTest(args, deps = {}) {
313
314
  const flags = parseFlags(args);
314
315
  const agent = isAgentDriven(flags);
315
316
  const sink = deps.sink ?? stdoutSink;
317
+ // `--desktop`: fire a BRANDED local desktop banner so the user can verify the
318
+ // computer-notification path (and its GoCode branding) works during install,
319
+ // independent of the phone push (GOCODE_NOTIFY_DESKTOP PRD §3). Unlike the
320
+ // fire-and-forget hook path, this interactive probe reports the real outcome
321
+ // and exits non-zero on failure so the human/agent sees it.
322
+ if (flagBool(flags, "desktop")) {
323
+ const desk = await notifyDesktop({
324
+ title: "GoCode test",
325
+ body: "If you can read this banner, GoCode desktop notifications work.",
326
+ kind: "finished",
327
+ sound: true,
328
+ }, { home: deps.home, timeoutMs: deps.timeoutMs, timestamp: deps.timestamp });
329
+ if (desk.ok) {
330
+ if (agent)
331
+ sink({ step: "test-desktop", ok: true, detail: `desktop banner shown (${desk.platform})` });
332
+ else
333
+ console.log(`✓ Desktop banner shown (${desk.platform}). Look for the GoCode notification on your screen.`);
334
+ return 0;
335
+ }
336
+ const detail = `desktop banner not shown (${desk.error ?? "unknown"})`;
337
+ if (agent)
338
+ sink({ step: "test-desktop", ok: false, detail });
339
+ else
340
+ console.error(`gocode-notify test --desktop: ${detail}.`);
341
+ return 1;
342
+ }
316
343
  const server = await resolveServerUrl(flagString(flags, "server"), deps);
317
344
  const sendOpts = {
318
345
  home: deps.home,
@@ -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) {