@trygocode/notify 0.6.8 → 0.6.9

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,25 @@ npm publish, real-device E2E), see
530
530
 
531
531
  ## Changelog
532
532
 
533
+ ### 0.6.9
534
+
535
+ - **Fixed: native Cursor questions now notify you again.** When the Cursor agent
536
+ (not Claude-in-Cursor) ends its turn by asking you something ("Want me to
537
+ deploy this?", "Should I continue?"), you now get an **"Agent needs you"**
538
+ ping instead of a plain "finished". Cursor's `stop` hook can't tell "asked a
539
+ question" from "finished" (it always reports `completed`), and its native
540
+ question hook (`AskQuestion` via `postToolUse`) never fires — an upstream bug.
541
+ So we now install an `afterAgentResponse` hook that **classifies the turn's
542
+ final message** and hands a short-lived, conversation-keyed state record to
543
+ `on-stop`, which stays the single notification owner. Precision-first
544
+ classifier: fires on real asks (direct openers like "Want me to…" even without
545
+ a "?", explicit "please confirm/approve" requests) and stays quiet on courtesy
546
+ sign-offs ("anything else?"), code blocks, and rhetorical headings.
547
+ - **Auto-push + question:** if the turn both changed files (auto-push commits and
548
+ pushes them) *and* asked a question, **both** the phone push and the desktop
549
+ banner now read "Agent needs you" with the question snippet — the work is still
550
+ committed/pushed, you're just told it's waiting on you.
551
+
533
552
  ### 0.6.8
534
553
 
535
554
  - **Fixed: Claude questions inside Cursor now notify.** When Claude Code runs
package/dist/src/cli.js CHANGED
@@ -21,6 +21,8 @@ import { setup } from "./setup.js";
21
21
  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
+ import { classifyResponse } from "./question_classifier.js";
25
+ import { writeTurnState, conversationIdFromHookStdin, } from "./turn_state.js";
24
26
  import { notifyDesktop, requestDesktopPermission, desktopDisabledByEnv, } from "./desktop_notify.js";
25
27
  import { resolveNotifySettings } from "./config.js";
26
28
  import { deriveRepoIdentity } from "./repo_key.js";
@@ -40,6 +42,7 @@ export const COMMANDS = [
40
42
  "config",
41
43
  "on-stop",
42
44
  "on-notification",
45
+ "on-agent-response",
43
46
  "launch",
44
47
  ];
45
48
  /** Aliases that route to a canonical command in {@link runAsync}. */
@@ -104,6 +107,7 @@ export function printHelp() {
104
107
  " config Get/set Notify settings (get | set <key> <value> | pull)",
105
108
  " on-stop End-of-turn dispatcher (auto-push or finished ping)",
106
109
  " on-notification Needs-input hook dispatcher (embedded-Cursor aware)",
110
+ " on-agent-response Classify a turn's final text (question vs done) for on-stop",
107
111
  " launch Hand a big task off to your GoCode server (alias: autopilot)",
108
112
  "",
109
113
  " -h, --help Show this help",
@@ -834,6 +838,109 @@ export async function cmdOnNotification(args, deps = {}) {
834
838
  forwarded.push("--server", server);
835
839
  return (deps.sendCommandImpl ?? cmdSend)(forwarded, deps);
836
840
  }
841
+ /**
842
+ * Extract the agent's final message text from a Cursor `afterAgentResponse` hook
843
+ * payload. Cursor puts the full assistant message in `text`; we accept a couple
844
+ * of defensive spellings. Returns "" when absent/unparseable. Never throws.
845
+ */
846
+ export function extractResponseText(hookStdin) {
847
+ if (!hookStdin || hookStdin.trim() === "")
848
+ return "";
849
+ try {
850
+ const parsed = JSON.parse(hookStdin);
851
+ if (!parsed || typeof parsed !== "object")
852
+ return "";
853
+ const p = parsed;
854
+ for (const k of ["text", "response", "message", "content"]) {
855
+ const v = p[k];
856
+ if (typeof v === "string" && v.trim() !== "")
857
+ return v;
858
+ }
859
+ }
860
+ catch {
861
+ return "";
862
+ }
863
+ return "";
864
+ }
865
+ /**
866
+ * Handle Cursor's `afterAgentResponse` hook. This is the CLASSIFIER half of the
867
+ * question-notification design (Codex-reviewed 2026-08-31): it does NOT send a
868
+ * notification. It reads the agent's final `text`, decides whether the turn is a
869
+ * QUESTION (awaiting user input) or a plain completion, and writes a tiny
870
+ * per-turn record keyed by `conversation_id`. The `stop` hook — the single
871
+ * notification owner — then reads that record and sends exactly one notification
872
+ * of the right kind (`error` > `awaiting_input` > `finished`).
873
+ *
874
+ * WHY a state file instead of sending here: `stop` owns settings resolution,
875
+ * auto-push, abort-suppression, dedupe, deep-linking and the desktop banner.
876
+ * Sending from here would either duplicate `stop`'s notification or bypass all
877
+ * that machinery. Classify-and-hand-off keeps ONE notification per turn.
878
+ *
879
+ * Non-blocking + total: always exits 0, never throws (a failed classify/write
880
+ * just means `stop` falls back to its status/transcript heuristics).
881
+ */
882
+ export async function cmdOnAgentResponse(args, deps = {}) {
883
+ const flags = parseFlags(args);
884
+ const agent = isAgentDriven(flags);
885
+ const quiet = flagBool(flags, "quiet");
886
+ const sink = deps.sink ?? stdoutSink;
887
+ try {
888
+ const hookStdin = deps.hookStdin !== undefined ? deps.hookStdin : await (deps.readStdin ?? readStdinIfPipe)();
889
+ const conversationId = conversationIdFromHookStdin(hookStdin);
890
+ // Without a conversation key, `stop` can't correlate — nothing to hand off.
891
+ if (!conversationId) {
892
+ if (agent)
893
+ sink({ step: "on-agent-response", ok: true, detail: "no conversation id — skipped" });
894
+ else if (!quiet)
895
+ console.log("✓ on-agent-response: no conversation id — skipped");
896
+ return 0;
897
+ }
898
+ const text = extractResponseText(hookStdin);
899
+ const verdict = classifyResponse(text);
900
+ let generationId;
901
+ try {
902
+ const p = JSON.parse(hookStdin);
903
+ const g = p.generation_id ?? p.generationId;
904
+ if (typeof g === "string" && g.trim() !== "")
905
+ generationId = g.trim();
906
+ }
907
+ catch {
908
+ /* advisory only */
909
+ }
910
+ const record = {
911
+ conversation_id: conversationId,
912
+ classification: verdict.awaiting ? "awaiting_input" : "finished",
913
+ created_at: (deps.now ?? Date.now)(),
914
+ };
915
+ if (generationId)
916
+ 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;
922
+ }
923
+ const writeImpl = deps.writeTurnStateImpl ?? writeTurnState;
924
+ await writeImpl(record, { home: deps.home, ttlMs: deps.ttlMs, now: deps.now });
925
+ const detail = verdict.awaiting
926
+ ? `classified as awaiting_input (${verdict.reason})`
927
+ : "classified as finished";
928
+ if (agent)
929
+ sink({ step: "on-agent-response", ok: true, detail });
930
+ else if (!quiet)
931
+ console.log(`✓ on-agent-response: ${detail}`);
932
+ }
933
+ catch (err) {
934
+ // Never block the turn — degrade to a logged no-op.
935
+ if (agent)
936
+ sink({
937
+ step: "on-agent-response",
938
+ ok: true,
939
+ detail: `ignored error: ${err instanceof Error ? err.message : String(err)}`,
940
+ });
941
+ }
942
+ return 0;
943
+ }
837
944
  /**
838
945
  * Handle `gocode-notify launch "<task>" [--repo owner/repo] [--branch suffix]
839
946
  * [--prd-file path.md] [--model "Profile"] [--runner-kind K] [--server URL]
@@ -973,6 +1080,8 @@ export async function runAsync(argv) {
973
1080
  return cmdOnStop(argv.slice(1));
974
1081
  if (cmd === "on-notification")
975
1082
  return cmdOnNotification(argv.slice(1));
1083
+ if (cmd === "on-agent-response")
1084
+ return cmdOnAgentResponse(argv.slice(1));
976
1085
  if (cmd === "launch" || cmd === "autopilot")
977
1086
  return cmdLaunch(argv.slice(1));
978
1087
  return run(argv);
@@ -83,6 +83,28 @@ export const CURSOR_STOP_COMMAND = "npx -y @trygocode/notify@latest on-stop --so
83
83
  * `--dedupe-key` so it coalesces with itself but not with the `stop` ping.
84
84
  */
85
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';
86
+ /**
87
+ * The Cursor `afterAgentResponse` hook command (question detection, 2026-08-31).
88
+ *
89
+ * THE REAL FIX for "questions don't notify." Empirically (427k captured Cursor
90
+ * 3.16.17 hook events), the `AskQuestion` `postToolUse` hook NEVER fires (the
91
+ * upstream bug is still live) and Cursor has no `Notification` event, so the
92
+ * `stop` hook alone can't tell "asked a question" from "finished." The
93
+ * `afterAgentResponse` hook, however, carries the agent's final message `text` —
94
+ * the ONLY signal that reveals a question.
95
+ *
96
+ * This command runs the `on-agent-response` CLASSIFIER: it does NOT send a
97
+ * notification. It classifies the turn (question vs completion) and writes a tiny
98
+ * per-turn record keyed by `conversation_id`. The `stop` hook (the single
99
+ * notification owner) reads that record and sends exactly one notification of the
100
+ * right kind (`error` > `awaiting_input` > `finished`). Splitting classify from
101
+ * send keeps ONE notification per turn while preserving all of on-stop's
102
+ * settings/auto-push/abort/deeplink/desktop machinery.
103
+ *
104
+ * Ends in `|| true` so it can never block the turn; `--quiet` so Cursor never
105
+ * sees non-JSON stdout.
106
+ */
107
+ export const CURSOR_AGENT_RESPONSE_COMMAND = "npx -y @trygocode/notify@latest on-agent-response --source cursor --quiet || true";
86
108
  /**
87
109
  * Tool names that mean "the agent is asking the user a question." Used as the
88
110
  * `postToolUse` matcher (a regex alternation). Cursor maps Claude Code tool
@@ -268,6 +290,40 @@ function mergeAskQuestionHook(config) {
268
290
  hooks.postToolUse = preserved;
269
291
  config.hooks = hooks;
270
292
  }
293
+ /**
294
+ * True when an `afterAgentResponse` entry is one we wrote. Cursor uses the
295
+ * direct `{ command }` shape for this event. Identified by our stable command
296
+ * markers (name token + `--source cursor`), so idempotent merge + surgical
297
+ * uninstall work across version bumps.
298
+ */
299
+ function isOurAgentResponseHook(h) {
300
+ if (!isRecord(h) || typeof h.command !== "string")
301
+ return false;
302
+ return isOurCommand(h.command);
303
+ }
304
+ /** Strip OUR entries out of the `afterAgentResponse` array. Never mutates input. */
305
+ function stripOurAgentResponseHooks(entries) {
306
+ const kept = entries.filter((h) => !isOurAgentResponseHook(h));
307
+ return { entries: kept, removed: kept.length !== entries.length };
308
+ }
309
+ /**
310
+ * Merge our `afterAgentResponse` classifier hook (question detection) into the
311
+ * hooks config, preserving the user's own `afterAgentResponse` entries and
312
+ * `version`. Strips any prior copy of OUR command (idempotent) then appends a
313
+ * single fresh entry. Mutates `config` in place.
314
+ */
315
+ function mergeAgentResponseHook(config) {
316
+ if (typeof config.version !== "number")
317
+ config.version = 1;
318
+ const hooks = isRecord(config.hooks) ? config.hooks : {};
319
+ const existing = Array.isArray(hooks.afterAgentResponse)
320
+ ? hooks.afterAgentResponse
321
+ : [];
322
+ const preserved = stripOurAgentResponseHooks(existing).entries;
323
+ preserved.push({ command: CURSOR_AGENT_RESPONSE_COMMAND });
324
+ hooks.afterAgentResponse = preserved;
325
+ config.hooks = hooks;
326
+ }
271
327
  /** Merge our MCP server entry into `mcp.mcpServers`. Mutates in place. */
272
328
  function mergeMcp(mcp) {
273
329
  const servers = isRecord(mcp.mcpServers) ? mcp.mcpServers : {};
@@ -291,6 +347,7 @@ export async function writeCursorConfig(runtime, opts) {
291
347
  const hooksConfig = (await readJsonObject(hooksPath)) ?? {};
292
348
  mergeStopHook(hooksConfig);
293
349
  mergeAskQuestionHook(hooksConfig);
350
+ mergeAgentResponseHook(hooksConfig);
294
351
  await writeJsonFile(hooksPath, hooksConfig);
295
352
  written.push(hooksPath);
296
353
  const mcpPath = cursorMcpPath(opts);
@@ -306,7 +363,7 @@ export async function writeCursorConfig(runtime, opts) {
306
363
  runtime: name,
307
364
  written,
308
365
  skipped: false,
309
- detail: "merged current-schema stop + postToolUse(AskQuestion) hooks + MCP entry; wrote rule",
366
+ detail: "merged current-schema stop + afterAgentResponse(question) + postToolUse(AskQuestion) hooks + MCP entry; wrote rule",
310
367
  };
311
368
  }
312
369
  catch (err) {
@@ -354,6 +411,16 @@ export async function uninstallCursorConfig(opts) {
354
411
  delete hooks.postToolUse;
355
412
  }
356
413
  }
414
+ if (Array.isArray(hooks.afterAgentResponse)) {
415
+ const { entries: kept, removed: r } = stripOurAgentResponseHooks(hooks.afterAgentResponse);
416
+ if (r) {
417
+ changed = true;
418
+ if (kept.length > 0)
419
+ hooks.afterAgentResponse = kept;
420
+ else
421
+ delete hooks.afterAgentResponse;
422
+ }
423
+ }
357
424
  if (Object.keys(hooks).length === 0)
358
425
  delete hooksConfig.hooks;
359
426
  if (changed) {
@@ -36,6 +36,8 @@ import { appendLog, send } from "./send.js";
36
36
  import { checkDedupLock } from "./dedup_lock.js";
37
37
  import { notifyDesktop, } from "./desktop_notify.js";
38
38
  import { decorateTitle, decorateBody } from "./notify_copy.js";
39
+ import { readTurnState, conversationIdFromHookStdin, } from "./turn_state.js";
40
+ import { classifyResponse } from "./question_classifier.js";
39
41
  /**
40
42
  * Parse the Cursor `stop` hook stdin JSON and extract the `status` field.
41
43
  * Best-effort: returns `undefined` on absent/empty/unparseable input or when the
@@ -568,6 +570,119 @@ export function autopilotOwnsTurn(env = process.env) {
568
570
  const v = raw.trim().toLowerCase();
569
571
  return v !== "" && v !== "0" && v !== "false" && v !== "no" && v !== "off";
570
572
  }
573
+ /**
574
+ * Read the last assistant message text from a Cursor transcript JSONL, so the
575
+ * classifier can run at stop-time when the `afterAgentResponse` turn-state record
576
+ * is missing (e.g. that hook process failed). Best-effort — returns undefined on
577
+ * any problem. Never throws. Cursor agent transcripts store role/content records;
578
+ * we take the LAST assistant/model message's text.
579
+ */
580
+ export async function lastAssistantTextFromTranscript(hookStdin) {
581
+ if (!hookStdin || hookStdin.trim() === "")
582
+ return undefined;
583
+ let transcriptPath;
584
+ try {
585
+ const p = JSON.parse(hookStdin);
586
+ const v = p.transcript_path ?? p.transcriptPath;
587
+ if (typeof v === "string" && v.trim() !== "")
588
+ transcriptPath = v;
589
+ }
590
+ catch {
591
+ return undefined;
592
+ }
593
+ if (!transcriptPath)
594
+ return undefined;
595
+ let raw;
596
+ try {
597
+ raw = await fs.readFile(transcriptPath, "utf8");
598
+ }
599
+ catch {
600
+ return undefined;
601
+ }
602
+ let lastText;
603
+ for (const line of raw.split("\n")) {
604
+ const t = line.trim();
605
+ if (!t)
606
+ continue;
607
+ let rec;
608
+ try {
609
+ rec = JSON.parse(t);
610
+ }
611
+ catch {
612
+ continue;
613
+ }
614
+ const nested = rec.message && typeof rec.message === "object"
615
+ ? rec.message
616
+ : undefined;
617
+ const role = String(rec.role ?? nested?.role ?? rec.type ?? "").toLowerCase();
618
+ if (role !== "assistant" && role !== "ai" && role !== "model")
619
+ continue;
620
+ let content = "";
621
+ const src = rec.content ?? rec.text ?? nested?.content;
622
+ if (typeof src === "string")
623
+ content = src;
624
+ else if (Array.isArray(src)) {
625
+ for (const b of src) {
626
+ if (typeof b === "string")
627
+ content += b + "\n";
628
+ else if (b && typeof b === "object" && typeof b.text === "string")
629
+ content += String(b.text) + "\n";
630
+ }
631
+ }
632
+ if (content.trim() !== "")
633
+ lastText = content; // keep scanning → LAST wins
634
+ }
635
+ return lastText;
636
+ }
637
+ /**
638
+ * Decide whether this stop turn should notify as a QUESTION (`awaiting_input`).
639
+ *
640
+ * Primary source: the per-turn record written by the Cursor `afterAgentResponse`
641
+ * hook (keyed by conversation_id), CONSUMED here so it drives at most one ping.
642
+ * Fallback: if no fresh record exists, classify the last assistant message from
643
+ * the transcript named in the hook stdin. Returns `{ awaiting:false }` when there
644
+ * is no signal (then on-stop sends its normal `finished`). Never throws.
645
+ *
646
+ * Only meaningful for Cursor (Claude Code has its own dedicated `Notification`
647
+ * question hook), so callers gate this on `source === "cursor"`.
648
+ */
649
+ export async function resolveTurnQuestion(hookStdin, opts = {}) {
650
+ const convId = conversationIdFromHookStdin(hookStdin);
651
+ const readImpl = opts.readTurnStateImpl ?? readTurnState;
652
+ let record;
653
+ try {
654
+ record = await readImpl(convId, {
655
+ home: opts.home,
656
+ ttlMs: opts.ttlMs,
657
+ now: opts.now,
658
+ consume: true,
659
+ });
660
+ }
661
+ catch {
662
+ record = undefined;
663
+ }
664
+ if (record) {
665
+ if (record.classification === "awaiting_input") {
666
+ return { awaiting: true, snippet: record.snippet, source: "turn-state" };
667
+ }
668
+ return { awaiting: false, source: "turn-state" };
669
+ }
670
+ // Fallback: classify the transcript's last assistant message.
671
+ const readTranscript = opts.readTranscriptText ?? lastAssistantTextFromTranscript;
672
+ let text;
673
+ try {
674
+ text = await readTranscript(hookStdin);
675
+ }
676
+ catch {
677
+ text = undefined;
678
+ }
679
+ if (!text)
680
+ return { awaiting: false, source: "none" };
681
+ const verdict = classifyResponse(text);
682
+ return verdict.awaiting
683
+ ? { awaiting: true, snippet: verdict.snippet, source: "transcript-fallback" }
684
+ : { awaiting: false, source: "transcript-fallback" };
685
+ }
571
686
  /**
572
687
  * The end-of-turn dispatcher (PRD §2.2). Resolves settings, then either delegates
573
688
  * to the auto-push flow (which sends its own notification) OR fires the plain
@@ -714,6 +829,34 @@ export async function onStop(opts = {}) {
714
829
  });
715
830
  const settings = resolved.settings;
716
831
  const autoPushOn = settings.auto_push?.enabled === true;
832
+ // ── Question detection, resolved ONCE per turn (Cursor, 2026-08-31). ──
833
+ // Cursor's `stop` status can't tell "asked a question" from "finished". The
834
+ // `afterAgentResponse` hook classified the turn and left a per-turn record.
835
+ // Consume it HERE (once) so BOTH the auto-push path and the plain-send path
836
+ // share the same verdict — never double-consuming the record. Precedence:
837
+ // error (status) > awaiting_input (record) > finished. Only for Cursor; an
838
+ // `error` status skips it (a real error outranks a question). Not consumed in
839
+ // --dry-run (so a dry run never eats a real turn's record).
840
+ let turnIsQuestion = false;
841
+ let turnQuestionSnippet;
842
+ if (source === "cursor" && earlyHookStatus !== "error" && !opts.dryRun) {
843
+ const resolveQ = opts.resolveTurnQuestionImpl ?? resolveTurnQuestion;
844
+ try {
845
+ const verdict = await resolveQ(opts.hookStdin, {
846
+ home: opts.home,
847
+ ttlMs: opts.turnStateTtlMs,
848
+ now: opts.now,
849
+ });
850
+ if (verdict.awaiting) {
851
+ turnIsQuestion = true;
852
+ turnQuestionSnippet = verdict.snippet;
853
+ await logLine(`turn is a QUESTION (${verdict.source})`);
854
+ }
855
+ }
856
+ catch {
857
+ /* keep normal finished/push behaviour */
858
+ }
859
+ }
717
860
  // ── Step 3a: auto-push owns the notification for this turn. ──
718
861
  if (autoPushOn) {
719
862
  const pushImpl = opts.pushImpl ?? pushOnStop;
@@ -724,6 +867,9 @@ export async function onStop(opts = {}) {
724
867
  project: projectLabel(repo, cwd),
725
868
  dedupeKey: opts.dedupeKey,
726
869
  dryRun: opts.dryRun,
870
+ // When the turn ended in a question, the push still commits the work but
871
+ // its SUCCESS notification reads as "Agent needs you" (Codex P1).
872
+ ...(turnIsQuestion ? { questionOverride: { snippet: turnQuestionSnippet } } : {}),
727
873
  server: opts.server,
728
874
  fetchImpl: opts.fetchImpl,
729
875
  timeoutMs: opts.timeoutMs,
@@ -736,10 +882,20 @@ export async function onStop(opts = {}) {
736
882
  // in that case send the normal completion/error fallback so enabling
737
883
  // auto-push never makes an otherwise valid end-of-turn notification vanish.
738
884
  if (push.notified !== true && !opts.dryRun) {
739
- const fallbackKind = push.outcome === "commit-failed" || push.outcome === "push-rejected" ||
885
+ let fallbackKind = push.outcome === "commit-failed" || push.outcome === "push-rejected" ||
740
886
  push.outcome === "conflict-aborted"
741
887
  ? "error"
742
888
  : cursorStopStatusToKind(earlyHookStatus);
889
+ // Question precedence (error > awaiting_input > finished): if the push
890
+ // was a benign no-op (clean-tree etc.) AND this Cursor turn was a
891
+ // question (resolved once above), notify as a question, not a plain
892
+ // finish. Never overrides an error outcome.
893
+ let fallbackQuestionSnippet;
894
+ if (fallbackKind !== "error" && turnIsQuestion) {
895
+ fallbackKind = "awaiting_input";
896
+ fallbackQuestionSnippet = turnQuestionSnippet;
897
+ await logLine(`auto-push no-op + QUESTION → awaiting_input fallback`);
898
+ }
743
899
  const fallbackPayload = { kind: fallbackKind, source };
744
900
  const fallbackProject = projectLabel(repo, cwd);
745
901
  if (fallbackProject)
@@ -754,7 +910,11 @@ export async function onStop(opts = {}) {
754
910
  fallbackPayload.chat = chatTitle;
755
911
  const fallbackBody = fallbackKind === "error" && push.detail
756
912
  ? push.detail
757
- : undefined;
913
+ : fallbackKind === "awaiting_input"
914
+ ? fallbackQuestionSnippet
915
+ : undefined;
916
+ if (fallbackKind === "awaiting_input")
917
+ fallbackPayload.title = "Agent needs you";
758
918
  if (fallbackBody)
759
919
  fallbackPayload.body = fallbackBody;
760
920
  const desktopBanner = {
@@ -791,11 +951,23 @@ export async function onStop(opts = {}) {
791
951
  if (push.notified === true) {
792
952
  const project = projectLabel(repo, cwd);
793
953
  const isError = push.outcome === "conflict-aborted" || push.outcome === "push-rejected";
794
- const pushKind = isError ? "error" : "finished";
954
+ // A question turn that ALSO auto-pushed: the phone push already sent
955
+ // `awaiting_input` ("Agent needs you") via `questionOverride`, so the
956
+ // desktop banner must mirror that — NOT say "finished" (Codex P2,
957
+ // 2026-08-31). Error still outranks a question.
958
+ const pushKind = isError
959
+ ? "error"
960
+ : turnIsQuestion
961
+ ? "awaiting_input"
962
+ : "finished";
795
963
  // Decorate WORD-FOR-WORD like the phone push (PRD §3): the auto-push
796
964
  // path leaves `title` to the server default per kind, and folds the
797
965
  // project into title + body exactly as `dispatcher._decorated_*` does.
798
- const rawBody = push.detail || (isError ? "Auto-push needs you" : `Pushed to ${push.branch ?? "branch"}`);
966
+ const rawBody = isError
967
+ ? push.detail || "Auto-push needs you"
968
+ : turnIsQuestion
969
+ ? turnQuestionSnippet || push.detail || `Pushed to ${push.branch ?? "branch"}`
970
+ : push.detail || `Pushed to ${push.branch ?? "branch"}`;
799
971
  desktop = await fireDesktop(settings, {
800
972
  title: decorateTitle({ kind: pushKind, source, project }),
801
973
  body: decorateBody({ kind: pushKind, body: rawBody, project }),
@@ -813,7 +985,16 @@ export async function onStop(opts = {}) {
813
985
  // absent → finished (back-compat: no stdin or unrecognised status)
814
986
  // Reuse the status already parsed at Step 0.5 (single parse per turn).
815
987
  const hookStatus = earlyHookStatus;
816
- const sendKind = cursorStopStatusToKind(hookStatus);
988
+ let sendKind = cursorStopStatusToKind(hookStatus);
989
+ // Apply the once-per-turn question verdict resolved above (error > question >
990
+ // finished). `turnIsQuestion` is only ever true for a Cursor, non-error,
991
+ // non-dry-run turn, so this never mis-classifies a completion or an error.
992
+ let questionSnippet;
993
+ if (sendKind !== "error" && turnIsQuestion) {
994
+ sendKind = "awaiting_input";
995
+ questionSnippet = turnQuestionSnippet;
996
+ await logLine(`turn QUESTION → awaiting_input ping`);
997
+ }
817
998
  if (opts.dryRun) {
818
999
  await logLine(`dry-run: would send ${sendKind} (auto-push off, source: ${source}, settings: ${resolved.source})`);
819
1000
  return { mode: "dry-run-send", settingsSource: resolved.source, repo };
@@ -848,6 +1029,13 @@ export async function onStop(opts = {}) {
848
1029
  const project = projectLabel(repo, cwd);
849
1030
  if (project)
850
1031
  payload.project = project;
1032
+ // A question ping gets the "Agent needs you" title + the sanitized question
1033
+ // snippet as its body, so the phone/desktop reads as a QUESTION not a finish.
1034
+ if (sendKind === "awaiting_input") {
1035
+ payload.title = "Agent needs you";
1036
+ if (questionSnippet)
1037
+ payload.body = questionSnippet;
1038
+ }
851
1039
  if (opts.dedupeKey)
852
1040
  payload.dedupe_key = opts.dedupeKey;
853
1041
  // Deep-link target: the synced chat's id (so tapping the push opens the
@@ -872,7 +1060,9 @@ export async function onStop(opts = {}) {
872
1060
  // ("Agent finished" / …) flows through identically on both surfaces.
873
1061
  const desktopBanner = {
874
1062
  title: decorateTitle({ kind: sendKind, source, project }),
875
- body: decorateBody({ kind: sendKind, project, chat: chatTitle }),
1063
+ body: sendKind === "awaiting_input"
1064
+ ? decorateBody({ kind: sendKind, body: questionSnippet, project, chat: chatTitle })
1065
+ : decorateBody({ kind: sendKind, project, chat: chatTitle }),
876
1066
  kind: sendKind,
877
1067
  };
878
1068
  const [sent, desktop] = await Promise.all([
package/dist/src/push.js CHANGED
@@ -287,17 +287,31 @@ export async function pushOnStop(opts = {}) {
287
287
  detail: errorBody,
288
288
  };
289
289
  }
290
- // ── Step 9: success → ONE finished notification carrying the commit summary. ──
290
+ // ── Step 9: success → ONE notification carrying the commit summary. ──
291
+ // If this Cursor turn ended in a QUESTION, the notification must read as
292
+ // "Agent needs you" (awaiting_input) even though we still pushed the work —
293
+ // the user's next action is answering, not admiring the push (Codex P1,
294
+ // 2026-08-31). Otherwise it's the normal finished push notification.
291
295
  const subject = subjectOf(composed.message);
292
296
  const where = sha ? `${branch} @ ${sha}` : branch;
293
- const res = await sendImpl({
294
- kind: "finished",
295
- title: `Auto-pushed to ${branch}`,
296
- body: `${subject} — ${where}`,
297
- source,
298
- project: opts.project,
299
- dedupe_key: opts.dedupeKey,
300
- });
297
+ const isQuestion = opts.questionOverride !== undefined;
298
+ const res = await sendImpl(isQuestion
299
+ ? {
300
+ kind: "awaiting_input",
301
+ title: "Agent needs you",
302
+ body: opts.questionOverride?.snippet ?? `Pushed to ${where}`,
303
+ source,
304
+ project: opts.project,
305
+ dedupe_key: opts.dedupeKey,
306
+ }
307
+ : {
308
+ kind: "finished",
309
+ title: `Auto-pushed to ${branch}`,
310
+ body: `${subject} — ${where}`,
311
+ source,
312
+ project: opts.project,
313
+ dedupe_key: opts.dedupeKey,
314
+ });
301
315
  await logLine(`pushed ${files.length} file(s) to ${remote} ${where} (${composed.generator})`);
302
316
  return {
303
317
  outcome: "pushed",
@@ -0,0 +1,225 @@
1
+ // Question / awaiting-input classifier for an agent's final response text.
2
+ //
3
+ // WHY THIS EXISTS
4
+ // Cursor gives us NO clean boolean for "the agent asked the user a question and
5
+ // is now waiting." Empirically (427k captured Cursor 3.16.17 hook events, 2026-08):
6
+ // • The `AskQuestion` tool NEVER fires through `postToolUse` (the documented
7
+ // upstream bug is still live), so the historical question hook is dead.
8
+ // • Cursor has no `Notification` event.
9
+ // • The `stop` hook reports only `status: completed | aborted | error` — a turn
10
+ // that ENDS by asking a question still reports `completed`, identical to a
11
+ // turn that finished the work. So on-stop always sent "finished" and every
12
+ // question looked like a completion (the exact "questions don't notify" bug).
13
+ // The ONLY signal is the CONTENT of the agent's final message, delivered in the
14
+ // `afterAgentResponse` hook's `text` field. This module classifies that text.
15
+ //
16
+ // DESIGN (Codex review 2026-08-31): PRECISION-FIRST. The user strongly prefers
17
+ // NO false/noisy notifications. A false "finished" on a real question is only a
18
+ // missed-question (recoverable — the chat is still there), but a false "Agent
19
+ // needs you" on a plain completion is NOISE the user hates. So we fire
20
+ // awaiting_input ONLY when the FINAL meaningful clause of the message is a
21
+ // direct, actionable question or an explicit blocking request — never because
22
+ // some earlier sentence, a heading, a URL, or a code sample contains a "?".
23
+ //
24
+ // Zero runtime deps — pure string logic, no I/O. Fully unit-testable.
25
+ /**
26
+ * Phrases that, when they OPEN the final clause, mark it as a direct ask to
27
+ * proceed — even if punctuation is loose. Matched case-insensitively at the
28
+ * START of the final clause (after trimming list bullets / emphasis). These are
29
+ * the real "want me to do X?" asks the user cares about.
30
+ */
31
+ const DIRECT_QUESTION_OPENERS = [
32
+ "want me to",
33
+ "would you like",
34
+ "do you want",
35
+ "should i",
36
+ "shall i",
37
+ "do you want me to",
38
+ "would you prefer",
39
+ "which would you",
40
+ "which do you",
41
+ "how would you like",
42
+ "what would you like",
43
+ "can you confirm",
44
+ "could you confirm",
45
+ "are you ok with",
46
+ "is it ok if",
47
+ "is that ok",
48
+ "does that work",
49
+ "does this look",
50
+ "did you want",
51
+ ];
52
+ /**
53
+ * Explicit blocking requests that mean "I will not proceed until you respond,"
54
+ * even when they do NOT end in a question mark. Matched anywhere in the final
55
+ * clause (case-insensitive). Kept tight to avoid false positives.
56
+ */
57
+ const EXPLICIT_CONFIRMATION_PATTERNS = [
58
+ /\bplease\s+confirm\b/i,
59
+ /\bplease\s+(let me know|advise|choose|pick|select|decide)\b/i,
60
+ /\bi\s+need\s+your\s+(approval|confirmation|input|decision|sign-?off)\b/i,
61
+ /\b(need|awaiting|waiting on|waiting for)\s+your\s+(approval|confirmation|input|decision|go-?ahead|sign-?off)\b/i,
62
+ /\bchoose\s+(one|an option|between)\b/i,
63
+ /\breply\s+with\b/i,
64
+ /\blet me know (which|whether|if you'?d like|if you want)\b/i,
65
+ /\bconfirm\s+(before|and i'?ll|and then)\b/i,
66
+ ];
67
+ /**
68
+ * Generic courtesy / sign-off lines that END in a question mark or look like an
69
+ * ask but are NOT a real blocking question — the agent is done and merely being
70
+ * polite. These must NEVER fire an awaiting_input ping (user hates the noise).
71
+ */
72
+ const COURTESY_NONQUESTIONS = [
73
+ /^let me know if (you|there)/i,
74
+ /^let me know if you (need|want|have)/i,
75
+ /^(is there )?anything else/i,
76
+ /^happy to (help|assist|dig|explain)/i,
77
+ /^feel free to/i,
78
+ /^hope (this|that) helps/i,
79
+ // "…anything else?" family — a polite sign-off, NOT a blocking question, no
80
+ // matter which opener precedes it (Would you like / Do you need / Can I help /
81
+ // Is there / Want me to help with … anything else?).
82
+ /\banything else\b[^?]*\??$/i,
83
+ /^(do you|would you like|can i|shall i|should i)\b[^?]*\bhelp\b[^?]*\?$/i,
84
+ /^(let me know|just say the word|say the word)\b/i,
85
+ ];
86
+ /**
87
+ * Strip fenced code blocks, inline code, block quotes, markdown headings, URLs,
88
+ * and horizontal rules from `text` so the classifier reasons about PROSE only.
89
+ * A "?" inside a code sample, a URL query string, or a rhetorical `## Heading?`
90
+ * must not be mistaken for the agent asking the user something.
91
+ */
92
+ export function stripNonProse(text) {
93
+ let t = text;
94
+ // Fenced code blocks ``` ... ``` (including language tag). Non-greedy, multi-line.
95
+ t = t.replace(/```[\s\S]*?```/g, " ");
96
+ // Indented/tilde fences ~~~ ... ~~~.
97
+ t = t.replace(/~~~[\s\S]*?~~~/g, " ");
98
+ // Inline code `...`.
99
+ t = t.replace(/`[^`]*`/g, " ");
100
+ // URLs (http/https) — a trailing ? in a query string is not a question.
101
+ t = t.replace(/https?:\/\/\S+/gi, " ");
102
+ // Markdown link/image targets (…)(url) — drop the parenthesised target.
103
+ t = t.replace(/\]\([^)]*\)/g, "] ");
104
+ // Horizontal rules.
105
+ t = t.replace(/^\s*([-*_])\1{2,}\s*$/gm, " ");
106
+ return t;
107
+ }
108
+ /**
109
+ * Extract the FINAL meaningful prose clause of a response — the sentence or list
110
+ * item the agent ends on. This is what determines whether the turn is a question:
111
+ * the agent's PARTING line. We:
112
+ * 1. strip non-prose (code/URLs/quotes/headings),
113
+ * 2. drop blank lines and pure markdown-heading lines,
114
+ * 3. take the last non-empty line,
115
+ * 4. from that line, take the last sentence (split on . ! ? but keep a trailing ?).
116
+ * Returns "" when there is no prose to reason about.
117
+ */
118
+ export function finalClause(text) {
119
+ const prose = stripNonProse(text);
120
+ const lines = prose
121
+ .split(/\r?\n/)
122
+ .map((l) => l.trim())
123
+ // drop bullets/numbering + emphasis so an opener like "- **Want me to…**" matches
124
+ .map((l) => l.replace(/^[-*+]\s+/, "").replace(/^\d+[.)]\s+/, ""))
125
+ .filter((l) => l !== "")
126
+ // drop pure heading lines (## ...) and blockquote markers (> ...)
127
+ .filter((l) => !/^#{1,6}\s/.test(l) && !/^>\s?/.test(l));
128
+ if (lines.length === 0)
129
+ return "";
130
+ const lastLine = lines[lines.length - 1].replace(/[*_`]+/g, "").trim();
131
+ if (lastLine === "")
132
+ return "";
133
+ // Take the last sentence of the last line. Split on sentence terminators but
134
+ // preserve the terminator context so "…done. Want me to X?" → "Want me to X?".
135
+ const parts = lastLine.split(/(?<=[.!?])\s+/);
136
+ const last = parts[parts.length - 1]?.trim() ?? lastLine;
137
+ return last;
138
+ }
139
+ /** Lowercased, punctuation-tolerant prefix test for the direct-question openers. */
140
+ function startsWithDirectOpener(clause) {
141
+ const c = clause.toLowerCase().replace(/^[^a-z]+/, ""); // drop leading emoji/space
142
+ return DIRECT_QUESTION_OPENERS.some((opener) => c.startsWith(opener));
143
+ }
144
+ /** True when the clause is a generic courtesy sign-off we must NOT ping for. */
145
+ function isCourtesyNonQuestion(clause) {
146
+ return COURTESY_NONQUESTIONS.some((re) => re.test(clause.trim()));
147
+ }
148
+ /**
149
+ * Build a short, SAFE notification snippet from the question clause: collapse
150
+ * whitespace, strip anything that looks like a filesystem path or secret-ish
151
+ * token, and cap length so a phone lock-screen never leaks repo internals or
152
+ * overflows. Returns undefined when nothing sensible remains.
153
+ */
154
+ export function questionSnippet(clause, maxLen = 160) {
155
+ let s = clause.replace(/\s+/g, " ").trim();
156
+ if (s === "")
157
+ return undefined;
158
+ // Redact absolute-ish paths (…/a/b/c) and long hex/token blobs from the body.
159
+ s = s.replace(/(?:\/[\w.-]+){2,}\/?/g, "…").replace(/\b[0-9a-f]{16,}\b/gi, "…");
160
+ s = s.replace(/\s+/g, " ").trim();
161
+ if (s === "")
162
+ return undefined;
163
+ if (s.length > maxLen)
164
+ s = s.slice(0, maxLen - 1).trimEnd() + "…";
165
+ return s;
166
+ }
167
+ /**
168
+ * Classify an agent's final response `text` as awaiting-input (a question the
169
+ * user must answer) or not. Precision-first: fires ONLY on a terminal direct
170
+ * question or an explicit blocking request. Never throws.
171
+ *
172
+ * Rules (in order):
173
+ * 1. Empty / non-prose-only text → NOT awaiting.
174
+ * 2. Final clause is a generic courtesy sign-off → NOT awaiting (even if it
175
+ * ends in "?", e.g. "Anything else you need?").
176
+ * 3. Final clause opens with a direct-question opener ("Want me to…", "Should
177
+ * I…") — even without a "?" (loose punctuation) — OR ends in "?" while being
178
+ * interrogative → awaiting (direct question).
179
+ * 4. Final clause matches an explicit confirmation/approval request → awaiting.
180
+ * 5. Otherwise → NOT awaiting.
181
+ */
182
+ export function classifyResponse(text) {
183
+ if (!text || text.trim() === "")
184
+ return { awaiting: false };
185
+ const clause = finalClause(text);
186
+ if (clause === "")
187
+ return { awaiting: false };
188
+ // (2) Generic courtesy sign-off — never a blocking question.
189
+ if (isCourtesyNonQuestion(clause))
190
+ return { awaiting: false };
191
+ const endsWithQuestionMark = /\?\s*$/.test(clause);
192
+ const opensDirect = startsWithDirectOpener(clause);
193
+ // (3) Direct question: a recognised opener ("Want me to…", "Should I…") is a
194
+ // real ask-to-proceed EVEN IF the model dropped the "?" (punctuation is
195
+ // loose). We still exclude the courtesy "…anything else" family (handled
196
+ // at step 2 above), so this only fires on a genuine actionable opener.
197
+ if (opensDirect) {
198
+ return {
199
+ awaiting: true,
200
+ reason: "terminal_direct_question",
201
+ snippet: questionSnippet(clause),
202
+ };
203
+ }
204
+ if (endsWithQuestionMark) {
205
+ const c = clause.toLowerCase().replace(/^[^a-z]+/, "");
206
+ const interrogativeLead = /^(do|does|did|are|is|was|were|can|could|will|would|should|shall|have|has|which|what|when|where|who|why|how)\b/.test(c) || opensDirect;
207
+ if (interrogativeLead) {
208
+ return {
209
+ awaiting: true,
210
+ reason: "terminal_direct_question",
211
+ snippet: questionSnippet(clause),
212
+ };
213
+ }
214
+ }
215
+ // (4) Explicit blocking confirmation/approval request (may lack a "?").
216
+ if (EXPLICIT_CONFIRMATION_PATTERNS.some((re) => re.test(clause))) {
217
+ return {
218
+ awaiting: true,
219
+ reason: "explicit_confirmation_request",
220
+ snippet: questionSnippet(clause),
221
+ };
222
+ }
223
+ // (5) Not a question we should ping for.
224
+ return { awaiting: false };
225
+ }
@@ -0,0 +1,222 @@
1
+ // Per-turn state hand-off between the Cursor `afterAgentResponse` hook and the
2
+ // `stop` hook (Codex-reviewed design, 2026-08-31).
3
+ //
4
+ // THE PROBLEM
5
+ // Cursor fires TWO hooks at the end of a turn: `afterAgentResponse` (carries the
6
+ // agent's final message `text`) and `stop` (carries only status, no text). Only
7
+ // `afterAgentResponse` can tell us whether the turn ended by ASKING A QUESTION,
8
+ // but `stop` is the single notification owner (it resolves settings, runs
9
+ // auto-push, handles abort/dedupe/deep-link/desktop). So `afterAgentResponse`
10
+ // must CLASSIFY the turn and hand the verdict to `stop`, which then sends
11
+ // exactly ONE notification of the right kind (error > awaiting_input > finished).
12
+ //
13
+ // THE MECHANISM
14
+ // `afterAgentResponse` writes a tiny per-turn record keyed by conversation_id.
15
+ // `stop` reads the FRESH record for that conversation, decides the kind, then
16
+ // CONSUMES (deletes) it. A record is written for EVERY response (question or
17
+ // completion) so a stale question record is always overwritten by the next turn.
18
+ //
19
+ // SAFETY (guardrail: never block the turn, never throw):
20
+ // • atomic write via temp-file + rename (never a half-written record read by
21
+ // a racing `stop`);
22
+ // • dir 0700, file 0600;
23
+ // • short freshness window (default 30s) — a record older than that is ignored
24
+ // and cleaned up, so a question record left behind when `stop` never fires
25
+ // can never mis-fire a future turn;
26
+ // • primarily keyed by conversation_id (NOT exact generation identity) because
27
+ // `stop`'s payload may not echo the same generation_id; generation_id is
28
+ // stored as advisory/debug only;
29
+ // • opportunistic stale-record sweep on every read/write.
30
+ //
31
+ // Zero runtime deps beyond Node built-ins. The fs layer is injectable so this is
32
+ // unit-testable with no real filesystem.
33
+ import { createHash } from "node:crypto";
34
+ import { promises as nodeFs } from "node:fs";
35
+ import path from "node:path";
36
+ import os from "node:os";
37
+ import { gocodeDir } from "./creds.js";
38
+ /** Default freshness window (ms): a record older than this is ignored + swept. */
39
+ export const DEFAULT_TURN_STATE_TTL_MS = 30_000;
40
+ /** Directory holding per-turn records (`~/.gocode/turn-state/`). */
41
+ export function turnStateDir(opts) {
42
+ return path.join(gocodeDir(opts), "turn-state");
43
+ }
44
+ /**
45
+ * Absolute path to the record file for `conversationId`. The id is hashed
46
+ * (sha256 prefix) so the filename is filesystem-safe regardless of the id's
47
+ * characters. One file per conversation → the latest turn always overwrites the
48
+ * previous, so a stale question record can never survive into the next turn.
49
+ */
50
+ export function turnStatePath(conversationId, opts) {
51
+ const hash = createHash("sha256").update(conversationId).digest("hex").slice(0, 24);
52
+ return path.join(turnStateDir(opts), `${hash}.json`);
53
+ }
54
+ function realFs() {
55
+ return {
56
+ readFile: (f, e) => nodeFs.readFile(f, e),
57
+ writeFile: (f, d, o) => nodeFs.writeFile(f, d, o),
58
+ rename: (a, b) => nodeFs.rename(a, b),
59
+ mkdir: (d, o) => nodeFs.mkdir(d, o),
60
+ rm: (f, o) => nodeFs.rm(f, o),
61
+ readdir: (d) => nodeFs.readdir(d),
62
+ stat: async (f) => {
63
+ const s = await nodeFs.stat(f);
64
+ return { mtimeMs: s.mtimeMs };
65
+ },
66
+ };
67
+ }
68
+ /**
69
+ * Sweep records older than the freshness window. Best-effort + total: never
70
+ * throws. Called opportunistically by read/write so an abandoned record (e.g. a
71
+ * `stop` that never fired) can't linger and mis-fire a later turn.
72
+ */
73
+ export async function sweepStaleTurnState(opts = {}) {
74
+ const fs = opts.fsImpl ?? realFs();
75
+ const ttl = opts.ttlMs ?? DEFAULT_TURN_STATE_TTL_MS;
76
+ const now = (opts.now ?? Date.now)();
77
+ const dir = turnStateDir(opts);
78
+ let files;
79
+ try {
80
+ files = await fs.readdir(dir);
81
+ }
82
+ catch {
83
+ return; // no dir yet → nothing to sweep
84
+ }
85
+ await Promise.all(files
86
+ .filter((f) => f.endsWith(".json"))
87
+ .map(async (f) => {
88
+ const full = path.join(dir, f);
89
+ try {
90
+ const st = await fs.stat(full);
91
+ if (now - st.mtimeMs > ttl)
92
+ await fs.rm(full, { force: true });
93
+ }
94
+ catch {
95
+ // ignore per-file errors — sweeping is best-effort
96
+ }
97
+ }));
98
+ }
99
+ /**
100
+ * Persist the per-turn classification for `conversationId`, atomically. Writes a
101
+ * temp file then renames it over the target so a racing `stop` read never sees a
102
+ * half-written record. Best-effort + total: never throws (a failed write just
103
+ * means `stop` falls back to its transcript/status heuristics).
104
+ */
105
+ export async function writeTurnState(record, opts = {}) {
106
+ if (!record.conversation_id || record.conversation_id.trim() === "")
107
+ return;
108
+ const fs = opts.fsImpl ?? realFs();
109
+ const dir = turnStateDir(opts);
110
+ const target = turnStatePath(record.conversation_id, opts);
111
+ const tmp = `${target}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
112
+ try {
113
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
114
+ await fs.writeFile(tmp, JSON.stringify(record), { mode: 0o600 });
115
+ await fs.rename(tmp, target);
116
+ }
117
+ catch {
118
+ // Best-effort: try to clean up the temp file, then give up silently.
119
+ try {
120
+ await fs.rm(tmp, { force: true });
121
+ }
122
+ catch {
123
+ /* ignore */
124
+ }
125
+ }
126
+ // Opportunistic sweep so abandoned records never accumulate.
127
+ await sweepStaleTurnState(opts).catch(() => undefined);
128
+ }
129
+ /**
130
+ * Read (and by default CONSUME) the fresh per-turn record for `conversationId`.
131
+ * Returns undefined when there is no record, it is stale (> ttl), or it is
132
+ * unreadable/corrupt — in every such case `stop` falls back to its normal
133
+ * status/transcript heuristics. Never throws.
134
+ *
135
+ * `consume` (default true): delete the record after a successful read so it can
136
+ * only drive ONE notification. Pass false for a non-destructive peek (tests).
137
+ */
138
+ export async function readTurnState(conversationId, opts = {}) {
139
+ if (!conversationId || conversationId.trim() === "")
140
+ return undefined;
141
+ const fs = opts.fsImpl ?? realFs();
142
+ const ttl = opts.ttlMs ?? DEFAULT_TURN_STATE_TTL_MS;
143
+ const now = (opts.now ?? Date.now)();
144
+ const file = turnStatePath(conversationId, opts);
145
+ let raw;
146
+ try {
147
+ raw = await fs.readFile(file, "utf8");
148
+ }
149
+ catch {
150
+ return undefined; // ENOENT (common) or unreadable → no record
151
+ }
152
+ let rec;
153
+ try {
154
+ const parsed = JSON.parse(raw);
155
+ if (parsed &&
156
+ typeof parsed.conversation_id === "string" &&
157
+ (parsed.classification === "awaiting_input" || parsed.classification === "finished") &&
158
+ typeof parsed.created_at === "number") {
159
+ rec = parsed;
160
+ }
161
+ }
162
+ catch {
163
+ rec = undefined; // corrupt → treat as absent
164
+ }
165
+ // Delete-after-read (consume) so a record drives at most one notification —
166
+ // AND so a corrupt/stale record is cleared rather than re-evaluated forever.
167
+ const shouldConsume = opts.consume !== false;
168
+ if (shouldConsume) {
169
+ try {
170
+ await fs.rm(file, { force: true });
171
+ }
172
+ catch {
173
+ /* ignore */
174
+ }
175
+ }
176
+ // Freshness gate AFTER consume: a stale record is ignored (and now removed).
177
+ if (!rec || now - rec.created_at > ttl || now - rec.created_at < -ttl) {
178
+ await sweepStaleTurnState(opts).catch(() => undefined);
179
+ return undefined;
180
+ }
181
+ return rec;
182
+ }
183
+ /**
184
+ * Extract the Cursor `conversation_id` (primary key) from a hook stdin payload.
185
+ * Accepts the documented spellings so both `afterAgentResponse` and `stop`
186
+ * resolve the SAME key. Falls back to the transcript filename (minus `.jsonl`)
187
+ * which both hooks carry, so a payload missing `conversation_id` still matches.
188
+ * Returns undefined when nothing usable is present. Never throws.
189
+ */
190
+ export function conversationIdFromHookStdin(hookStdin) {
191
+ if (!hookStdin || hookStdin.trim() === "")
192
+ return undefined;
193
+ let p;
194
+ try {
195
+ const parsed = JSON.parse(hookStdin);
196
+ if (!parsed || typeof parsed !== "object")
197
+ return undefined;
198
+ p = parsed;
199
+ }
200
+ catch {
201
+ return undefined;
202
+ }
203
+ const str = (...vals) => {
204
+ for (const v of vals)
205
+ if (typeof v === "string" && v.trim() !== "")
206
+ return v.trim();
207
+ return undefined;
208
+ };
209
+ const direct = str(p.conversation_id, p.conversationId, p.session_id, p.sessionId);
210
+ if (direct)
211
+ return direct;
212
+ const transcriptPath = str(p.transcript_path, p.transcriptPath);
213
+ if (transcriptPath) {
214
+ const base = path.basename(transcriptPath).replace(/\.jsonl$/i, "");
215
+ return base || undefined;
216
+ }
217
+ return undefined;
218
+ }
219
+ /** Home dir helper (kept local so this module has no import cycle). */
220
+ export function homeDir() {
221
+ return os.homedir();
222
+ }
@@ -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.8";
2
+ export const VERSION = "0.6.9";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trygocode/notify",
3
- "version": "0.6.8",
3
+ "version": "0.6.9",
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",