@trygocode/notify 0.6.12 → 0.6.13

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,33 @@ npm publish, real-device E2E), see
530
530
 
531
531
  ## Changelog
532
532
 
533
+ ### 0.6.13
534
+
535
+ - **Fix: Cursor questions still didn't notify on real turns (the live race).**
536
+ `afterAgentResponse` fires per assistant *message* and can run BEFORE the
537
+ transcript records the final `AskQuestion` tool call, so it writes a `finished`
538
+ record; `on-stop` then trusted that stored `finished` and skipped the tool
539
+ recheck, silently dropping a real A/B/C-card question. Corrected the stop-time
540
+ precedence (Codex-reviewed) to: `error > stop-time AskQuestion tool > stored
541
+ awaiting_input > final-text awaiting_input > finished` — a stored `finished`
542
+ no longer has precedence, so `on-stop` ALWAYS re-checks the transcript for a
543
+ tool ask. Re-running detection is safe; delivery stays bounded by the per-turn
544
+ `--dedupe-key`.
545
+ - **Fix (double-ping): Cursor-imported Claude questions.** Now that the native
546
+ Cursor `afterAgentResponse`→`stop` path detects in-Cursor questions, the
547
+ imported Claude `Notification` copy (carrying `cursor_version`) is suppressed
548
+ in `on-notification` so a Claude-in-Cursor question fires exactly one ping.
549
+ Standalone Claude questions (no `cursor_version`) still notify.
550
+ - **Perf: bounded the stop-time transcript text scan.** The finished-turn text
551
+ classifier now reads only a bounded tail of the transcript (same cap as the
552
+ AskQuestion reader) instead of loading the whole growing JSONL every stop.
553
+ - **Privacy: redact Windows paths from question snippets** (drive-letter
554
+ `C:\…` and UNC `\\host\share\…`), matching the existing Unix-path redaction.
555
+ - **Diagnostics: added always-on evidence lines** to `on-agent-response` and
556
+ `on-stop` (`ON-AGENT-RESPONSE: …`, `question resolve → …`) so a real live turn
557
+ is diagnosable from `~/.gocode/notify.log` — id prefixes + verdicts only, no
558
+ raw text or paths.
559
+
533
560
  ### 0.6.12
534
561
 
535
562
  - **Hardening for the 0.6.11 AskQuestion-tool detection** (Codex-reviewed): the
package/dist/src/cli.js CHANGED
@@ -12,7 +12,7 @@ import { VERSION } from "./version.js";
12
12
  import { login } from "./login.js";
13
13
  import { resolveServerUrl } from "./creds.js";
14
14
  import { launch } from "./launch.js";
15
- import { send, isNotifyKind, NOTIFY_KINDS, } from "./send.js";
15
+ import { send, appendLog, isNotifyKind, NOTIFY_KINDS, } from "./send.js";
16
16
  import { enqueue, flush } from "./outbox.js";
17
17
  import { gatherStatus, formatStatus, formatStatusSteps } from "./status.js";
18
18
  import { isAgentDriven, stdoutSink } from "./agent.js";
@@ -20,7 +20,7 @@ import { serveStdio } from "./mcp.js";
20
20
  import { setup } from "./setup.js";
21
21
  import { uninstall } from "./uninstall.js";
22
22
  import { cmdConfig } from "./config.js";
23
- import { onStop, parseCursorStopStatus, cursorStopStatusToKind, clickTarget, projectLabel, } from "./on_stop.js";
23
+ import { onStop, parseCursorStopStatus, cursorStopStatusToKind, isClaudeHookImportedByCursor, clickTarget, projectLabel, } from "./on_stop.js";
24
24
  import { classifyResponse } from "./question_classifier.js";
25
25
  import { detectAskToolFromHookStdin } from "./transcript_ask.js";
26
26
  import { writeTurnState, conversationIdFromHookStdin, } from "./turn_state.js";
@@ -803,23 +803,33 @@ export async function cmdOnNotification(args, deps = {}) {
803
803
  const hookStdin = deps.hookStdin !== undefined
804
804
  ? deps.hookStdin
805
805
  : await (deps.readStdin ?? readStdinIfPipe)();
806
- // NOTE (2026-07-28): we DELIBERATELY do NOT suppress Cursor-imported Claude
807
- // hooks here (unlike the `on-stop`/completion path). The `cursor_version`
808
- // suppression exists to avoid DOUBLE notifications when BOTH a native Cursor
809
- // hook AND the imported Claude hook fire for the same turn. That double-fire
810
- // only happens for COMPLETION: Cursor's native `stop` hook works, so its
811
- // imported Claude `Stop` would duplicate it → suppress.
806
+ // ── Cursor-imported Claude question suppression (Codex P1, 2026-08-31) ──────
807
+ // HISTORY: the 2026-07-28 build deliberately FORWARDED Cursor-imported Claude
808
+ // `Notification` questions because, at the time, Cursor had NO working native
809
+ // question signal (its `AskQuestion` `postToolUse` hook is broken upstream and
810
+ // it has no `Notification` event), so the imported Claude hook was the ONLY
811
+ // signal for an in-Cursor Claude question.
812
812
  //
813
- // For QUESTIONS there is no competing native hook to dedupe against: Cursor's
814
- // own question signal (the `AskQuestion` `postToolUse` hook) is BROKEN upstream
815
- // (Cursor docs: "postToolUse ... excluding the AskQuestion tool, which is a
816
- // known bug") and Cursor has NO `Notification` event at all. So when Claude
817
- // runs inside Cursor and asks a question, the imported Claude `Notification`
818
- // hook is the ONLY signal we get. Suppressing it (the old behaviour) silently
819
- // dropped every in-Cursor Claude question the exact "questions don't notify"
820
- // regression. We now always forward a genuine input-requiring notification,
821
- // regardless of whether Cursor imported it. The per-turn `--dedupe-key` still
822
- // coalesces any accidental repeat of the SAME question.
813
+ // THAT IS NO LONGER TRUE. The `afterAgentResponse` `stop` classifier path
814
+ // (turn-state + transcript AskQuestion reader) NOW gives Cursor a native
815
+ // question signal for EVERY turn, including Claude-in-Cursor ones (verified in
816
+ // notify.log: an in-Cursor Claude question fires `turn is a QUESTION
817
+ // (turn-state)` on the native `stop`). So forwarding the imported Claude
818
+ // `Notification` here as well produces TWO `awaiting_input` pings with
819
+ // DIFFERENT dedupe keys (`$CLAUDE_SESSION_ID-notify` vs `cursor-stop`) that the
820
+ // server can't coalesce a double ping. Suppress the imported copy: the native
821
+ // Cursor path is authoritative when Cursor imported the hook (payload carries
822
+ // `cursor_version`). A STANDALONE Claude `Notification` (no `cursor_version`)
823
+ // is still forwarded — that's Claude's only question signal outside Cursor.
824
+ if (isClaudeHookImportedByCursor(source, hookStdin)) {
825
+ try {
826
+ await appendLog("ON-NOTIFICATION: Cursor-imported Claude question → suppressed (native Cursor path is authoritative)", { home: deps.home });
827
+ }
828
+ catch {
829
+ /* logging must never block */
830
+ }
831
+ return 0;
832
+ }
823
833
  const notificationType = parseClaudeNotificationType(hookStdin);
824
834
  if (notificationType !== undefined &&
825
835
  !CLAUDE_INPUT_NOTIFICATION_TYPES.has(notificationType)) {
@@ -944,7 +954,28 @@ export async function cmdOnAgentResponse(args, deps = {}) {
944
954
  record.reason = reason;
945
955
  }
946
956
  const writeImpl = deps.writeTurnStateImpl ?? writeTurnState;
947
- await writeImpl(record, { home: deps.home, ttlMs: deps.ttlMs, now: deps.now });
957
+ let stateWrite = "ok";
958
+ try {
959
+ await writeImpl(record, { home: deps.home, ttlMs: deps.ttlMs, now: deps.now });
960
+ }
961
+ catch {
962
+ stateWrite = "failed";
963
+ }
964
+ // Unconditional evidence line (independent of --quiet) so a real live turn is
965
+ // diagnosable from ~/.gocode/notify.log without a debug apparatus (Codex
966
+ // 2026-08-31). Redaction-safe: id PREFIXES + lengths + verdicts only, never
967
+ // raw response text or paths.
968
+ try {
969
+ const convShort = conversationId.slice(0, 8);
970
+ const genShort = generationId ? generationId.slice(0, 8) : "-";
971
+ await appendLog(`ON-AGENT-RESPONSE: conv=${convShort} gen=${genShort} text_len=${text.length} ` +
972
+ `text_verdict=${textVerdict.awaiting ? "awaiting_input" : "finished"} ` +
973
+ `ask_tool=${askVerdict.verdict} chosen=${awaiting ? "awaiting_input" : "finished"} ` +
974
+ `reason=${reason ?? "-"} state_write=${stateWrite}`, { home: deps.home });
975
+ }
976
+ catch {
977
+ /* logging must never block */
978
+ }
948
979
  const detail = awaiting
949
980
  ? `classified as awaiting_input (${reason})`
950
981
  : "classified as finished";
@@ -38,7 +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
+ import { detectAskToolFromHookStdin, transcriptPathFromHookStdin, readTail, } from "./transcript_ask.js";
42
42
  /**
43
43
  * Parse the Cursor `stop` hook stdin JSON and extract the `status` field.
44
44
  * Best-effort: returns `undefined` on absent/empty/unparseable input or when the
@@ -581,25 +581,22 @@ export function autopilotOwnsTurn(env = process.env) {
581
581
  export async function lastAssistantTextFromTranscript(hookStdin) {
582
582
  if (!hookStdin || hookStdin.trim() === "")
583
583
  return undefined;
584
- let transcriptPath;
585
- try {
586
- const p = JSON.parse(hookStdin);
587
- const v = p.transcript_path ?? p.transcriptPath;
588
- if (typeof v === "string" && v.trim() !== "")
589
- transcriptPath = v;
590
- }
591
- catch {
592
- return undefined;
593
- }
584
+ const transcriptPath = transcriptPathFromHookStdin(hookStdin);
594
585
  if (!transcriptPath)
595
586
  return undefined;
587
+ // Bounded tail read (Codex 2026-08-31): a long-lived conversation's transcript
588
+ // grows without bound, and this classifier runs on EVERY finished Cursor stop.
589
+ // We only need the tail (the current turn's assistant message), so cap the read
590
+ // exactly like the AskQuestion reader instead of loading O(total history).
596
591
  let raw;
597
592
  try {
598
- raw = await fs.readFile(transcriptPath, "utf8");
593
+ raw = await readTail(transcriptPath);
599
594
  }
600
595
  catch {
601
596
  return undefined;
602
597
  }
598
+ if (raw == null)
599
+ return undefined;
603
600
  let lastText;
604
601
  for (const line of raw.split("\n")) {
605
602
  const t = line.trim();
@@ -662,37 +659,43 @@ export async function resolveTurnQuestion(hookStdin, opts = {}) {
662
659
  catch {
663
660
  record = undefined;
664
661
  }
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).
671
- if (record) {
672
- if (record.classification === "awaiting_input") {
673
- return { awaiting: true, snippet: record.snippet, source: "turn-state" };
674
- }
675
- return { awaiting: false, source: "turn-state" };
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.
662
+ // ── Precedence (Codex 2026-08-31, corrected) ──────────────────────────────
663
+ // error (handled by the caller via stop status)
664
+ // > stop-time AskQuestion TOOL (ALWAYS re-checked see below)
665
+ // > stored awaiting_input (afterAgentResponse's positive verdict)
666
+ // > final-text awaiting_input (transcript text classifier fallback)
667
+ // > finished
668
+ // A stored `finished` has NO precedence — it is only fallback evidence. THE
669
+ // BUG we're fixing: `afterAgentResponse` fires PER ASSISTANT MESSAGE and can
670
+ // run BEFORE the transcript records the final AskQuestion `tool_use`, so it
671
+ // writes `finished`; if `on-stop` then trusts that record and skips the tool
672
+ // recheck, a real A/B/C-card question is silently dropped. So we ALWAYS run
673
+ // the transcript tool recheck here. Re-running DETECTION is safe; re-running
674
+ // DELIVERY is bounded by the send-layer `--dedupe-key cursor-stop` (a repeated
675
+ // stop for the same turn coalesces server-side), NOT by consuming the record.
682
676
  try {
683
677
  const ask = await (opts.detectAskToolImpl ?? detectAskToolFromHookStdin)(hookStdin);
684
678
  if (ask.verdict === "ask") {
685
679
  return {
680
+ // Prefer the record's snippet only if it too was a tool ask; otherwise
681
+ // the freshly-read tool question is the most accurate.
686
682
  awaiting: true,
687
- snippet: ask.snippet ?? "Agent asked you a question",
683
+ snippet: ask.snippet ?? record?.snippet ?? "Agent asked you a question",
688
684
  source: "askquestion-tool",
689
685
  };
690
686
  }
691
687
  }
692
688
  catch {
693
- /* fall through to the text classifier */
689
+ /* fall through to the record / text classifier */
694
690
  }
695
- // Fallback: classify the transcript's last assistant message.
691
+ // Stored positive verdict from afterAgentResponse (a real free-text question).
692
+ if (record?.classification === "awaiting_input") {
693
+ return { awaiting: true, snippet: record.snippet, source: "turn-state" };
694
+ }
695
+ // A stored `finished` is NOT trusted to end the decision — fall through to the
696
+ // text classifier on the transcript (covers a text question the per-message
697
+ // classifier missed at afterAgentResponse time). Only when the transcript adds
698
+ // no signal do we honour `finished`.
696
699
  const readTranscript = opts.readTranscriptText ?? lastAssistantTextFromTranscript;
697
700
  let text;
698
701
  try {
@@ -701,8 +704,12 @@ export async function resolveTurnQuestion(hookStdin, opts = {}) {
701
704
  catch {
702
705
  text = undefined;
703
706
  }
704
- if (!text)
707
+ if (!text) {
708
+ // No transcript text: honour a stored verdict if we have one, else no signal.
709
+ if (record)
710
+ return { awaiting: false, source: "turn-state" };
705
711
  return { awaiting: false, source: "none" };
712
+ }
706
713
  const verdict = classifyResponse(text);
707
714
  return verdict.awaiting
708
715
  ? { awaiting: true, snippet: verdict.snippet, source: "transcript-fallback" }
@@ -872,6 +879,9 @@ export async function onStop(opts = {}) {
872
879
  ttlMs: opts.turnStateTtlMs,
873
880
  now: opts.now,
874
881
  });
882
+ // Unconditional evidence line so a real turn's decision is visible in the
883
+ // log (Codex 2026-08-31): shows which signal won.
884
+ await logLine(`question resolve → awaiting=${verdict.awaiting} source=${verdict.source}`);
875
885
  if (verdict.awaiting) {
876
886
  turnIsQuestion = true;
877
887
  turnQuestionSnippet = verdict.snippet;
@@ -157,6 +157,12 @@ export function questionSnippet(clause, maxLen = 160) {
157
157
  return undefined;
158
158
  // Redact absolute-ish paths (…/a/b/c) and long hex/token blobs from the body.
159
159
  s = s.replace(/(?:\/[\w.-]+){2,}\/?/g, "…").replace(/\b[0-9a-f]{16,}\b/gi, "…");
160
+ // Redact Windows path forms too (Codex 2026-08-31): drive-letter absolute
161
+ // paths (C:\Users\joe\repo) and UNC shares (\\host\share\dir). Both can leak a
162
+ // username or repo layout on a lock screen, same as the Unix form above.
163
+ s = s
164
+ .replace(/\\\\[\w.-]+(?:\\[\w.$-]+)+\\?/g, "…") // \\host\share\dir (UNC)
165
+ .replace(/\b[A-Za-z]:\\(?:[\w.$-]+\\?)+/g, "…"); // C:\a\b\c (drive-letter)
160
166
  s = s.replace(/\s+/g, " ").trim();
161
167
  if (s === "")
162
168
  return undefined;
@@ -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.12";
2
+ export const VERSION = "0.6.13";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trygocode/notify",
3
- "version": "0.6.12",
3
+ "version": "0.6.13",
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",