@quantiya/codevibe-antigravity-plugin 2.0.84 → 2.0.85
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 +5 -4
- package/dist/agy-hook.js +191 -18
- package/dist/installer-cli.js +14 -3
- package/dist/server.js +2145 -1318
- package/libexec/agy-hook.sh +23 -7
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -88,19 +88,19 @@ var os2 = __toESM(require("os"));
|
|
|
88
88
|
var path2 = __toESM(require("path"));
|
|
89
89
|
var HOOK_ENTRY_NAME = "codevibe-antigravity";
|
|
90
90
|
var HOOK_ROUTE_PREFIX = "/agy-hook/v1/";
|
|
91
|
-
var HOOK_EVENTS = ["PreInvocation", "PostToolUse"];
|
|
91
|
+
var HOOK_EVENTS = ["PreInvocation", "PostToolUse", "PreToolUse", "Stop"];
|
|
92
92
|
var HOOK_SHELL_BASENAME = "agy-hook.sh";
|
|
93
93
|
var HOOK_SCRIPT_BASENAME = "agy-hook.js";
|
|
94
94
|
function isHookEvent(value) {
|
|
95
95
|
return typeof value === "string" && HOOK_EVENTS.includes(value);
|
|
96
96
|
}
|
|
97
|
-
function codevibeAgyDir(home = os2.homedir()) {
|
|
97
|
+
function codevibeAgyDir(home = process.env.HOME || os2.homedir()) {
|
|
98
98
|
return path2.join(home, ".codevibe", "codevibe-agy");
|
|
99
99
|
}
|
|
100
|
-
function hooksDir(home = os2.homedir()) {
|
|
100
|
+
function hooksDir(home = process.env.HOME || os2.homedir()) {
|
|
101
101
|
return path2.join(codevibeAgyDir(home), "hooks");
|
|
102
102
|
}
|
|
103
|
-
function globalHooksConfigPath(home = os2.homedir()) {
|
|
103
|
+
function globalHooksConfigPath(home = process.env.HOME || os2.homedir()) {
|
|
104
104
|
return path2.join(home, ".gemini", "config", "hooks.json");
|
|
105
105
|
}
|
|
106
106
|
|
|
@@ -1987,7 +1987,7 @@ var ConversationRouter = class {
|
|
|
1987
1987
|
dropped: entry.overflowed
|
|
1988
1988
|
});
|
|
1989
1989
|
}
|
|
1990
|
-
if (decision) this.options.onRelease(entry.items, decision);
|
|
1990
|
+
if (decision) this.options.onRelease(entry.items, decision, conversationId);
|
|
1991
1991
|
}
|
|
1992
1992
|
expire(conversationId) {
|
|
1993
1993
|
const entry = this.held.get(conversationId);
|
|
@@ -2019,7 +2019,7 @@ var ConversationRouter = class {
|
|
|
2019
2019
|
entry.items = entry.items.slice(tailStart);
|
|
2020
2020
|
if (earlier.length > 0) {
|
|
2021
2021
|
if (this.roles.get(conversationId) === "main" && !this.movedElsewhere(conversationId) && !entry.moved) {
|
|
2022
|
-
this.options.onRelease(earlier, "process");
|
|
2022
|
+
this.options.onRelease(earlier, "process", conversationId);
|
|
2023
2023
|
} else {
|
|
2024
2024
|
this.options.onLog?.("Dropping agy events held from an earlier turn of another session", {
|
|
2025
2025
|
conversationId,
|
|
@@ -2224,6 +2224,14 @@ function buildHookEntry(hooksFolder) {
|
|
|
2224
2224
|
PreInvocation: [
|
|
2225
2225
|
{ type: "command", command: `${shell} PreInvocation`, timeout: HOOK_TIMEOUT_SECONDS }
|
|
2226
2226
|
],
|
|
2227
|
+
PreToolUse: [
|
|
2228
|
+
{
|
|
2229
|
+
matcher: "^(?:ask_question|question|ask)$",
|
|
2230
|
+
hooks: [
|
|
2231
|
+
{ type: "command", command: `${shell} PreToolUse`, timeout: HOOK_TIMEOUT_SECONDS }
|
|
2232
|
+
]
|
|
2233
|
+
}
|
|
2234
|
+
],
|
|
2227
2235
|
PostToolUse: [
|
|
2228
2236
|
{
|
|
2229
2237
|
matcher: "*",
|
|
@@ -2231,6 +2239,9 @@ function buildHookEntry(hooksFolder) {
|
|
|
2231
2239
|
{ type: "command", command: `${shell} PostToolUse`, timeout: HOOK_TIMEOUT_SECONDS }
|
|
2232
2240
|
]
|
|
2233
2241
|
}
|
|
2242
|
+
],
|
|
2243
|
+
Stop: [
|
|
2244
|
+
{ type: "command", command: `${shell} Stop`, timeout: HOOK_TIMEOUT_SECONDS }
|
|
2234
2245
|
]
|
|
2235
2246
|
};
|
|
2236
2247
|
}
|
|
@@ -3159,7 +3170,7 @@ function isAgyStatusFooterLine(trimmed) {
|
|
|
3159
3170
|
if (/^\s*(?:[❯>]\s*)?\?\s+for shortcuts\b/i.test(trimmed)) {
|
|
3160
3171
|
return true;
|
|
3161
3172
|
}
|
|
3162
|
-
if (/^\s*esc to cancel
|
|
3173
|
+
if (/^\s*esc to cancel/i.test(trimmed)) {
|
|
3163
3174
|
return true;
|
|
3164
3175
|
}
|
|
3165
3176
|
if (/^(?:\(initializing\.\.\.\)|\d+\s+task\(s\)\s+·\s+\/tasks|\d+\s+subagent\s*\(s\)|AI:\s+(?:\d+\s+\(\/credits to add more\)|Out of credits)|AI Credits:\s+\d+|Quota:\s*\d+(?:\.\d+)?%)$/i.test(trimmed)) {
|
|
@@ -3258,7 +3269,7 @@ function reconstructFooter(rawLines) {
|
|
|
3258
3269
|
}
|
|
3259
3270
|
const hasMiddleDot = cleaned.some((l) => l.includes("\xB7"));
|
|
3260
3271
|
const hasFooterKeyword = cleaned.some(
|
|
3261
|
-
(l) => /^\s*(?:\?\s+for shortcuts|esc to cancel)
|
|
3272
|
+
(l) => /^\s*(?:\?\s+for shortcuts|esc to cancel)/i.test(l) || /\b(?:auto|accept-edits|plan|Gemini|Claude|GPT|Gemma|high|low|subagent|quota|credits)\b/i.test(l)
|
|
3262
3273
|
);
|
|
3263
3274
|
if (!hasMiddleDot && !hasFooterKeyword) {
|
|
3264
3275
|
return cleaned.join(" ").replace(/\s*·\s*/g, " \xB7 ").trim();
|
|
@@ -3346,7 +3357,7 @@ function isSystemErrorActiveFromIndex(lines, errorIdx) {
|
|
|
3346
3357
|
}
|
|
3347
3358
|
if (remainingRaw.length <= 4) {
|
|
3348
3359
|
const hasFooterMarkers = remainingRaw.some(
|
|
3349
|
-
(l) => l.includes("\xB7") || /^\s*(?:\?\s+for shortcuts|esc to cancel)
|
|
3360
|
+
(l) => l.includes("\xB7") || /^\s*(?:\?\s+for shortcuts|esc to cancel)/i.test(l.trim()) || /\b(?:auto|accept-edits|plan|Gemini|Claude|GPT|Gemma|high|low|subagent|quota|credits)\b/i.test(l)
|
|
3350
3361
|
);
|
|
3351
3362
|
if (hasFooterMarkers) {
|
|
3352
3363
|
const reconstructed = reconstructFooter(remainingRaw);
|
|
@@ -3382,7 +3393,7 @@ function isLegacyYNActiveInLines(lines) {
|
|
|
3382
3393
|
const trimmed = lines[j].trim();
|
|
3383
3394
|
if (!trimmed) continue;
|
|
3384
3395
|
if (/^[─━│╭╰╮╯\s_-]*$/.test(trimmed)) continue;
|
|
3385
|
-
if (/^\s*(?:\?\s+for shortcuts|esc to cancel)
|
|
3396
|
+
if (/^\s*(?:\?\s+for shortcuts|esc to cancel)/i.test(trimmed)) continue;
|
|
3386
3397
|
return { active: false, lineIdx: -1 };
|
|
3387
3398
|
}
|
|
3388
3399
|
return { active: true, lineIdx: ynIdx };
|
|
@@ -3513,7 +3524,7 @@ function extractFileAccessPrompt(snapshot) {
|
|
|
3513
3524
|
headingIndex = index;
|
|
3514
3525
|
break;
|
|
3515
3526
|
}
|
|
3516
|
-
if (/^[─━│╭╰╮╯\s_-]*$/.test(line) || /^[│\s]*[❯>]\s+\S/.test(line) || /^\s*(?:Requesting permission for:|Question \d+\/\d+:|Allow access to this file\?|Do you want to proceed\?)/i.test(line) || /^[▶▸\s]*Thought|^●\s*/i.test(line) || /^\s*(?:\?\s+for shortcuts|esc to cancel)
|
|
3527
|
+
if (/^[─━│╭╰╮╯\s_-]*$/.test(line) || /^[│\s]*[❯>]\s+\S/.test(line) || /^\s*(?:Requesting permission for:|Question \d+\/\d+:|Allow access to this file\?|Do you want to proceed\?)/i.test(line) || /^[▶▸\s]*Thought|^●\s*/i.test(line) || /^\s*(?:\?\s+for shortcuts|esc to cancel)/i.test(line) || /^\s*(?:[↑↓/\s]*Navigate\b|ctrl\+r\s+Review\b)/i.test(line)) {
|
|
3517
3528
|
break;
|
|
3518
3529
|
}
|
|
3519
3530
|
break;
|
|
@@ -4247,7 +4258,7 @@ function isQuestionUISnapshot(snapshot) {
|
|
|
4247
4258
|
|
|
4248
4259
|
// src/approval-detector.ts
|
|
4249
4260
|
var import_events3 = require("events");
|
|
4250
|
-
var
|
|
4261
|
+
var import_uuid2 = require("uuid");
|
|
4251
4262
|
|
|
4252
4263
|
// src/prompt-parser.ts
|
|
4253
4264
|
var import_crypto2 = require("crypto");
|
|
@@ -4276,17 +4287,17 @@ function parseApprovalSnapshot(snapshot) {
|
|
|
4276
4287
|
return parseApprovalUISnapshot(stripped, snapshot);
|
|
4277
4288
|
}
|
|
4278
4289
|
function buildApprovalSemanticKey(candidate) {
|
|
4279
|
-
const normalize = (value) => value.replace(/\s+/g, " ").trim();
|
|
4290
|
+
const normalize = (value) => (value ?? "").replace(/\s+/g, " ").trim();
|
|
4280
4291
|
const payload = {
|
|
4281
4292
|
kind: candidate.kind,
|
|
4282
4293
|
headerText: normalize(candidate.headerText),
|
|
4283
4294
|
body: normalize(candidate.body),
|
|
4284
|
-
options: candidate.options.map((option) => ({
|
|
4295
|
+
options: (candidate.options ?? []).map((option) => ({
|
|
4285
4296
|
number: option.number,
|
|
4286
4297
|
text: normalize(option.text)
|
|
4287
4298
|
})),
|
|
4288
4299
|
submitMap: Object.fromEntries(
|
|
4289
|
-
Object.entries(candidate.submitMap).sort(([left], [right]) => Number(left) - Number(right)).map(([number, keys]) => [number, [...keys]])
|
|
4300
|
+
Object.entries(candidate.submitMap ?? {}).sort(([left], [right]) => Number(left) - Number(right)).map(([number, keys]) => [number, [...keys]])
|
|
4290
4301
|
)
|
|
4291
4302
|
};
|
|
4292
4303
|
return (0, import_crypto2.createHash)("sha256").update(JSON.stringify(payload)).digest("hex");
|
|
@@ -4561,1428 +4572,1885 @@ function stripAnsi(s) {
|
|
|
4561
4572
|
return s.replace(ANSI_ESCAPE_REGEX2, "");
|
|
4562
4573
|
}
|
|
4563
4574
|
|
|
4564
|
-
// src/
|
|
4565
|
-
var
|
|
4566
|
-
var
|
|
4567
|
-
var
|
|
4568
|
-
var
|
|
4569
|
-
var
|
|
4570
|
-
|
|
4571
|
-
|
|
4572
|
-
|
|
4573
|
-
|
|
4574
|
-
|
|
4575
|
-
|
|
4576
|
-
|
|
4577
|
-
|
|
4578
|
-
|
|
4579
|
-
|
|
4580
|
-
|
|
4581
|
-
|
|
4582
|
-
|
|
4583
|
-
|
|
4584
|
-
|
|
4585
|
-
|
|
4586
|
-
|
|
4587
|
-
|
|
4588
|
-
|
|
4589
|
-
|
|
4590
|
-
|
|
4591
|
-
|
|
4592
|
-
|
|
4593
|
-
|
|
4594
|
-
|
|
4595
|
-
|
|
4596
|
-
|
|
4597
|
-
|
|
4598
|
-
|
|
4599
|
-
|
|
4575
|
+
// src/event-mapper.ts
|
|
4576
|
+
var import_uuid = require("uuid");
|
|
4577
|
+
var import_codevibe_core5 = require("@quantiya/codevibe-core");
|
|
4578
|
+
var CONTENT_MAX = 150 * 1024;
|
|
4579
|
+
var TOOL_OUTPUT_MAX = 50 * 1024;
|
|
4580
|
+
var TOOL_DIFF_MAX = 100 * 1024;
|
|
4581
|
+
function mapTranscriptEvent(emit, sessionId, notBeforeMs) {
|
|
4582
|
+
const { event } = emit;
|
|
4583
|
+
const base = {
|
|
4584
|
+
sessionId,
|
|
4585
|
+
source: import_codevibe_core5.EventSource.DESKTOP
|
|
4586
|
+
};
|
|
4587
|
+
const orderingKey = `${sessionId}:${emit.conversationId}`;
|
|
4588
|
+
const nextTimestamp = () => (0, import_codevibe_core5.prepareEventTimestamp)({ orderingKey, agentClock: event.created_at, notBeforeMs });
|
|
4589
|
+
const baseMetadata = {
|
|
4590
|
+
step_index: event.step_index,
|
|
4591
|
+
agy_source: event.source,
|
|
4592
|
+
agy_status: event.status,
|
|
4593
|
+
agy_created_at: event.created_at,
|
|
4594
|
+
agy_type: event.type,
|
|
4595
|
+
agy_conversation_id: emit.conversationId
|
|
4596
|
+
};
|
|
4597
|
+
if (event.source === "SYSTEM") {
|
|
4598
|
+
if (event.type === "CONVERSATION_HISTORY" || event.type === "SYSTEM_MESSAGE") {
|
|
4599
|
+
logger.debug("Dropping internal transcript event", {
|
|
4600
|
+
type: event.type,
|
|
4601
|
+
stepIndex: event.step_index
|
|
4602
|
+
});
|
|
4603
|
+
return { events: [], pendingCallRegistrations: [] };
|
|
4604
|
+
}
|
|
4605
|
+
if (event.type !== "ERROR_MESSAGE") {
|
|
4606
|
+
logger.warn("Dropping unknown SYSTEM-source transcript event (safety)", {
|
|
4607
|
+
type: event.type,
|
|
4608
|
+
stepIndex: event.step_index
|
|
4609
|
+
});
|
|
4610
|
+
return { events: [], pendingCallRegistrations: [] };
|
|
4611
|
+
}
|
|
4600
4612
|
}
|
|
4601
|
-
|
|
4602
|
-
|
|
4603
|
-
|
|
4604
|
-
|
|
4605
|
-
|
|
4606
|
-
promptTtlMs: this.promptTtlMs,
|
|
4607
|
-
pendingCallTtlMs: this.pendingCallTtlMs
|
|
4613
|
+
if (event.type === "CONVERSATION_HISTORY" || event.type === "SYSTEM_MESSAGE") {
|
|
4614
|
+
logger.debug("Dropping internal transcript event (non-SYSTEM source)", {
|
|
4615
|
+
type: event.type,
|
|
4616
|
+
source: event.source,
|
|
4617
|
+
stepIndex: event.step_index
|
|
4608
4618
|
});
|
|
4619
|
+
return { events: [], pendingCallRegistrations: [] };
|
|
4609
4620
|
}
|
|
4610
|
-
|
|
4611
|
-
|
|
4612
|
-
|
|
4613
|
-
|
|
4614
|
-
|
|
4615
|
-
|
|
4616
|
-
|
|
4617
|
-
|
|
4618
|
-
|
|
4619
|
-
|
|
4620
|
-
|
|
4621
|
+
if (event.type === "USER_INPUT") {
|
|
4622
|
+
const cleaned = stripUserRequestWrapper(event.content || "");
|
|
4623
|
+
return {
|
|
4624
|
+
events: [
|
|
4625
|
+
{
|
|
4626
|
+
...base,
|
|
4627
|
+
timestamp: nextTimestamp(),
|
|
4628
|
+
type: import_codevibe_core5.EventType.USER_PROMPT,
|
|
4629
|
+
content: truncate(cleaned, CONTENT_MAX),
|
|
4630
|
+
metadata: { ...baseMetadata }
|
|
4631
|
+
}
|
|
4632
|
+
],
|
|
4633
|
+
pendingCallRegistrations: []
|
|
4634
|
+
};
|
|
4621
4635
|
}
|
|
4622
|
-
|
|
4623
|
-
|
|
4624
|
-
|
|
4625
|
-
|
|
4626
|
-
|
|
4627
|
-
|
|
4628
|
-
|
|
4629
|
-
|
|
4630
|
-
|
|
4631
|
-
|
|
4632
|
-
* shown) → no transcript write → buffer expires.
|
|
4633
|
-
*
|
|
4634
|
-
* Solution: pane is authoritative for "approval UI is showing right
|
|
4635
|
-
* now." Skip the pending-call requirement and emit directly. The
|
|
4636
|
-
* parsed candidate has everything needed (command, options, submitMap).
|
|
4637
|
-
*
|
|
4638
|
-
* Synthesizes a minimal PendingToolCall from the candidate so the
|
|
4639
|
-
* existing handlePendingPrompt code path doesn't need to branch on
|
|
4640
|
-
* `pendingCall: null`.
|
|
4641
|
-
*
|
|
4642
|
-
* Returns the emitted state, or null if dedupe (already emitted for
|
|
4643
|
-
* this paneHash) or disabled.
|
|
4644
|
-
*/
|
|
4645
|
-
emitPaneOnlyPrompt(candidate, conversationId, forcedPromptId) {
|
|
4646
|
-
if (this.paneOnlyEmittedHashes.has(candidate.paneHash)) {
|
|
4647
|
-
logger.debug("Skipping pane-only re-emit for duplicate paneHash", {
|
|
4648
|
-
paneHash: candidate.paneHash.substring(0, 16)
|
|
4636
|
+
if (event.type === "PLANNER_RESPONSE") {
|
|
4637
|
+
const events = [];
|
|
4638
|
+
const pendingCallRegistrations = [];
|
|
4639
|
+
if (typeof event.thinking === "string" && event.thinking.trim().length > 0) {
|
|
4640
|
+
events.push({
|
|
4641
|
+
...base,
|
|
4642
|
+
timestamp: nextTimestamp(),
|
|
4643
|
+
type: import_codevibe_core5.EventType.REASONING,
|
|
4644
|
+
content: truncate(event.thinking, CONTENT_MAX),
|
|
4645
|
+
metadata: { ...baseMetadata }
|
|
4649
4646
|
});
|
|
4650
|
-
return null;
|
|
4651
|
-
}
|
|
4652
|
-
const promptId = forcedPromptId ?? (0, import_uuid.v4)();
|
|
4653
|
-
const matchingCall = candidate.kind === "question" ? this.findMatchingPendingCall(candidate, conversationId) : null;
|
|
4654
|
-
if (matchingCall) {
|
|
4655
|
-
matchingCall.matchedPromptId = promptId;
|
|
4656
4647
|
}
|
|
4657
|
-
|
|
4658
|
-
|
|
4659
|
-
|
|
4660
|
-
|
|
4661
|
-
|
|
4662
|
-
|
|
4663
|
-
|
|
4664
|
-
// commands → RUN_COMMAND. For edit-style headers with a file
|
|
4665
|
-
// path → CODE_ACTION. For questions → ASK_QUESTION. Default: GENERIC.
|
|
4666
|
-
toolType: matchingCall?.toolType ?? guessToolTypeFromIdentity(candidate),
|
|
4667
|
-
command: matchingCall?.command ?? candidate.command,
|
|
4668
|
-
filePath: matchingCall?.filePath ?? candidate.filePath,
|
|
4669
|
-
rawToolName: matchingCall?.rawToolName ?? (candidate.kind === "question" ? "ask_question" : candidate.command ?? candidate.filePath ?? "unknown"),
|
|
4670
|
-
questionText: candidate.kind === "question" && candidate.headerText ? candidate.headerText : matchingCall?.questionText,
|
|
4671
|
-
// NOTE (Codex R3-F1 / Claude R3-F2): Pane options are authoritative ground truth
|
|
4672
|
-
// that directly correspond to desktop keystrokes (submitMap). Transcript options
|
|
4673
|
-
// serve solely as fallback when pane options cannot be parsed.
|
|
4674
|
-
questionOptions: candidate.kind === "question" && candidate.options && candidate.options.length > 0 ? candidate.options : matchingCall?.questionOptions,
|
|
4675
|
-
promptId,
|
|
4676
|
-
startedAt: matchingCall?.startedAt ?? Date.now(),
|
|
4677
|
-
intentCreatedAt: matchingCall?.intentCreatedAt ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
4678
|
-
};
|
|
4679
|
-
const state = {
|
|
4680
|
-
promptId,
|
|
4681
|
-
conversationId,
|
|
4682
|
-
pendingCall: syntheticCall,
|
|
4683
|
-
submitMap: candidate.submitMap,
|
|
4684
|
-
matchedPaneHeader: candidate.headerText,
|
|
4685
|
-
paneDisplayHeader: candidate.fullHeaderLine,
|
|
4686
|
-
body: candidate.body,
|
|
4687
|
-
paneOptions: candidate.options,
|
|
4688
|
-
paneSemanticKey: buildApprovalSemanticKey(candidate),
|
|
4689
|
-
kind: candidate.kind,
|
|
4690
|
-
emittedAt: Date.now(),
|
|
4691
|
-
ttlMs: this.promptTtlMs,
|
|
4692
|
-
// E1 (§6a-2b / F5) — carry the consumed pane hash so an emit-failure
|
|
4693
|
-
// rollback can reset the observer's lastPromptHash and let the unchanged
|
|
4694
|
-
// live pane re-emit.
|
|
4695
|
-
paneHash: candidate.paneHash
|
|
4696
|
-
};
|
|
4697
|
-
this.pendingPrompts.set(promptId, state);
|
|
4698
|
-
this.paneOnlyEmittedHashes.add(candidate.paneHash);
|
|
4699
|
-
logger.info("Emitting pane-only INTERACTIVE_PROMPT", {
|
|
4700
|
-
promptId,
|
|
4701
|
-
command: candidate.command,
|
|
4702
|
-
filePath: candidate.filePath,
|
|
4703
|
-
optionsCount: candidate.options.length
|
|
4704
|
-
});
|
|
4705
|
-
this.emit("pending-prompt", state);
|
|
4706
|
-
return state;
|
|
4707
|
-
}
|
|
4708
|
-
// ─── Pending-call registration (called from event-mapper hook) ──────────
|
|
4709
|
-
/**
|
|
4710
|
-
* Register a tool-call intent from a PLANNER_RESPONSE.tool_calls[].
|
|
4711
|
-
* Indexed by composite key so agy rollback (which can replay
|
|
4712
|
-
* step_index values) doesn't cause collisions.
|
|
4713
|
-
*/
|
|
4714
|
-
registerPendingCall(call) {
|
|
4715
|
-
const key = this.callKey(call);
|
|
4716
|
-
if (this.pendingCalls.has(key)) {
|
|
4717
|
-
logger.debug("Pending call already registered, skipping", {
|
|
4718
|
-
key,
|
|
4719
|
-
intentStepIndex: call.intentStepIndex,
|
|
4720
|
-
toolType: call.toolType
|
|
4648
|
+
if (typeof event.content === "string" && event.content.trim().length > 0) {
|
|
4649
|
+
events.push({
|
|
4650
|
+
...base,
|
|
4651
|
+
timestamp: nextTimestamp(),
|
|
4652
|
+
type: import_codevibe_core5.EventType.ASSISTANT_RESPONSE,
|
|
4653
|
+
content: truncate(event.content, CONTENT_MAX),
|
|
4654
|
+
metadata: { ...baseMetadata }
|
|
4721
4655
|
});
|
|
4722
|
-
return;
|
|
4723
4656
|
}
|
|
4724
|
-
|
|
4725
|
-
|
|
4726
|
-
|
|
4727
|
-
|
|
4728
|
-
|
|
4729
|
-
|
|
4730
|
-
|
|
4731
|
-
|
|
4732
|
-
|
|
4733
|
-
|
|
4734
|
-
|
|
4735
|
-
|
|
4736
|
-
|
|
4737
|
-
|
|
4738
|
-
|
|
4739
|
-
|
|
4740
|
-
|
|
4741
|
-
|
|
4742
|
-
|
|
4743
|
-
|
|
4744
|
-
|
|
4745
|
-
|
|
4746
|
-
|
|
4747
|
-
ageMs: now - buffered.queuedAt
|
|
4748
|
-
});
|
|
4657
|
+
const toolCalls = event.tool_calls;
|
|
4658
|
+
if (Array.isArray(toolCalls)) {
|
|
4659
|
+
for (let i = 0; i < toolCalls.length; i++) {
|
|
4660
|
+
const tc = toolCalls[i];
|
|
4661
|
+
if (!tc || typeof tc !== "object") continue;
|
|
4662
|
+
const name = tc.name;
|
|
4663
|
+
if (typeof name !== "string") continue;
|
|
4664
|
+
const args = tc.args;
|
|
4665
|
+
pendingCallRegistrations.push({
|
|
4666
|
+
intentStepIndex: event.step_index,
|
|
4667
|
+
toolCallIndex: i,
|
|
4668
|
+
intentByteOffset: emit.byteOffset,
|
|
4669
|
+
toolType: hookToolNameToTranscriptType(name),
|
|
4670
|
+
conversationId: emit.conversationId,
|
|
4671
|
+
command: extractCommandFromArgs(name, args),
|
|
4672
|
+
filePath: extractFilePathFromArgs(name, args),
|
|
4673
|
+
questionText: extractQuestionFromArgs(name, args),
|
|
4674
|
+
questionOptions: extractQuestionOptionsFromArgs(name, args),
|
|
4675
|
+
cwd: extractCwdFromArgs(args),
|
|
4676
|
+
rawToolName: name,
|
|
4677
|
+
startedAt: Date.now(),
|
|
4678
|
+
intentCreatedAt: event.created_at
|
|
4679
|
+
});
|
|
4749
4680
|
}
|
|
4750
4681
|
}
|
|
4682
|
+
return { events, pendingCallRegistrations };
|
|
4751
4683
|
}
|
|
4752
|
-
|
|
4753
|
-
|
|
4754
|
-
|
|
4755
|
-
|
|
4756
|
-
|
|
4757
|
-
|
|
4758
|
-
|
|
4759
|
-
|
|
4760
|
-
|
|
4684
|
+
if (event.type === "ERROR_MESSAGE") {
|
|
4685
|
+
const content = stripTimestampPrefix(event.content || "Agy error");
|
|
4686
|
+
return {
|
|
4687
|
+
events: [
|
|
4688
|
+
{
|
|
4689
|
+
...base,
|
|
4690
|
+
timestamp: nextTimestamp(),
|
|
4691
|
+
type: import_codevibe_core5.EventType.NOTIFICATION,
|
|
4692
|
+
content: truncate(content, CONTENT_MAX),
|
|
4693
|
+
metadata: {
|
|
4694
|
+
...baseMetadata,
|
|
4695
|
+
severity: "error"
|
|
4696
|
+
}
|
|
4697
|
+
}
|
|
4698
|
+
],
|
|
4699
|
+
pendingCallRegistrations: []
|
|
4700
|
+
};
|
|
4761
4701
|
}
|
|
4762
|
-
|
|
4763
|
-
|
|
4764
|
-
|
|
4765
|
-
|
|
4766
|
-
|
|
4767
|
-
|
|
4768
|
-
|
|
4769
|
-
|
|
4770
|
-
|
|
4771
|
-
|
|
4772
|
-
|
|
4773
|
-
|
|
4774
|
-
|
|
4775
|
-
|
|
4776
|
-
|
|
4777
|
-
|
|
4778
|
-
const expected = call.intentStepIndex + call.toolCallIndex + 1;
|
|
4779
|
-
if (expected !== args.resultStepIndex) continue;
|
|
4780
|
-
if (typeof args.resultByteOffset === "number") {
|
|
4781
|
-
if (call.intentByteOffset >= args.resultByteOffset) continue;
|
|
4782
|
-
if (!matched || call.intentByteOffset > matched.intentByteOffset) {
|
|
4783
|
-
matched = call;
|
|
4784
|
-
matchedKey = key;
|
|
4702
|
+
const toolName = transcriptTypeToFriendlyToolName(event.type);
|
|
4703
|
+
const isErrorStatus = event.status === "ERROR";
|
|
4704
|
+
const rawContent = stripTimestampPrefix(event.content || "");
|
|
4705
|
+
const truncatedContent = truncate(rawContent, TOOL_OUTPUT_MAX);
|
|
4706
|
+
return {
|
|
4707
|
+
events: [
|
|
4708
|
+
{
|
|
4709
|
+
...base,
|
|
4710
|
+
timestamp: nextTimestamp(),
|
|
4711
|
+
type: import_codevibe_core5.EventType.TOOL_USE,
|
|
4712
|
+
content: formatToolResultContent(event.type, toolName, truncatedContent, isErrorStatus),
|
|
4713
|
+
metadata: {
|
|
4714
|
+
...baseMetadata,
|
|
4715
|
+
tool_name: toolName,
|
|
4716
|
+
tool_output: truncatedContent,
|
|
4717
|
+
success: !isErrorStatus
|
|
4785
4718
|
}
|
|
4786
|
-
} else if (!matched || call.intentStepIndex < matched.intentStepIndex) {
|
|
4787
|
-
matched = call;
|
|
4788
|
-
matchedKey = key;
|
|
4789
4719
|
}
|
|
4720
|
+
],
|
|
4721
|
+
pendingCallRegistrations: []
|
|
4722
|
+
};
|
|
4723
|
+
}
|
|
4724
|
+
function stripUserRequestWrapper(content) {
|
|
4725
|
+
const match = content.match(/<USER_REQUEST>([\s\S]*?)<\/USER_REQUEST>/);
|
|
4726
|
+
if (match && match[1] !== void 0) {
|
|
4727
|
+
return match[1].trim();
|
|
4728
|
+
}
|
|
4729
|
+
return content.trim();
|
|
4730
|
+
}
|
|
4731
|
+
function stripTimestampPrefix(content) {
|
|
4732
|
+
return content.replace(
|
|
4733
|
+
/^Created At:[^\n]*\nCompleted At:[^\n]*\n+(?:\s*\n)?/,
|
|
4734
|
+
""
|
|
4735
|
+
).trim();
|
|
4736
|
+
}
|
|
4737
|
+
function transcriptTypeToFriendlyToolName(type) {
|
|
4738
|
+
switch (type) {
|
|
4739
|
+
case "RUN_COMMAND":
|
|
4740
|
+
return "Bash";
|
|
4741
|
+
case "LIST_DIRECTORY":
|
|
4742
|
+
return "LS";
|
|
4743
|
+
case "GREP_SEARCH":
|
|
4744
|
+
return "Grep";
|
|
4745
|
+
case "VIEW_FILE":
|
|
4746
|
+
return "Read";
|
|
4747
|
+
case "CODE_ACTION":
|
|
4748
|
+
return "Edit";
|
|
4749
|
+
case "SEARCH_WEB":
|
|
4750
|
+
return "WebSearch";
|
|
4751
|
+
case "GENERIC":
|
|
4752
|
+
return "Other";
|
|
4753
|
+
default:
|
|
4754
|
+
return "Other";
|
|
4755
|
+
}
|
|
4756
|
+
}
|
|
4757
|
+
function hookToolNameToTranscriptType(hookName) {
|
|
4758
|
+
const upper = hookName.toUpperCase();
|
|
4759
|
+
switch (upper) {
|
|
4760
|
+
case "LIST_DIR":
|
|
4761
|
+
return "LIST_DIRECTORY";
|
|
4762
|
+
case "REPLACE_FILE_CONTENT":
|
|
4763
|
+
case "MULTI_REPLACE_FILE_CONTENT":
|
|
4764
|
+
case "WRITE_TO_FILE":
|
|
4765
|
+
return "CODE_ACTION";
|
|
4766
|
+
case "GENERATE_IMAGE":
|
|
4767
|
+
case "READ_URL_CONTENT":
|
|
4768
|
+
case "INVOKE_SUBAGENT":
|
|
4769
|
+
case "DEFINE_SUBAGENT":
|
|
4770
|
+
case "MANAGE_SUBAGENTS":
|
|
4771
|
+
case "SEND_MESSAGE":
|
|
4772
|
+
case "LIST_PERMISSIONS":
|
|
4773
|
+
case "MANAGE_TASK":
|
|
4774
|
+
case "SCHEDULE":
|
|
4775
|
+
case "ASK_PERMISSION":
|
|
4776
|
+
return "GENERIC";
|
|
4777
|
+
case "ASK_QUESTION":
|
|
4778
|
+
return "ASK_QUESTION";
|
|
4779
|
+
default:
|
|
4780
|
+
return upper;
|
|
4781
|
+
}
|
|
4782
|
+
}
|
|
4783
|
+
function extractCommandFromArgs(toolName, args) {
|
|
4784
|
+
if (!args || typeof args !== "object") return void 0;
|
|
4785
|
+
const a = args;
|
|
4786
|
+
for (const key of ["CommandLine", "Command", "command", "cmd", "exec"]) {
|
|
4787
|
+
const v = a[key];
|
|
4788
|
+
if (typeof v === "string" && v.trim().length > 0) {
|
|
4789
|
+
return stripWrappingQuotes2(v.trim());
|
|
4790
4790
|
}
|
|
4791
|
-
|
|
4792
|
-
|
|
4793
|
-
|
|
4794
|
-
|
|
4795
|
-
|
|
4796
|
-
|
|
4797
|
-
|
|
4798
|
-
|
|
4799
|
-
|
|
4800
|
-
|
|
4801
|
-
|
|
4802
|
-
|
|
4803
|
-
|
|
4791
|
+
}
|
|
4792
|
+
return void 0;
|
|
4793
|
+
}
|
|
4794
|
+
function extractFilePathFromArgs(toolName, args) {
|
|
4795
|
+
if (!args || typeof args !== "object") return void 0;
|
|
4796
|
+
const a = args;
|
|
4797
|
+
for (const key of [
|
|
4798
|
+
"TargetFile",
|
|
4799
|
+
"FilePath",
|
|
4800
|
+
"file_path",
|
|
4801
|
+
"filePath",
|
|
4802
|
+
"path",
|
|
4803
|
+
"DirectoryPath",
|
|
4804
|
+
"directory",
|
|
4805
|
+
"Directory"
|
|
4806
|
+
]) {
|
|
4807
|
+
const v = a[key];
|
|
4808
|
+
if (typeof v === "string" && v.trim().length > 0) {
|
|
4809
|
+
return stripWrappingQuotes2(v.trim());
|
|
4804
4810
|
}
|
|
4805
|
-
return matched;
|
|
4806
4811
|
}
|
|
4807
|
-
|
|
4808
|
-
|
|
4809
|
-
|
|
4810
|
-
|
|
4811
|
-
|
|
4812
|
-
|
|
4813
|
-
|
|
4814
|
-
|
|
4815
|
-
|
|
4816
|
-
|
|
4817
|
-
|
|
4818
|
-
|
|
4819
|
-
|
|
4820
|
-
|
|
4821
|
-
|
|
4822
|
-
|
|
4823
|
-
|
|
4824
|
-
|
|
4825
|
-
|
|
4826
|
-
* 6. Otherwise: create a fresh promptId + PendingPromptState, attach
|
|
4827
|
-
* to the matched call, store in pendingPrompts, return it.
|
|
4828
|
-
*/
|
|
4829
|
-
matchCandidate(candidate, conversationId) {
|
|
4830
|
-
const state = this.matchCandidateInternal(candidate, conversationId);
|
|
4831
|
-
if (state) return state;
|
|
4832
|
-
const existing = this.pendingCandidates.get(this.candidateKey(conversationId, candidate.paneHash));
|
|
4833
|
-
if (existing) {
|
|
4834
|
-
logger.debug("Candidate already buffered (same paneHash)", { paneHash: this.candidateKey(conversationId, candidate.paneHash) });
|
|
4835
|
-
} else {
|
|
4836
|
-
this.pendingCandidates.set(this.candidateKey(conversationId, candidate.paneHash), {
|
|
4837
|
-
candidate,
|
|
4838
|
-
conversationId,
|
|
4839
|
-
queuedAt: Date.now()
|
|
4840
|
-
});
|
|
4841
|
-
logger.debug("No pending tool-call matched \u2014 buffering candidate for pre-transcript window", {
|
|
4842
|
-
candidateCommand: candidate.command,
|
|
4843
|
-
candidateFilePath: candidate.filePath,
|
|
4844
|
-
pendingCount: this.pendingCalls.size,
|
|
4845
|
-
bufferSize: this.pendingCandidates.size
|
|
4846
|
-
});
|
|
4812
|
+
return void 0;
|
|
4813
|
+
}
|
|
4814
|
+
function extractQuestionFromArgs(toolName, args) {
|
|
4815
|
+
if (!args || typeof args !== "object") return void 0;
|
|
4816
|
+
const tn = (toolName || "").toLowerCase().trim();
|
|
4817
|
+
const isQuestionTool = tn === "ask_question" || tn === "question" || tn === "ask";
|
|
4818
|
+
if (!isQuestionTool) return void 0;
|
|
4819
|
+
const a = args;
|
|
4820
|
+
if (typeof a.question === "string" && a.question.trim().length > 0) {
|
|
4821
|
+
return stripWrappingQuotes2(a.question.trim());
|
|
4822
|
+
}
|
|
4823
|
+
if (typeof a.prompt === "string" && a.prompt.trim().length > 0) {
|
|
4824
|
+
return stripWrappingQuotes2(a.prompt.trim());
|
|
4825
|
+
}
|
|
4826
|
+
let questions = a.questions;
|
|
4827
|
+
if (typeof questions === "string") {
|
|
4828
|
+
try {
|
|
4829
|
+
questions = JSON.parse(questions.trim());
|
|
4830
|
+
} catch {
|
|
4847
4831
|
}
|
|
4848
|
-
return null;
|
|
4849
4832
|
}
|
|
4850
|
-
|
|
4851
|
-
|
|
4852
|
-
|
|
4853
|
-
|
|
4854
|
-
matchCandidateInternal(candidate, conversationId) {
|
|
4855
|
-
const matched = this.findMatchingPendingCall(candidate, conversationId);
|
|
4856
|
-
if (!matched) {
|
|
4857
|
-
return null;
|
|
4858
|
-
}
|
|
4859
|
-
if (matched.promptId && this.resolvedPrompts.has(matched.promptId)) {
|
|
4860
|
-
logger.debug("Skipping re-emit for already-resolved prompt", {
|
|
4861
|
-
promptId: matched.promptId
|
|
4862
|
-
});
|
|
4863
|
-
return null;
|
|
4833
|
+
if (Array.isArray(questions) && questions.length > 0) {
|
|
4834
|
+
const first = questions[0];
|
|
4835
|
+
if (typeof first === "string" && first.trim().length > 0) {
|
|
4836
|
+
return stripWrappingQuotes2(first.trim());
|
|
4864
4837
|
}
|
|
4865
|
-
if (
|
|
4866
|
-
|
|
4838
|
+
if (first && typeof first === "object") {
|
|
4839
|
+
const q = first.question;
|
|
4840
|
+
if (typeof q === "string" && q.trim().length > 0) {
|
|
4841
|
+
return stripWrappingQuotes2(q.trim());
|
|
4842
|
+
}
|
|
4867
4843
|
}
|
|
4868
|
-
const promptId = (0, import_uuid.v4)();
|
|
4869
|
-
matched.promptId = promptId;
|
|
4870
|
-
const state = {
|
|
4871
|
-
promptId,
|
|
4872
|
-
conversationId,
|
|
4873
|
-
pendingCall: matched,
|
|
4874
|
-
submitMap: candidate.submitMap,
|
|
4875
|
-
matchedPaneHeader: candidate.headerText,
|
|
4876
|
-
paneDisplayHeader: candidate.fullHeaderLine,
|
|
4877
|
-
body: candidate.body,
|
|
4878
|
-
paneOptions: candidate.options,
|
|
4879
|
-
paneSemanticKey: buildApprovalSemanticKey(candidate),
|
|
4880
|
-
kind: candidate.kind,
|
|
4881
|
-
emittedAt: Date.now(),
|
|
4882
|
-
ttlMs: this.promptTtlMs
|
|
4883
|
-
};
|
|
4884
|
-
this.pendingPrompts.set(promptId, state);
|
|
4885
|
-
logger.info("Emitting INTERACTIVE_PROMPT for matched candidate", {
|
|
4886
|
-
promptId,
|
|
4887
|
-
command: matched.command,
|
|
4888
|
-
filePath: matched.filePath,
|
|
4889
|
-
intentStepIndex: matched.intentStepIndex,
|
|
4890
|
-
toolCallIndex: matched.toolCallIndex
|
|
4891
|
-
});
|
|
4892
|
-
this.emit("pending-prompt", state);
|
|
4893
|
-
return state;
|
|
4894
4844
|
}
|
|
4895
|
-
|
|
4896
|
-
|
|
4897
|
-
|
|
4898
|
-
|
|
4899
|
-
|
|
4900
|
-
|
|
4901
|
-
|
|
4902
|
-
|
|
4903
|
-
|
|
4904
|
-
|
|
4905
|
-
|
|
4906
|
-
|
|
4907
|
-
|
|
4908
|
-
* routing hold proves only that prompts raised BEFORE it was typed are gone.
|
|
4909
|
-
* A prompt emitted after that moment stays pending, and so does its dedup
|
|
4910
|
-
* hash (clearing it would re-emit the still-visible chooser).
|
|
4911
|
-
*/
|
|
4912
|
-
onPanePromptCleared(emittedBeforeMs) {
|
|
4913
|
-
if (this.pendingPrompts.size === 0 && this.paneOnlyEmittedHashes.size === 0) {
|
|
4914
|
-
return [];
|
|
4845
|
+
return void 0;
|
|
4846
|
+
}
|
|
4847
|
+
function extractQuestionOptionsFromArgs(toolName, args) {
|
|
4848
|
+
if (!args || typeof args !== "object") return void 0;
|
|
4849
|
+
const tn = (toolName || "").toLowerCase().trim();
|
|
4850
|
+
const isQuestionTool = tn === "ask_question" || tn === "question" || tn === "ask";
|
|
4851
|
+
if (!isQuestionTool) return void 0;
|
|
4852
|
+
const a = args;
|
|
4853
|
+
let questions = a.questions;
|
|
4854
|
+
if (typeof questions === "string") {
|
|
4855
|
+
try {
|
|
4856
|
+
questions = JSON.parse(questions.trim());
|
|
4857
|
+
} catch {
|
|
4915
4858
|
}
|
|
4916
|
-
|
|
4917
|
-
|
|
4918
|
-
|
|
4919
|
-
|
|
4920
|
-
|
|
4921
|
-
|
|
4922
|
-
|
|
4859
|
+
}
|
|
4860
|
+
if (Array.isArray(questions) && questions.length > 0) {
|
|
4861
|
+
const first = questions[0];
|
|
4862
|
+
if (first && typeof first === "object" && Array.isArray(first.options)) {
|
|
4863
|
+
const rawOptions = first.options;
|
|
4864
|
+
const result = [];
|
|
4865
|
+
for (let i = 0; i < rawOptions.length; i++) {
|
|
4866
|
+
const opt = rawOptions[i];
|
|
4867
|
+
if (typeof opt === "string" && opt.trim().length > 0) {
|
|
4868
|
+
result.push({ number: String(i + 1), text: stripWrappingQuotes2(opt.trim()) });
|
|
4869
|
+
} else if (opt && typeof opt === "object" && typeof opt.text === "string") {
|
|
4870
|
+
result.push({
|
|
4871
|
+
number: String(opt.number ?? i + 1),
|
|
4872
|
+
text: stripWrappingQuotes2(String(opt.text).trim())
|
|
4873
|
+
});
|
|
4874
|
+
}
|
|
4923
4875
|
}
|
|
4924
|
-
|
|
4925
|
-
cleared.push(promptId);
|
|
4876
|
+
if (result.length > 0) return result;
|
|
4926
4877
|
}
|
|
4927
|
-
|
|
4928
|
-
|
|
4929
|
-
|
|
4930
|
-
|
|
4931
|
-
|
|
4932
|
-
|
|
4933
|
-
|
|
4934
|
-
this.pendingPrompts.delete(promptId);
|
|
4878
|
+
}
|
|
4879
|
+
if (Array.isArray(a.options) || typeof a.options === "string") {
|
|
4880
|
+
let topOptions = a.options;
|
|
4881
|
+
if (typeof topOptions === "string") {
|
|
4882
|
+
try {
|
|
4883
|
+
topOptions = JSON.parse(topOptions.trim());
|
|
4884
|
+
} catch {
|
|
4935
4885
|
}
|
|
4936
4886
|
}
|
|
4937
|
-
if (
|
|
4938
|
-
|
|
4939
|
-
|
|
4940
|
-
|
|
4887
|
+
if (Array.isArray(topOptions)) {
|
|
4888
|
+
const result = [];
|
|
4889
|
+
for (let i = 0; i < topOptions.length; i++) {
|
|
4890
|
+
const opt = topOptions[i];
|
|
4891
|
+
if (typeof opt === "string" && opt.trim().length > 0) {
|
|
4892
|
+
result.push({ number: String(i + 1), text: stripWrappingQuotes2(opt.trim()) });
|
|
4893
|
+
} else if (opt && typeof opt === "object" && typeof opt.text === "string") {
|
|
4894
|
+
result.push({
|
|
4895
|
+
number: String(opt.number ?? i + 1),
|
|
4896
|
+
text: stripWrappingQuotes2(String(opt.text).trim())
|
|
4897
|
+
});
|
|
4898
|
+
}
|
|
4899
|
+
}
|
|
4900
|
+
if (result.length > 0) return result;
|
|
4941
4901
|
}
|
|
4942
|
-
return cleared;
|
|
4943
4902
|
}
|
|
4944
|
-
|
|
4945
|
-
|
|
4946
|
-
|
|
4947
|
-
|
|
4948
|
-
|
|
4949
|
-
|
|
4950
|
-
return
|
|
4903
|
+
return void 0;
|
|
4904
|
+
}
|
|
4905
|
+
function decodeBoolean(val) {
|
|
4906
|
+
if (typeof val === "boolean") return val;
|
|
4907
|
+
if (typeof val === "string") {
|
|
4908
|
+
const s = val.trim().toLowerCase();
|
|
4909
|
+
return s === "true" || s === "1" || s === "yes";
|
|
4951
4910
|
}
|
|
4952
|
-
|
|
4953
|
-
|
|
4954
|
-
|
|
4955
|
-
|
|
4956
|
-
|
|
4957
|
-
|
|
4958
|
-
|
|
4959
|
-
|
|
4960
|
-
|
|
4961
|
-
|
|
4962
|
-
|
|
4963
|
-
|
|
4964
|
-
|
|
4965
|
-
|
|
4966
|
-
if (!state) return null;
|
|
4967
|
-
this.pendingPrompts.delete(promptId);
|
|
4968
|
-
if (state.pendingCall.promptId === promptId) {
|
|
4969
|
-
state.pendingCall.promptId = void 0;
|
|
4911
|
+
if (typeof val === "number") return val !== 0;
|
|
4912
|
+
return false;
|
|
4913
|
+
}
|
|
4914
|
+
function extractAllQuestionsFromArgs(toolName, args) {
|
|
4915
|
+
if (!args || typeof args !== "object") return void 0;
|
|
4916
|
+
const tn = (toolName || "").toLowerCase().trim();
|
|
4917
|
+
const isQuestionTool = tn === "ask_question" || tn === "question" || tn === "ask";
|
|
4918
|
+
if (!isQuestionTool) return void 0;
|
|
4919
|
+
const a = args;
|
|
4920
|
+
let questions = a.questions;
|
|
4921
|
+
if (typeof questions === "string") {
|
|
4922
|
+
try {
|
|
4923
|
+
questions = JSON.parse(questions.trim());
|
|
4924
|
+
} catch {
|
|
4970
4925
|
}
|
|
4971
|
-
|
|
4972
|
-
|
|
4973
|
-
|
|
4926
|
+
}
|
|
4927
|
+
const items = [];
|
|
4928
|
+
if (Array.isArray(questions) && questions.length > 0) {
|
|
4929
|
+
for (const qItem of questions) {
|
|
4930
|
+
if (typeof qItem === "string" && qItem.trim().length > 0) {
|
|
4931
|
+
items.push({ question: stripWrappingQuotes2(qItem.trim()), options: [] });
|
|
4932
|
+
} else if (qItem && typeof qItem === "object") {
|
|
4933
|
+
const qObj = qItem;
|
|
4934
|
+
const qText = typeof qObj.question === "string" ? stripWrappingQuotes2(qObj.question.trim()) : "Question";
|
|
4935
|
+
const opts = [];
|
|
4936
|
+
if (Array.isArray(qObj.options)) {
|
|
4937
|
+
for (let i = 0; i < qObj.options.length; i++) {
|
|
4938
|
+
const opt = qObj.options[i];
|
|
4939
|
+
if (typeof opt === "string" && opt.trim().length > 0) {
|
|
4940
|
+
opts.push({ number: String(i + 1), text: stripWrappingQuotes2(opt.trim()) });
|
|
4941
|
+
} else if (opt && typeof opt === "object" && typeof opt.text === "string") {
|
|
4942
|
+
opts.push({
|
|
4943
|
+
number: String(opt.number ?? i + 1),
|
|
4944
|
+
text: stripWrappingQuotes2(String(opt.text).trim())
|
|
4945
|
+
});
|
|
4946
|
+
}
|
|
4947
|
+
}
|
|
4948
|
+
}
|
|
4949
|
+
const isMulti = decodeBoolean(qObj.is_multi_select ?? qObj.isMultiSelect);
|
|
4950
|
+
items.push({ question: qText, options: opts, isMultiSelect: isMulti });
|
|
4974
4951
|
}
|
|
4975
4952
|
}
|
|
4976
|
-
|
|
4977
|
-
|
|
4953
|
+
} else if (typeof a.question === "string" || typeof a.prompt === "string") {
|
|
4954
|
+
const qText = stripWrappingQuotes2(String(a.question ?? a.prompt).trim());
|
|
4955
|
+
const opts = extractQuestionOptionsFromArgs(toolName, args) ?? [];
|
|
4956
|
+
const isMulti = decodeBoolean(a.is_multi_select ?? a.isMultiSelect);
|
|
4957
|
+
items.push({ question: qText, options: opts, isMultiSelect: isMulti });
|
|
4978
4958
|
}
|
|
4979
|
-
|
|
4980
|
-
|
|
4981
|
-
|
|
4982
|
-
|
|
4983
|
-
|
|
4984
|
-
|
|
4985
|
-
|
|
4986
|
-
|
|
4987
|
-
|
|
4959
|
+
return items.length > 0 ? items : void 0;
|
|
4960
|
+
}
|
|
4961
|
+
function extractCwdFromArgs(args) {
|
|
4962
|
+
if (!args || typeof args !== "object") return void 0;
|
|
4963
|
+
const a = args;
|
|
4964
|
+
for (const key of ["Cwd", "cwd", "workingDirectory", "WorkingDirectory"]) {
|
|
4965
|
+
const v = a[key];
|
|
4966
|
+
if (typeof v === "string" && v.trim().length > 0) {
|
|
4967
|
+
return stripWrappingQuotes2(v.trim());
|
|
4968
|
+
}
|
|
4988
4969
|
}
|
|
4989
|
-
|
|
4990
|
-
|
|
4991
|
-
|
|
4970
|
+
return void 0;
|
|
4971
|
+
}
|
|
4972
|
+
function stripWrappingQuotes2(s) {
|
|
4973
|
+
if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
|
|
4974
|
+
return s.slice(1, -1);
|
|
4992
4975
|
}
|
|
4993
|
-
|
|
4994
|
-
|
|
4976
|
+
return s;
|
|
4977
|
+
}
|
|
4978
|
+
function formatToolResultContent(agyType, toolName, output, isError) {
|
|
4979
|
+
const prefix = isError ? "\u26A0 " : "";
|
|
4980
|
+
const firstLine = output.split("\n", 1)[0]?.trim();
|
|
4981
|
+
switch (agyType) {
|
|
4982
|
+
case "RUN_COMMAND":
|
|
4983
|
+
return `${prefix}Bash: ${firstLine || "command executed"}`;
|
|
4984
|
+
case "LIST_DIRECTORY":
|
|
4985
|
+
return `${prefix}LS: ${firstLine || "directory listed"}`;
|
|
4986
|
+
case "GREP_SEARCH":
|
|
4987
|
+
return `${prefix}Grep: ${firstLine || "search executed"}`;
|
|
4988
|
+
case "VIEW_FILE":
|
|
4989
|
+
return `${prefix}Read: ${firstLine || "file read"}`;
|
|
4990
|
+
case "CODE_ACTION":
|
|
4991
|
+
return `${prefix}Edit: ${firstLine || "file edited"}`;
|
|
4992
|
+
case "SEARCH_WEB":
|
|
4993
|
+
return `${prefix}WebSearch: ${firstLine || "search executed"}`;
|
|
4994
|
+
case "GENERIC":
|
|
4995
|
+
return `${prefix}${toolName}: ${firstLine || "tool executed"}`;
|
|
4996
|
+
default:
|
|
4997
|
+
return `${prefix}${toolName} (${agyType}): ${firstLine || "unknown event"}`;
|
|
4995
4998
|
}
|
|
4996
|
-
|
|
4997
|
-
|
|
4998
|
-
|
|
4999
|
+
}
|
|
5000
|
+
function truncate(s, maxBytes) {
|
|
5001
|
+
if (!s) return "";
|
|
5002
|
+
if (Buffer.byteLength(s, "utf-8") <= maxBytes) return s;
|
|
5003
|
+
let lo = 0;
|
|
5004
|
+
let hi = s.length;
|
|
5005
|
+
while (lo < hi) {
|
|
5006
|
+
const mid = lo + hi + 1 >>> 1;
|
|
5007
|
+
if (Buffer.byteLength(s.slice(0, mid), "utf-8") <= maxBytes - 32) {
|
|
5008
|
+
lo = mid;
|
|
5009
|
+
} else {
|
|
5010
|
+
hi = mid - 1;
|
|
5011
|
+
}
|
|
4999
5012
|
}
|
|
5000
|
-
|
|
5001
|
-
|
|
5002
|
-
|
|
5003
|
-
|
|
5004
|
-
|
|
5005
|
-
|
|
5006
|
-
|
|
5007
|
-
|
|
5008
|
-
|
|
5009
|
-
|
|
5010
|
-
|
|
5011
|
-
|
|
5012
|
-
|
|
5013
|
-
|
|
5014
|
-
|
|
5015
|
-
|
|
5016
|
-
|
|
5013
|
+
return s.slice(0, lo) + ` [...truncated ${s.length - lo} chars]`;
|
|
5014
|
+
}
|
|
5015
|
+
|
|
5016
|
+
// src/approval-detector.ts
|
|
5017
|
+
var DEFAULT_PROMPT_TTL_MS = 6e4;
|
|
5018
|
+
var PENDING_CALL_TTL_MS = 5 * 6e4;
|
|
5019
|
+
var SWEEP_INTERVAL_MS = 3e4;
|
|
5020
|
+
var PASS3_MIN_IDENTITY_LEN = 4;
|
|
5021
|
+
var PENDING_CANDIDATE_BUFFER_MS = 5e3;
|
|
5022
|
+
function normalizeQuestionText(q) {
|
|
5023
|
+
if (!q) return "";
|
|
5024
|
+
return q.normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
|
|
5025
|
+
}
|
|
5026
|
+
function questionsMatch(hookText, paneText) {
|
|
5027
|
+
const h = normalizeQuestionText(hookText);
|
|
5028
|
+
const p = normalizeQuestionText(paneText);
|
|
5029
|
+
if (!h || !p) return false;
|
|
5030
|
+
return h === p;
|
|
5031
|
+
}
|
|
5032
|
+
function correlatesQuestionInvocation(call, delivery) {
|
|
5033
|
+
if (!call) return false;
|
|
5034
|
+
const tn = (delivery.toolName ?? "").toLowerCase().trim();
|
|
5035
|
+
if (!["ask_question", "question", "ask"].includes(tn)) {
|
|
5036
|
+
return false;
|
|
5037
|
+
}
|
|
5038
|
+
const hasPromptStep = call.intentStepIndex !== void 0 && call.intentStepIndex >= 0;
|
|
5039
|
+
const hasDeliveryStep = delivery.stepIdx !== void 0 && delivery.stepIdx >= 0;
|
|
5040
|
+
if (hasPromptStep && !hasDeliveryStep) {
|
|
5041
|
+
return false;
|
|
5042
|
+
}
|
|
5043
|
+
if (hasPromptStep && hasDeliveryStep) {
|
|
5044
|
+
const stepDiff = delivery.stepIdx - call.intentStepIndex;
|
|
5045
|
+
if (stepDiff < 0 || stepDiff > 1) return false;
|
|
5046
|
+
} else {
|
|
5047
|
+
if (!delivery.toolArgs) return false;
|
|
5048
|
+
}
|
|
5049
|
+
if (delivery.toolArgs) {
|
|
5050
|
+
const completedQ = extractQuestionFromArgs(delivery.toolName ?? "", delivery.toolArgs);
|
|
5051
|
+
const allCompleted = extractAllQuestionsFromArgs(delivery.toolName ?? "", delivery.toolArgs);
|
|
5052
|
+
if (completedQ || allCompleted && allCompleted.length > 0) {
|
|
5053
|
+
const matchesCurrent = completedQ ? questionsMatch(call.questionText, completedQ) : false;
|
|
5054
|
+
const matchesStructured = call.structuredQuestions?.some(
|
|
5055
|
+
(sq) => completedQ && questionsMatch(sq.question, completedQ) || allCompleted?.some((ac) => questionsMatch(sq.question, ac.question))
|
|
5056
|
+
) ?? false;
|
|
5057
|
+
const matchesAllCompleted = allCompleted?.some(
|
|
5058
|
+
(ac) => questionsMatch(call.questionText, ac.question)
|
|
5059
|
+
) ?? false;
|
|
5060
|
+
if (!matchesCurrent && !matchesStructured && !matchesAllCompleted) {
|
|
5061
|
+
return false;
|
|
5017
5062
|
}
|
|
5018
|
-
|
|
5019
|
-
|
|
5020
|
-
if (candidate.command) {
|
|
5021
|
-
const matches = Array.from(this.pendingCalls.values()).filter((c) => c.conversationId === conversationId).filter((c) => c.command && c.command === candidate.command).sort((a, b) => a.intentStepIndex - b.intentStepIndex);
|
|
5022
|
-
if (matches.length > 0) return matches[0];
|
|
5023
|
-
}
|
|
5024
|
-
if (candidate.filePath) {
|
|
5025
|
-
const matches = Array.from(this.pendingCalls.values()).filter((c) => c.conversationId === conversationId).filter((c) => c.filePath && c.filePath === candidate.filePath).sort((a, b) => a.intentStepIndex - b.intentStepIndex);
|
|
5026
|
-
if (matches.length > 0) return matches[0];
|
|
5027
|
-
}
|
|
5028
|
-
const allFields = (candidate.command ?? "") + " " + (candidate.filePath ?? "");
|
|
5029
|
-
if (allFields.trim()) {
|
|
5030
|
-
const matches = Array.from(this.pendingCalls.values()).filter((c) => c.conversationId === conversationId).filter((c) => {
|
|
5031
|
-
const cmd = (c.command ?? "").trim();
|
|
5032
|
-
const fp = (c.filePath ?? "").trim();
|
|
5033
|
-
if (!cmd && !fp) return false;
|
|
5034
|
-
const cmdOk = cmd.length >= PASS3_MIN_IDENTITY_LEN && containsAsWord(allFields, cmd);
|
|
5035
|
-
const fpOk = fp.length >= PASS3_MIN_IDENTITY_LEN && containsAsWord(allFields, fp);
|
|
5036
|
-
return cmdOk || fpOk;
|
|
5037
|
-
}).sort((a, b) => a.intentStepIndex - b.intentStepIndex);
|
|
5038
|
-
if (matches.length > 0) return matches[0];
|
|
5063
|
+
} else if (!hasPromptStep || !hasDeliveryStep) {
|
|
5064
|
+
return false;
|
|
5039
5065
|
}
|
|
5040
|
-
return null;
|
|
5041
5066
|
}
|
|
5042
|
-
|
|
5043
|
-
|
|
5044
|
-
|
|
5045
|
-
|
|
5046
|
-
|
|
5047
|
-
|
|
5048
|
-
|
|
5049
|
-
|
|
5050
|
-
|
|
5051
|
-
|
|
5052
|
-
|
|
5053
|
-
|
|
5054
|
-
|
|
5055
|
-
|
|
5056
|
-
|
|
5057
|
-
|
|
5058
|
-
|
|
5059
|
-
|
|
5067
|
+
return true;
|
|
5068
|
+
}
|
|
5069
|
+
var ApprovalDetector = class extends import_events3.EventEmitter {
|
|
5070
|
+
constructor(options = {}) {
|
|
5071
|
+
super();
|
|
5072
|
+
this.pendingCalls = /* @__PURE__ */ new Map();
|
|
5073
|
+
this.pendingPrompts = /* @__PURE__ */ new Map();
|
|
5074
|
+
/** Track recently-resolved promptIds so a duplicate candidate-emit
|
|
5075
|
+
* doesn't re-fire INTERACTIVE_PROMPT. Maps promptId → ms-epoch when
|
|
5076
|
+
* resolved. Pruned by sweep. */
|
|
5077
|
+
this.resolvedPrompts = /* @__PURE__ */ new Map();
|
|
5078
|
+
/** Pre-transcript candidate buffer keyed by `${conversationId}|${paneHash}`
|
|
5079
|
+
* (composite key prevents collision when two conversations on the same
|
|
5080
|
+
* tmux pane produce identical paneHashes — agy R2 Stage 2 round-3 MED).
|
|
5081
|
+
* When a candidate arrives with no matching pending call, we park it
|
|
5082
|
+
* here for pendingCandidateBufferMs and re-try the match each time
|
|
5083
|
+
* registerPendingCall fires. */
|
|
5084
|
+
this.pendingCandidates = /* @__PURE__ */ new Map();
|
|
5085
|
+
/** Authoritative live wizard invocations keyed by conversationId (Codex S2-Codex-R6-F15).
|
|
5086
|
+
* Retains invocation identity and structured questions independently of transcript TTL sweeps. */
|
|
5087
|
+
this.liveWizardInvocations = /* @__PURE__ */ new Map();
|
|
5088
|
+
this.sweepTimer = null;
|
|
5089
|
+
// ─── Pane-only prompt emission (v9.1 — bypasses pending-call match) ─────
|
|
5090
|
+
/** Set of pane-snapshot hashes we've already emitted INTERACTIVE_PROMPT
|
|
5091
|
+
* for. Belt-and-suspenders dedupe against the case where paneObserver
|
|
5092
|
+
* somehow re-fires `prompt-candidate` with the same snapshot bytes
|
|
5093
|
+
* for the same prompt. The primary dedupe for cosmetic redraws of a
|
|
5094
|
+
* single approval (spinner ticks, cursor blink, partial repaints)
|
|
5095
|
+
* lives in tmux-pane-observer's debounce window — by the time a
|
|
5096
|
+
* candidate reaches this layer, the pane has settled. */
|
|
5097
|
+
this.paneOnlyEmittedHashes = /* @__PURE__ */ new Set();
|
|
5098
|
+
this.promptTtlMs = options.promptTtlMs ?? DEFAULT_PROMPT_TTL_MS;
|
|
5099
|
+
this.pendingCallTtlMs = options.pendingCallTtlMs ?? PENDING_CALL_TTL_MS;
|
|
5100
|
+
this.sweepIntervalMs = options.sweepIntervalMs ?? SWEEP_INTERVAL_MS;
|
|
5101
|
+
this.pendingCandidateBufferMs = options.pendingCandidateBufferMs ?? PENDING_CANDIDATE_BUFFER_MS;
|
|
5102
|
+
}
|
|
5103
|
+
start() {
|
|
5104
|
+
if (this.sweepTimer) return;
|
|
5105
|
+
this.sweepTimer = setInterval(() => this.sweep(), this.sweepIntervalMs);
|
|
5106
|
+
if (this.sweepTimer.unref) this.sweepTimer.unref();
|
|
5107
|
+
logger.info("Approval detector started", {
|
|
5108
|
+
promptTtlMs: this.promptTtlMs,
|
|
5109
|
+
pendingCallTtlMs: this.pendingCallTtlMs
|
|
5110
|
+
});
|
|
5111
|
+
}
|
|
5112
|
+
async stop() {
|
|
5113
|
+
if (this.sweepTimer) {
|
|
5114
|
+
clearInterval(this.sweepTimer);
|
|
5115
|
+
this.sweepTimer = null;
|
|
5060
5116
|
}
|
|
5061
|
-
|
|
5062
|
-
|
|
5063
|
-
|
|
5117
|
+
this.pendingCalls.clear();
|
|
5118
|
+
this.pendingPrompts.clear();
|
|
5119
|
+
this.resolvedPrompts.clear();
|
|
5120
|
+
this.pendingCandidates.clear();
|
|
5121
|
+
this.paneOnlyEmittedHashes.clear();
|
|
5122
|
+
this.liveWizardInvocations.clear();
|
|
5123
|
+
logger.info("Approval detector stopped");
|
|
5124
|
+
}
|
|
5125
|
+
/** Explicitly retire a live wizard invocation upon completion (PostToolUse / Stop). (Codex S2-Codex-R6-F15) */
|
|
5126
|
+
retireLiveWizard(conversationId) {
|
|
5127
|
+
const live = this.liveWizardInvocations.get(conversationId);
|
|
5128
|
+
if (live) {
|
|
5129
|
+
if (live.promptId) {
|
|
5130
|
+
this.resolvedPrompts.set(live.promptId, Date.now());
|
|
5064
5131
|
}
|
|
5065
|
-
|
|
5066
|
-
|
|
5067
|
-
if (
|
|
5068
|
-
|
|
5069
|
-
logger.debug("Buffered candidate expired without ever matching a pending call", {
|
|
5070
|
-
key,
|
|
5071
|
-
candidateCommand: buffered.candidate.command,
|
|
5072
|
-
ageMs: now - buffered.queuedAt
|
|
5073
|
-
});
|
|
5132
|
+
const callKey = this.callKey(live.call);
|
|
5133
|
+
const call = this.pendingCalls.get(callKey);
|
|
5134
|
+
if (call) {
|
|
5135
|
+
call.resultStepIndex = call.intentStepIndex;
|
|
5074
5136
|
}
|
|
5137
|
+
this.liveWizardInvocations.delete(conversationId);
|
|
5075
5138
|
}
|
|
5076
5139
|
}
|
|
5077
|
-
|
|
5078
|
-
|
|
5079
|
-
|
|
5080
|
-
|
|
5081
|
-
|
|
5082
|
-
|
|
5083
|
-
|
|
5084
|
-
var import_child_process2 = require("child_process");
|
|
5085
|
-
var import_util2 = require("util");
|
|
5086
|
-
var execAsync2 = (0, import_util2.promisify)(import_child_process2.exec);
|
|
5087
|
-
var ENTER_DELAY_MS = 100;
|
|
5088
|
-
var CODEVIBE_AGY_ESCAPE_INPUT = "__CODEVIBE_AGY_KEY_ESCAPE__";
|
|
5089
|
-
var PromptResponder = class {
|
|
5090
|
-
constructor(options = {}) {
|
|
5091
|
-
this.tmuxTargetOverride = options.tmuxTarget;
|
|
5092
|
-
this.execFn = options.execFn ?? (async (cmd) => execAsync2(cmd));
|
|
5093
|
-
this.paneObserver = options.paneObserver ?? null;
|
|
5094
|
-
this.detector = options.detector ?? null;
|
|
5095
|
-
if (this.detector && !this.paneObserver) {
|
|
5096
|
-
throw new Error(
|
|
5097
|
-
"PromptResponder misconfigured: detector wired without paneObserver. Approval-reply identity guard requires the observer to re-probe the pane before send. Either wire both, or wire neither."
|
|
5098
|
-
);
|
|
5140
|
+
/** Explicitly retire a live wizard invocation matching a completed tool delivery (PostToolUse) (Codex S2-Codex-R6-F15, Claude R8-F6).
|
|
5141
|
+
* Runs synchronously before any async backend awaits, using the shared invocation correlation predicate. */
|
|
5142
|
+
retireLiveWizardMatching(conversationId, delivery) {
|
|
5143
|
+
const live = this.liveWizardInvocations.get(conversationId);
|
|
5144
|
+
if (!live) return false;
|
|
5145
|
+
if (!correlatesQuestionInvocation(live.call, delivery)) {
|
|
5146
|
+
return false;
|
|
5099
5147
|
}
|
|
5100
|
-
|
|
5101
|
-
|
|
5102
|
-
* Send free-form text to the agy session. The text is typed (via
|
|
5103
|
-
* `send-keys -l`) then Enter is sent after a short delay so agy's
|
|
5104
|
-
* input handler has time to process the buffer.
|
|
5105
|
-
*/
|
|
5106
|
-
async sendFreeForm(text) {
|
|
5107
|
-
const target = this.resolveTmuxTarget();
|
|
5108
|
-
if (!target) {
|
|
5109
|
-
return { ok: false, reason: "no-tmux-target" };
|
|
5148
|
+
if (live.promptId) {
|
|
5149
|
+
this.resolvedPrompts.set(live.promptId, Date.now());
|
|
5110
5150
|
}
|
|
5111
|
-
|
|
5112
|
-
|
|
5113
|
-
|
|
5114
|
-
|
|
5115
|
-
await this.typeLiteral(target, text);
|
|
5116
|
-
await delay(ENTER_DELAY_MS);
|
|
5117
|
-
await this.sendKey(target, "Enter");
|
|
5118
|
-
}
|
|
5119
|
-
logger.info("Sent free-form input to agy", {
|
|
5120
|
-
target,
|
|
5121
|
-
textLength: text.length
|
|
5122
|
-
});
|
|
5123
|
-
return { ok: true };
|
|
5124
|
-
} catch (error) {
|
|
5125
|
-
logger.error("tmux send-keys failed for free-form input", { target, error });
|
|
5126
|
-
return {
|
|
5127
|
-
ok: false,
|
|
5128
|
-
reason: "tmux-failed",
|
|
5129
|
-
details: error instanceof Error ? error.message : String(error)
|
|
5130
|
-
};
|
|
5151
|
+
const callKey = this.callKey(live.call);
|
|
5152
|
+
const call = this.pendingCalls.get(callKey);
|
|
5153
|
+
if (call) {
|
|
5154
|
+
call.resultStepIndex = call.intentStepIndex;
|
|
5131
5155
|
}
|
|
5156
|
+
this.liveWizardInvocations.delete(conversationId);
|
|
5157
|
+
logger.info("Retired live wizard invocation matching completed tool", {
|
|
5158
|
+
conversationId,
|
|
5159
|
+
toolName: delivery.toolName,
|
|
5160
|
+
stepIdx: delivery.stepIdx,
|
|
5161
|
+
intentStepIndex: live.call.intentStepIndex
|
|
5162
|
+
});
|
|
5163
|
+
return true;
|
|
5132
5164
|
}
|
|
5133
5165
|
/**
|
|
5134
|
-
*
|
|
5135
|
-
*
|
|
5136
|
-
* translate option-number → terminal keystrokes (digit alone for agy's
|
|
5137
|
-
* 4-numbered UI; future Y/N prompts would use 'y'/'n'/'Escape').
|
|
5166
|
+
* Emit INTERACTIVE_PROMPT directly from a pane snapshot, WITHOUT
|
|
5167
|
+
* requiring a matching PendingToolCall from a transcript PLANNER_RESPONSE.
|
|
5138
5168
|
*
|
|
5139
|
-
*
|
|
5140
|
-
*
|
|
5141
|
-
*
|
|
5142
|
-
*
|
|
5143
|
-
*
|
|
5144
|
-
*
|
|
5145
|
-
*
|
|
5146
|
-
*
|
|
5147
|
-
*
|
|
5148
|
-
*
|
|
5149
|
-
*
|
|
5169
|
+
* Background (v9.1, 2026-05-21): agy does NOT write tool_call entries
|
|
5170
|
+
* to the transcript BEFORE rendering the approval UI for shell
|
|
5171
|
+
* commands. It only writes them AFTER the user approves. So the
|
|
5172
|
+
* transcript-based pending-call matching path deadlocks for shell
|
|
5173
|
+
* commands: paneObserver sees the UI, but no pending call exists →
|
|
5174
|
+
* candidate buffered → user can't approve from mobile (no prompt
|
|
5175
|
+
* shown) → no transcript write → buffer expires.
|
|
5176
|
+
*
|
|
5177
|
+
* Solution: pane is authoritative for "approval UI is showing right
|
|
5178
|
+
* now." Skip the pending-call requirement and emit directly. The
|
|
5179
|
+
* parsed candidate has everything needed (command, options, submitMap).
|
|
5180
|
+
*
|
|
5181
|
+
* Synthesizes a minimal PendingToolCall from the candidate so the
|
|
5182
|
+
* existing handlePendingPrompt code path doesn't need to branch on
|
|
5183
|
+
* `pendingCall: null`.
|
|
5184
|
+
*
|
|
5185
|
+
* Returns the emitted state, or null if dedupe (already emitted for
|
|
5186
|
+
* this paneHash) or disabled.
|
|
5150
5187
|
*/
|
|
5151
|
-
|
|
5152
|
-
|
|
5153
|
-
|
|
5154
|
-
|
|
5188
|
+
emitPaneOnlyPrompt(candidate, conversationId, forcedPromptId) {
|
|
5189
|
+
if (this.paneOnlyEmittedHashes.has(candidate.paneHash)) {
|
|
5190
|
+
logger.debug("Skipping pane-only re-emit for duplicate paneHash", {
|
|
5191
|
+
paneHash: candidate.paneHash.substring(0, 16)
|
|
5192
|
+
});
|
|
5193
|
+
return null;
|
|
5155
5194
|
}
|
|
5156
|
-
if (
|
|
5157
|
-
const
|
|
5158
|
-
|
|
5159
|
-
|
|
5160
|
-
|
|
5161
|
-
|
|
5162
|
-
|
|
5163
|
-
|
|
5195
|
+
if (candidate.kind === "question") {
|
|
5196
|
+
const paneQuestionText = candidate.headerText || candidate.fullHeaderLine;
|
|
5197
|
+
const candidateSemanticKey = buildApprovalSemanticKey(candidate);
|
|
5198
|
+
const liveWizard2 = this.liveWizardInvocations.get(conversationId);
|
|
5199
|
+
if (liveWizard2 && liveWizard2.call.structuredQuestions && liveWizard2.call.structuredQuestions.length > 0) {
|
|
5200
|
+
const sqs = liveWizard2.call.structuredQuestions;
|
|
5201
|
+
const currentActiveIdx = liveWizard2.activeStepIndex ?? 0;
|
|
5202
|
+
const scoreStep = (stepIdx) => {
|
|
5203
|
+
if (stepIdx < 0 || stepIdx >= sqs.length) return { score: -1, textMatch: false };
|
|
5204
|
+
const targetQ = sqs[stepIdx];
|
|
5205
|
+
const textMatch = questionsMatch(targetQ.question, paneQuestionText);
|
|
5206
|
+
if (!textMatch) return { score: -1, textMatch: false };
|
|
5207
|
+
const qOptTexts = (targetQ.options || []).map((o) => (typeof o === "string" ? o : o.text).toLowerCase().trim());
|
|
5208
|
+
const cOptTexts = (candidate.options || []).map((o) => (typeof o === "string" ? o : o.text).toLowerCase().trim());
|
|
5209
|
+
if (cOptTexts.length > 0 && qOptTexts.length > 0) {
|
|
5210
|
+
const matchedQCount = qOptTexts.filter((q) => cOptTexts.includes(q)).length;
|
|
5211
|
+
const matchedCCount = cOptTexts.filter((c) => qOptTexts.includes(c)).length;
|
|
5212
|
+
const qRatio = matchedQCount / qOptTexts.length;
|
|
5213
|
+
if (cOptTexts.length !== qOptTexts.length || matchedQCount !== qOptTexts.length || matchedCCount !== cOptTexts.length) {
|
|
5214
|
+
return { score: -1, textMatch: true };
|
|
5215
|
+
}
|
|
5216
|
+
const posMatches = qOptTexts.filter((q, idx) => idx < cOptTexts.length && cOptTexts[idx] === q).length;
|
|
5217
|
+
const posRatio = posMatches / qOptTexts.length;
|
|
5218
|
+
const score = qRatio * 0.5 + posRatio * 0.5;
|
|
5219
|
+
return { score, textMatch: true };
|
|
5220
|
+
}
|
|
5221
|
+
if (cOptTexts.length === 0 && qOptTexts.length === 0) {
|
|
5222
|
+
return { score: 1, textMatch: true };
|
|
5223
|
+
}
|
|
5224
|
+
return { score: -1, textMatch: true };
|
|
5164
5225
|
};
|
|
5165
|
-
|
|
5166
|
-
|
|
5167
|
-
if (
|
|
5168
|
-
|
|
5169
|
-
|
|
5170
|
-
|
|
5171
|
-
|
|
5172
|
-
|
|
5226
|
+
const allScores = sqs.map((_, idx) => ({ stepIdx: idx, ...scoreStep(idx) })).filter((res) => res.score > 0);
|
|
5227
|
+
let bestStepIdx = -1;
|
|
5228
|
+
if (allScores.length > 0) {
|
|
5229
|
+
const maxScore = Math.max(...allScores.map((s) => s.score));
|
|
5230
|
+
const topSteps = allScores.filter((s) => Math.abs(s.score - maxScore) < 1e-3);
|
|
5231
|
+
if (topSteps.length > 1) {
|
|
5232
|
+
logger.warn("Ambiguous pane options match: multiple wizard steps tied with score", {
|
|
5233
|
+
maxScore,
|
|
5234
|
+
tiedSteps: topSteps.map((s) => s.stepIdx)
|
|
5235
|
+
});
|
|
5236
|
+
bestStepIdx = -1;
|
|
5237
|
+
} else {
|
|
5238
|
+
bestStepIdx = topSteps[0].stepIdx;
|
|
5239
|
+
}
|
|
5173
5240
|
}
|
|
5174
|
-
|
|
5175
|
-
|
|
5176
|
-
|
|
5177
|
-
|
|
5178
|
-
|
|
5179
|
-
|
|
5180
|
-
|
|
5181
|
-
}
|
|
5182
|
-
|
|
5183
|
-
|
|
5184
|
-
|
|
5185
|
-
|
|
5186
|
-
|
|
5187
|
-
|
|
5188
|
-
|
|
5189
|
-
|
|
5190
|
-
|
|
5241
|
+
if (bestStepIdx === -1) {
|
|
5242
|
+
logger.debug("Pane candidate did not match any step of active live wizard", {
|
|
5243
|
+
paneQuestionText,
|
|
5244
|
+
currentActiveIdx,
|
|
5245
|
+
totalSteps: sqs.length
|
|
5246
|
+
});
|
|
5247
|
+
return null;
|
|
5248
|
+
}
|
|
5249
|
+
const isAdvancement = bestStepIdx !== currentActiveIdx;
|
|
5250
|
+
if (!isAdvancement) {
|
|
5251
|
+
const existing = [...this.pendingPrompts.values()].find(
|
|
5252
|
+
(p) => p.conversationId === conversationId && p.kind === "question"
|
|
5253
|
+
);
|
|
5254
|
+
if (existing) {
|
|
5255
|
+
const isInitialUpgrade = existing.isHookPrompt && Object.keys(existing.submitMap || {}).length === 0 && Object.keys(candidate.submitMap || {}).length > 0;
|
|
5256
|
+
if (isInitialUpgrade) {
|
|
5257
|
+
existing.submitMap = candidate.submitMap;
|
|
5258
|
+
if (candidate.options && candidate.options.length > 0) {
|
|
5259
|
+
existing.paneOptions = candidate.options;
|
|
5260
|
+
}
|
|
5261
|
+
existing.paneSemanticKey = candidateSemanticKey;
|
|
5262
|
+
existing.paneHash = candidate.paneHash;
|
|
5263
|
+
if (candidate.headerText) existing.matchedPaneHeader = candidate.headerText;
|
|
5264
|
+
if (candidate.fullHeaderLine) existing.paneDisplayHeader = candidate.fullHeaderLine;
|
|
5265
|
+
if (candidate.body) existing.body = candidate.body;
|
|
5266
|
+
if (candidate.paneHash) this.paneOnlyEmittedHashes.add(candidate.paneHash);
|
|
5267
|
+
logger.info("Upgraded hook-driven question prompt to actionable from pane observer", {
|
|
5268
|
+
promptId: existing.promptId,
|
|
5269
|
+
optionsCount: existing.paneOptions?.length ?? 0
|
|
5270
|
+
});
|
|
5271
|
+
this.emit("prompt-upgraded", existing);
|
|
5272
|
+
return existing;
|
|
5273
|
+
}
|
|
5274
|
+
if (!existing.paneSemanticKey) {
|
|
5275
|
+
existing.paneSemanticKey = candidateSemanticKey;
|
|
5276
|
+
}
|
|
5277
|
+
if (candidate.body) existing.body = candidate.body;
|
|
5278
|
+
if (candidate.paneHash) this.paneOnlyEmittedHashes.add(candidate.paneHash);
|
|
5279
|
+
return null;
|
|
5191
5280
|
}
|
|
5192
|
-
|
|
5193
|
-
|
|
5281
|
+
} else {
|
|
5282
|
+
liveWizard2.activeStepIndex = bestStepIdx;
|
|
5283
|
+
for (const p of [...this.pendingPrompts.values()]) {
|
|
5284
|
+
if (p.conversationId === conversationId && p.kind === "question") {
|
|
5285
|
+
logger.info("Resolving older question prompt on wizard step progression", {
|
|
5286
|
+
oldPromptId: p.promptId,
|
|
5287
|
+
fromStep: currentActiveIdx,
|
|
5288
|
+
toStep: bestStepIdx
|
|
5289
|
+
});
|
|
5290
|
+
this.resolvePrompt(p.promptId, { advanceWizard: false });
|
|
5291
|
+
this.emit("prompt-resolved", p.promptId);
|
|
5292
|
+
}
|
|
5194
5293
|
}
|
|
5195
|
-
|
|
5196
|
-
|
|
5197
|
-
|
|
5198
|
-
|
|
5199
|
-
|
|
5200
|
-
|
|
5201
|
-
|
|
5202
|
-
|
|
5203
|
-
|
|
5204
|
-
|
|
5205
|
-
|
|
5206
|
-
|
|
5294
|
+
}
|
|
5295
|
+
} else {
|
|
5296
|
+
for (const p of [...this.pendingPrompts.values()]) {
|
|
5297
|
+
if (p.conversationId === conversationId && p.kind === "question") {
|
|
5298
|
+
const hookQuestionText = p.pendingCall?.questionText || p.matchedPaneHeader || p.paneDisplayHeader;
|
|
5299
|
+
const isSameQuestion = questionsMatch(hookQuestionText, paneQuestionText);
|
|
5300
|
+
if (isSameQuestion) {
|
|
5301
|
+
const isInitialUpgrade = p.isHookPrompt && Object.keys(p.submitMap || {}).length === 0 && Object.keys(candidate.submitMap || {}).length > 0;
|
|
5302
|
+
const isControlDelta = !isInitialUpgrade && Boolean(
|
|
5303
|
+
p.paneSemanticKey && candidateSemanticKey !== p.paneSemanticKey
|
|
5304
|
+
);
|
|
5305
|
+
if (!isControlDelta) {
|
|
5306
|
+
if (p.isHookPrompt && Object.keys(p.submitMap || {}).length === 0 && Object.keys(candidate.submitMap || {}).length > 0) {
|
|
5307
|
+
p.submitMap = candidate.submitMap;
|
|
5308
|
+
if (candidate.options && candidate.options.length > 0) {
|
|
5309
|
+
p.paneOptions = candidate.options;
|
|
5310
|
+
}
|
|
5311
|
+
p.paneSemanticKey = candidateSemanticKey;
|
|
5312
|
+
p.paneHash = candidate.paneHash;
|
|
5313
|
+
if (candidate.headerText) p.matchedPaneHeader = candidate.headerText;
|
|
5314
|
+
if (candidate.fullHeaderLine) p.paneDisplayHeader = candidate.fullHeaderLine;
|
|
5315
|
+
if (candidate.body) p.body = candidate.body;
|
|
5316
|
+
if (candidate.paneHash) this.paneOnlyEmittedHashes.add(candidate.paneHash);
|
|
5317
|
+
logger.info("Upgraded hook-driven question prompt to actionable from pane observer", {
|
|
5318
|
+
promptId: p.promptId,
|
|
5319
|
+
optionsCount: p.paneOptions?.length ?? 0
|
|
5320
|
+
});
|
|
5321
|
+
this.emit("prompt-upgraded", p);
|
|
5322
|
+
return p;
|
|
5323
|
+
}
|
|
5324
|
+
if (!p.paneSemanticKey) {
|
|
5325
|
+
p.paneSemanticKey = candidateSemanticKey;
|
|
5326
|
+
}
|
|
5327
|
+
if (candidate.paneHash) this.paneOnlyEmittedHashes.add(candidate.paneHash);
|
|
5328
|
+
return null;
|
|
5207
5329
|
}
|
|
5208
|
-
return {
|
|
5209
|
-
ok: false,
|
|
5210
|
-
reason: "prompt-probe-failed",
|
|
5211
|
-
details: "the active chooser could not be parsed exactly"
|
|
5212
|
-
};
|
|
5213
5330
|
}
|
|
5214
|
-
|
|
5215
|
-
|
|
5216
|
-
return {
|
|
5217
|
-
ok: false,
|
|
5218
|
-
reason: "prompt-probe-failed",
|
|
5219
|
-
details: error instanceof Error ? error.message : String(error)
|
|
5220
|
-
};
|
|
5221
|
-
}
|
|
5222
|
-
if (liveSemanticKey !== state.paneSemanticKey) {
|
|
5223
|
-
this.detector.resolvePrompt(promptId);
|
|
5224
|
-
return {
|
|
5225
|
-
ok: false,
|
|
5226
|
-
reason: "prompt-superseded",
|
|
5227
|
-
details: "active chooser semantics differ from the mobile prompt"
|
|
5228
|
-
};
|
|
5331
|
+
this.resolvePrompt(p.promptId);
|
|
5332
|
+
this.emit("prompt-resolved", p.promptId);
|
|
5229
5333
|
}
|
|
5230
|
-
|
|
5231
|
-
|
|
5334
|
+
}
|
|
5335
|
+
}
|
|
5336
|
+
}
|
|
5337
|
+
const promptId = forcedPromptId ?? (0, import_uuid2.v4)();
|
|
5338
|
+
const liveWizard = candidate.kind === "question" ? this.liveWizardInvocations.get(conversationId) : void 0;
|
|
5339
|
+
const matchingCall = liveWizard ? liveWizard.call : candidate.kind === "question" ? this.findMatchingPendingCall(candidate, conversationId) : null;
|
|
5340
|
+
if (liveWizard) {
|
|
5341
|
+
liveWizard.promptId = promptId;
|
|
5342
|
+
} else if (matchingCall) {
|
|
5343
|
+
matchingCall.matchedPromptId = promptId;
|
|
5344
|
+
}
|
|
5345
|
+
const syntheticCall = {
|
|
5346
|
+
conversationId,
|
|
5347
|
+
intentStepIndex: matchingCall?.intentStepIndex ?? -1,
|
|
5348
|
+
// sentinel: "no transcript origin"
|
|
5349
|
+
toolCallIndex: matchingCall?.toolCallIndex ?? 0,
|
|
5350
|
+
intentByteOffset: matchingCall?.intentByteOffset ?? 0,
|
|
5351
|
+
// Best-effort tool-type inference from the candidate. For shell
|
|
5352
|
+
// commands → RUN_COMMAND. For edit-style headers with a file
|
|
5353
|
+
// path → CODE_ACTION. For questions → ASK_QUESTION. Default: GENERIC.
|
|
5354
|
+
toolType: matchingCall?.toolType ?? guessToolTypeFromIdentity(candidate),
|
|
5355
|
+
command: matchingCall?.command ?? candidate.command,
|
|
5356
|
+
filePath: matchingCall?.filePath ?? candidate.filePath,
|
|
5357
|
+
rawToolName: matchingCall?.rawToolName ?? (candidate.kind === "question" ? "ask_question" : candidate.command ?? candidate.filePath ?? "unknown"),
|
|
5358
|
+
questionText: candidate.kind === "question" && candidate.headerText ? candidate.headerText : matchingCall?.questionText,
|
|
5359
|
+
// NOTE (Codex R3-F1 / Claude R3-F2): Pane options are authoritative ground truth
|
|
5360
|
+
// that directly correspond to desktop keystrokes (submitMap). Transcript options
|
|
5361
|
+
// serve solely as fallback when pane options cannot be parsed.
|
|
5362
|
+
questionOptions: candidate.kind === "question" && candidate.options && candidate.options.length > 0 ? candidate.options : matchingCall?.questionOptions,
|
|
5363
|
+
structuredQuestions: matchingCall?.structuredQuestions,
|
|
5364
|
+
promptId,
|
|
5365
|
+
startedAt: matchingCall?.startedAt ?? Date.now(),
|
|
5366
|
+
intentCreatedAt: matchingCall?.intentCreatedAt ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
5367
|
+
};
|
|
5368
|
+
const isHookCall = Boolean(
|
|
5369
|
+
matchingCall?.structuredQuestions && matchingCall.structuredQuestions.length > 0
|
|
5370
|
+
);
|
|
5371
|
+
const state = {
|
|
5372
|
+
promptId,
|
|
5373
|
+
conversationId,
|
|
5374
|
+
pendingCall: syntheticCall,
|
|
5375
|
+
submitMap: candidate.submitMap,
|
|
5376
|
+
matchedPaneHeader: candidate.headerText,
|
|
5377
|
+
paneDisplayHeader: candidate.fullHeaderLine,
|
|
5378
|
+
body: candidate.body,
|
|
5379
|
+
paneOptions: candidate.options,
|
|
5380
|
+
paneSemanticKey: buildApprovalSemanticKey(candidate),
|
|
5381
|
+
kind: candidate.kind,
|
|
5382
|
+
isHookPrompt: isHookCall,
|
|
5383
|
+
// Carry hook ownership forward so pane redraws do not drop it! (Codex S2-Codex-R6-F15)
|
|
5384
|
+
wizardStepIndex: liveWizard ? liveWizard.activeStepIndex : void 0,
|
|
5385
|
+
emittedAt: Date.now(),
|
|
5386
|
+
ttlMs: this.promptTtlMs,
|
|
5387
|
+
// E1 (§6a-2b / F5) — carry the consumed pane hash so an emit-failure
|
|
5388
|
+
// rollback can reset the observer's lastPromptHash and let the unchanged
|
|
5389
|
+
// live pane re-emit.
|
|
5390
|
+
paneHash: candidate.paneHash
|
|
5391
|
+
};
|
|
5392
|
+
this.pendingPrompts.set(promptId, state);
|
|
5393
|
+
this.paneOnlyEmittedHashes.add(candidate.paneHash);
|
|
5394
|
+
logger.info("Emitting pane-only INTERACTIVE_PROMPT", {
|
|
5395
|
+
promptId,
|
|
5396
|
+
command: candidate.command,
|
|
5397
|
+
filePath: candidate.filePath,
|
|
5398
|
+
optionsCount: candidate.options.length
|
|
5399
|
+
});
|
|
5400
|
+
this.emit("pending-prompt", state);
|
|
5401
|
+
return state;
|
|
5402
|
+
}
|
|
5403
|
+
/**
|
|
5404
|
+
* Emit an INTERACTIVE_PROMPT for a question tool call directly from the PreToolUse hook.
|
|
5405
|
+
* This guarantees prompt delivery to mobile without relying on fragile TUI terminal screen-scraping.
|
|
5406
|
+
*/
|
|
5407
|
+
emitHookQuestionPrompt(call, forcedPromptId) {
|
|
5408
|
+
const questionText = call.questionText ?? "Question";
|
|
5409
|
+
if (!call.questionOptions && call.structuredQuestions && call.structuredQuestions[0]?.options) {
|
|
5410
|
+
call.questionOptions = call.structuredQuestions[0].options;
|
|
5411
|
+
}
|
|
5412
|
+
for (const [id, p] of this.pendingPrompts.entries()) {
|
|
5413
|
+
if (p.conversationId === call.conversationId && p.kind === "question") {
|
|
5414
|
+
const existingText = p.pendingCall?.questionText || p.matchedPaneHeader || p.paneDisplayHeader;
|
|
5415
|
+
const isSameInvocation = !p.pendingCall || p.pendingCall.intentStepIndex === -1 || call.intentStepIndex === -1 || p.pendingCall.intentStepIndex === call.intentStepIndex;
|
|
5416
|
+
if (isSameInvocation && questionsMatch(existingText, questionText)) {
|
|
5417
|
+
if (p.pendingCall) {
|
|
5418
|
+
p.pendingCall.structuredQuestions = call.structuredQuestions;
|
|
5419
|
+
if (call.intentStepIndex !== -1) {
|
|
5420
|
+
p.pendingCall.intentStepIndex = call.intentStepIndex;
|
|
5421
|
+
}
|
|
5422
|
+
p.pendingCall.rawToolName = call.rawToolName;
|
|
5423
|
+
p.pendingCall.toolType = call.toolType;
|
|
5232
5424
|
} else {
|
|
5233
|
-
|
|
5425
|
+
p.pendingCall = call;
|
|
5426
|
+
}
|
|
5427
|
+
p.isHookPrompt = true;
|
|
5428
|
+
call.matchedPromptId = p.promptId;
|
|
5429
|
+
if (call.structuredQuestions && call.structuredQuestions.length > 0) {
|
|
5430
|
+
this.liveWizardInvocations.set(call.conversationId, {
|
|
5431
|
+
call,
|
|
5432
|
+
activeStepIndex: 0,
|
|
5433
|
+
promptId: p.promptId,
|
|
5434
|
+
registeredAt: Date.now()
|
|
5435
|
+
});
|
|
5234
5436
|
}
|
|
5437
|
+
logger.info("emitHookQuestionPrompt: reusing existing matching prompt", {
|
|
5438
|
+
promptId: p.promptId,
|
|
5439
|
+
isActionable: Object.keys(p.submitMap || {}).length > 0
|
|
5440
|
+
});
|
|
5441
|
+
return p;
|
|
5235
5442
|
}
|
|
5236
|
-
|
|
5237
|
-
|
|
5238
|
-
|
|
5239
|
-
|
|
5240
|
-
|
|
5443
|
+
logger.info("Replacing older question prompt with new hook question", {
|
|
5444
|
+
oldPromptId: id,
|
|
5445
|
+
oldStep: p.pendingCall?.intentStepIndex,
|
|
5446
|
+
newStep: call.intentStepIndex,
|
|
5447
|
+
conversationId: call.conversationId
|
|
5241
5448
|
});
|
|
5242
|
-
|
|
5243
|
-
|
|
5244
|
-
return {
|
|
5245
|
-
ok: false,
|
|
5246
|
-
reason: "tmux-failed",
|
|
5247
|
-
details: error instanceof Error ? error.message : String(error)
|
|
5248
|
-
};
|
|
5449
|
+
this.resolvePrompt(id);
|
|
5450
|
+
this.emit("prompt-resolved", id);
|
|
5249
5451
|
}
|
|
5250
5452
|
}
|
|
5251
|
-
|
|
5252
|
-
|
|
5253
|
-
|
|
5254
|
-
|
|
5255
|
-
|
|
5256
|
-
|
|
5257
|
-
|
|
5258
|
-
|
|
5259
|
-
|
|
5453
|
+
const promptId = forcedPromptId ?? (0, import_uuid2.v4)();
|
|
5454
|
+
this.registerPendingCall(call);
|
|
5455
|
+
call.matchedPromptId = promptId;
|
|
5456
|
+
const state = {
|
|
5457
|
+
promptId,
|
|
5458
|
+
conversationId: call.conversationId,
|
|
5459
|
+
pendingCall: call,
|
|
5460
|
+
submitMap: {},
|
|
5461
|
+
matchedPaneHeader: call.questionText ?? "Question",
|
|
5462
|
+
paneDisplayHeader: call.questionText ?? "Question",
|
|
5463
|
+
body: void 0,
|
|
5464
|
+
paneOptions: call.questionOptions,
|
|
5465
|
+
paneSemanticKey: buildApprovalSemanticKey({
|
|
5466
|
+
kind: "question",
|
|
5467
|
+
headerText: call.questionText ?? "Question",
|
|
5468
|
+
fullHeaderLine: call.questionText ?? "Question",
|
|
5469
|
+
options: call.questionOptions ?? [],
|
|
5470
|
+
submitMap: {},
|
|
5471
|
+
body: "",
|
|
5472
|
+
paneHash: ""
|
|
5473
|
+
}),
|
|
5474
|
+
kind: "question",
|
|
5475
|
+
isHookPrompt: true,
|
|
5476
|
+
wizardStepIndex: 0,
|
|
5477
|
+
emittedAt: Date.now(),
|
|
5478
|
+
ttlMs: this.promptTtlMs
|
|
5479
|
+
};
|
|
5480
|
+
if (call.structuredQuestions && call.structuredQuestions.length > 0) {
|
|
5481
|
+
this.liveWizardInvocations.set(call.conversationId, {
|
|
5482
|
+
call,
|
|
5483
|
+
activeStepIndex: 0,
|
|
5484
|
+
promptId,
|
|
5485
|
+
registeredAt: Date.now()
|
|
5486
|
+
});
|
|
5260
5487
|
}
|
|
5488
|
+
this.pendingPrompts.set(promptId, state);
|
|
5489
|
+
logger.info("Emitting hook-driven INTERACTIVE_PROMPT for question", {
|
|
5490
|
+
promptId,
|
|
5491
|
+
conversationId: call.conversationId,
|
|
5492
|
+
questionText: call.questionText,
|
|
5493
|
+
optionsCount: call.questionOptions?.length ?? 0
|
|
5494
|
+
});
|
|
5495
|
+
this.emit("pending-prompt", state);
|
|
5496
|
+
return state;
|
|
5261
5497
|
}
|
|
5262
|
-
|
|
5263
|
-
|
|
5264
|
-
|
|
5265
|
-
|
|
5266
|
-
|
|
5267
|
-
|
|
5268
|
-
|
|
5269
|
-
|
|
5270
|
-
|
|
5498
|
+
// ─── Pending-call registration (called from event-mapper hook) ──────────
|
|
5499
|
+
/**
|
|
5500
|
+
* Register a tool-call intent from a PLANNER_RESPONSE.tool_calls[].
|
|
5501
|
+
* Indexed by composite key so agy rollback (which can replay
|
|
5502
|
+
* step_index values) doesn't cause collisions.
|
|
5503
|
+
*/
|
|
5504
|
+
registerPendingCall(call) {
|
|
5505
|
+
const key = this.callKey(call);
|
|
5506
|
+
if (this.pendingCalls.has(key)) {
|
|
5507
|
+
logger.debug("Pending call already registered, skipping", {
|
|
5508
|
+
key,
|
|
5509
|
+
intentStepIndex: call.intentStepIndex,
|
|
5510
|
+
toolType: call.toolType
|
|
5511
|
+
});
|
|
5512
|
+
return;
|
|
5271
5513
|
}
|
|
5272
|
-
|
|
5273
|
-
|
|
5274
|
-
|
|
5275
|
-
|
|
5276
|
-
|
|
5277
|
-
|
|
5278
|
-
|
|
5514
|
+
this.pendingCalls.set(key, call);
|
|
5515
|
+
logger.debug("Pending tool call registered", {
|
|
5516
|
+
key,
|
|
5517
|
+
toolType: call.toolType,
|
|
5518
|
+
command: call.command,
|
|
5519
|
+
filePath: call.filePath
|
|
5520
|
+
});
|
|
5521
|
+
this.drainBufferedCandidates(call.conversationId);
|
|
5522
|
+
}
|
|
5523
|
+
drainBufferedCandidates(conversationId) {
|
|
5524
|
+
if (this.pendingCandidates.size === 0) return;
|
|
5525
|
+
const now = Date.now();
|
|
5526
|
+
for (const [key, buffered] of this.pendingCandidates.entries()) {
|
|
5527
|
+
if (buffered.conversationId !== conversationId) continue;
|
|
5528
|
+
if (now - buffered.queuedAt > this.pendingCandidateBufferMs) {
|
|
5529
|
+
this.pendingCandidates.delete(key);
|
|
5530
|
+
continue;
|
|
5279
5531
|
}
|
|
5280
|
-
|
|
5281
|
-
|
|
5282
|
-
|
|
5283
|
-
|
|
5284
|
-
|
|
5285
|
-
|
|
5286
|
-
|
|
5287
|
-
try {
|
|
5288
|
-
await beforeType();
|
|
5289
|
-
} catch (error) {
|
|
5290
|
-
preparationFailed = true;
|
|
5291
|
-
throw error;
|
|
5292
|
-
}
|
|
5293
|
-
const afterPreparation = await chooserIsAbsent();
|
|
5294
|
-
if (afterPreparation) return afterPreparation;
|
|
5532
|
+
const state = this.matchCandidateInternal(buffered.candidate, buffered.conversationId);
|
|
5533
|
+
if (state) {
|
|
5534
|
+
this.pendingCandidates.delete(key);
|
|
5535
|
+
logger.info("Drained buffered candidate after pending-call arrived", {
|
|
5536
|
+
key,
|
|
5537
|
+
ageMs: now - buffered.queuedAt
|
|
5538
|
+
});
|
|
5295
5539
|
}
|
|
5296
|
-
await this.typeLiteral(target, text);
|
|
5297
|
-
await delay(ENTER_DELAY_MS);
|
|
5298
|
-
const beforeEnter = await chooserIsAbsent();
|
|
5299
|
-
if (beforeEnter) return beforeEnter;
|
|
5300
|
-
await this.sendKey(target, "Enter");
|
|
5301
|
-
return { ok: true };
|
|
5302
|
-
} catch (error) {
|
|
5303
|
-
if (preparationFailed) throw error;
|
|
5304
|
-
return {
|
|
5305
|
-
ok: false,
|
|
5306
|
-
reason: "tmux-failed",
|
|
5307
|
-
details: error instanceof Error ? error.message : String(error)
|
|
5308
|
-
};
|
|
5309
5540
|
}
|
|
5310
5541
|
}
|
|
5311
|
-
/**
|
|
5312
|
-
|
|
5313
|
-
|
|
5314
|
-
|
|
5315
|
-
|
|
5316
|
-
|
|
5317
|
-
|
|
5318
|
-
|
|
5319
|
-
|
|
5320
|
-
if (!before.probeSucceeded) return { ok: false, reason: "prompt-probe-failed" };
|
|
5321
|
-
if (!before.active) return { ok: true };
|
|
5322
|
-
if (before.ambiguous) {
|
|
5323
|
-
return {
|
|
5324
|
-
ok: false,
|
|
5325
|
-
reason: "ambiguous-prompt-active",
|
|
5326
|
-
details: "masked/ambiguous prompt cannot be dismissed via Escape from mobile"
|
|
5327
|
-
};
|
|
5328
|
-
}
|
|
5329
|
-
if (before.kind === "question") {
|
|
5330
|
-
let isActionableWizard = false;
|
|
5331
|
-
try {
|
|
5332
|
-
const snapshot = await this.paneObserver.captureSnapshot();
|
|
5333
|
-
if (snapshot) {
|
|
5334
|
-
const parsed = parseApprovalSnapshot(snapshot);
|
|
5335
|
-
if (parsed && parsed.kind === "question" && Object.keys(parsed.submitMap || {}).length > 0) {
|
|
5336
|
-
isActionableWizard = true;
|
|
5337
|
-
}
|
|
5338
|
-
}
|
|
5339
|
-
} catch {
|
|
5340
|
-
}
|
|
5341
|
-
if (!isActionableWizard) {
|
|
5342
|
-
return {
|
|
5343
|
-
ok: false,
|
|
5344
|
-
reason: "display-only-question-active",
|
|
5345
|
-
details: "active question prompt cannot be dismissed via Escape from mobile"
|
|
5346
|
-
};
|
|
5347
|
-
}
|
|
5348
|
-
}
|
|
5349
|
-
await this.sendKey(target, "Escape");
|
|
5350
|
-
await delay(250);
|
|
5351
|
-
const after = await this.paneObserver.probeApprovalUIActive();
|
|
5352
|
-
if (!after.probeSucceeded) return { ok: false, reason: "prompt-probe-failed" };
|
|
5353
|
-
if (after.active) {
|
|
5354
|
-
return {
|
|
5355
|
-
ok: false,
|
|
5356
|
-
reason: "prompt-superseded",
|
|
5357
|
-
details: "approval chooser remained active after Escape"
|
|
5358
|
-
};
|
|
5359
|
-
}
|
|
5360
|
-
return { ok: true };
|
|
5361
|
-
} catch (error) {
|
|
5362
|
-
return {
|
|
5363
|
-
ok: false,
|
|
5364
|
-
reason: "tmux-failed",
|
|
5365
|
-
details: error instanceof Error ? error.message : String(error)
|
|
5366
|
-
};
|
|
5367
|
-
}
|
|
5368
|
-
}
|
|
5369
|
-
// ─── Tmux primitives ────────────────────────────────────────────────────
|
|
5370
|
-
async typeLiteral(target, text) {
|
|
5371
|
-
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, "\\$").replace(/`/g, "\\`");
|
|
5372
|
-
const safeTarget = escapeShellArg2(target);
|
|
5373
|
-
const cmd = `tmux send-keys -t '${safeTarget}' -l "${escaped}"`;
|
|
5374
|
-
await this.execFn(cmd);
|
|
5375
|
-
}
|
|
5376
|
-
async sendKey(target, key) {
|
|
5377
|
-
const safeTarget = escapeShellArg2(target);
|
|
5378
|
-
const cmd = `tmux send-keys -t '${safeTarget}' ${key}`;
|
|
5379
|
-
await this.execFn(cmd);
|
|
5380
|
-
}
|
|
5381
|
-
resolveTmuxTarget() {
|
|
5382
|
-
return this.tmuxTargetOverride ?? process.env.CODEVIBE_AGY_TMUX_TARGET ?? null;
|
|
5383
|
-
}
|
|
5384
|
-
};
|
|
5385
|
-
function escapeShellArg2(value) {
|
|
5386
|
-
return value.replace(/'/g, `'\\''`);
|
|
5387
|
-
}
|
|
5388
|
-
function isNamedKey(key) {
|
|
5389
|
-
return /^[A-Z][A-Za-z0-9]+$/.test(key);
|
|
5390
|
-
}
|
|
5391
|
-
function delay(ms) {
|
|
5392
|
-
return new Promise((r) => setTimeout(r, ms));
|
|
5393
|
-
}
|
|
5394
|
-
|
|
5395
|
-
// src/mobile-prompt-dedupe.ts
|
|
5396
|
-
var DEFAULT_EXPIRY_MS = 15e3;
|
|
5397
|
-
var DEFAULT_MIN_FUZZY_ECHO_LENGTH = 16;
|
|
5398
|
-
var DEFAULT_MIN_FUZZY_ECHO_RATIO = 0.35;
|
|
5399
|
-
var MobilePromptDeduper = class {
|
|
5400
|
-
constructor(options = {}) {
|
|
5401
|
-
this.pendingBySession = /* @__PURE__ */ new Map();
|
|
5402
|
-
this.expiryMs = options.expiryMs ?? DEFAULT_EXPIRY_MS;
|
|
5403
|
-
this.minFuzzyEchoLength = options.minFuzzyEchoLength ?? DEFAULT_MIN_FUZZY_ECHO_LENGTH;
|
|
5404
|
-
this.minFuzzyEchoRatio = options.minFuzzyEchoRatio ?? DEFAULT_MIN_FUZZY_ECHO_RATIO;
|
|
5405
|
-
this.now = options.now ?? Date.now;
|
|
5406
|
-
}
|
|
5407
|
-
/** Called by the MCP server immediately BEFORE prompt-responder
|
|
5408
|
-
* performs the tmux send-keys. Records the normalized text + timestamp
|
|
5409
|
-
* for future echo-suppression. */
|
|
5410
|
-
track(sessionId, prompt) {
|
|
5411
|
-
this.sweep();
|
|
5412
|
-
const normalized = this.normalize(prompt);
|
|
5413
|
-
if (!normalized) return;
|
|
5414
|
-
const entries = this.validEntries(sessionId);
|
|
5415
|
-
entries.push({ normalized, timestamp: this.now() });
|
|
5416
|
-
this.pendingBySession.set(sessionId, entries);
|
|
5417
|
-
}
|
|
5418
|
-
/** Symmetric helper for callers that want to retract a tracked entry
|
|
5419
|
-
* WITHOUT consuming it via dedupe (e.g. responder bailed before send). */
|
|
5420
|
-
forget(sessionId, prompt) {
|
|
5421
|
-
this.sweep();
|
|
5422
|
-
const normalized = this.normalize(prompt);
|
|
5423
|
-
if (!normalized) return;
|
|
5424
|
-
let removed = false;
|
|
5425
|
-
const entries = this.validEntries(sessionId).filter((entry) => {
|
|
5426
|
-
if (!removed && entry.normalized === normalized) {
|
|
5427
|
-
removed = true;
|
|
5428
|
-
return false;
|
|
5429
|
-
}
|
|
5430
|
-
return true;
|
|
5431
|
-
});
|
|
5432
|
-
this.replaceEntries(sessionId, entries);
|
|
5542
|
+
/** Composite key for the pre-transcript buffer. Scoping by
|
|
5543
|
+
* conversationId prevents the collision case where two simultaneous
|
|
5544
|
+
* conversations on the same tmux pane (contract-discouraged but not
|
|
5545
|
+
* contract-impossible) produce identical paneHashes — without
|
|
5546
|
+
* conversation scoping, conv-B's candidate would be silently dropped
|
|
5547
|
+
* at the same-key dedupe check. (agy R2 Stage 2 round-3 MED finding
|
|
5548
|
+
* 2026-05-20.) */
|
|
5549
|
+
candidateKey(conversationId, paneHash) {
|
|
5550
|
+
return `${conversationId}|${paneHash}`;
|
|
5433
5551
|
}
|
|
5434
5552
|
/**
|
|
5435
|
-
*
|
|
5436
|
-
*
|
|
5437
|
-
*
|
|
5438
|
-
*
|
|
5439
|
-
*
|
|
5553
|
+
* Clear a pending call when its matching RESULT event arrives in the
|
|
5554
|
+
* transcript. event-mapper.ts calls this from its tool-result handlers.
|
|
5555
|
+
*
|
|
5556
|
+
* The result event's step_index = intentStepIndex + toolCallIndex + 1
|
|
5557
|
+
* per agy R2 v2 (the deterministic correlation formula). We try to
|
|
5558
|
+
* match by (conversationId, expected step_index, toolType) — when one
|
|
5559
|
+
* matches, clear it. If multiple pending calls match (rollback edge),
|
|
5560
|
+
* clear the earliest by intentStepIndex.
|
|
5440
5561
|
*/
|
|
5441
|
-
|
|
5442
|
-
|
|
5443
|
-
|
|
5444
|
-
|
|
5445
|
-
|
|
5446
|
-
|
|
5447
|
-
|
|
5448
|
-
|
|
5449
|
-
|
|
5450
|
-
|
|
5451
|
-
|
|
5452
|
-
|
|
5453
|
-
|
|
5454
|
-
};
|
|
5455
|
-
return false;
|
|
5562
|
+
clearOnResultEvent(args) {
|
|
5563
|
+
let matched = null;
|
|
5564
|
+
let matchedKey = null;
|
|
5565
|
+
for (const [key, call] of this.pendingCalls.entries()) {
|
|
5566
|
+
if (call.conversationId !== args.conversationId) continue;
|
|
5567
|
+
if (args.toolType !== "GENERIC" && call.toolType !== args.toolType) continue;
|
|
5568
|
+
const expected = call.intentStepIndex + call.toolCallIndex + 1;
|
|
5569
|
+
if (expected !== args.resultStepIndex) continue;
|
|
5570
|
+
if (typeof args.resultByteOffset === "number") {
|
|
5571
|
+
if (call.intentByteOffset >= args.resultByteOffset) continue;
|
|
5572
|
+
if (!matched || call.intentByteOffset > matched.intentByteOffset) {
|
|
5573
|
+
matched = call;
|
|
5574
|
+
matchedKey = key;
|
|
5456
5575
|
}
|
|
5576
|
+
} else if (!matched || call.intentStepIndex < matched.intentStepIndex) {
|
|
5577
|
+
matched = call;
|
|
5578
|
+
matchedKey = key;
|
|
5457
5579
|
}
|
|
5458
|
-
return true;
|
|
5459
|
-
});
|
|
5460
|
-
this.replaceEntries(sessionId, entries);
|
|
5461
|
-
return match;
|
|
5462
|
-
}
|
|
5463
|
-
/** Inspect without spending the token before durable native admission. */
|
|
5464
|
-
peekIfDuplicate(sessionId, echo) {
|
|
5465
|
-
const normalized = this.normalize(echo);
|
|
5466
|
-
if (!normalized) return null;
|
|
5467
|
-
for (const entry of this.validEntries(sessionId)) {
|
|
5468
|
-
const matchType = this.matchType(entry.normalized, normalized);
|
|
5469
|
-
if (matchType) return { matchType, originalLength: entry.normalized.length, echoLength: normalized.length };
|
|
5470
5580
|
}
|
|
5471
|
-
|
|
5472
|
-
|
|
5473
|
-
|
|
5474
|
-
|
|
5475
|
-
|
|
5476
|
-
|
|
5477
|
-
const valid = entries.filter((entry) => entry.timestamp >= cutoff);
|
|
5478
|
-
if (valid.length > 0) {
|
|
5479
|
-
this.pendingBySession.set(sessId, valid);
|
|
5480
|
-
} else {
|
|
5481
|
-
this.pendingBySession.delete(sessId);
|
|
5581
|
+
if (matched && matchedKey) {
|
|
5582
|
+
matched.resultStepIndex = args.resultStepIndex;
|
|
5583
|
+
this.pendingCalls.delete(matchedKey);
|
|
5584
|
+
if (matched.promptId) {
|
|
5585
|
+
this.resolvedPrompts.set(matched.promptId, Date.now());
|
|
5586
|
+
this.pendingPrompts.delete(matched.promptId);
|
|
5482
5587
|
}
|
|
5588
|
+
logger.debug("Pending tool call cleared on result event", {
|
|
5589
|
+
intentStepIndex: matched.intentStepIndex,
|
|
5590
|
+
toolCallIndex: matched.toolCallIndex,
|
|
5591
|
+
resultStepIndex: args.resultStepIndex,
|
|
5592
|
+
toolType: args.toolType
|
|
5593
|
+
});
|
|
5483
5594
|
}
|
|
5595
|
+
return matched;
|
|
5484
5596
|
}
|
|
5485
|
-
|
|
5486
|
-
|
|
5487
|
-
|
|
5488
|
-
|
|
5489
|
-
|
|
5490
|
-
|
|
5491
|
-
|
|
5597
|
+
// ─── Candidate matching (called from tmux-pane-observer's prompt-candidate) ─
|
|
5598
|
+
/**
|
|
5599
|
+
* Cross-reference a parsed ApprovalCandidate against the pending-call
|
|
5600
|
+
* list. Returns the matched PendingPromptState (which the MCP server
|
|
5601
|
+
* uses to create the INTERACTIVE_PROMPT event), or null if no match
|
|
5602
|
+
* (stale scrollback).
|
|
5603
|
+
*
|
|
5604
|
+
* Match algorithm:
|
|
5605
|
+
* 1. Filter pendingCalls by candidate.command (if set) — exact match
|
|
5606
|
+
* against PendingToolCall.command.
|
|
5607
|
+
* 2. Fallback: filter by candidate.filePath (if set) — exact match
|
|
5608
|
+
* against PendingToolCall.filePath.
|
|
5609
|
+
* 3. If multiple candidates match (rare), prefer the earliest
|
|
5610
|
+
* intentStepIndex.
|
|
5611
|
+
* 4. If a match exists AND it already has a promptId AND that
|
|
5612
|
+
* promptId is in resolvedPrompts → null (don't re-emit).
|
|
5613
|
+
* 5. If a match exists AND has a promptId BUT NOT resolved → return
|
|
5614
|
+
* the existing PendingPromptState (idempotent re-emit on the same
|
|
5615
|
+
* promptId for a duplicate candidate hash).
|
|
5616
|
+
* 6. Otherwise: create a fresh promptId + PendingPromptState, attach
|
|
5617
|
+
* to the matched call, store in pendingPrompts, return it.
|
|
5618
|
+
*/
|
|
5619
|
+
matchCandidate(candidate, conversationId) {
|
|
5620
|
+
const state = this.matchCandidateInternal(candidate, conversationId);
|
|
5621
|
+
if (state) return state;
|
|
5622
|
+
const existing = this.pendingCandidates.get(this.candidateKey(conversationId, candidate.paneHash));
|
|
5623
|
+
if (existing) {
|
|
5624
|
+
logger.debug("Candidate already buffered (same paneHash)", { paneHash: this.candidateKey(conversationId, candidate.paneHash) });
|
|
5492
5625
|
} else {
|
|
5493
|
-
this.
|
|
5494
|
-
|
|
5495
|
-
|
|
5496
|
-
|
|
5497
|
-
|
|
5498
|
-
|
|
5499
|
-
|
|
5500
|
-
|
|
5501
|
-
|
|
5502
|
-
|
|
5503
|
-
|
|
5626
|
+
this.pendingCandidates.set(this.candidateKey(conversationId, candidate.paneHash), {
|
|
5627
|
+
candidate,
|
|
5628
|
+
conversationId,
|
|
5629
|
+
queuedAt: Date.now()
|
|
5630
|
+
});
|
|
5631
|
+
logger.debug("No pending tool-call matched \u2014 buffering candidate for pre-transcript window", {
|
|
5632
|
+
candidateCommand: candidate.command,
|
|
5633
|
+
candidateFilePath: candidate.filePath,
|
|
5634
|
+
pendingCount: this.pendingCalls.size,
|
|
5635
|
+
bufferSize: this.pendingCandidates.size
|
|
5636
|
+
});
|
|
5504
5637
|
}
|
|
5505
5638
|
return null;
|
|
5506
5639
|
}
|
|
5507
|
-
|
|
5508
|
-
|
|
5640
|
+
/** Internal match logic — does NOT buffer on miss. Used both by the
|
|
5641
|
+
* public matchCandidate (which buffers on miss) and by
|
|
5642
|
+
* drainBufferedCandidates (which doesn't re-buffer; misses just stay
|
|
5643
|
+
* in the buffer until their TTL expires). */
|
|
5644
|
+
matchCandidateInternal(candidate, conversationId) {
|
|
5645
|
+
const matched = this.findMatchingPendingCall(candidate, conversationId);
|
|
5646
|
+
if (!matched) {
|
|
5647
|
+
return null;
|
|
5648
|
+
}
|
|
5649
|
+
if (matched.promptId && this.resolvedPrompts.has(matched.promptId)) {
|
|
5650
|
+
logger.debug("Skipping re-emit for already-resolved prompt", {
|
|
5651
|
+
promptId: matched.promptId
|
|
5652
|
+
});
|
|
5653
|
+
return null;
|
|
5654
|
+
}
|
|
5655
|
+
if (matched.promptId && this.pendingPrompts.has(matched.promptId)) {
|
|
5656
|
+
return this.pendingPrompts.get(matched.promptId) ?? null;
|
|
5657
|
+
}
|
|
5658
|
+
const promptId = (0, import_uuid2.v4)();
|
|
5659
|
+
matched.promptId = promptId;
|
|
5660
|
+
const state = {
|
|
5661
|
+
promptId,
|
|
5662
|
+
conversationId,
|
|
5663
|
+
pendingCall: matched,
|
|
5664
|
+
submitMap: candidate.submitMap,
|
|
5665
|
+
matchedPaneHeader: candidate.headerText,
|
|
5666
|
+
paneDisplayHeader: candidate.fullHeaderLine,
|
|
5667
|
+
body: candidate.body,
|
|
5668
|
+
paneOptions: candidate.options,
|
|
5669
|
+
paneSemanticKey: buildApprovalSemanticKey(candidate),
|
|
5670
|
+
kind: candidate.kind,
|
|
5671
|
+
emittedAt: Date.now(),
|
|
5672
|
+
ttlMs: this.promptTtlMs
|
|
5673
|
+
};
|
|
5674
|
+
this.pendingPrompts.set(promptId, state);
|
|
5675
|
+
logger.info("Emitting INTERACTIVE_PROMPT for matched candidate", {
|
|
5676
|
+
promptId,
|
|
5677
|
+
command: matched.command,
|
|
5678
|
+
filePath: matched.filePath,
|
|
5679
|
+
intentStepIndex: matched.intentStepIndex,
|
|
5680
|
+
toolCallIndex: matched.toolCallIndex
|
|
5681
|
+
});
|
|
5682
|
+
this.emit("pending-prompt", state);
|
|
5683
|
+
return state;
|
|
5509
5684
|
}
|
|
5510
|
-
|
|
5511
|
-
|
|
5512
|
-
|
|
5513
|
-
|
|
5514
|
-
|
|
5515
|
-
|
|
5516
|
-
|
|
5517
|
-
|
|
5518
|
-
|
|
5519
|
-
|
|
5520
|
-
|
|
5521
|
-
|
|
5522
|
-
|
|
5523
|
-
|
|
5524
|
-
|
|
5525
|
-
|
|
5526
|
-
|
|
5527
|
-
|
|
5528
|
-
if (
|
|
5529
|
-
return
|
|
5685
|
+
/** Resolve a pending prompt — called from MCP server when mobile sends
|
|
5686
|
+
* the option-number reply with metadata.promptId. */
|
|
5687
|
+
/**
|
|
5688
|
+
* Called from the MCP server when paneObserver emits 'prompt-cleared'
|
|
5689
|
+
* — i.e. the approval UI disappeared from the pane (desktop user
|
|
5690
|
+
* picked an option, agy timed out the prompt, or the dispatcher
|
|
5691
|
+
* answered). Resolves ALL outstanding pane-only pending prompts and
|
|
5692
|
+
* releases the paneOnly dedup hashes so a subsequent approval can
|
|
5693
|
+
* fresh-emit. Without this, mobile prompt state dangles until TTL
|
|
5694
|
+
* AND the next identical approval is forever-suppressed by stale
|
|
5695
|
+
* dedup. (Stage 2 R1 MED finding 2026-05-21.)
|
|
5696
|
+
*
|
|
5697
|
+
* `emittedBeforeMs` (F1, Stage 2 R1-F2): a desktop input that waited in the
|
|
5698
|
+
* routing hold proves only that prompts raised BEFORE it was typed are gone.
|
|
5699
|
+
* A prompt emitted after that moment stays pending, and so does its dedup
|
|
5700
|
+
* hash (clearing it would re-emit the still-visible chooser).
|
|
5701
|
+
*/
|
|
5702
|
+
onPanePromptCleared(emittedBeforeMs) {
|
|
5703
|
+
if (this.pendingPrompts.size === 0 && this.paneOnlyEmittedHashes.size === 0) {
|
|
5704
|
+
return [];
|
|
5530
5705
|
}
|
|
5531
|
-
|
|
5706
|
+
const now = Date.now();
|
|
5707
|
+
const cleared = [];
|
|
5708
|
+
let retained = false;
|
|
5709
|
+
for (const [promptId, state] of this.pendingPrompts.entries()) {
|
|
5710
|
+
if (emittedBeforeMs !== void 0 && state.emittedAt >= emittedBeforeMs) {
|
|
5711
|
+
retained = true;
|
|
5712
|
+
continue;
|
|
5713
|
+
}
|
|
5714
|
+
if (state.isHookPrompt) {
|
|
5715
|
+
retained = true;
|
|
5716
|
+
continue;
|
|
5717
|
+
}
|
|
5718
|
+
this.resolvedPrompts.set(promptId, now);
|
|
5719
|
+
cleared.push(promptId);
|
|
5720
|
+
}
|
|
5721
|
+
if (!retained) {
|
|
5722
|
+
this.pendingPrompts.clear();
|
|
5723
|
+
this.paneOnlyEmittedHashes.clear();
|
|
5724
|
+
} else {
|
|
5725
|
+
for (const promptId of cleared) {
|
|
5726
|
+
const state = this.pendingPrompts.get(promptId);
|
|
5727
|
+
if (state?.paneHash) this.paneOnlyEmittedHashes.delete(state.paneHash);
|
|
5728
|
+
this.pendingPrompts.delete(promptId);
|
|
5729
|
+
}
|
|
5730
|
+
}
|
|
5731
|
+
if (cleared.length > 0) {
|
|
5732
|
+
logger.info("Pane prompt cleared \u2014 resolved pending prompts", {
|
|
5733
|
+
count: cleared.length
|
|
5734
|
+
});
|
|
5735
|
+
}
|
|
5736
|
+
return cleared;
|
|
5532
5737
|
}
|
|
5533
|
-
|
|
5534
|
-
|
|
5535
|
-
|
|
5536
|
-
|
|
5537
|
-
|
|
5538
|
-
|
|
5539
|
-
|
|
5540
|
-
|
|
5541
|
-
|
|
5542
|
-
|
|
5543
|
-
|
|
5544
|
-
|
|
5545
|
-
|
|
5546
|
-
|
|
5738
|
+
resolvePrompt(promptId, options) {
|
|
5739
|
+
const state = this.pendingPrompts.get(promptId);
|
|
5740
|
+
if (!state) return null;
|
|
5741
|
+
this.resolvedPrompts.set(promptId, Date.now());
|
|
5742
|
+
this.pendingPrompts.delete(promptId);
|
|
5743
|
+
const advanceWizard = options?.advanceWizard !== false;
|
|
5744
|
+
const liveWizard = this.liveWizardInvocations.get(state.conversationId);
|
|
5745
|
+
if (liveWizard && liveWizard.promptId === promptId) {
|
|
5746
|
+
if (advanceWizard) {
|
|
5747
|
+
const maxStep = (liveWizard.call.structuredQuestions?.length ?? 1) - 1;
|
|
5748
|
+
liveWizard.activeStepIndex = Math.min(liveWizard.activeStepIndex + 1, maxStep);
|
|
5749
|
+
}
|
|
5750
|
+
liveWizard.promptId = "";
|
|
5751
|
+
}
|
|
5752
|
+
this.paneOnlyEmittedHashes.clear();
|
|
5753
|
+
return state;
|
|
5547
5754
|
}
|
|
5548
|
-
|
|
5549
|
-
|
|
5550
|
-
|
|
5551
|
-
|
|
5552
|
-
|
|
5553
|
-
|
|
5554
|
-
|
|
5555
|
-
|
|
5556
|
-
|
|
5557
|
-
|
|
5558
|
-
|
|
5559
|
-
|
|
5560
|
-
|
|
5561
|
-
const
|
|
5562
|
-
|
|
5563
|
-
|
|
5564
|
-
|
|
5755
|
+
/**
|
|
5756
|
+
* Roll back a pending prompt WITHOUT marking it as resolved. Used by
|
|
5757
|
+
* the MCP server when the INTERACTIVE_PROMPT createEvent fails — the
|
|
5758
|
+
* prompt was never delivered to mobile, so a future tmux pane
|
|
5759
|
+
* candidate for the same pending tool-call should be eligible to
|
|
5760
|
+
* re-emit. `resolvePrompt` is the WRONG call there because it adds
|
|
5761
|
+
* the promptId to `resolvedPrompts`, which the stale-scrollback guard
|
|
5762
|
+
* uses to suppress re-emit forever. This method instead detaches the
|
|
5763
|
+
* promptId from the pending call + removes from pendingPrompts +
|
|
5764
|
+
* leaves resolvedPrompts UNTOUCHED so the fresh-emit path runs on
|
|
5765
|
+
* the next match. (agy R2 Stage 2 round-2 MED finding 2026-05-20.)
|
|
5766
|
+
*/
|
|
5767
|
+
rollbackPrompt(promptId) {
|
|
5768
|
+
const state = this.pendingPrompts.get(promptId);
|
|
5769
|
+
if (!state) return null;
|
|
5770
|
+
this.pendingPrompts.delete(promptId);
|
|
5771
|
+
if (state.pendingCall.promptId === promptId) {
|
|
5772
|
+
state.pendingCall.promptId = void 0;
|
|
5773
|
+
}
|
|
5774
|
+
for (const call of this.pendingCalls.values()) {
|
|
5775
|
+
if (call.matchedPromptId === promptId) {
|
|
5776
|
+
call.matchedPromptId = void 0;
|
|
5565
5777
|
}
|
|
5566
|
-
settle();
|
|
5567
|
-
}, BEACON_TOTAL_TIMEOUT_MS);
|
|
5568
|
-
if (typeof overall.unref === "function") {
|
|
5569
|
-
overall.unref();
|
|
5570
5778
|
}
|
|
5571
|
-
|
|
5572
|
-
|
|
5573
|
-
|
|
5574
|
-
|
|
5575
|
-
|
|
5576
|
-
|
|
5577
|
-
|
|
5779
|
+
this.paneOnlyEmittedHashes.clear();
|
|
5780
|
+
return state;
|
|
5781
|
+
}
|
|
5782
|
+
/**
|
|
5783
|
+
* Rekey a pending prompt in the detector's internal map and associated pending calls.
|
|
5784
|
+
* Keeps map keys and state.promptId synchronized when a server layer adopts an admission identity.
|
|
5785
|
+
*/
|
|
5786
|
+
rekeyPrompt(oldPromptId, newPromptId) {
|
|
5787
|
+
if (oldPromptId === newPromptId) return;
|
|
5788
|
+
const state = this.pendingPrompts.get(oldPromptId);
|
|
5789
|
+
if (!state) return;
|
|
5790
|
+
this.pendingPrompts.delete(oldPromptId);
|
|
5791
|
+
state.promptId = newPromptId;
|
|
5792
|
+
if (state.pendingCall) {
|
|
5793
|
+
if (state.pendingCall.promptId === oldPromptId) state.pendingCall.promptId = newPromptId;
|
|
5794
|
+
if (state.pendingCall.matchedPromptId === oldPromptId) state.pendingCall.matchedPromptId = newPromptId;
|
|
5795
|
+
}
|
|
5796
|
+
for (const call of this.pendingCalls.values()) {
|
|
5797
|
+
if (call.promptId === oldPromptId) call.promptId = newPromptId;
|
|
5798
|
+
if (call.matchedPromptId === oldPromptId) call.matchedPromptId = newPromptId;
|
|
5799
|
+
}
|
|
5800
|
+
this.pendingPrompts.set(newPromptId, state);
|
|
5801
|
+
}
|
|
5802
|
+
/** Returns the current pending-prompt state by id, or null if expired /
|
|
5803
|
+
* unknown. Used by prompt-responder for the active-prompt-identity
|
|
5804
|
+
* guard right before send-keys. */
|
|
5805
|
+
getPendingPrompt(promptId) {
|
|
5806
|
+
return this.pendingPrompts.get(promptId) ?? null;
|
|
5807
|
+
}
|
|
5808
|
+
getPendingPromptsForConversation(conversationId) {
|
|
5809
|
+
const pending = Array.from(this.pendingPrompts.values());
|
|
5810
|
+
return conversationId ? pending.filter((state) => state.conversationId === conversationId) : pending;
|
|
5811
|
+
}
|
|
5812
|
+
// ─── Read-only accessors ────────────────────────────────────────────────
|
|
5813
|
+
getPendingCalls() {
|
|
5814
|
+
return Array.from(this.pendingCalls.values());
|
|
5815
|
+
}
|
|
5816
|
+
getActivePromptCount() {
|
|
5817
|
+
return this.pendingPrompts.size;
|
|
5818
|
+
}
|
|
5819
|
+
// ─── Internals ──────────────────────────────────────────────────────────
|
|
5820
|
+
callKey(call) {
|
|
5821
|
+
return `${call.conversationId}|${call.intentStepIndex}|${call.toolCallIndex}|${call.intentByteOffset}`;
|
|
5822
|
+
}
|
|
5823
|
+
findMatchingPendingCall(candidate, conversationId) {
|
|
5824
|
+
if (candidate.kind === "question") {
|
|
5825
|
+
const questionCalls = Array.from(this.pendingCalls.values()).filter((c) => c.conversationId === conversationId).filter((c) => c.resultStepIndex === void 0).filter((c) => {
|
|
5826
|
+
if (c.structuredQuestions && candidate.headerText) {
|
|
5827
|
+
const matchesStructured = c.structuredQuestions.some((sq) => questionsMatch(sq.question, candidate.headerText));
|
|
5828
|
+
if (matchesStructured) return true;
|
|
5578
5829
|
}
|
|
5579
|
-
|
|
5580
|
-
|
|
5581
|
-
|
|
5582
|
-
|
|
5583
|
-
|
|
5584
|
-
|
|
5585
|
-
|
|
5586
|
-
|
|
5587
|
-
|
|
5588
|
-
|
|
5589
|
-
|
|
5590
|
-
|
|
5591
|
-
}
|
|
5830
|
+
const associatedPromptId = c.matchedPromptId || c.promptId;
|
|
5831
|
+
if (!associatedPromptId) return true;
|
|
5832
|
+
return !this.resolvedPrompts.has(associatedPromptId) && !this.pendingPrompts.has(associatedPromptId);
|
|
5833
|
+
}).filter(
|
|
5834
|
+
(c) => c.toolType === "ASK_QUESTION" || ["ask_question", "question", "ask"].includes(c.rawToolName?.toLowerCase() ?? "")
|
|
5835
|
+
).sort((a, b) => (b.intentStepIndex ?? 0) - (a.intentStepIndex ?? 0));
|
|
5836
|
+
if (questionCalls.length > 0 && candidate.headerText) {
|
|
5837
|
+
const textMatch = questionCalls.find((c) => {
|
|
5838
|
+
if (!c.questionText) return false;
|
|
5839
|
+
if (questionsMatch(c.questionText, candidate.headerText)) return true;
|
|
5840
|
+
if (c.structuredQuestions) {
|
|
5841
|
+
return c.structuredQuestions.some((sq) => questionsMatch(sq.question, candidate.headerText));
|
|
5592
5842
|
}
|
|
5593
|
-
|
|
5594
|
-
|
|
5595
|
-
|
|
5596
|
-
|
|
5597
|
-
|
|
5598
|
-
|
|
5599
|
-
|
|
5600
|
-
|
|
5601
|
-
|
|
5602
|
-
|
|
5603
|
-
|
|
5604
|
-
|
|
5605
|
-
|
|
5606
|
-
|
|
5607
|
-
|
|
5608
|
-
|
|
5609
|
-
)
|
|
5610
|
-
|
|
5611
|
-
|
|
5612
|
-
|
|
5613
|
-
|
|
5614
|
-
|
|
5843
|
+
return false;
|
|
5844
|
+
});
|
|
5845
|
+
if (textMatch) return textMatch;
|
|
5846
|
+
}
|
|
5847
|
+
return null;
|
|
5848
|
+
}
|
|
5849
|
+
if (candidate.command) {
|
|
5850
|
+
const matches = Array.from(this.pendingCalls.values()).filter((c) => c.conversationId === conversationId).filter((c) => c.command && c.command === candidate.command).sort((a, b) => a.intentStepIndex - b.intentStepIndex);
|
|
5851
|
+
if (matches.length > 0) return matches[0];
|
|
5852
|
+
}
|
|
5853
|
+
if (candidate.filePath) {
|
|
5854
|
+
const matches = Array.from(this.pendingCalls.values()).filter((c) => c.conversationId === conversationId).filter((c) => c.filePath && c.filePath === candidate.filePath).sort((a, b) => a.intentStepIndex - b.intentStepIndex);
|
|
5855
|
+
if (matches.length > 0) return matches[0];
|
|
5856
|
+
}
|
|
5857
|
+
const allFields = (candidate.command ?? "") + " " + (candidate.filePath ?? "");
|
|
5858
|
+
if (allFields.trim()) {
|
|
5859
|
+
const matches = Array.from(this.pendingCalls.values()).filter((c) => c.conversationId === conversationId).filter((c) => {
|
|
5860
|
+
const cmd = (c.command ?? "").trim();
|
|
5861
|
+
const fp = (c.filePath ?? "").trim();
|
|
5862
|
+
if (!cmd && !fp) return false;
|
|
5863
|
+
const cmdOk = cmd.length >= PASS3_MIN_IDENTITY_LEN && containsAsWord(allFields, cmd);
|
|
5864
|
+
const fpOk = fp.length >= PASS3_MIN_IDENTITY_LEN && containsAsWord(allFields, fp);
|
|
5865
|
+
return cmdOk || fpOk;
|
|
5866
|
+
}).sort((a, b) => a.intentStepIndex - b.intentStepIndex);
|
|
5867
|
+
if (matches.length > 0) return matches[0];
|
|
5868
|
+
}
|
|
5869
|
+
return null;
|
|
5870
|
+
}
|
|
5871
|
+
sweep() {
|
|
5872
|
+
const now = Date.now();
|
|
5873
|
+
for (const [key, call] of this.pendingCalls.entries()) {
|
|
5874
|
+
if (now - call.startedAt > this.pendingCallTtlMs) {
|
|
5875
|
+
this.pendingCalls.delete(key);
|
|
5876
|
+
if (call.promptId) {
|
|
5877
|
+
this.pendingPrompts.delete(call.promptId);
|
|
5878
|
+
this.resolvedPrompts.set(call.promptId, now);
|
|
5615
5879
|
}
|
|
5616
|
-
|
|
5617
|
-
|
|
5618
|
-
|
|
5619
|
-
|
|
5620
|
-
|
|
5621
|
-
|
|
5622
|
-
|
|
5880
|
+
logger.warn("Pending tool call expired (no matching result event)", {
|
|
5881
|
+
intentStepIndex: call.intentStepIndex,
|
|
5882
|
+
toolCallIndex: call.toolCallIndex,
|
|
5883
|
+
command: call.command,
|
|
5884
|
+
filePath: call.filePath,
|
|
5885
|
+
ageMs: now - call.startedAt
|
|
5886
|
+
});
|
|
5887
|
+
this.emit("pending-call-expired", call);
|
|
5888
|
+
}
|
|
5623
5889
|
}
|
|
5624
|
-
|
|
5890
|
+
for (const [promptId, resolvedAt] of this.resolvedPrompts.entries()) {
|
|
5891
|
+
if (now - resolvedAt > 5 * 6e4) {
|
|
5892
|
+
this.resolvedPrompts.delete(promptId);
|
|
5893
|
+
}
|
|
5894
|
+
}
|
|
5895
|
+
for (const [key, buffered] of this.pendingCandidates.entries()) {
|
|
5896
|
+
if (now - buffered.queuedAt > this.pendingCandidateBufferMs) {
|
|
5897
|
+
this.pendingCandidates.delete(key);
|
|
5898
|
+
logger.debug("Buffered candidate expired without ever matching a pending call", {
|
|
5899
|
+
key,
|
|
5900
|
+
candidateCommand: buffered.candidate.command,
|
|
5901
|
+
ageMs: now - buffered.queuedAt
|
|
5902
|
+
});
|
|
5903
|
+
}
|
|
5904
|
+
}
|
|
5905
|
+
}
|
|
5906
|
+
};
|
|
5907
|
+
function containsAsWord(haystack, needle) {
|
|
5908
|
+
const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
5909
|
+
return new RegExp(`(^|[\\s"'\`])${escaped}([\\s"'\`]|$)`).test(haystack);
|
|
5625
5910
|
}
|
|
5626
5911
|
|
|
5627
|
-
// src/
|
|
5628
|
-
var
|
|
5629
|
-
var
|
|
5630
|
-
var
|
|
5631
|
-
var
|
|
5632
|
-
var
|
|
5633
|
-
|
|
5634
|
-
|
|
5635
|
-
|
|
5636
|
-
|
|
5637
|
-
|
|
5638
|
-
|
|
5639
|
-
|
|
5640
|
-
|
|
5641
|
-
|
|
5642
|
-
|
|
5643
|
-
|
|
5644
|
-
|
|
5645
|
-
|
|
5646
|
-
|
|
5647
|
-
|
|
5648
|
-
|
|
5649
|
-
|
|
5650
|
-
|
|
5651
|
-
|
|
5652
|
-
|
|
5653
|
-
|
|
5912
|
+
// src/prompt-responder.ts
|
|
5913
|
+
var import_child_process2 = require("child_process");
|
|
5914
|
+
var import_util2 = require("util");
|
|
5915
|
+
var execAsync2 = (0, import_util2.promisify)(import_child_process2.exec);
|
|
5916
|
+
var ENTER_DELAY_MS = 100;
|
|
5917
|
+
var CODEVIBE_AGY_ESCAPE_INPUT = "__CODEVIBE_AGY_KEY_ESCAPE__";
|
|
5918
|
+
var PromptResponder = class {
|
|
5919
|
+
constructor(options = {}) {
|
|
5920
|
+
this.tmuxTargetOverride = options.tmuxTarget;
|
|
5921
|
+
this.execFn = options.execFn ?? (async (cmd) => execAsync2(cmd));
|
|
5922
|
+
this.paneObserver = options.paneObserver ?? null;
|
|
5923
|
+
this.detector = options.detector ?? null;
|
|
5924
|
+
if (this.detector && !this.paneObserver) {
|
|
5925
|
+
throw new Error(
|
|
5926
|
+
"PromptResponder misconfigured: detector wired without paneObserver. Approval-reply identity guard requires the observer to re-probe the pane before send. Either wire both, or wire neither."
|
|
5927
|
+
);
|
|
5928
|
+
}
|
|
5929
|
+
}
|
|
5930
|
+
/**
|
|
5931
|
+
* Send free-form text to the agy session. The text is typed (via
|
|
5932
|
+
* `send-keys -l`) then Enter is sent after a short delay so agy's
|
|
5933
|
+
* input handler has time to process the buffer.
|
|
5934
|
+
*/
|
|
5935
|
+
async sendFreeForm(text) {
|
|
5936
|
+
const target = this.resolveTmuxTarget();
|
|
5937
|
+
if (!target) {
|
|
5938
|
+
return { ok: false, reason: "no-tmux-target" };
|
|
5939
|
+
}
|
|
5940
|
+
try {
|
|
5941
|
+
if (text === CODEVIBE_AGY_ESCAPE_INPUT) {
|
|
5942
|
+
await this.sendKey(target, "Escape");
|
|
5943
|
+
} else {
|
|
5944
|
+
await this.typeLiteral(target, text);
|
|
5945
|
+
await delay(ENTER_DELAY_MS);
|
|
5946
|
+
await this.sendKey(target, "Enter");
|
|
5947
|
+
}
|
|
5948
|
+
logger.info("Sent free-form input to agy", {
|
|
5949
|
+
target,
|
|
5950
|
+
textLength: text.length
|
|
5654
5951
|
});
|
|
5655
|
-
return {
|
|
5952
|
+
return { ok: true };
|
|
5953
|
+
} catch (error) {
|
|
5954
|
+
logger.error("tmux send-keys failed for free-form input", { target, error });
|
|
5955
|
+
return {
|
|
5956
|
+
ok: false,
|
|
5957
|
+
reason: "tmux-failed",
|
|
5958
|
+
details: error instanceof Error ? error.message : String(error)
|
|
5959
|
+
};
|
|
5960
|
+
}
|
|
5961
|
+
}
|
|
5962
|
+
/**
|
|
5963
|
+
* Resolve a pending approval prompt with the mobile-user's chosen
|
|
5964
|
+
* option number. Uses the submitMap from the PendingPromptState to
|
|
5965
|
+
* translate option-number → terminal keystrokes (digit alone for agy's
|
|
5966
|
+
* 4-numbered UI; future Y/N prompts would use 'y'/'n'/'Escape').
|
|
5967
|
+
*
|
|
5968
|
+
* Active-prompt-identity guard (DESIGN.md §3.4):
|
|
5969
|
+
* 1. Detector check: is `promptId` still in pendingPrompts? If not
|
|
5970
|
+
* (expired or already-resolved), drop with `prompt-expired`.
|
|
5971
|
+
* 2. Pane-observer check: probeApprovalUIActive() — does the pane
|
|
5972
|
+
* STILL show an approval UI whose header matches our pending
|
|
5973
|
+
* call's identity? If active-UI is gone OR header differs,
|
|
5974
|
+
* drop with `prompt-superseded`.
|
|
5975
|
+
* 3. submitMap lookup: is `optionNumber` a valid key? If not (e.g.
|
|
5976
|
+
* mobile sent "5" for a 4-option prompt), drop with
|
|
5977
|
+
* `invalid-option`.
|
|
5978
|
+
* 4. Send the keystrokes via tmux send-keys.
|
|
5979
|
+
*/
|
|
5980
|
+
async sendApprovalReply(promptId, optionNumber) {
|
|
5981
|
+
const target = this.resolveTmuxTarget();
|
|
5982
|
+
if (!target) {
|
|
5983
|
+
return { ok: false, reason: "no-tmux-target" };
|
|
5984
|
+
}
|
|
5985
|
+
if (this.detector) {
|
|
5986
|
+
const state = this.detector.getPendingPrompt(promptId);
|
|
5987
|
+
if (!state) {
|
|
5988
|
+
logger.warn("Mobile reply for unknown / expired promptId", { promptId, optionNumber });
|
|
5989
|
+
return {
|
|
5990
|
+
ok: false,
|
|
5991
|
+
reason: "prompt-expired",
|
|
5992
|
+
details: `promptId ${promptId} is not in pendingPrompts`
|
|
5993
|
+
};
|
|
5994
|
+
}
|
|
5995
|
+
if (this.paneObserver) {
|
|
5996
|
+
if (!state.paneSemanticKey) {
|
|
5997
|
+
return {
|
|
5998
|
+
ok: false,
|
|
5999
|
+
reason: "prompt-probe-failed",
|
|
6000
|
+
details: "pending prompt has no exact semantic identity"
|
|
6001
|
+
};
|
|
6002
|
+
}
|
|
6003
|
+
}
|
|
6004
|
+
const keys = state.submitMap[optionNumber];
|
|
6005
|
+
if (!keys || keys.length === 0) {
|
|
6006
|
+
return {
|
|
6007
|
+
ok: false,
|
|
6008
|
+
reason: "invalid-option",
|
|
6009
|
+
details: `option '${optionNumber}' not in submitMap (valid: ${Object.keys(state.submitMap).join(",")})`
|
|
6010
|
+
};
|
|
6011
|
+
}
|
|
6012
|
+
try {
|
|
6013
|
+
for (const key of keys) {
|
|
6014
|
+
if (!this.detector || !this.paneObserver) {
|
|
6015
|
+
return {
|
|
6016
|
+
ok: false,
|
|
6017
|
+
reason: "prompt-probe-failed",
|
|
6018
|
+
details: "approval responder is missing its detector or pane observer"
|
|
6019
|
+
};
|
|
6020
|
+
}
|
|
6021
|
+
if (this.detector.getPendingPrompt(promptId) !== state) {
|
|
6022
|
+
return { ok: false, reason: "prompt-expired" };
|
|
6023
|
+
}
|
|
6024
|
+
let liveSemanticKey = null;
|
|
6025
|
+
try {
|
|
6026
|
+
const snapshot = await this.paneObserver.captureSnapshot();
|
|
6027
|
+
const live = parseApprovalSnapshot(snapshot);
|
|
6028
|
+
if (!live) {
|
|
6029
|
+
if (detectActivePromptKind(snapshot) === null) {
|
|
6030
|
+
this.detector.resolvePrompt(promptId);
|
|
6031
|
+
return {
|
|
6032
|
+
ok: false,
|
|
6033
|
+
reason: "prompt-superseded",
|
|
6034
|
+
details: "the approval chooser is no longer active"
|
|
6035
|
+
};
|
|
6036
|
+
}
|
|
6037
|
+
return {
|
|
6038
|
+
ok: false,
|
|
6039
|
+
reason: "prompt-probe-failed",
|
|
6040
|
+
details: "the active chooser could not be parsed exactly"
|
|
6041
|
+
};
|
|
6042
|
+
}
|
|
6043
|
+
liveSemanticKey = live ? buildApprovalSemanticKey(live) : null;
|
|
6044
|
+
} catch (error) {
|
|
6045
|
+
return {
|
|
6046
|
+
ok: false,
|
|
6047
|
+
reason: "prompt-probe-failed",
|
|
6048
|
+
details: error instanceof Error ? error.message : String(error)
|
|
6049
|
+
};
|
|
6050
|
+
}
|
|
6051
|
+
if (liveSemanticKey !== state.paneSemanticKey) {
|
|
6052
|
+
this.detector.resolvePrompt(promptId);
|
|
6053
|
+
return {
|
|
6054
|
+
ok: false,
|
|
6055
|
+
reason: "prompt-superseded",
|
|
6056
|
+
details: "active chooser semantics differ from the mobile prompt"
|
|
6057
|
+
};
|
|
6058
|
+
}
|
|
6059
|
+
if (isNamedKey(key)) {
|
|
6060
|
+
await this.sendKey(target, key);
|
|
6061
|
+
} else {
|
|
6062
|
+
await this.typeLiteral(target, key);
|
|
6063
|
+
}
|
|
6064
|
+
}
|
|
6065
|
+
this.detector.resolvePrompt(promptId);
|
|
6066
|
+
logger.info("Sent approval reply via tmux", {
|
|
6067
|
+
promptId,
|
|
6068
|
+
optionNumber,
|
|
6069
|
+
keysSent: keys
|
|
6070
|
+
});
|
|
6071
|
+
return { ok: true };
|
|
6072
|
+
} catch (error) {
|
|
6073
|
+
return {
|
|
6074
|
+
ok: false,
|
|
6075
|
+
reason: "tmux-failed",
|
|
6076
|
+
details: error instanceof Error ? error.message : String(error)
|
|
6077
|
+
};
|
|
6078
|
+
}
|
|
5656
6079
|
}
|
|
5657
|
-
|
|
5658
|
-
|
|
5659
|
-
|
|
5660
|
-
|
|
5661
|
-
|
|
5662
|
-
|
|
6080
|
+
try {
|
|
6081
|
+
await this.typeLiteral(target, optionNumber);
|
|
6082
|
+
return { ok: true };
|
|
6083
|
+
} catch (error) {
|
|
6084
|
+
return {
|
|
6085
|
+
ok: false,
|
|
6086
|
+
reason: "tmux-failed",
|
|
6087
|
+
details: error instanceof Error ? error.message : String(error)
|
|
6088
|
+
};
|
|
5663
6089
|
}
|
|
5664
6090
|
}
|
|
5665
|
-
|
|
5666
|
-
|
|
5667
|
-
|
|
5668
|
-
|
|
5669
|
-
|
|
5670
|
-
|
|
5671
|
-
|
|
5672
|
-
|
|
5673
|
-
|
|
5674
|
-
const cleaned = stripUserRequestWrapper(event.content || "");
|
|
5675
|
-
return {
|
|
5676
|
-
events: [
|
|
5677
|
-
{
|
|
5678
|
-
...base,
|
|
5679
|
-
timestamp: nextTimestamp(),
|
|
5680
|
-
type: import_codevibe_core5.EventType.USER_PROMPT,
|
|
5681
|
-
content: truncate(cleaned, CONTENT_MAX),
|
|
5682
|
-
metadata: { ...baseMetadata }
|
|
5683
|
-
}
|
|
5684
|
-
],
|
|
5685
|
-
pendingCallRegistrations: []
|
|
5686
|
-
};
|
|
5687
|
-
}
|
|
5688
|
-
if (event.type === "PLANNER_RESPONSE") {
|
|
5689
|
-
const events = [];
|
|
5690
|
-
const pendingCallRegistrations = [];
|
|
5691
|
-
if (typeof event.thinking === "string" && event.thinking.trim().length > 0) {
|
|
5692
|
-
events.push({
|
|
5693
|
-
...base,
|
|
5694
|
-
timestamp: nextTimestamp(),
|
|
5695
|
-
type: import_codevibe_core5.EventType.REASONING,
|
|
5696
|
-
content: truncate(event.thinking, CONTENT_MAX),
|
|
5697
|
-
metadata: { ...baseMetadata }
|
|
5698
|
-
});
|
|
5699
|
-
}
|
|
5700
|
-
if (typeof event.content === "string" && event.content.trim().length > 0) {
|
|
5701
|
-
events.push({
|
|
5702
|
-
...base,
|
|
5703
|
-
timestamp: nextTimestamp(),
|
|
5704
|
-
type: import_codevibe_core5.EventType.ASSISTANT_RESPONSE,
|
|
5705
|
-
content: truncate(event.content, CONTENT_MAX),
|
|
5706
|
-
metadata: { ...baseMetadata }
|
|
5707
|
-
});
|
|
6091
|
+
async sendFreeFormWhenNoApprovalActive(text, beforeType) {
|
|
6092
|
+
const target = this.resolveTmuxTarget();
|
|
6093
|
+
if (!target) return { ok: false, reason: "no-tmux-target" };
|
|
6094
|
+
if (!this.paneObserver) {
|
|
6095
|
+
return {
|
|
6096
|
+
ok: false,
|
|
6097
|
+
reason: "prompt-probe-failed",
|
|
6098
|
+
details: "pane observer is required for safe free-form input"
|
|
6099
|
+
};
|
|
5708
6100
|
}
|
|
5709
|
-
const
|
|
5710
|
-
|
|
5711
|
-
|
|
5712
|
-
|
|
5713
|
-
|
|
5714
|
-
|
|
5715
|
-
|
|
5716
|
-
|
|
5717
|
-
|
|
5718
|
-
|
|
5719
|
-
|
|
5720
|
-
|
|
5721
|
-
|
|
5722
|
-
|
|
5723
|
-
|
|
5724
|
-
|
|
5725
|
-
|
|
5726
|
-
|
|
5727
|
-
|
|
5728
|
-
|
|
5729
|
-
|
|
5730
|
-
|
|
5731
|
-
|
|
6101
|
+
const chooserIsAbsent = async () => {
|
|
6102
|
+
const probe = await this.paneObserver.probeApprovalUIActive();
|
|
6103
|
+
if (!probe.probeSucceeded) {
|
|
6104
|
+
return { ok: false, reason: "prompt-probe-failed" };
|
|
6105
|
+
}
|
|
6106
|
+
if (probe.active) {
|
|
6107
|
+
return { ok: false, reason: "prompt-superseded" };
|
|
6108
|
+
}
|
|
6109
|
+
return null;
|
|
6110
|
+
};
|
|
6111
|
+
let preparationFailed = false;
|
|
6112
|
+
try {
|
|
6113
|
+
const beforeText = await chooserIsAbsent();
|
|
6114
|
+
if (beforeText) return beforeText;
|
|
6115
|
+
if (beforeType) {
|
|
6116
|
+
try {
|
|
6117
|
+
await beforeType();
|
|
6118
|
+
} catch (error) {
|
|
6119
|
+
preparationFailed = true;
|
|
6120
|
+
throw error;
|
|
6121
|
+
}
|
|
6122
|
+
const afterPreparation = await chooserIsAbsent();
|
|
6123
|
+
if (afterPreparation) return afterPreparation;
|
|
5732
6124
|
}
|
|
6125
|
+
await this.typeLiteral(target, text);
|
|
6126
|
+
await delay(ENTER_DELAY_MS);
|
|
6127
|
+
const beforeEnter = await chooserIsAbsent();
|
|
6128
|
+
if (beforeEnter) return beforeEnter;
|
|
6129
|
+
await this.sendKey(target, "Enter");
|
|
6130
|
+
return { ok: true };
|
|
6131
|
+
} catch (error) {
|
|
6132
|
+
if (preparationFailed) throw error;
|
|
6133
|
+
return {
|
|
6134
|
+
ok: false,
|
|
6135
|
+
reason: "tmux-failed",
|
|
6136
|
+
details: error instanceof Error ? error.message : String(error)
|
|
6137
|
+
};
|
|
5733
6138
|
}
|
|
5734
|
-
return { events, pendingCallRegistrations };
|
|
5735
6139
|
}
|
|
5736
|
-
|
|
5737
|
-
|
|
5738
|
-
|
|
5739
|
-
|
|
5740
|
-
|
|
5741
|
-
|
|
5742
|
-
|
|
5743
|
-
|
|
5744
|
-
|
|
5745
|
-
|
|
5746
|
-
|
|
5747
|
-
|
|
6140
|
+
/** Dismiss whichever approval UI is active without binding to a stale promptId. */
|
|
6141
|
+
async dismissActivePrompt() {
|
|
6142
|
+
const target = this.resolveTmuxTarget();
|
|
6143
|
+
if (!target) return { ok: false, reason: "no-tmux-target" };
|
|
6144
|
+
if (!this.paneObserver) {
|
|
6145
|
+
return { ok: false, reason: "prompt-probe-failed" };
|
|
6146
|
+
}
|
|
6147
|
+
try {
|
|
6148
|
+
const before = await this.paneObserver.probeApprovalUIActive();
|
|
6149
|
+
if (!before.probeSucceeded) return { ok: false, reason: "prompt-probe-failed" };
|
|
6150
|
+
if (!before.active) return { ok: true };
|
|
6151
|
+
if (before.ambiguous) {
|
|
6152
|
+
return {
|
|
6153
|
+
ok: false,
|
|
6154
|
+
reason: "ambiguous-prompt-active",
|
|
6155
|
+
details: "masked/ambiguous prompt cannot be dismissed via Escape from mobile"
|
|
6156
|
+
};
|
|
6157
|
+
}
|
|
6158
|
+
if (before.kind === "question") {
|
|
6159
|
+
let isActionableWizard = false;
|
|
6160
|
+
try {
|
|
6161
|
+
const snapshot = await this.paneObserver.captureSnapshot();
|
|
6162
|
+
if (snapshot) {
|
|
6163
|
+
const parsed = parseApprovalSnapshot(snapshot);
|
|
6164
|
+
if (parsed && parsed.kind === "question" && Object.keys(parsed.submitMap || {}).length > 0) {
|
|
6165
|
+
isActionableWizard = true;
|
|
6166
|
+
}
|
|
5748
6167
|
}
|
|
6168
|
+
} catch {
|
|
5749
6169
|
}
|
|
5750
|
-
|
|
5751
|
-
|
|
5752
|
-
|
|
5753
|
-
|
|
5754
|
-
|
|
5755
|
-
|
|
5756
|
-
const rawContent = stripTimestampPrefix(event.content || "");
|
|
5757
|
-
const truncatedContent = truncate(rawContent, TOOL_OUTPUT_MAX);
|
|
5758
|
-
return {
|
|
5759
|
-
events: [
|
|
5760
|
-
{
|
|
5761
|
-
...base,
|
|
5762
|
-
timestamp: nextTimestamp(),
|
|
5763
|
-
type: import_codevibe_core5.EventType.TOOL_USE,
|
|
5764
|
-
content: formatToolResultContent(event.type, toolName, truncatedContent, isErrorStatus),
|
|
5765
|
-
metadata: {
|
|
5766
|
-
...baseMetadata,
|
|
5767
|
-
tool_name: toolName,
|
|
5768
|
-
tool_output: truncatedContent,
|
|
5769
|
-
success: !isErrorStatus
|
|
6170
|
+
if (!isActionableWizard) {
|
|
6171
|
+
return {
|
|
6172
|
+
ok: false,
|
|
6173
|
+
reason: "display-only-question-active",
|
|
6174
|
+
details: "active question prompt cannot be dismissed via Escape from mobile"
|
|
6175
|
+
};
|
|
5770
6176
|
}
|
|
5771
6177
|
}
|
|
5772
|
-
|
|
5773
|
-
|
|
5774
|
-
|
|
5775
|
-
}
|
|
5776
|
-
|
|
5777
|
-
|
|
5778
|
-
|
|
5779
|
-
|
|
5780
|
-
|
|
5781
|
-
|
|
5782
|
-
}
|
|
5783
|
-
|
|
5784
|
-
|
|
5785
|
-
|
|
5786
|
-
|
|
5787
|
-
|
|
5788
|
-
|
|
5789
|
-
|
|
5790
|
-
|
|
5791
|
-
case "RUN_COMMAND":
|
|
5792
|
-
return "Bash";
|
|
5793
|
-
case "LIST_DIRECTORY":
|
|
5794
|
-
return "LS";
|
|
5795
|
-
case "GREP_SEARCH":
|
|
5796
|
-
return "Grep";
|
|
5797
|
-
case "VIEW_FILE":
|
|
5798
|
-
return "Read";
|
|
5799
|
-
case "CODE_ACTION":
|
|
5800
|
-
return "Edit";
|
|
5801
|
-
case "SEARCH_WEB":
|
|
5802
|
-
return "WebSearch";
|
|
5803
|
-
case "GENERIC":
|
|
5804
|
-
return "Other";
|
|
5805
|
-
default:
|
|
5806
|
-
return "Other";
|
|
6178
|
+
await this.sendKey(target, "Escape");
|
|
6179
|
+
await delay(250);
|
|
6180
|
+
const after = await this.paneObserver.probeApprovalUIActive();
|
|
6181
|
+
if (!after.probeSucceeded) return { ok: false, reason: "prompt-probe-failed" };
|
|
6182
|
+
if (after.active) {
|
|
6183
|
+
return {
|
|
6184
|
+
ok: false,
|
|
6185
|
+
reason: "prompt-superseded",
|
|
6186
|
+
details: "approval chooser remained active after Escape"
|
|
6187
|
+
};
|
|
6188
|
+
}
|
|
6189
|
+
return { ok: true };
|
|
6190
|
+
} catch (error) {
|
|
6191
|
+
return {
|
|
6192
|
+
ok: false,
|
|
6193
|
+
reason: "tmux-failed",
|
|
6194
|
+
details: error instanceof Error ? error.message : String(error)
|
|
6195
|
+
};
|
|
6196
|
+
}
|
|
5807
6197
|
}
|
|
5808
|
-
|
|
5809
|
-
|
|
5810
|
-
|
|
5811
|
-
|
|
5812
|
-
|
|
5813
|
-
|
|
5814
|
-
case "REPLACE_FILE_CONTENT":
|
|
5815
|
-
case "MULTI_REPLACE_FILE_CONTENT":
|
|
5816
|
-
case "WRITE_TO_FILE":
|
|
5817
|
-
return "CODE_ACTION";
|
|
5818
|
-
case "GENERATE_IMAGE":
|
|
5819
|
-
case "READ_URL_CONTENT":
|
|
5820
|
-
case "INVOKE_SUBAGENT":
|
|
5821
|
-
case "DEFINE_SUBAGENT":
|
|
5822
|
-
case "MANAGE_SUBAGENTS":
|
|
5823
|
-
case "SEND_MESSAGE":
|
|
5824
|
-
case "LIST_PERMISSIONS":
|
|
5825
|
-
case "MANAGE_TASK":
|
|
5826
|
-
case "SCHEDULE":
|
|
5827
|
-
case "ASK_PERMISSION":
|
|
5828
|
-
return "GENERIC";
|
|
5829
|
-
case "ASK_QUESTION":
|
|
5830
|
-
return "ASK_QUESTION";
|
|
5831
|
-
default:
|
|
5832
|
-
return upper;
|
|
6198
|
+
// ─── Tmux primitives ────────────────────────────────────────────────────
|
|
6199
|
+
async typeLiteral(target, text) {
|
|
6200
|
+
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, "\\$").replace(/`/g, "\\`");
|
|
6201
|
+
const safeTarget = escapeShellArg2(target);
|
|
6202
|
+
const cmd = `tmux send-keys -t '${safeTarget}' -l "${escaped}"`;
|
|
6203
|
+
await this.execFn(cmd);
|
|
5833
6204
|
}
|
|
5834
|
-
|
|
5835
|
-
|
|
5836
|
-
|
|
5837
|
-
|
|
5838
|
-
for (const key of ["CommandLine", "Command", "command", "cmd", "exec"]) {
|
|
5839
|
-
const v = a[key];
|
|
5840
|
-
if (typeof v === "string" && v.trim().length > 0) {
|
|
5841
|
-
return stripWrappingQuotes2(v.trim());
|
|
5842
|
-
}
|
|
6205
|
+
async sendKey(target, key) {
|
|
6206
|
+
const safeTarget = escapeShellArg2(target);
|
|
6207
|
+
const cmd = `tmux send-keys -t '${safeTarget}' ${key}`;
|
|
6208
|
+
await this.execFn(cmd);
|
|
5843
6209
|
}
|
|
5844
|
-
|
|
5845
|
-
|
|
5846
|
-
function extractFilePathFromArgs(toolName, args) {
|
|
5847
|
-
if (!args || typeof args !== "object") return void 0;
|
|
5848
|
-
const a = args;
|
|
5849
|
-
for (const key of [
|
|
5850
|
-
"TargetFile",
|
|
5851
|
-
"FilePath",
|
|
5852
|
-
"file_path",
|
|
5853
|
-
"filePath",
|
|
5854
|
-
"path",
|
|
5855
|
-
"DirectoryPath",
|
|
5856
|
-
"directory",
|
|
5857
|
-
"Directory"
|
|
5858
|
-
]) {
|
|
5859
|
-
const v = a[key];
|
|
5860
|
-
if (typeof v === "string" && v.trim().length > 0) {
|
|
5861
|
-
return stripWrappingQuotes2(v.trim());
|
|
5862
|
-
}
|
|
6210
|
+
resolveTmuxTarget() {
|
|
6211
|
+
return this.tmuxTargetOverride ?? process.env.CODEVIBE_AGY_TMUX_TARGET ?? null;
|
|
5863
6212
|
}
|
|
5864
|
-
|
|
6213
|
+
};
|
|
6214
|
+
function escapeShellArg2(value) {
|
|
6215
|
+
return value.replace(/'/g, `'\\''`);
|
|
5865
6216
|
}
|
|
5866
|
-
function
|
|
5867
|
-
|
|
5868
|
-
|
|
5869
|
-
|
|
5870
|
-
|
|
5871
|
-
|
|
5872
|
-
|
|
5873
|
-
|
|
6217
|
+
function isNamedKey(key) {
|
|
6218
|
+
return /^[A-Z][A-Za-z0-9]+$/.test(key);
|
|
6219
|
+
}
|
|
6220
|
+
function delay(ms) {
|
|
6221
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
6222
|
+
}
|
|
6223
|
+
|
|
6224
|
+
// src/mobile-prompt-dedupe.ts
|
|
6225
|
+
var DEFAULT_EXPIRY_MS = 15e3;
|
|
6226
|
+
var DEFAULT_MIN_FUZZY_ECHO_LENGTH = 16;
|
|
6227
|
+
var DEFAULT_MIN_FUZZY_ECHO_RATIO = 0.35;
|
|
6228
|
+
var MobilePromptDeduper = class {
|
|
6229
|
+
constructor(options = {}) {
|
|
6230
|
+
this.pendingBySession = /* @__PURE__ */ new Map();
|
|
6231
|
+
this.expiryMs = options.expiryMs ?? DEFAULT_EXPIRY_MS;
|
|
6232
|
+
this.minFuzzyEchoLength = options.minFuzzyEchoLength ?? DEFAULT_MIN_FUZZY_ECHO_LENGTH;
|
|
6233
|
+
this.minFuzzyEchoRatio = options.minFuzzyEchoRatio ?? DEFAULT_MIN_FUZZY_ECHO_RATIO;
|
|
6234
|
+
this.now = options.now ?? Date.now;
|
|
5874
6235
|
}
|
|
5875
|
-
|
|
5876
|
-
|
|
6236
|
+
/** Called by the MCP server immediately BEFORE prompt-responder
|
|
6237
|
+
* performs the tmux send-keys. Records the normalized text + timestamp
|
|
6238
|
+
* for future echo-suppression. */
|
|
6239
|
+
track(sessionId, prompt) {
|
|
6240
|
+
this.sweep();
|
|
6241
|
+
const normalized = this.normalize(prompt);
|
|
6242
|
+
if (!normalized) return;
|
|
6243
|
+
const entries = this.validEntries(sessionId);
|
|
6244
|
+
entries.push({ normalized, timestamp: this.now() });
|
|
6245
|
+
this.pendingBySession.set(sessionId, entries);
|
|
5877
6246
|
}
|
|
5878
|
-
|
|
5879
|
-
|
|
5880
|
-
|
|
5881
|
-
|
|
5882
|
-
|
|
5883
|
-
|
|
6247
|
+
/** Symmetric helper for callers that want to retract a tracked entry
|
|
6248
|
+
* WITHOUT consuming it via dedupe (e.g. responder bailed before send). */
|
|
6249
|
+
forget(sessionId, prompt) {
|
|
6250
|
+
this.sweep();
|
|
6251
|
+
const normalized = this.normalize(prompt);
|
|
6252
|
+
if (!normalized) return;
|
|
6253
|
+
let removed = false;
|
|
6254
|
+
const entries = this.validEntries(sessionId).filter((entry) => {
|
|
6255
|
+
if (!removed && entry.normalized === normalized) {
|
|
6256
|
+
removed = true;
|
|
6257
|
+
return false;
|
|
6258
|
+
}
|
|
6259
|
+
return true;
|
|
6260
|
+
});
|
|
6261
|
+
this.replaceEntries(sessionId, entries);
|
|
5884
6262
|
}
|
|
5885
|
-
|
|
5886
|
-
|
|
5887
|
-
|
|
5888
|
-
|
|
6263
|
+
/**
|
|
6264
|
+
* Called by event-mapper / MCP server immediately BEFORE forwarding a
|
|
6265
|
+
* USER_PROMPT to AppSync. If the echo matches a recently-tracked mobile
|
|
6266
|
+
* prompt, returns the match metadata + CONSUMES the tracked entry (so a
|
|
6267
|
+
* second similar echo doesn't suppress an actual second mobile prompt).
|
|
6268
|
+
* Returns null when no match → caller proceeds with the AppSync emit.
|
|
6269
|
+
*/
|
|
6270
|
+
consumeIfDuplicate(sessionId, echo) {
|
|
6271
|
+
this.sweep();
|
|
6272
|
+
const normalizedEcho = this.normalize(echo);
|
|
6273
|
+
if (!normalizedEcho) return null;
|
|
6274
|
+
let match = null;
|
|
6275
|
+
const entries = this.validEntries(sessionId).filter((entry) => {
|
|
6276
|
+
if (!match) {
|
|
6277
|
+
const matchType = this.matchType(entry.normalized, normalizedEcho);
|
|
6278
|
+
if (matchType) {
|
|
6279
|
+
match = {
|
|
6280
|
+
matchType,
|
|
6281
|
+
originalLength: entry.normalized.length,
|
|
6282
|
+
echoLength: normalizedEcho.length
|
|
6283
|
+
};
|
|
6284
|
+
return false;
|
|
6285
|
+
}
|
|
6286
|
+
}
|
|
6287
|
+
return true;
|
|
6288
|
+
});
|
|
6289
|
+
this.replaceEntries(sessionId, entries);
|
|
6290
|
+
return match;
|
|
6291
|
+
}
|
|
6292
|
+
/** Inspect without spending the token before durable native admission. */
|
|
6293
|
+
peekIfDuplicate(sessionId, echo) {
|
|
6294
|
+
const normalized = this.normalize(echo);
|
|
6295
|
+
if (!normalized) return null;
|
|
6296
|
+
for (const entry of this.validEntries(sessionId)) {
|
|
6297
|
+
const matchType = this.matchType(entry.normalized, normalized);
|
|
6298
|
+
if (matchType) return { matchType, originalLength: entry.normalized.length, echoLength: normalized.length };
|
|
5889
6299
|
}
|
|
5890
|
-
|
|
5891
|
-
|
|
5892
|
-
|
|
5893
|
-
|
|
6300
|
+
return null;
|
|
6301
|
+
}
|
|
6302
|
+
// ─── Internals ──────────────────────────────────────────────────────────
|
|
6303
|
+
sweep() {
|
|
6304
|
+
const cutoff = this.now() - this.expiryMs;
|
|
6305
|
+
for (const [sessId, entries] of this.pendingBySession.entries()) {
|
|
6306
|
+
const valid = entries.filter((entry) => entry.timestamp >= cutoff);
|
|
6307
|
+
if (valid.length > 0) {
|
|
6308
|
+
this.pendingBySession.set(sessId, valid);
|
|
6309
|
+
} else {
|
|
6310
|
+
this.pendingBySession.delete(sessId);
|
|
5894
6311
|
}
|
|
5895
6312
|
}
|
|
5896
6313
|
}
|
|
5897
|
-
|
|
5898
|
-
|
|
5899
|
-
|
|
5900
|
-
|
|
5901
|
-
|
|
5902
|
-
|
|
5903
|
-
|
|
5904
|
-
|
|
5905
|
-
|
|
5906
|
-
if (typeof questions === "string") {
|
|
5907
|
-
try {
|
|
5908
|
-
questions = JSON.parse(questions.trim());
|
|
5909
|
-
} catch {
|
|
6314
|
+
validEntries(sessionId) {
|
|
6315
|
+
const cutoff = this.now() - this.expiryMs;
|
|
6316
|
+
return (this.pendingBySession.get(sessionId) || []).filter((entry) => entry.timestamp >= cutoff);
|
|
6317
|
+
}
|
|
6318
|
+
replaceEntries(sessionId, entries) {
|
|
6319
|
+
if (entries.length > 0) {
|
|
6320
|
+
this.pendingBySession.set(sessionId, entries);
|
|
6321
|
+
} else {
|
|
6322
|
+
this.pendingBySession.delete(sessionId);
|
|
5910
6323
|
}
|
|
5911
6324
|
}
|
|
5912
|
-
|
|
5913
|
-
|
|
5914
|
-
|
|
5915
|
-
|
|
5916
|
-
|
|
5917
|
-
|
|
5918
|
-
|
|
5919
|
-
|
|
5920
|
-
result.push({ number: String(i + 1), text: stripWrappingQuotes2(opt.trim()) });
|
|
5921
|
-
} else if (opt && typeof opt === "object" && typeof opt.text === "string") {
|
|
5922
|
-
result.push({
|
|
5923
|
-
number: String(opt.number ?? i + 1),
|
|
5924
|
-
text: stripWrappingQuotes2(String(opt.text).trim())
|
|
5925
|
-
});
|
|
5926
|
-
}
|
|
5927
|
-
}
|
|
5928
|
-
if (result.length > 0) return result;
|
|
6325
|
+
matchType(original, echo) {
|
|
6326
|
+
if (original === echo) {
|
|
6327
|
+
return "exact";
|
|
6328
|
+
}
|
|
6329
|
+
const longEnough = echo.length >= this.minFuzzyEchoLength;
|
|
6330
|
+
const enoughOfOriginal = echo.length / original.length >= this.minFuzzyEchoRatio;
|
|
6331
|
+
if (longEnough && enoughOfOriginal && original.endsWith(echo)) {
|
|
6332
|
+
return "suffix";
|
|
5929
6333
|
}
|
|
6334
|
+
return null;
|
|
5930
6335
|
}
|
|
5931
|
-
|
|
5932
|
-
|
|
5933
|
-
|
|
5934
|
-
|
|
5935
|
-
|
|
5936
|
-
|
|
5937
|
-
|
|
5938
|
-
|
|
5939
|
-
|
|
6336
|
+
normalize(text) {
|
|
6337
|
+
return text.replace(/\s+/g, " ").trim();
|
|
6338
|
+
}
|
|
6339
|
+
};
|
|
6340
|
+
|
|
6341
|
+
// src/daemon-telemetry.ts
|
|
6342
|
+
var crypto2 = __toESM(require("crypto"));
|
|
6343
|
+
var fs6 = __toESM(require("fs"));
|
|
6344
|
+
var https = __toESM(require("https"));
|
|
6345
|
+
var os6 = __toESM(require("os"));
|
|
6346
|
+
var path7 = __toESM(require("path"));
|
|
6347
|
+
var MEASUREMENT_ID = "G-GS74YEQTB8";
|
|
6348
|
+
var API_SECRET = "lAfOF6OxRzSQ-NsLBRjhAg";
|
|
6349
|
+
var GA4_HOSTNAME = "www.google-analytics.com";
|
|
6350
|
+
var GA4_PATH = `/mp/collect?measurement_id=${MEASUREMENT_ID}&api_secret=${API_SECRET}`;
|
|
6351
|
+
var BEACON_TOTAL_TIMEOUT_MS = 800;
|
|
6352
|
+
function readPluginVersion() {
|
|
6353
|
+
try {
|
|
6354
|
+
const pkgPath = path7.resolve(__dirname, "..", "package.json");
|
|
6355
|
+
const raw = fs6.readFileSync(pkgPath, "utf-8");
|
|
6356
|
+
const pkg = JSON.parse(raw);
|
|
6357
|
+
if (typeof pkg.version === "string" && pkg.version.length > 0 && pkg.version.length < 30) {
|
|
6358
|
+
return pkg.version;
|
|
5940
6359
|
}
|
|
6360
|
+
} catch {
|
|
5941
6361
|
}
|
|
5942
|
-
return
|
|
6362
|
+
return "unknown";
|
|
5943
6363
|
}
|
|
5944
|
-
|
|
5945
|
-
|
|
5946
|
-
|
|
5947
|
-
}
|
|
5948
|
-
return s;
|
|
6364
|
+
var PLUGIN_VERSION = readPluginVersion();
|
|
6365
|
+
function getClientId() {
|
|
6366
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : 0;
|
|
6367
|
+
return crypto2.createHash("sha256").update(`${os6.hostname()}-${uid}`).digest("hex").substring(0, 36);
|
|
5949
6368
|
}
|
|
5950
|
-
function
|
|
5951
|
-
|
|
5952
|
-
const
|
|
5953
|
-
|
|
5954
|
-
|
|
5955
|
-
|
|
5956
|
-
|
|
5957
|
-
return `${prefix}LS: ${firstLine || "directory listed"}`;
|
|
5958
|
-
case "GREP_SEARCH":
|
|
5959
|
-
return `${prefix}Grep: ${firstLine || "search executed"}`;
|
|
5960
|
-
case "VIEW_FILE":
|
|
5961
|
-
return `${prefix}Read: ${firstLine || "file read"}`;
|
|
5962
|
-
case "CODE_ACTION":
|
|
5963
|
-
return `${prefix}Edit: ${firstLine || "file edited"}`;
|
|
5964
|
-
case "SEARCH_WEB":
|
|
5965
|
-
return `${prefix}WebSearch: ${firstLine || "search executed"}`;
|
|
5966
|
-
case "GENERIC":
|
|
5967
|
-
return `${prefix}${toolName}: ${firstLine || "tool executed"}`;
|
|
5968
|
-
default:
|
|
5969
|
-
return `${prefix}${toolName} (${agyType}): ${firstLine || "unknown event"}`;
|
|
6369
|
+
function sanitizeErrorMessage(msg) {
|
|
6370
|
+
if (!msg) return "";
|
|
6371
|
+
const homedir5 = os6.homedir();
|
|
6372
|
+
let out = msg.replace(/[\n\r\t]/g, " ");
|
|
6373
|
+
if (homedir5 && homedir5.length > 0) {
|
|
6374
|
+
const safeHome = homedir5.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
6375
|
+
out = out.replace(new RegExp(safeHome, "g"), "~");
|
|
5970
6376
|
}
|
|
6377
|
+
out = out.replace(/\/Users\/[^/\s"'`]+/g, "/Users/<user>").replace(/\/home\/[^/\s"'`]+/g, "/home/<user>").replace(/[A-Za-z]:\\Users\\[^\\\s"'`]+/gi, "C:\\Users\\<user>").replace(/[^\x20-\x7E]/g, "");
|
|
6378
|
+
return out.trim().substring(0, 100);
|
|
5971
6379
|
}
|
|
5972
|
-
function
|
|
5973
|
-
|
|
5974
|
-
|
|
5975
|
-
|
|
5976
|
-
|
|
5977
|
-
|
|
5978
|
-
|
|
5979
|
-
|
|
5980
|
-
|
|
5981
|
-
}
|
|
5982
|
-
|
|
6380
|
+
function fireDaemonBeacon(name, params) {
|
|
6381
|
+
return new Promise((resolve2) => {
|
|
6382
|
+
let req;
|
|
6383
|
+
let settled = false;
|
|
6384
|
+
const settle = () => {
|
|
6385
|
+
if (settled) return;
|
|
6386
|
+
settled = true;
|
|
6387
|
+
clearTimeout(overall);
|
|
6388
|
+
resolve2();
|
|
6389
|
+
};
|
|
6390
|
+
const overall = setTimeout(() => {
|
|
6391
|
+
try {
|
|
6392
|
+
req?.destroy();
|
|
6393
|
+
} catch {
|
|
6394
|
+
}
|
|
6395
|
+
settle();
|
|
6396
|
+
}, BEACON_TOTAL_TIMEOUT_MS);
|
|
6397
|
+
if (typeof overall.unref === "function") {
|
|
6398
|
+
overall.unref();
|
|
5983
6399
|
}
|
|
5984
|
-
|
|
5985
|
-
|
|
6400
|
+
try {
|
|
6401
|
+
const sanitizedParams = {};
|
|
6402
|
+
for (const [key, value] of Object.entries(params)) {
|
|
6403
|
+
if (typeof value === "string") {
|
|
6404
|
+
sanitizedParams[key] = sanitizeErrorMessage(value);
|
|
6405
|
+
} else {
|
|
6406
|
+
sanitizedParams[key] = value;
|
|
6407
|
+
}
|
|
6408
|
+
}
|
|
6409
|
+
const payload = JSON.stringify({
|
|
6410
|
+
client_id: getClientId(),
|
|
6411
|
+
events: [
|
|
6412
|
+
{
|
|
6413
|
+
name,
|
|
6414
|
+
params: {
|
|
6415
|
+
agent: "antigravity",
|
|
6416
|
+
plugin_version: PLUGIN_VERSION,
|
|
6417
|
+
platform: process.platform,
|
|
6418
|
+
source: process.env.CODEVIBE_TELEMETRY_SOURCE || "production",
|
|
6419
|
+
...sanitizedParams
|
|
6420
|
+
}
|
|
6421
|
+
}
|
|
6422
|
+
]
|
|
6423
|
+
});
|
|
6424
|
+
req = https.request(
|
|
6425
|
+
{
|
|
6426
|
+
hostname: GA4_HOSTNAME,
|
|
6427
|
+
path: GA4_PATH,
|
|
6428
|
+
method: "POST",
|
|
6429
|
+
headers: { "Content-Type": "application/json" },
|
|
6430
|
+
timeout: BEACON_TOTAL_TIMEOUT_MS
|
|
6431
|
+
},
|
|
6432
|
+
(res) => {
|
|
6433
|
+
res.resume();
|
|
6434
|
+
res.on("end", settle);
|
|
6435
|
+
res.on("close", settle);
|
|
6436
|
+
res.on("error", settle);
|
|
6437
|
+
}
|
|
6438
|
+
);
|
|
6439
|
+
req.on("error", settle);
|
|
6440
|
+
req.on("timeout", () => {
|
|
6441
|
+
try {
|
|
6442
|
+
req?.destroy();
|
|
6443
|
+
} catch {
|
|
6444
|
+
}
|
|
6445
|
+
settle();
|
|
6446
|
+
});
|
|
6447
|
+
req.on("close", settle);
|
|
6448
|
+
req.write(payload);
|
|
6449
|
+
req.end();
|
|
6450
|
+
} catch {
|
|
6451
|
+
settle();
|
|
6452
|
+
}
|
|
6453
|
+
});
|
|
5986
6454
|
}
|
|
5987
6455
|
|
|
5988
6456
|
// src/tool-activity-integration.ts
|
|
@@ -6437,6 +6905,20 @@ var McpServer = class _McpServer {
|
|
|
6437
6905
|
* bounded by TTL. (Codex Stage 2 R1 v12 HIGH.) */
|
|
6438
6906
|
this.observedMainConversationIds = /* @__PURE__ */ new Set();
|
|
6439
6907
|
this.hookRegistrationCache = null;
|
|
6908
|
+
/** Tracks generation numbers per conversation to invalidate in-flight async PreToolUse callbacks upon turn transition, Stop, or PostToolUse. (Codex S2-Codex-F6) */
|
|
6909
|
+
this.conversationGenerations = /* @__PURE__ */ new Map();
|
|
6910
|
+
/** Tracks turn transition and Stop generations per conversation to distinguish turn boundaries from tool completions. (Codex S2-Codex-F6, S2-Codex-R12-F2) */
|
|
6911
|
+
this.conversationTurnGenerations = /* @__PURE__ */ new Map();
|
|
6912
|
+
/** Monotonic sequence counter for hook lifecycle events (PreToolUse arrival, PostToolUse completion, Stop). (Claude R13-F2, Codex S2-Codex-R13-F1) */
|
|
6913
|
+
this.hookEventSeq = 0;
|
|
6914
|
+
/** Tracks Stop sequence per conversation to invalidate in-flight async bootstrap across stop events. (Codex S2-Codex-F6, Claude R13-N1) */
|
|
6915
|
+
this.conversationStopSeq = /* @__PURE__ */ new Map();
|
|
6916
|
+
/** Tracks completed PostToolUse deliveries per conversation with monotonic sequence watermarks to affirmatively correlate concurrent in-flight bootstrap work. (Codex S2-Codex-F6, S2-Codex-R13-F1, Claude R13-F2) */
|
|
6917
|
+
this.conversationCompletedPostDeliveries = /* @__PURE__ */ new Map();
|
|
6918
|
+
/** Tracks the latest turn initialNumSteps to filter stale in-flight or held deliveries from earlier turns. (Codex S2-Codex-F6) */
|
|
6919
|
+
this.conversationInitialNumSteps = /* @__PURE__ */ new Map();
|
|
6920
|
+
/** Bounded buffer for PreToolUse deliveries arriving while conversation routing is in 'hold'. (Codex S2-Codex-F6) */
|
|
6921
|
+
this.heldHookDeliveries = /* @__PURE__ */ new Map();
|
|
6440
6922
|
this.started = false;
|
|
6441
6923
|
/** Monotonically-increasing counter incremented on every start(). Captured
|
|
6442
6924
|
* by async callbacks at entry; mismatch after an await means stop()→start()
|
|
@@ -6563,8 +7045,16 @@ var McpServer = class _McpServer {
|
|
|
6563
7045
|
isClaimedElsewhere: (conversationId) => this.claims.isClaimedElsewhere(conversationId),
|
|
6564
7046
|
otherInvokedAt: (conversationId) => this.claims.otherInvokedAt(conversationId),
|
|
6565
7047
|
readTranscriptFirstType: (conversationId) => this.readConversationFirstType(conversationId),
|
|
6566
|
-
onRelease: (emits, decision) => {
|
|
7048
|
+
onRelease: (emits, decision, convId) => {
|
|
6567
7049
|
for (const emit of emits) this.dispatchTranscriptEmit(emit, decision);
|
|
7050
|
+
const targetConvId = convId ?? (emits.length > 0 ? emits[0].conversationId : void 0);
|
|
7051
|
+
if (targetConvId) {
|
|
7052
|
+
const turnStep = emits.find((e) => e.event?.type === "USER_INPUT")?.event?.step_index;
|
|
7053
|
+
const minStep = this.conversationInitialNumSteps.get(targetConvId) ?? (typeof turnStep === "number" ? turnStep : void 0);
|
|
7054
|
+
void this.drainHeldHookDeliveries(targetConvId, decision, minStep).catch((err) => {
|
|
7055
|
+
logger.warn("Failed draining held hook deliveries onRelease", { error: String(err) });
|
|
7056
|
+
});
|
|
7057
|
+
}
|
|
6568
7058
|
},
|
|
6569
7059
|
isModelOutput: (emit) => emit.event?.source === "MODEL",
|
|
6570
7060
|
turnStep: (emit) => emit.event?.type === "USER_INPUT" ? typeof emit.event.step_index === "number" ? emit.event.step_index : -1 : null,
|
|
@@ -6572,6 +7062,7 @@ var McpServer = class _McpServer {
|
|
|
6572
7062
|
onLog: (message, data) => logger.info(message, data),
|
|
6573
7063
|
holdMs: options.conversationHoldMs
|
|
6574
7064
|
});
|
|
7065
|
+
this.conversationHoldMs = options.conversationHoldMs;
|
|
6575
7066
|
this.httpApi.setHandlers({
|
|
6576
7067
|
pushEvent: (p) => this.handleHttpPushEvent(p),
|
|
6577
7068
|
getActiveSession: () => this.getAnySession(),
|
|
@@ -6581,6 +7072,9 @@ var McpServer = class _McpServer {
|
|
|
6581
7072
|
static {
|
|
6582
7073
|
this.E1_TTL_REFRESH_MS = 5 * 60 * 1e3;
|
|
6583
7074
|
}
|
|
7075
|
+
static {
|
|
7076
|
+
this.MAX_HELD_CONVERSATIONS = 50;
|
|
7077
|
+
}
|
|
6584
7078
|
// ─── Lifecycle ──────────────────────────────────────────────────────────
|
|
6585
7079
|
async start() {
|
|
6586
7080
|
if (this.started) throw new Error("McpServer.start() called twice");
|
|
@@ -6668,6 +7162,18 @@ var McpServer = class _McpServer {
|
|
|
6668
7162
|
logger.error("handlePendingPrompt failed", { error: String(err) });
|
|
6669
7163
|
});
|
|
6670
7164
|
});
|
|
7165
|
+
this.addListener(this.approvalDetector, "prompt-upgraded", (state) => {
|
|
7166
|
+
void this.handlePendingPrompt(state).catch((err) => {
|
|
7167
|
+
logger.error("handlePendingPrompt failed on prompt-upgraded", { error: String(err) });
|
|
7168
|
+
});
|
|
7169
|
+
});
|
|
7170
|
+
this.addListener(this.approvalDetector, "prompt-resolved", (promptId) => {
|
|
7171
|
+
const session = this.session;
|
|
7172
|
+
if (!session) return;
|
|
7173
|
+
void this.resolveE1PluginApproval(session.sessionId, promptId).catch((err) => {
|
|
7174
|
+
logger.warn("resolveE1PluginApproval failed on prompt-resolved", { promptId, error: String(err) });
|
|
7175
|
+
});
|
|
7176
|
+
});
|
|
6671
7177
|
this.launchSessionPromise = this.createLaunchSession();
|
|
6672
7178
|
await this.launchSessionPromise;
|
|
6673
7179
|
if (!this.started) {
|
|
@@ -6812,6 +7318,12 @@ var McpServer = class _McpServer {
|
|
|
6812
7318
|
await this.boundedTeardownStep("paneObserver.stop", () => this.paneObserver.stop());
|
|
6813
7319
|
await this.boundedTeardownStep("approvalDetector.stop", () => this.approvalDetector.stop());
|
|
6814
7320
|
await this.boundedTeardownStep("httpApi.stop", () => this.httpApi.stop());
|
|
7321
|
+
for (const [, list] of this.heldHookDeliveries) {
|
|
7322
|
+
for (const entry of list) {
|
|
7323
|
+
clearTimeout(entry.timer);
|
|
7324
|
+
}
|
|
7325
|
+
}
|
|
7326
|
+
this.heldHookDeliveries.clear();
|
|
6815
7327
|
this.removeOrphanedRuntimeDir();
|
|
6816
7328
|
try {
|
|
6817
7329
|
this.appSyncClient.cleanupSubscriptions();
|
|
@@ -7163,16 +7675,38 @@ var McpServer = class _McpServer {
|
|
|
7163
7675
|
));
|
|
7164
7676
|
}
|
|
7165
7677
|
// ─── agy hooks (F1) ─────────────────────────────────────────────────────
|
|
7678
|
+
dropHeldHookDeliveries(conversationId, reason) {
|
|
7679
|
+
const list = this.heldHookDeliveries.get(conversationId);
|
|
7680
|
+
if (!list) return;
|
|
7681
|
+
for (const entry of list) {
|
|
7682
|
+
clearTimeout(entry.timer);
|
|
7683
|
+
}
|
|
7684
|
+
this.heldHookDeliveries.delete(conversationId);
|
|
7685
|
+
logger.info("Dropped held hook deliveries", { conversationId, reason, count: list.length });
|
|
7686
|
+
}
|
|
7166
7687
|
/**
|
|
7167
7688
|
* One hook delivery from OUR agy (agy-hook.js only posts here when the
|
|
7168
7689
|
* launcher's inline CODEVIBE_AGY_HOOK_OWNER names this daemon's runtime
|
|
7169
7690
|
* dir). Updates routing state synchronously; network work runs afterwards so
|
|
7170
7691
|
* the HTTP reply — which agy is waiting for — goes out at once.
|
|
7171
7692
|
*/
|
|
7172
|
-
handleAgyHook(delivery) {
|
|
7693
|
+
async handleAgyHook(delivery) {
|
|
7173
7694
|
if (!this.started) return;
|
|
7174
7695
|
const { conversationId } = delivery;
|
|
7175
7696
|
if (delivery.event === "PreInvocation") {
|
|
7697
|
+
const isTurnStart = delivery.invocationNum === void 0 || delivery.invocationNum === 0;
|
|
7698
|
+
const wasAlreadyAdmitted = this.router.roleOf(conversationId) === "main" || this.observedMainConversationIds.has(conversationId);
|
|
7699
|
+
if (isTurnStart && wasAlreadyAdmitted) {
|
|
7700
|
+
this.conversationGenerations.set(conversationId, (this.conversationGenerations.get(conversationId) ?? 0) + 1);
|
|
7701
|
+
this.conversationTurnGenerations.set(conversationId, (this.conversationTurnGenerations.get(conversationId) ?? 0) + 1);
|
|
7702
|
+
this.approvalDetector.retireLiveWizard(conversationId);
|
|
7703
|
+
this.resolveLingeringQuestionPrompts(conversationId);
|
|
7704
|
+
this.dropHeldHookDeliveries(conversationId, "turn-transition");
|
|
7705
|
+
this.conversationCompletedPostDeliveries.delete(conversationId);
|
|
7706
|
+
}
|
|
7707
|
+
if (delivery.initialNumSteps !== void 0) {
|
|
7708
|
+
this.conversationInitialNumSteps.set(conversationId, delivery.initialNumSteps);
|
|
7709
|
+
}
|
|
7176
7710
|
const previousBound = this.router.getBound();
|
|
7177
7711
|
const { role, bound } = this.router.onPreInvocation({
|
|
7178
7712
|
conversationId,
|
|
@@ -7181,6 +7715,9 @@ var McpServer = class _McpServer {
|
|
|
7181
7715
|
});
|
|
7182
7716
|
if (role === "main") {
|
|
7183
7717
|
this.observedMainConversationIds.add(conversationId);
|
|
7718
|
+
if (!wasAlreadyAdmitted) {
|
|
7719
|
+
await this.drainHeldHookDeliveries(conversationId, "process", delivery.initialNumSteps);
|
|
7720
|
+
}
|
|
7184
7721
|
} else {
|
|
7185
7722
|
this.observedMainConversationIds.delete(conversationId);
|
|
7186
7723
|
}
|
|
@@ -7193,10 +7730,264 @@ var McpServer = class _McpServer {
|
|
|
7193
7730
|
}
|
|
7194
7731
|
return;
|
|
7195
7732
|
}
|
|
7196
|
-
|
|
7733
|
+
if (delivery.event === "Stop") {
|
|
7734
|
+
this.conversationGenerations.set(conversationId, (this.conversationGenerations.get(conversationId) ?? 0) + 1);
|
|
7735
|
+
this.conversationTurnGenerations.set(conversationId, (this.conversationTurnGenerations.get(conversationId) ?? 0) + 1);
|
|
7736
|
+
this.conversationStopSeq.set(conversationId, ++this.hookEventSeq);
|
|
7737
|
+
this.dropHeldHookDeliveries(conversationId, "stop-hook");
|
|
7738
|
+
this.conversationCompletedPostDeliveries.delete(conversationId);
|
|
7739
|
+
this.approvalDetector.retireLiveWizard(conversationId);
|
|
7740
|
+
this.resolveLingeringQuestionPrompts(conversationId);
|
|
7741
|
+
return;
|
|
7742
|
+
}
|
|
7743
|
+
if (delivery.event === "PreToolUse") {
|
|
7744
|
+
void this.handlePreToolUse(delivery).catch((error) => {
|
|
7745
|
+
logger.warn("PreToolUse hook handling failed", { error: String(error) });
|
|
7746
|
+
});
|
|
7747
|
+
return;
|
|
7748
|
+
}
|
|
7749
|
+
const tn = (delivery.toolName ?? "").toLowerCase().trim();
|
|
7750
|
+
const isQuestionTool = ["ask_question", "question", "ask"].includes(tn);
|
|
7751
|
+
if (isQuestionTool) {
|
|
7752
|
+
this.conversationGenerations.set(delivery.conversationId, (this.conversationGenerations.get(delivery.conversationId) ?? 0) + 1);
|
|
7753
|
+
let list = this.conversationCompletedPostDeliveries.get(delivery.conversationId);
|
|
7754
|
+
if (!list) {
|
|
7755
|
+
list = [];
|
|
7756
|
+
this.conversationCompletedPostDeliveries.set(delivery.conversationId, list);
|
|
7757
|
+
}
|
|
7758
|
+
list.push({ toolName: delivery.toolName, stepIdx: delivery.stepIdx, toolArgs: delivery.toolArgs, seq: ++this.hookEventSeq });
|
|
7759
|
+
this.dropHeldHookDeliveries(delivery.conversationId, "post-tool-use-completion");
|
|
7760
|
+
this.approvalDetector.retireLiveWizardMatching(delivery.conversationId, delivery);
|
|
7761
|
+
}
|
|
7197
7762
|
void this.resolvePromptsForCompletedTool(delivery).catch((error) => {
|
|
7198
7763
|
logger.warn("PostToolUse prompt resolution failed", { error: String(error) });
|
|
7199
7764
|
});
|
|
7765
|
+
this.router.onPostToolUse(conversationId);
|
|
7766
|
+
}
|
|
7767
|
+
resolveLingeringQuestionPrompts(conversationId) {
|
|
7768
|
+
const lingeringPrompts = this.approvalDetector.getPendingPromptsForConversation(conversationId).filter((p) => p.kind === "question");
|
|
7769
|
+
for (const p of lingeringPrompts) {
|
|
7770
|
+
this.approvalDetector.resolvePrompt(p.promptId);
|
|
7771
|
+
if (this.session) {
|
|
7772
|
+
void this.resolveE1PluginApproval(this.session.sessionId, p.promptId).catch(() => {
|
|
7773
|
+
});
|
|
7774
|
+
}
|
|
7775
|
+
}
|
|
7776
|
+
}
|
|
7777
|
+
async drainHeldHookDeliveries(conversationId, decision, initialNumSteps) {
|
|
7778
|
+
const list = this.heldHookDeliveries.get(conversationId);
|
|
7779
|
+
if (!list || list.length === 0) return;
|
|
7780
|
+
for (const entry of list) {
|
|
7781
|
+
clearTimeout(entry.timer);
|
|
7782
|
+
}
|
|
7783
|
+
this.heldHookDeliveries.delete(conversationId);
|
|
7784
|
+
if (decision === "drop") {
|
|
7785
|
+
logger.info("Disposing held hook deliveries for foreign conversation", {
|
|
7786
|
+
conversationId,
|
|
7787
|
+
count: list.length
|
|
7788
|
+
});
|
|
7789
|
+
return;
|
|
7790
|
+
}
|
|
7791
|
+
logger.info("Replaying held hook deliveries after affirmative admission", {
|
|
7792
|
+
conversationId,
|
|
7793
|
+
count: list.length,
|
|
7794
|
+
decision
|
|
7795
|
+
});
|
|
7796
|
+
const now = Date.now();
|
|
7797
|
+
const HELD_TTL_MS = 3e4;
|
|
7798
|
+
const capturedGen = this.conversationGenerations.get(conversationId) ?? 0;
|
|
7799
|
+
for (const entry of list) {
|
|
7800
|
+
if (now - entry.receivedAt > HELD_TTL_MS) {
|
|
7801
|
+
logger.info("Discarding expired held hook delivery", {
|
|
7802
|
+
conversationId,
|
|
7803
|
+
toolName: entry.delivery.toolName,
|
|
7804
|
+
ageMs: now - entry.receivedAt
|
|
7805
|
+
});
|
|
7806
|
+
continue;
|
|
7807
|
+
}
|
|
7808
|
+
if (initialNumSteps !== void 0 && entry.delivery.stepIdx !== void 0 && entry.delivery.stepIdx < initialNumSteps - 1) {
|
|
7809
|
+
logger.info("Discarding held delivery from prior step before initialNumSteps", {
|
|
7810
|
+
conversationId,
|
|
7811
|
+
stepIdx: entry.delivery.stepIdx,
|
|
7812
|
+
initialNumSteps
|
|
7813
|
+
});
|
|
7814
|
+
continue;
|
|
7815
|
+
}
|
|
7816
|
+
if (capturedGen !== (this.conversationGenerations.get(conversationId) ?? 0)) {
|
|
7817
|
+
logger.info("Aborting drain of held hook delivery due to generation advance", {
|
|
7818
|
+
conversationId
|
|
7819
|
+
});
|
|
7820
|
+
break;
|
|
7821
|
+
}
|
|
7822
|
+
try {
|
|
7823
|
+
await this.handlePreToolUse(entry.delivery);
|
|
7824
|
+
} catch (err) {
|
|
7825
|
+
logger.warn("Failed replaying held hook delivery", { error: String(err) });
|
|
7826
|
+
}
|
|
7827
|
+
}
|
|
7828
|
+
}
|
|
7829
|
+
async handlePreToolUse(delivery) {
|
|
7830
|
+
if (!this.started || !delivery.toolName) return;
|
|
7831
|
+
logger.info("PreToolUse hook delivery received", {
|
|
7832
|
+
conversationId: delivery.conversationId,
|
|
7833
|
+
toolName: delivery.toolName,
|
|
7834
|
+
stepIdx: delivery.stepIdx
|
|
7835
|
+
});
|
|
7836
|
+
const tn = delivery.toolName.toLowerCase().trim();
|
|
7837
|
+
if (tn === "ask_question" || tn === "question" || tn === "ask") {
|
|
7838
|
+
const capturedTurnGen = this.conversationTurnGenerations.get(delivery.conversationId) ?? 0;
|
|
7839
|
+
const startedSeq = ++this.hookEventSeq;
|
|
7840
|
+
await this.ensureLaunchSession();
|
|
7841
|
+
if (!this.session && !this.sessionDisabled && !this.launchKeyUnavailable) {
|
|
7842
|
+
for (let attempt = 0; attempt < 2 && !this.session; attempt++) {
|
|
7843
|
+
await this.ensureLaunchSession();
|
|
7844
|
+
if (this.sessionDisabled || this.launchKeyUnavailable) break;
|
|
7845
|
+
if (!this.session) await new Promise((r) => setTimeout(r, 150));
|
|
7846
|
+
}
|
|
7847
|
+
}
|
|
7848
|
+
if (this.sessionDisabled || this.launchKeyUnavailable) {
|
|
7849
|
+
logger.warn("PreToolUse dropped due to terminal session state", {
|
|
7850
|
+
conversationId: delivery.conversationId
|
|
7851
|
+
});
|
|
7852
|
+
return;
|
|
7853
|
+
}
|
|
7854
|
+
const completedPosts = this.conversationCompletedPostDeliveries.get(delivery.conversationId) ?? [];
|
|
7855
|
+
const qText = extractQuestionFromArgs(delivery.toolName ?? "ask_question", delivery.toolArgs ?? {});
|
|
7856
|
+
const structQuestions = extractAllQuestionsFromArgs(delivery.toolName ?? "ask_question", delivery.toolArgs ?? {});
|
|
7857
|
+
const candidateCall = {
|
|
7858
|
+
intentStepIndex: delivery.stepIdx,
|
|
7859
|
+
questionText: qText,
|
|
7860
|
+
structuredQuestions: structQuestions,
|
|
7861
|
+
rawToolName: delivery.toolName ?? "ask_question"
|
|
7862
|
+
};
|
|
7863
|
+
const isAlreadyCompleted = completedPosts.some(
|
|
7864
|
+
(post) => post.seq > startedSeq && correlatesQuestionInvocation(candidateCall, post)
|
|
7865
|
+
);
|
|
7866
|
+
if (isAlreadyCompleted) {
|
|
7867
|
+
logger.info("PreToolUse already completed during async bootstrap await", {
|
|
7868
|
+
conversationId: delivery.conversationId,
|
|
7869
|
+
stepIdx: delivery.stepIdx
|
|
7870
|
+
});
|
|
7871
|
+
return;
|
|
7872
|
+
}
|
|
7873
|
+
const stoppedSeq = this.conversationStopSeq.get(delivery.conversationId) ?? 0;
|
|
7874
|
+
if (stoppedSeq > startedSeq) {
|
|
7875
|
+
logger.info("PreToolUse invalidated by Stop during async bootstrap await", {
|
|
7876
|
+
conversationId: delivery.conversationId,
|
|
7877
|
+
stepIdx: delivery.stepIdx
|
|
7878
|
+
});
|
|
7879
|
+
return;
|
|
7880
|
+
}
|
|
7881
|
+
const currentTurnGen = this.conversationTurnGenerations.get(delivery.conversationId) ?? 0;
|
|
7882
|
+
if (capturedTurnGen !== currentTurnGen) {
|
|
7883
|
+
const minStep2 = this.conversationInitialNumSteps.get(delivery.conversationId);
|
|
7884
|
+
const isCurrentTurn = minStep2 !== void 0 && delivery.stepIdx !== void 0 && delivery.stepIdx >= minStep2 - 1;
|
|
7885
|
+
if (!isCurrentTurn) {
|
|
7886
|
+
logger.info("PreToolUse invalidated by turn transition during async bootstrap await", {
|
|
7887
|
+
conversationId: delivery.conversationId,
|
|
7888
|
+
capturedTurnGen,
|
|
7889
|
+
currentTurnGen,
|
|
7890
|
+
stepIdx: delivery.stepIdx,
|
|
7891
|
+
minStep: minStep2
|
|
7892
|
+
});
|
|
7893
|
+
return;
|
|
7894
|
+
}
|
|
7895
|
+
logger.info("PreToolUse correlated with admitted current turn across bootstrap await", {
|
|
7896
|
+
conversationId: delivery.conversationId,
|
|
7897
|
+
stepIdx: delivery.stepIdx,
|
|
7898
|
+
minStep: minStep2
|
|
7899
|
+
});
|
|
7900
|
+
}
|
|
7901
|
+
const minStep = this.conversationInitialNumSteps.get(delivery.conversationId);
|
|
7902
|
+
if (minStep !== void 0 && delivery.stepIdx !== void 0 && delivery.stepIdx < minStep - 1) {
|
|
7903
|
+
logger.info("PreToolUse dropped as stale prior step before initialNumSteps", {
|
|
7904
|
+
conversationId: delivery.conversationId,
|
|
7905
|
+
stepIdx: delivery.stepIdx,
|
|
7906
|
+
minStep
|
|
7907
|
+
});
|
|
7908
|
+
return;
|
|
7909
|
+
}
|
|
7910
|
+
const decision = this.router.route(delivery.conversationId);
|
|
7911
|
+
if (decision === "drop") {
|
|
7912
|
+
logger.debug("PreToolUse dropped for conversation outside session", {
|
|
7913
|
+
conversationId: delivery.conversationId
|
|
7914
|
+
});
|
|
7915
|
+
return;
|
|
7916
|
+
}
|
|
7917
|
+
if (decision === "hold") {
|
|
7918
|
+
if (!this.heldHookDeliveries.has(delivery.conversationId) && this.heldHookDeliveries.size >= _McpServer.MAX_HELD_CONVERSATIONS) {
|
|
7919
|
+
const oldestConv = this.heldHookDeliveries.keys().next().value;
|
|
7920
|
+
if (oldestConv) {
|
|
7921
|
+
this.dropHeldHookDeliveries(oldestConv, "global-capacity-exceeded");
|
|
7922
|
+
}
|
|
7923
|
+
}
|
|
7924
|
+
let held = this.heldHookDeliveries.get(delivery.conversationId);
|
|
7925
|
+
if (!held) {
|
|
7926
|
+
held = [];
|
|
7927
|
+
this.heldHookDeliveries.set(delivery.conversationId, held);
|
|
7928
|
+
}
|
|
7929
|
+
if (held.length < 10) {
|
|
7930
|
+
const timer = setTimeout(() => {
|
|
7931
|
+
this.dropHeldHookDeliveries(delivery.conversationId, "hold-timeout");
|
|
7932
|
+
}, this.conversationHoldMs ?? 3e4);
|
|
7933
|
+
held.push({ delivery, receivedAt: Date.now(), timer });
|
|
7934
|
+
}
|
|
7935
|
+
this.registerHeldConversationBeforeFirstHook(delivery.conversationId);
|
|
7936
|
+
return;
|
|
7937
|
+
}
|
|
7938
|
+
const structuredQuestions = extractAllQuestionsFromArgs(delivery.toolName, delivery.toolArgs);
|
|
7939
|
+
const questionText = extractQuestionFromArgs(delivery.toolName, delivery.toolArgs);
|
|
7940
|
+
const questionOptions = extractQuestionOptionsFromArgs(delivery.toolName, delivery.toolArgs);
|
|
7941
|
+
logger.info("PreToolUse received ask_question hook", {
|
|
7942
|
+
conversationId: delivery.conversationId,
|
|
7943
|
+
questionText: questionText ? questionText.slice(0, 100) : null,
|
|
7944
|
+
optionCount: questionOptions ? questionOptions.length : 0,
|
|
7945
|
+
structuredQuestionsCount: structuredQuestions ? structuredQuestions.length : 0
|
|
7946
|
+
});
|
|
7947
|
+
const pendingCall = {
|
|
7948
|
+
intentStepIndex: delivery.stepIdx ?? -1,
|
|
7949
|
+
toolCallIndex: 0,
|
|
7950
|
+
intentByteOffset: 0,
|
|
7951
|
+
toolType: "ASK_QUESTION",
|
|
7952
|
+
conversationId: delivery.conversationId,
|
|
7953
|
+
rawToolName: delivery.toolName,
|
|
7954
|
+
questionText,
|
|
7955
|
+
questionOptions,
|
|
7956
|
+
structuredQuestions,
|
|
7957
|
+
startedAt: Date.now(),
|
|
7958
|
+
intentCreatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
7959
|
+
};
|
|
7960
|
+
this.approvalDetector.registerPendingCall(pendingCall);
|
|
7961
|
+
this.emitQuestionPromptFromHook(delivery.conversationId, pendingCall);
|
|
7962
|
+
}
|
|
7963
|
+
}
|
|
7964
|
+
emitQuestionPromptFromHook(conversationId, pendingCall) {
|
|
7965
|
+
const decision = this.router.route(conversationId);
|
|
7966
|
+
if (decision !== "process" && decision !== "legacy") {
|
|
7967
|
+
logger.debug("emitQuestionPromptFromHook skipped for non-admitted conversation", { conversationId, decision });
|
|
7968
|
+
return;
|
|
7969
|
+
}
|
|
7970
|
+
let session = this.getSessionByConversation(conversationId);
|
|
7971
|
+
if (!session && this.session) {
|
|
7972
|
+
if (decision === "process" || decision === "legacy" && this.legacyAcceptsConversation(conversationId)) {
|
|
7973
|
+
this.observedMainConversationIds.add(conversationId);
|
|
7974
|
+
session = this.session;
|
|
7975
|
+
}
|
|
7976
|
+
}
|
|
7977
|
+
if (!session) {
|
|
7978
|
+
logger.warn("PreToolUse ask_question hook fired but conversation not owned or no session known", {
|
|
7979
|
+
conversationId,
|
|
7980
|
+
decision
|
|
7981
|
+
});
|
|
7982
|
+
return;
|
|
7983
|
+
}
|
|
7984
|
+
const e1rec = this.newE1Raise(session.sessionId, false);
|
|
7985
|
+
const emitted = this.approvalDetector.emitHookQuestionPrompt(pendingCall, e1rec.promptId);
|
|
7986
|
+
if (!emitted) {
|
|
7987
|
+
this.e1PromptRaises.delete(e1rec.promptId);
|
|
7988
|
+
} else if (emitted.promptId !== e1rec.promptId) {
|
|
7989
|
+
this.e1PromptRaises.delete(e1rec.promptId);
|
|
7990
|
+
}
|
|
7200
7991
|
}
|
|
7201
7992
|
/**
|
|
7202
7993
|
* A tool our agy just ran was blocked on its approval prompt, so a pending
|
|
@@ -7206,8 +7997,14 @@ var McpServer = class _McpServer {
|
|
|
7206
7997
|
async resolvePromptsForCompletedTool(delivery) {
|
|
7207
7998
|
const session = this.session;
|
|
7208
7999
|
if (!session) return;
|
|
7209
|
-
const pending = this.approvalDetector.getPendingPromptsForConversation();
|
|
7210
|
-
const matched = pending.filter((state) =>
|
|
8000
|
+
const pending = this.approvalDetector.getPendingPromptsForConversation(delivery.conversationId);
|
|
8001
|
+
const matched = pending.filter((state) => {
|
|
8002
|
+
if (state.conversationId !== delivery.conversationId) return false;
|
|
8003
|
+
if (state.kind === "question") {
|
|
8004
|
+
return correlatesQuestionInvocation(state.pendingCall, delivery);
|
|
8005
|
+
}
|
|
8006
|
+
return promptMatchesCompletedTool(state, delivery.toolName, delivery.toolArgs);
|
|
8007
|
+
});
|
|
7211
8008
|
logger.info("PostToolUse checked pending prompts", {
|
|
7212
8009
|
conversationId: delivery.conversationId,
|
|
7213
8010
|
toolName: delivery.toolName ?? null,
|
|
@@ -8235,11 +9032,14 @@ var McpServer = class _McpServer {
|
|
|
8235
9032
|
const e1rec = this.newE1Raise(session.sessionId, isActionable);
|
|
8236
9033
|
const emitted = this.approvalDetector.emitPaneOnlyPrompt(parsed, activeConvId, e1rec.promptId);
|
|
8237
9034
|
if (emitted) {
|
|
9035
|
+
if (emitted.promptId !== e1rec.promptId) {
|
|
9036
|
+
this.e1PromptRaises.delete(e1rec.promptId);
|
|
9037
|
+
}
|
|
8238
9038
|
if (cand.snapshotHash) {
|
|
8239
|
-
this.e1ContentKeyIndex.set(`${session.sessionId}::${cand.snapshotHash}`,
|
|
9039
|
+
this.e1ContentKeyIndex.set(`${session.sessionId}::${cand.snapshotHash}`, emitted.promptId);
|
|
8240
9040
|
}
|
|
8241
9041
|
logger.info("Pane prompt identity", {
|
|
8242
|
-
promptId:
|
|
9042
|
+
promptId: emitted.promptId,
|
|
8243
9043
|
conversationId: activeConvId,
|
|
8244
9044
|
commandDigest: commandDigest(parsed.command)
|
|
8245
9045
|
});
|
|
@@ -8283,8 +9083,18 @@ var McpServer = class _McpServer {
|
|
|
8283
9083
|
}
|
|
8284
9084
|
const isDisplayOnly = Object.keys(state.submitMap || {}).length === 0;
|
|
8285
9085
|
const e1existing = this.e1PromptRaises.get(state.promptId);
|
|
8286
|
-
|
|
8287
|
-
if (
|
|
9086
|
+
let e1rec;
|
|
9087
|
+
if (e1existing) {
|
|
9088
|
+
e1rec = e1existing.record;
|
|
9089
|
+
if (!isDisplayOnly && !e1rec.mobileActionable) {
|
|
9090
|
+
e1rec.mobileActionable = true;
|
|
9091
|
+
}
|
|
9092
|
+
} else {
|
|
9093
|
+
e1rec = this.newE1Raise(session.sessionId, !isDisplayOnly);
|
|
9094
|
+
const oldPromptId = state.promptId;
|
|
9095
|
+
state.promptId = e1rec.promptId;
|
|
9096
|
+
this.approvalDetector.rekeyPrompt(oldPromptId, e1rec.promptId);
|
|
9097
|
+
}
|
|
8288
9098
|
const resolvedOptions = state.paneOptions && state.paneOptions.length > 0 ? state.paneOptions : state.pendingCall?.questionOptions && state.pendingCall.questionOptions.length > 0 ? state.pendingCall.questionOptions : void 0;
|
|
8289
9099
|
const optionsArr = isDisplayOnly ? [] : resolvedOptions ? resolvedOptions.map((o) => ({ number: o.number, text: o.text })) : Object.keys(state.submitMap).map((n) => ({ number: n }));
|
|
8290
9100
|
const diffParsed = parseFencedDiffFromBody(
|
|
@@ -8317,12 +9127,29 @@ var McpServer = class _McpServer {
|
|
|
8317
9127
|
file_path: filePath,
|
|
8318
9128
|
options: optionsArr,
|
|
8319
9129
|
...toolInput ? { tool_input: toolInput } : {},
|
|
8320
|
-
...state.kind === "question"
|
|
8321
|
-
questions:
|
|
8322
|
-
|
|
8323
|
-
|
|
8324
|
-
|
|
8325
|
-
|
|
9130
|
+
...state.kind === "question" ? {
|
|
9131
|
+
questions: (() => {
|
|
9132
|
+
if (!state.pendingCall?.structuredQuestions || state.pendingCall.structuredQuestions.length === 0) {
|
|
9133
|
+
return resolvedOptions && resolvedOptions.length > 0 ? [{
|
|
9134
|
+
question: state.pendingCall?.questionText || state.matchedPaneHeader || state.paneDisplayHeader || "Question",
|
|
9135
|
+
options: resolvedOptions,
|
|
9136
|
+
...isDisplayOnly ? { instructions: "Answer this question in your desktop terminal" } : {}
|
|
9137
|
+
}] : void 0;
|
|
9138
|
+
}
|
|
9139
|
+
const activeQuestionText = state.matchedPaneHeader || state.paneDisplayHeader || state.pendingCall?.questionText || "";
|
|
9140
|
+
let activeIdx = state.wizardStepIndex !== void 0 ? state.wizardStepIndex : state.pendingCall.structuredQuestions.findIndex((sq) => questionsMatch(sq.question, activeQuestionText));
|
|
9141
|
+
if (activeIdx < 0 || activeIdx >= state.pendingCall.structuredQuestions.length) activeIdx = 0;
|
|
9142
|
+
const activeQ = state.pendingCall.structuredQuestions[activeIdx] ?? {
|
|
9143
|
+
question: activeQuestionText || "Question",
|
|
9144
|
+
options: resolvedOptions || []
|
|
9145
|
+
};
|
|
9146
|
+
return [{
|
|
9147
|
+
question: activeQ.question,
|
|
9148
|
+
options: resolvedOptions && resolvedOptions.length > 0 ? resolvedOptions : activeQ.options,
|
|
9149
|
+
...activeQ.isMultiSelect !== void 0 ? { is_multi_select: activeQ.isMultiSelect } : {},
|
|
9150
|
+
...isDisplayOnly ? { instructions: "Answer this question in your desktop terminal" } : {}
|
|
9151
|
+
}];
|
|
9152
|
+
})()
|
|
8326
9153
|
} : {}
|
|
8327
9154
|
} : { promptId: state.promptId };
|
|
8328
9155
|
const content = state.paneDisplayHeader || state.matchedPaneHeader || state.pendingCall?.command || state.pendingCall?.filePath || "approval requested";
|