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