@trygocode/notify 0.6.11 → 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 +38 -0
- package/dist/src/cli.js +50 -19
- package/dist/src/on_stop.js +43 -33
- package/dist/src/question_classifier.js +6 -0
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -530,6 +530,44 @@ 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
|
+
|
|
560
|
+
### 0.6.12
|
|
561
|
+
|
|
562
|
+
- **Hardening for the 0.6.11 AskQuestion-tool detection** (Codex-reviewed): the
|
|
563
|
+
stop-time transcript recheck now only runs when there is no per-turn record
|
|
564
|
+
(removes a double-notify vector on a repeated stop); tool question snippets are
|
|
565
|
+
sanitized + length-capped like every other snippet; the bounded transcript
|
|
566
|
+
tail-reader keeps a complete first line when the read lands exactly on a line
|
|
567
|
+
boundary; snippet field precedence is `questions[0].question → question →
|
|
568
|
+
prompt → title → text`; and non-object JSONL records (`null`, scalars, arrays)
|
|
569
|
+
are skipped instead of dereferenced. Same behaviour as 0.6.11, just safer.
|
|
570
|
+
|
|
533
571
|
### 0.6.11
|
|
534
572
|
|
|
535
573
|
- **Fixed: the `AskQuestion` TOOL (the A/B/C options card) now notifies you.**
|
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
|
-
//
|
|
807
|
-
//
|
|
808
|
-
//
|
|
809
|
-
//
|
|
810
|
-
//
|
|
811
|
-
//
|
|
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
|
-
//
|
|
814
|
-
//
|
|
815
|
-
//
|
|
816
|
-
//
|
|
817
|
-
//
|
|
818
|
-
//
|
|
819
|
-
//
|
|
820
|
-
//
|
|
821
|
-
//
|
|
822
|
-
//
|
|
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
|
-
|
|
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";
|
package/dist/src/on_stop.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
//
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
//
|
|
669
|
-
//
|
|
670
|
-
//
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
//
|
|
678
|
-
//
|
|
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
|
-
//
|
|
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;
|
package/dist/src/version.js
CHANGED
|
@@ -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.
|
|
2
|
+
export const VERSION = "0.6.13";
|
package/package.json
CHANGED