@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 +156 -9
- package/assets/gocode-icon.ico +0 -0
- package/assets/gocode-icon.png +0 -0
- package/dist/src/cli.js +28 -1
- package/dist/src/cursor.js +113 -7
- package/dist/src/desktop_notify.js +584 -0
- package/dist/src/notify_copy.js +189 -0
- package/dist/src/on_stop.js +150 -3
- package/dist/src/opencode.js +64 -15
- package/dist/src/settings.js +9 -0
- package/dist/src/version.js +1 -1
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# @trygocode/notify
|
|
2
2
|
|
|
3
|
-
Free phone notifications for any coding agent — **Cursor,
|
|
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
|
|
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
|
-
—
|
|
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
|
|
130
|
-
> carries a `status` field in its stdin JSON. The `on-stop`
|
|
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` |
|
|
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,
|
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) {
|