@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 +81 -4
- package/assets/GoCodeNotifier.app/Contents/Info.plist +37 -0
- package/assets/GoCodeNotifier.app/Contents/MacOS/GoCodeNotifier +0 -0
- package/assets/GoCodeNotifier.app/Contents/Resources/AppIcon.icns +0 -0
- package/assets/GoCodeNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
- package/dist/src/cli.js +69 -1
- package/dist/src/cursor.js +113 -7
- package/dist/src/desktop_notify.js +232 -1
- package/dist/src/doctor.js +26 -7
- package/dist/src/mac_helper.js +335 -0
- package/dist/src/opencode.js +64 -15
- package/dist/src/send.js +109 -36
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
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
|
|
132
|
-
> carries a `status` field in its stdin JSON. The `on-stop`
|
|
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` |
|
|
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>
|
|
Binary file
|
|
Binary file
|
|
@@ -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")
|
package/dist/src/cursor.js
CHANGED
|
@@ -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
|
|
6
|
-
// stop
|
|
7
|
-
//
|
|
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
|
-
|
|
151
|
-
|
|
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
|
|
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) {
|