@trygocode/notify 0.6.7 → 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 +34 -0
- package/dist/src/cli.js +127 -3
- package/dist/src/cursor.js +68 -1
- package/dist/src/on_stop.js +196 -6
- package/dist/src/push.js +23 -9
- package/dist/src/question_classifier.js +225 -0
- package/dist/src/turn_state.js +222 -0
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -530,6 +530,40 @@ 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
|
+
|
|
552
|
+
### 0.6.8
|
|
553
|
+
|
|
554
|
+
- **Fixed: Claude questions inside Cursor now notify.** When Claude Code runs
|
|
555
|
+
inside Cursor and asks a question (permission prompt, idle/elicitation, agent
|
|
556
|
+
needs input), the notification was being **suppressed** because the imported
|
|
557
|
+
Claude `Notification` payload carries `cursor_version`. That suppression is
|
|
558
|
+
correct for *completions* (Cursor's own `stop` hook already fires, so the
|
|
559
|
+
imported Claude `Stop` would double-notify) — but **wrong for questions**:
|
|
560
|
+
Cursor's native question hook (`AskQuestion` via `postToolUse`) is broken
|
|
561
|
+
upstream and Cursor has no `Notification` event, so the imported Claude
|
|
562
|
+
`Notification` is the *only* question signal. `on-notification` now always
|
|
563
|
+
forwards a genuine input-requiring notification, even when Cursor imported it.
|
|
564
|
+
The non-input `notification_type` filter and per-question `--dedupe-key` still
|
|
565
|
+
apply, so no extra noise or duplicates.
|
|
566
|
+
|
|
533
567
|
### 0.6.7
|
|
534
568
|
|
|
535
569
|
- **Notification project name now matches your IDE.** The per-turn completion
|
package/dist/src/cli.js
CHANGED
|
@@ -20,7 +20,9 @@ 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,
|
|
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",
|
|
@@ -795,8 +799,23 @@ export async function cmdOnNotification(args, deps = {}) {
|
|
|
795
799
|
const hookStdin = deps.hookStdin !== undefined
|
|
796
800
|
? deps.hookStdin
|
|
797
801
|
: await (deps.readStdin ?? readStdinIfPipe)();
|
|
798
|
-
|
|
799
|
-
|
|
802
|
+
// NOTE (2026-07-28): we DELIBERATELY do NOT suppress Cursor-imported Claude
|
|
803
|
+
// hooks here (unlike the `on-stop`/completion path). The `cursor_version`
|
|
804
|
+
// suppression exists to avoid DOUBLE notifications when BOTH a native Cursor
|
|
805
|
+
// hook AND the imported Claude hook fire for the same turn. That double-fire
|
|
806
|
+
// only happens for COMPLETION: Cursor's native `stop` hook works, so its
|
|
807
|
+
// imported Claude `Stop` would duplicate it → suppress.
|
|
808
|
+
//
|
|
809
|
+
// For QUESTIONS there is no competing native hook to dedupe against: Cursor's
|
|
810
|
+
// own question signal (the `AskQuestion` `postToolUse` hook) is BROKEN upstream
|
|
811
|
+
// (Cursor docs: "postToolUse ... excluding the AskQuestion tool, which is a
|
|
812
|
+
// known bug") and Cursor has NO `Notification` event at all. So when Claude
|
|
813
|
+
// runs inside Cursor and asks a question, the imported Claude `Notification`
|
|
814
|
+
// hook is the ONLY signal we get. Suppressing it (the old behaviour) silently
|
|
815
|
+
// dropped every in-Cursor Claude question — the exact "questions don't notify"
|
|
816
|
+
// regression. We now always forward a genuine input-requiring notification,
|
|
817
|
+
// regardless of whether Cursor imported it. The per-turn `--dedupe-key` still
|
|
818
|
+
// coalesces any accidental repeat of the SAME question.
|
|
800
819
|
const notificationType = parseClaudeNotificationType(hookStdin);
|
|
801
820
|
if (notificationType !== undefined &&
|
|
802
821
|
!CLAUDE_INPUT_NOTIFICATION_TYPES.has(notificationType)) {
|
|
@@ -819,6 +838,109 @@ export async function cmdOnNotification(args, deps = {}) {
|
|
|
819
838
|
forwarded.push("--server", server);
|
|
820
839
|
return (deps.sendCommandImpl ?? cmdSend)(forwarded, deps);
|
|
821
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
|
+
}
|
|
822
944
|
/**
|
|
823
945
|
* Handle `gocode-notify launch "<task>" [--repo owner/repo] [--branch suffix]
|
|
824
946
|
* [--prd-file path.md] [--model "Profile"] [--runner-kind K] [--server URL]
|
|
@@ -958,6 +1080,8 @@ export async function runAsync(argv) {
|
|
|
958
1080
|
return cmdOnStop(argv.slice(1));
|
|
959
1081
|
if (cmd === "on-notification")
|
|
960
1082
|
return cmdOnNotification(argv.slice(1));
|
|
1083
|
+
if (cmd === "on-agent-response")
|
|
1084
|
+
return cmdOnAgentResponse(argv.slice(1));
|
|
961
1085
|
if (cmd === "launch" || cmd === "autopilot")
|
|
962
1086
|
return cmdLaunch(argv.slice(1));
|
|
963
1087
|
return run(argv);
|
package/dist/src/cursor.js
CHANGED
|
@@ -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) {
|
package/dist/src/on_stop.js
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
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
+
}
|
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.9";
|
package/package.json
CHANGED