@trygocode/notify 0.6.9 → 0.6.11

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
@@ -530,6 +530,31 @@ npm publish, real-device E2E), see
530
530
 
531
531
  ## Changelog
532
532
 
533
+ ### 0.6.11
534
+
535
+ - **Fixed: the `AskQuestion` TOOL (the A/B/C options card) now notifies you.**
536
+ This is the *other* way a Cursor agent asks you something — and it was silently
537
+ missed. When the agent uses the AskQuestion / AskUserQuestion tool, its final
538
+ message text is just filler ("I'll ask you a quick question"), so the text
539
+ classifier can't see the question, and Cursor **never fires `postToolUse` for
540
+ the AskQuestion tool** (a confirmed upstream Cursor bug). The only reliable
541
+ signal is the transcript, so `on-agent-response` and `on-stop` now read the
542
+ tail of the turn's transcript, inspect the **latest assistant message**, and if
543
+ it invoked an AskQuestion tool they send **"Agent needs you"** with the actual
544
+ question text. A tool ask is definitive — it overrides the text heuristic and
545
+ even a stored "finished" — and `on-stop` remains the single sender so there are
546
+ no duplicate pings. The question text is sanitized/length-capped like every
547
+ other snippet.
548
+
549
+ ### 0.6.10
550
+
551
+ - **Accurate `on-stop` status line.** The CLI now prints the notification kind it
552
+ ACTUALLY sent (e.g. `awaiting_input delivered` for a question turn) instead of
553
+ always re-deriving `finished` from the Cursor stop status — the phone push was
554
+ already correct in 0.6.9; this only fixes the local console/debug line so it no
555
+ longer looks like a question was sent as "finished". Same question-notify
556
+ behaviour as 0.6.9, no hook changes.
557
+
533
558
  ### 0.6.9
534
559
 
535
560
  - **Fixed: native Cursor questions now notify you again.** When the Cursor agent
package/dist/src/cli.js CHANGED
@@ -22,6 +22,7 @@ import { uninstall } from "./uninstall.js";
22
22
  import { cmdConfig } from "./config.js";
23
23
  import { onStop, parseCursorStopStatus, cursorStopStatusToKind, clickTarget, projectLabel, } from "./on_stop.js";
24
24
  import { classifyResponse } from "./question_classifier.js";
25
+ import { detectAskToolFromHookStdin } from "./transcript_ask.js";
25
26
  import { writeTurnState, conversationIdFromHookStdin, } from "./turn_state.js";
26
27
  import { notifyDesktop, requestDesktopPermission, desktopDisabledByEnv, } from "./desktop_notify.js";
27
28
  import { resolveNotifySettings } from "./config.js";
@@ -742,7 +743,10 @@ export async function cmdOnStop(args, deps = {}) {
742
743
  ? "autopilot-suppressed: an Autopilot loop owns this turn's ping"
743
744
  : result.mode === "embedded-claude-suppressed"
744
745
  ? "embedded-claude-suppressed: native Cursor hook owns this turn"
745
- : `${fallbackKind} ${delivered ? "delivered" : "not delivered"}`;
746
+ : // Prefer the kind ACTUALLY sent (e.g. awaiting_input for a
747
+ // question turn) over re-deriving from the stop status, which
748
+ // always reads "finished" and can't see a question.
749
+ `${result.sentKind ?? fallbackKind} ${delivered ? "delivered" : "not delivered"}`;
746
750
  if (agent) {
747
751
  sink({ step: "on-stop", ok: true, detail });
748
752
  }
@@ -896,7 +900,26 @@ export async function cmdOnAgentResponse(args, deps = {}) {
896
900
  return 0;
897
901
  }
898
902
  const text = extractResponseText(hookStdin);
899
- const verdict = classifyResponse(text);
903
+ const textVerdict = classifyResponse(text);
904
+ // The AskQuestion / AskUserQuestion TOOL (the A/B/C options card) leaves the
905
+ // final `text` as a non-question ("I'll ask you a quick question"), and
906
+ // Cursor never fires `postToolUse` for it — so the transcript is the only
907
+ // signal. A tool ask is DEFINITIVE and overrides the text heuristic
908
+ // (Codex-reviewed 2026-08-31). `unknown` (unreadable transcript) keeps the
909
+ // text verdict.
910
+ let askVerdict = { verdict: "unknown" };
911
+ try {
912
+ askVerdict = await (deps.detectAskToolImpl ?? detectAskToolFromHookStdin)(hookStdin);
913
+ }
914
+ catch {
915
+ askVerdict = { verdict: "unknown" };
916
+ }
917
+ const isAsk = askVerdict.verdict === "ask";
918
+ const awaiting = isAsk ? true : textVerdict.awaiting;
919
+ const snippet = isAsk
920
+ ? askVerdict.snippet ?? textVerdict.snippet ?? "Agent asked you a question"
921
+ : textVerdict.snippet;
922
+ const reason = isAsk ? "askquestion_tool" : textVerdict.reason;
900
923
  let generationId;
901
924
  try {
902
925
  const p = JSON.parse(hookStdin);
@@ -909,21 +932,21 @@ export async function cmdOnAgentResponse(args, deps = {}) {
909
932
  }
910
933
  const record = {
911
934
  conversation_id: conversationId,
912
- classification: verdict.awaiting ? "awaiting_input" : "finished",
935
+ classification: awaiting ? "awaiting_input" : "finished",
913
936
  created_at: (deps.now ?? Date.now)(),
914
937
  };
915
938
  if (generationId)
916
939
  record.generation_id = generationId;
917
- if (verdict.awaiting) {
918
- if (verdict.snippet)
919
- record.snippet = verdict.snippet;
920
- if (verdict.reason)
921
- record.reason = verdict.reason;
940
+ if (awaiting) {
941
+ if (snippet)
942
+ record.snippet = snippet;
943
+ if (reason)
944
+ record.reason = reason;
922
945
  }
923
946
  const writeImpl = deps.writeTurnStateImpl ?? writeTurnState;
924
947
  await writeImpl(record, { home: deps.home, ttlMs: deps.ttlMs, now: deps.now });
925
- const detail = verdict.awaiting
926
- ? `classified as awaiting_input (${verdict.reason})`
948
+ const detail = awaiting
949
+ ? `classified as awaiting_input (${reason})`
927
950
  : "classified as finished";
928
951
  if (agent)
929
952
  sink({ step: "on-agent-response", ok: true, detail });
@@ -79,10 +79,15 @@ export const CURSOR_STOP_COMMAND = "npx -y @trygocode/notify@latest on-stop --so
79
79
  * "finished the work." This dormant tool hook is the best available approximation
80
80
  * and becomes a real signal once upstream lands.
81
81
  *
82
- * Ends in `|| true` so a failed push NEVER blocks the turn; carries a distinct
83
- * `--dedupe-key` so it coalesces with itself but not with the `stop` ping.
82
+ * Ends in `|| true` so a failed push NEVER blocks the turn.
83
+ *
84
+ * Dedupe key = `cursor-stop` ON PURPOSE (Codex 2026-08-31): stop-time now
85
+ * detects the AskQuestion TOOL from the transcript and sends its OWN
86
+ * `awaiting_input` ping (dedupe key `cursor-stop`). If Cursor ever FIXES the
87
+ * `postToolUse` bug, this hook would fire too — sharing the SAME dedupe key
88
+ * makes the server coalesce the two into ONE ping instead of double-notifying.
84
89
  */
85
- 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 --quiet || true';
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-stop --quiet || true';
86
91
  /**
87
92
  * The Cursor `afterAgentResponse` hook command (question detection, 2026-08-31).
88
93
  *
@@ -38,6 +38,7 @@ import { notifyDesktop, } from "./desktop_notify.js";
38
38
  import { decorateTitle, decorateBody } from "./notify_copy.js";
39
39
  import { readTurnState, conversationIdFromHookStdin, } from "./turn_state.js";
40
40
  import { classifyResponse } from "./question_classifier.js";
41
+ import { detectAskToolFromHookStdin } from "./transcript_ask.js";
41
42
  /**
42
43
  * Parse the Cursor `stop` hook stdin JSON and extract the `status` field.
43
44
  * Best-effort: returns `undefined` on absent/empty/unparseable input or when the
@@ -661,12 +662,36 @@ export async function resolveTurnQuestion(hookStdin, opts = {}) {
661
662
  catch {
662
663
  record = undefined;
663
664
  }
665
+ // The turn-state record (written by `on-agent-response`) is authoritative when
666
+ // present — it ALREADY folds in the AskQuestion-tool signal (cli.ts writes
667
+ // `awaiting_input` with reason `askquestion_tool`). Consuming it exactly once
668
+ // per turn is what bounds this to a single notification, so when a record
669
+ // exists we DO NOT re-run the transcript tool check (that redundant recheck was
670
+ // a double-notify vector on a repeated stop — Codex 2026-08-31).
664
671
  if (record) {
665
672
  if (record.classification === "awaiting_input") {
666
673
  return { awaiting: true, snippet: record.snippet, source: "turn-state" };
667
674
  }
668
675
  return { awaiting: false, source: "turn-state" };
669
676
  }
677
+ // No record (e.g. `on-agent-response` never ran, or its record expired). The
678
+ // AskQuestion TOOL (A/B/C card) is still a DEFINITIVE signal Cursor won't
679
+ // surface any other way, so re-check the transcript's latest assistant turn.
680
+ // This only runs on the no-record path, so it can't double-fire against the
681
+ // record above. Best-effort, never throws.
682
+ try {
683
+ const ask = await (opts.detectAskToolImpl ?? detectAskToolFromHookStdin)(hookStdin);
684
+ if (ask.verdict === "ask") {
685
+ return {
686
+ awaiting: true,
687
+ snippet: ask.snippet ?? "Agent asked you a question",
688
+ source: "askquestion-tool",
689
+ };
690
+ }
691
+ }
692
+ catch {
693
+ /* fall through to the text classifier */
694
+ }
670
695
  // Fallback: classify the transcript's last assistant message.
671
696
  const readTranscript = opts.readTranscriptText ?? lastAssistantTextFromTranscript;
672
697
  let text;
@@ -1070,7 +1095,7 @@ export async function onStop(opts = {}) {
1070
1095
  fireDesktop(settings, desktopBanner, source, ideChatId),
1071
1096
  ]);
1072
1097
  await logLine(`send path → ${sendKind} ${sent.ok ? "delivered" : "failed"} (source: ${source}, settings: ${resolved.source})`);
1073
- return { mode: "send", settingsSource: resolved.source, send: sent, desktop, repo };
1098
+ return { mode: "send", settingsSource: resolved.source, send: sent, desktop, repo, sentKind: sendKind };
1074
1099
  }
1075
1100
  catch (err) {
1076
1101
  // Defence in depth: the dispatcher must never throw. Degrade to a logged
@@ -0,0 +1,230 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // AskQuestion TOOL detection from the Cursor transcript (2026-08-31).
3
+ //
4
+ // WHY THIS EXISTS. Cursor asks the user in two ways:
5
+ // (1) free-text in the final message ("Want me to deploy?") — caught by the
6
+ // text classifier in question_classifier.ts.
7
+ // (2) the `AskQuestion` / `AskUserQuestion` TOOL — the A/B/C options card. Here
8
+ // the assistant's final `text` is just a filler line ("I'll ask you a quick
9
+ // question"), so the text classifier says NOT a question. Worse, Cursor's
10
+ // `postToolUse` hook NEVER fires for the AskQuestion tool (confirmed
11
+ // empirically on Cursor 3.16.17 — postToolUse only ever reports
12
+ // Shell/Grep/Write/MCP), so there is no live hook signal for style (2).
13
+ //
14
+ // THE ONE RELIABLE SIGNAL is the transcript JSONL (its path is in the hook
15
+ // stdin as `transcript_path`). Assistant tool calls are recorded there as
16
+ // `{"type":"tool_use","name":"AskQuestion", ...}` inside the message content.
17
+ //
18
+ // This module reads a BOUNDED tail of that file (a single transcript line can be
19
+ // ~1 MiB, so we cap by bytes, not lines) and inspects ONLY the latest assistant
20
+ // record. If that record contains an AskQuestion/AskUserQuestion tool_use, the
21
+ // turn is DEFINITIVELY a question. We never scan back into a previous turn (an
22
+ // earlier AskQuestion must not re-trigger). Everything is best-effort: any
23
+ // missing / truncated / malformed / oversized transcript returns "unknown" so
24
+ // the caller falls back to the text classifier and the turn is never blocked.
25
+ // ─────────────────────────────────────────────────────────────────────────────
26
+ import fs from "node:fs/promises";
27
+ import { questionSnippet } from "./question_classifier.js";
28
+ /** Tool names that mean "the agent is asking the user to pick / answer". */
29
+ const ASK_TOOL_NAMES = new Set(["askquestion", "askuserquestion", "ask_question"]);
30
+ /** Cap the tail read so one giant transcript line can't blow up memory. */
31
+ export const TRANSCRIPT_TAIL_CAP_BYTES = 8 * 1024 * 1024; // 8 MiB
32
+ /** Pull `transcript_path` out of a hook stdin JSON blob (best-effort). */
33
+ export function transcriptPathFromHookStdin(hookStdin) {
34
+ if (!hookStdin || hookStdin.trim() === "")
35
+ return undefined;
36
+ try {
37
+ const p = JSON.parse(hookStdin);
38
+ const v = p.transcript_path ?? p.transcriptPath;
39
+ if (typeof v === "string" && v.trim() !== "")
40
+ return v;
41
+ }
42
+ catch {
43
+ /* fall through */
44
+ }
45
+ return undefined;
46
+ }
47
+ /**
48
+ * Read the last `cap` bytes of a file as UTF-8, dropping a leading partial line
49
+ * (so reverse line-parsing never sees a half-record). Returns undefined on any
50
+ * IO error. Streams from the END via a positional read — never loads the whole
51
+ * file, so a multi-MB transcript with a ~1 MiB line stays bounded.
52
+ */
53
+ export async function readTail(file, cap = TRANSCRIPT_TAIL_CAP_BYTES) {
54
+ let fh;
55
+ try {
56
+ fh = await fs.open(file, "r");
57
+ const { size } = await fh.stat();
58
+ const length = Math.min(size, cap);
59
+ if (length === 0)
60
+ return "";
61
+ const start = size - length;
62
+ // When the cap lands EXACTLY on a line boundary (the byte before `start` is a
63
+ // newline), the first retained line is COMPLETE and must be kept. Only when
64
+ // `start` falls mid-line do we drop the partial first line (Codex 2026-08-31).
65
+ let firstLineIsPartial = start > 0;
66
+ if (start > 0) {
67
+ const probe = Buffer.allocUnsafe(1);
68
+ const { bytesRead: pr } = await fh.read(probe, 0, 1, start - 1);
69
+ if (pr === 1 && probe[0] === 0x0a /* \n */)
70
+ firstLineIsPartial = false;
71
+ }
72
+ const buf = Buffer.allocUnsafe(length);
73
+ let offset = 0;
74
+ while (offset < length) {
75
+ const { bytesRead } = await fh.read(buf, offset, length - offset, start + offset);
76
+ if (bytesRead === 0)
77
+ break;
78
+ offset += bytesRead;
79
+ }
80
+ let tail = buf.subarray(0, offset).toString("utf8");
81
+ // Drop the (truncated) first line only when `start` fell mid-line.
82
+ if (firstLineIsPartial) {
83
+ const nl = tail.indexOf("\n");
84
+ if (nl < 0)
85
+ return undefined; // one giant partial line, nothing usable
86
+ tail = tail.slice(nl + 1);
87
+ }
88
+ return tail;
89
+ }
90
+ catch {
91
+ return undefined;
92
+ }
93
+ finally {
94
+ if (fh) {
95
+ try {
96
+ await fh.close();
97
+ }
98
+ catch {
99
+ /* ignore */
100
+ }
101
+ }
102
+ }
103
+ }
104
+ /**
105
+ * Extract a short question snippet from an AskQuestion tool_use `input`.
106
+ * Cursor shapes vary; try the documented spellings, else undefined.
107
+ */
108
+ function snippetFromAskInput(input) {
109
+ if (!input || typeof input !== "object")
110
+ return undefined;
111
+ const p = input;
112
+ // Precedence (Codex 2026-08-31): questions[0].question → top-level question →
113
+ // prompt → title → text. The nested-array form (AskUserQuestion) wins first,
114
+ // then the flat top-level spellings in documented order.
115
+ let raw;
116
+ const qs = p.questions;
117
+ if (Array.isArray(qs) && qs.length > 0 && qs[0] && typeof qs[0] === "object") {
118
+ const q0 = qs[0];
119
+ for (const k of ["question", "prompt", "title", "text"]) {
120
+ const v = q0[k];
121
+ if (typeof v === "string" && v.trim() !== "") {
122
+ raw = v;
123
+ break;
124
+ }
125
+ }
126
+ }
127
+ if (raw === undefined) {
128
+ for (const k of ["question", "prompt", "title", "text"]) {
129
+ const v = p[k];
130
+ if (typeof v === "string" && v.trim() !== "") {
131
+ raw = v;
132
+ break;
133
+ }
134
+ }
135
+ }
136
+ if (raw === undefined)
137
+ return undefined;
138
+ // Sanitize + length-cap exactly like the text-classifier snippets (redacts
139
+ // paths / hex blobs, collapses whitespace, caps at 160 chars) so a huge or
140
+ // sensitive question can't be sent verbatim in the notification.
141
+ return questionSnippet(raw);
142
+ }
143
+ /**
144
+ * Does a message-content value contain an AskQuestion/AskUserQuestion tool_use?
145
+ * Returns the extracted snippet (possibly undefined) when found, else null.
146
+ */
147
+ function findAskToolInContent(content) {
148
+ if (!Array.isArray(content))
149
+ return null;
150
+ for (const block of content) {
151
+ if (!block || typeof block !== "object")
152
+ continue;
153
+ const b = block;
154
+ // Accept `type: tool_use|tool-call|function` shapes; the NAME is what matters.
155
+ const name = String(b.name ?? b.toolName ?? b.tool_name ?? "").toLowerCase();
156
+ if (!name)
157
+ continue;
158
+ if (ASK_TOOL_NAMES.has(name)) {
159
+ const input = b.input ?? b.args ?? b.arguments ?? b.tool_input;
160
+ return { snippet: snippetFromAskInput(input) };
161
+ }
162
+ }
163
+ return null;
164
+ }
165
+ /** Normalise a transcript record's role (Cursor top-level or Claude nested). */
166
+ function roleOf(rec, nested) {
167
+ return String(rec.role ?? nested?.role ?? rec.type ?? "").toLowerCase();
168
+ }
169
+ /**
170
+ * Inspect the LATEST assistant record in the transcript and decide whether it
171
+ * ended in an AskQuestion tool call. Walks lines in REVERSE:
172
+ * • skips blank lines and non-conversational `{type,status}` metadata records;
173
+ * • the FIRST assistant record found is "this turn" → inspect it and STOP;
174
+ * • if a user/human record is reached before any assistant → `no_ask`
175
+ * (the current turn produced no assistant tool call we can see);
176
+ * • never continues past the current turn into a prior AskQuestion.
177
+ * Returns `unknown` only when the transcript can't be read at all.
178
+ */
179
+ export function detectAskToolInTail(tail) {
180
+ if (tail === undefined)
181
+ return { verdict: "unknown" };
182
+ const lines = tail.split("\n");
183
+ for (let i = lines.length - 1; i >= 0; i--) {
184
+ const t = lines[i].trim();
185
+ if (!t)
186
+ continue;
187
+ let parsed;
188
+ try {
189
+ parsed = JSON.parse(t);
190
+ }
191
+ catch {
192
+ continue; // tolerate a stray non-JSON line
193
+ }
194
+ // A valid-JSON but non-object record (e.g. `null`, `42`, `"x"`) is not a
195
+ // conversational line — skip it instead of dereferencing (would throw).
196
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
197
+ continue;
198
+ const rec = parsed;
199
+ const nested = rec.message && typeof rec.message === "object"
200
+ ? rec.message
201
+ : undefined;
202
+ const role = roleOf(rec, nested);
203
+ if (role === "assistant" || role === "ai" || role === "model") {
204
+ const content = rec.content ?? nested?.content ?? rec.message;
205
+ const hit = findAskToolInContent(content);
206
+ // This IS the latest assistant turn — its verdict is final either way.
207
+ return hit ? { verdict: "ask", snippet: hit.snippet } : { verdict: "no_ask" };
208
+ }
209
+ if (role === "user" || role === "human") {
210
+ // Reached the user turn before any assistant record → current turn has no
211
+ // visible assistant tool call. Do NOT look further back.
212
+ return { verdict: "no_ask" };
213
+ }
214
+ // Any other record (tool result, status, meta) → keep scanning backwards.
215
+ }
216
+ // No conversational record found in the tail.
217
+ return { verdict: "unknown" };
218
+ }
219
+ /**
220
+ * Convenience: read the transcript named in the hook stdin and detect an
221
+ * AskQuestion tool call in the latest assistant turn. Best-effort; never throws.
222
+ */
223
+ export async function detectAskToolFromHookStdin(hookStdin, deps = {}) {
224
+ const p = transcriptPathFromHookStdin(hookStdin);
225
+ if (!p)
226
+ return { verdict: "unknown" };
227
+ const readTailImpl = deps.readTailImpl ?? readTail;
228
+ const tail = await readTailImpl(p);
229
+ return detectAskToolInTail(tail);
230
+ }
@@ -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.6.9";
2
+ export const VERSION = "0.6.11";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trygocode/notify",
3
- "version": "0.6.9",
3
+ "version": "0.6.11",
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",