@trygocode/notify 0.4.0 → 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
@@ -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,33 @@ npm publish, real-device E2E), see
507
529
 
508
530
  ## Changelog
509
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
+
510
559
  ### 0.4.0
511
560
 
512
561
  - **New: branded desktop notifications (on by default).** Every event that pings
@@ -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) {
@@ -8,14 +8,20 @@
8
8
  // "command": ["npx","-y","@trygocode/notify","mcp"],
9
9
  // "enabled": true }
10
10
  // `command` is a single ARRAY (binary + args), plus `type` + `enabled`.
11
- // 2. WRITE a `session.idle` plugin to `<config-dir>/plugin/gocode-notify.js`.
12
- // OpenCode has NO stop-hook command array; instead a plugin subscribes to
13
- // the `session.idle` event (the OpenCode equivalent of Cursor `stop` /
14
- // Claude `Stop`) and fires the SHARED `on-stop` dispatcher:
15
- // gocode-notify on-stop --source opencode --dedupe-key opencode-idle || true
16
- // The child is detached + unref'd and errors are swallowed (`|| true`), so a
17
- // notification/push failure can never block the session — exactly one ping
18
- // per idle, in lockstep with Claude/Cursor (same `on-stop` dispatcher).
11
+ // 2. WRITE an event plugin to `<config-dir>/plugin/gocode-notify.js` that
12
+ // subscribes to TWO event classes:
13
+ // a) END-OF-TURN — `session.idle` (legacy) + `session.status` (modern),
14
+ // the OpenCode equivalent of Cursor `stop` / Claude `Stop`. Fires the
15
+ // SHARED `on-stop` dispatcher a `finished` ping.
16
+ // b) QUESTION `permission.asked` (the "asking now" edge), the OpenCode
17
+ // equivalent of Claude's `Notification` hook. Fires a plain
18
+ // `awaiting_input` ping ("Agent needs you") so a real question shows as
19
+ // a QUESTION, not a misleading `finished`. (Cursor has NO such event;
20
+ // OpenCode genuinely exposes it via permission events. We do NOT listen
21
+ // to permission.updated/replied — those also fire after the user
22
+ // answers and would re-ping a resolved question.)
23
+ // Each child is detached + unref'd and errors are swallowed (`|| true`), so a
24
+ // notification/push failure can never block the session.
19
25
  //
20
26
  // The on-demand rule/skill (Claude SKILL.md / Cursor rule) is SKIPPED for
21
27
  // OpenCode: OpenCode has no auto-loaded standalone per-file rule mechanism (the
@@ -72,6 +78,21 @@ export const OPENCODE_MCP_ENTRY = {
72
78
  // resolved version forever and never auto-updates (npm/cli#6664). Pinning
73
79
  // `@latest` makes the hook always fetch the newest publish so users self-update.
74
80
  export const OPENCODE_STOP_COMMAND = "npx -y @trygocode/notify@latest on-stop --source opencode --dedupe-key opencode-idle || true";
81
+ /**
82
+ * The shell command the plugin fires when OpenCode raises a PERMISSION request —
83
+ * the OpenCode equivalent of "the agent needs the user" (Claude's `Notification`
84
+ * hook / the Cursor AskQuestion hook). Unlike Cursor, OpenCode genuinely exposes
85
+ * this signal: the `permission.asked` event fires the instant the agent pauses to
86
+ * ask the user to approve/answer something. We map it to a plain `awaiting_input`
87
+ * ping ("Agent needs you") — the SAME kind Claude Code fires — so a real question
88
+ * surfaces as a QUESTION notification, not a misleading `finished`. (We listen to
89
+ * `permission.asked` ONLY, never `permission.updated`/`permission.replied`, which
90
+ * also fire AFTER the user answers and would re-ping a resolved question.)
91
+ *
92
+ * Ends in `|| true` so a failed push can never block the session; carries a
93
+ * distinct `--dedupe-key` so it coalesces with itself but not with the idle ping.
94
+ */
95
+ export const OPENCODE_ASK_COMMAND = 'npx -y @trygocode/notify@latest send --kind awaiting_input --source opencode --title "Agent needs you" --dedupe-key opencode-permission || true';
75
96
  /**
76
97
  * Substrings that together identify our plugin file as OURS. Used to keep
77
98
  * uninstall surgical: we only delete the plugin file when BOTH markers are
@@ -157,13 +178,15 @@ import { spawn } from "node:child_process";
157
178
  // Per-session last-fire timestamps so idle+status for the SAME turn coalesce.
158
179
  const lastFiredAt = new Map();
159
180
  const DEDUPE_MS = 4000;
181
+ // Separate per-session debounce for permission/question events so a rapid burst
182
+ // of permission.asked events for one prompt coalesces into a single ping, while
183
+ // staying INDEPENDENT of the end-of-turn debounce (a question must not be
184
+ // suppressed by a recent finished ping — different event class).
185
+ const lastAskedAt = new Map();
160
186
 
161
- function fire() {
187
+ function fire(command) {
162
188
  try {
163
- const child = spawn(
164
- ${JSON.stringify(OPENCODE_STOP_COMMAND)},
165
- { shell: true, detached: true, stdio: "ignore" },
166
- );
189
+ const child = spawn(command, { shell: true, detached: true, stdio: "ignore" });
167
190
  child.unref();
168
191
  } catch {
169
192
  // never block the session on a notification failure
@@ -195,13 +218,39 @@ function maybeFire(sessionID) {
195
218
  const prev = lastFiredAt.get(key) ?? 0;
196
219
  if (now - prev < DEDUPE_MS) return; // coalesce idle+status for one turn
197
220
  lastFiredAt.set(key, now);
198
- fire();
221
+ fire(${JSON.stringify(OPENCODE_STOP_COMMAND)});
222
+ }
223
+
224
+ // The agent paused to ask the user (permission.asked). Fire an "Agent needs
225
+ // you" question ping. Debounced separately from the end-of-turn ping so a burst
226
+ // of asks for one prompt coalesces into one.
227
+ function maybeAsk(sessionID) {
228
+ const key = sessionID || "_";
229
+ const now = Date.now();
230
+ const prev = lastAskedAt.get(key) ?? 0;
231
+ if (now - prev < DEDUPE_MS) return;
232
+ lastAskedAt.set(key, now);
233
+ fire(${JSON.stringify(OPENCODE_ASK_COMMAND)});
199
234
  }
200
235
 
201
236
  export const GocodeNotify = async () => ({
202
237
  event: async ({ event }) => {
203
238
  if (!event) return;
204
239
  const props = event.properties ?? {};
240
+ // QUESTION signal — the agent is waiting on the user (OpenCode's real
241
+ // "needs you" event; Cursor has no equivalent). Fired BEFORE the idle/status
242
+ // checks so a permission request is never misread as a plain end-of-turn.
243
+ //
244
+ // We listen ONLY to \`permission.asked\` — the moment the agent RAISES a
245
+ // request. We deliberately do NOT listen to \`permission.updated\` /
246
+ // \`permission.replied\`: those are state transitions that ALSO fire AFTER the
247
+ // user answers, which would re-emit "Agent needs you" once the question is
248
+ // already resolved — exactly the false-notification class this fix exists to
249
+ // avoid. \`permission.asked\` is the unambiguous "asking now" edge.
250
+ if (event.type === "permission.asked") {
251
+ maybeAsk(props.sessionID);
252
+ return;
253
+ }
205
254
  if (event.type === "session.idle") {
206
255
  maybeFire(props.sessionID);
207
256
  return;
@@ -332,7 +381,7 @@ export async function writeOpenCodeConfig(runtime, opts) {
332
381
  runtime: name,
333
382
  written,
334
383
  skipped: false,
335
- detail: "merged mcp.gocode-notify entry; wrote session.idle plugin (rule skipped — OpenCode has no standalone rule file)",
384
+ detail: "merged mcp.gocode-notify entry; wrote end-of-turn + permission(question) plugin (rule skipped — OpenCode has no standalone rule file)",
336
385
  };
337
386
  }
338
387
  catch (err) {
@@ -1,2 +1,2 @@
1
1
  // Single source of truth for the CLI version. Keep in sync with package.json.
2
- export const VERSION = "0.4.0";
2
+ export const VERSION = "0.5.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trygocode/notify",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Free phone + branded desktop notifications for any coding agent (Cursor, Claude Code, OpenCode, Ralph/Homer) via the GoCode app.",
5
5
  "license": "MIT",
6
6
  "type": "module",