@quantiya/codevibe-antigravity-plugin 2.0.84 → 2.0.86

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/dist/server.js CHANGED
@@ -32,6 +32,7 @@ var server_exports = {};
32
32
  __export(server_exports, {
33
33
  ApprovalDetector: () => ApprovalDetector,
34
34
  McpServer: () => McpServer,
35
+ PromptResponder: () => PromptResponder,
35
36
  SessionNotFoundError: () => SessionNotFoundError,
36
37
  StoredAuthFailureError: () => StoredAuthFailureError,
37
38
  TERMINAL_SHUTDOWN_SIGNALS: () => TERMINAL_SHUTDOWN_SIGNALS,
@@ -88,19 +89,19 @@ var os2 = __toESM(require("os"));
88
89
  var path2 = __toESM(require("path"));
89
90
  var HOOK_ENTRY_NAME = "codevibe-antigravity";
90
91
  var HOOK_ROUTE_PREFIX = "/agy-hook/v1/";
91
- var HOOK_EVENTS = ["PreInvocation", "PostToolUse"];
92
+ var HOOK_EVENTS = ["PreInvocation", "PostToolUse", "PreToolUse", "Stop"];
92
93
  var HOOK_SHELL_BASENAME = "agy-hook.sh";
93
94
  var HOOK_SCRIPT_BASENAME = "agy-hook.js";
94
95
  function isHookEvent(value) {
95
96
  return typeof value === "string" && HOOK_EVENTS.includes(value);
96
97
  }
97
- function codevibeAgyDir(home = os2.homedir()) {
98
+ function codevibeAgyDir(home = process.env.HOME || os2.homedir()) {
98
99
  return path2.join(home, ".codevibe", "codevibe-agy");
99
100
  }
100
- function hooksDir(home = os2.homedir()) {
101
+ function hooksDir(home = process.env.HOME || os2.homedir()) {
101
102
  return path2.join(codevibeAgyDir(home), "hooks");
102
103
  }
103
- function globalHooksConfigPath(home = os2.homedir()) {
104
+ function globalHooksConfigPath(home = process.env.HOME || os2.homedir()) {
104
105
  return path2.join(home, ".gemini", "config", "hooks.json");
105
106
  }
106
107
 
@@ -1987,7 +1988,7 @@ var ConversationRouter = class {
1987
1988
  dropped: entry.overflowed
1988
1989
  });
1989
1990
  }
1990
- if (decision) this.options.onRelease(entry.items, decision);
1991
+ if (decision) this.options.onRelease(entry.items, decision, conversationId);
1991
1992
  }
1992
1993
  expire(conversationId) {
1993
1994
  const entry = this.held.get(conversationId);
@@ -2019,7 +2020,7 @@ var ConversationRouter = class {
2019
2020
  entry.items = entry.items.slice(tailStart);
2020
2021
  if (earlier.length > 0) {
2021
2022
  if (this.roles.get(conversationId) === "main" && !this.movedElsewhere(conversationId) && !entry.moved) {
2022
- this.options.onRelease(earlier, "process");
2023
+ this.options.onRelease(earlier, "process", conversationId);
2023
2024
  } else {
2024
2025
  this.options.onLog?.("Dropping agy events held from an earlier turn of another session", {
2025
2026
  conversationId,
@@ -2224,6 +2225,14 @@ function buildHookEntry(hooksFolder) {
2224
2225
  PreInvocation: [
2225
2226
  { type: "command", command: `${shell} PreInvocation`, timeout: HOOK_TIMEOUT_SECONDS }
2226
2227
  ],
2228
+ PreToolUse: [
2229
+ {
2230
+ matcher: "^(?:ask_question|question|ask)$",
2231
+ hooks: [
2232
+ { type: "command", command: `${shell} PreToolUse`, timeout: HOOK_TIMEOUT_SECONDS }
2233
+ ]
2234
+ }
2235
+ ],
2227
2236
  PostToolUse: [
2228
2237
  {
2229
2238
  matcher: "*",
@@ -2231,6 +2240,9 @@ function buildHookEntry(hooksFolder) {
2231
2240
  { type: "command", command: `${shell} PostToolUse`, timeout: HOOK_TIMEOUT_SECONDS }
2232
2241
  ]
2233
2242
  }
2243
+ ],
2244
+ Stop: [
2245
+ { type: "command", command: `${shell} Stop`, timeout: HOOK_TIMEOUT_SECONDS }
2234
2246
  ]
2235
2247
  };
2236
2248
  }
@@ -2913,6 +2925,7 @@ function isComposerBoxAt(lines, idx) {
2913
2925
  }
2914
2926
  return false;
2915
2927
  }
2928
+ var ANSI_ESCAPE_REGEX = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
2916
2929
  function isPaneInputSafetyBlocked(input) {
2917
2930
  const rawText = Array.isArray(input) ? input.join("\n") : input;
2918
2931
  if (!rawText) return { blocked: false, kind: null };
@@ -3082,9 +3095,10 @@ function isPaneInputSafetyBlocked(input) {
3082
3095
  return { blocked: false, kind: null };
3083
3096
  }
3084
3097
  function looksLikePromptDelta(chunk) {
3098
+ const text = chunk.includes("\x1B") ? chunk.replace(ANSI_ESCAPE_REGEX, "") : chunk;
3085
3099
  return (
3086
3100
  // Approval UI signatures
3087
- /Requesting permission for:/i.test(chunk) || /Do you want to proceed\?/i.test(chunk) || /\besc to cancel\b/i.test(chunk) || /^\s*[1-9]\. (?:Yes|No)\b/im.test(chunk) || /\[(?:y\/n|Y\/n|y\/N)\]/.test(chunk) || /^\s*(?:[❯>]\s*)?\?\s+(?!for\s+(?:shortcuts|help|commands))\S/im.test(chunk) || /Question\b(?:\s+\d+(?:[\/:of\s]+\d+)?)?:/i.test(chunk) || /\benter\s+(?:to\s+)?select\b/i.test(chunk) || /\b(?:navigate|use\s+arrow\s+keys)\b/i.test(chunk) || /^File access\s*$/im.test(chunk) || /^\s*Allow access to this file\?\s*$/im.test(chunk) || /(?:Yes, allow access|No, deny access)/i.test(chunk) || /(?:↑\/↓\s+Navigate|ctrl\+r\s+Review)/i.test(chunk) || /Our servers are experiencing/i.test(chunk) || /Error ID:\s*[0-9a-fA-F-]*/i.test(chunk) || /(?:⚠️|⚠\uFE0F?)\s+.*(?:high[- ]traffic|try again in a minute|try again later|stream was interrupted|stream interrupted|overloaded|unexpected error|quota\b|upgrade your subscription|increase your limits|resets in\s+\d|credits balance is too low|out of credits|rate[- ]limit|capacity exhausted|exhausted your quota|internal error|currently unreachable|network issue connecting|authentication expired|authentication issue|terminated due to error|invalid output|invalid tool call)/i.test(chunk)
3101
+ /Requesting permission for:/i.test(chunk) || /Do you want to proceed\?/i.test(chunk) || /\besc to cancel\b/i.test(text) || /^\s*[1-9]\. (?:Yes|No)\b/im.test(text) || /\[(?:y\/n|Y\/n|y\/N)\]/.test(chunk) || /^\s*(?:[❯>]\s*)?\?\s+(?!for\s+(?:shortcuts|help|commands))\S/im.test(text) || /Question\b(?:\s+\d+(?:[\/:of\s]+\d+)?)?:/i.test(text) || /\benter\s+(?:to\s+)?(?:select|submit(?:\s+all)?|next)\b/i.test(text) || /\b(?:navigate|use\s+arrow\s+keys)\b/i.test(text) || /\b\d+[\/:of\s]+\d+:\s+\S/i.test(text) || /\bSubmit(?:\s+All)?\b/i.test(text) || /(?:^|[\s·|│])(?:←|<-)\s*Back\b/i.test(text) || /^File access\s*$/im.test(text) || /^\s*Allow access to this file\?\s*$/im.test(text) || /(?:Yes, allow access|No, deny access)/i.test(text) || /(?:↑\/↓\s+Navigate|ctrl\+r\s+Review)/i.test(text) || /Our servers are experiencing/i.test(text) || /Error ID:\s*[0-9a-fA-F-]*/i.test(text) || /(?:⚠️|⚠\uFE0F?)\s+.*(?:high[- ]traffic|try again in a minute|try again later|stream was interrupted|stream interrupted|overloaded|unexpected error|quota\b|upgrade your subscription|increase your limits|resets in\s+\d|credits balance is too low|out of credits|rate[- ]limit|capacity exhausted|exhausted your quota|internal error|currently unreachable|network issue connecting|authentication expired|authentication issue|terminated due to error|invalid output|invalid tool call)/i.test(text)
3088
3102
  );
3089
3103
  }
3090
3104
  function isNonSystemErrorWarningLine(trimmed) {
@@ -3159,7 +3173,7 @@ function isAgyStatusFooterLine(trimmed) {
3159
3173
  if (/^\s*(?:[❯>]\s*)?\?\s+for shortcuts\b/i.test(trimmed)) {
3160
3174
  return true;
3161
3175
  }
3162
- if (/^\s*esc to cancel\b/i.test(trimmed)) {
3176
+ if (/^\s*esc to cancel/i.test(trimmed)) {
3163
3177
  return true;
3164
3178
  }
3165
3179
  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 +3272,7 @@ function reconstructFooter(rawLines) {
3258
3272
  }
3259
3273
  const hasMiddleDot = cleaned.some((l) => l.includes("\xB7"));
3260
3274
  const hasFooterKeyword = cleaned.some(
3261
- (l) => /^\s*(?:\?\s+for shortcuts|esc to cancel)\b/i.test(l) || /\b(?:auto|accept-edits|plan|Gemini|Claude|GPT|Gemma|high|low|subagent|quota|credits)\b/i.test(l)
3275
+ (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
3276
  );
3263
3277
  if (!hasMiddleDot && !hasFooterKeyword) {
3264
3278
  return cleaned.join(" ").replace(/\s*·\s*/g, " \xB7 ").trim();
@@ -3346,7 +3360,7 @@ function isSystemErrorActiveFromIndex(lines, errorIdx) {
3346
3360
  }
3347
3361
  if (remainingRaw.length <= 4) {
3348
3362
  const hasFooterMarkers = remainingRaw.some(
3349
- (l) => l.includes("\xB7") || /^\s*(?:\?\s+for shortcuts|esc to cancel)\b/i.test(l.trim()) || /\b(?:auto|accept-edits|plan|Gemini|Claude|GPT|Gemma|high|low|subagent|quota|credits)\b/i.test(l)
3363
+ (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
3364
  );
3351
3365
  if (hasFooterMarkers) {
3352
3366
  const reconstructed = reconstructFooter(remainingRaw);
@@ -3382,7 +3396,7 @@ function isLegacyYNActiveInLines(lines) {
3382
3396
  const trimmed = lines[j].trim();
3383
3397
  if (!trimmed) continue;
3384
3398
  if (/^[─━│╭╰╮╯\s_-]*$/.test(trimmed)) continue;
3385
- if (/^\s*(?:\?\s+for shortcuts|esc to cancel)\b/i.test(trimmed)) continue;
3399
+ if (/^\s*(?:\?\s+for shortcuts|esc to cancel)/i.test(trimmed)) continue;
3386
3400
  return { active: false, lineIdx: -1 };
3387
3401
  }
3388
3402
  return { active: true, lineIdx: ynIdx };
@@ -3513,7 +3527,7 @@ function extractFileAccessPrompt(snapshot) {
3513
3527
  headingIndex = index;
3514
3528
  break;
3515
3529
  }
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)\b/i.test(line) || /^\s*(?:[↑↓/\s]*Navigate\b|ctrl\+r\s+Review\b)/i.test(line)) {
3530
+ 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
3531
  break;
3518
3532
  }
3519
3533
  break;
@@ -4205,7 +4219,7 @@ function isQuestionFooterLine(line) {
4205
4219
  }
4206
4220
  if (/^[>\s❯]*[1-9][.)]\s+\S/.test(trimmed)) return false;
4207
4221
  if (isQuestionHeaderLine(trimmed)) return false;
4208
- return /^(?:esc|<esc>)\s+(?:to\s+)?(?:cancel|skip)(?:\s*[·│,]\s*(?:enter|<enter>)\s+(?:to\s+)?select)?(?:\s*[·│,]\s*ctrl\+[a-z0-9]+\s+[a-zA-Z]+)*$/i.test(trimmed) || /^(?:enter|<enter>)\s+(?:to\s+)?select(?:\s*[·│,]\s*(?:esc|<esc>)\s+(?:to\s+)?(?:cancel|skip))?(?:\s*[·│,]\s*ctrl\+[a-z0-9]+\s+[a-zA-Z]+)*$/i.test(trimmed) || /^(?:↑\/↓|↑|↓|\^|v)\s*(?:\/|\s+)?Navigate(?:\s*[·│,]\s*(?:enter|<enter>)?\s*(?:Select|to select))(?:\s*[·│,]\s*(?:esc|<esc>)?\s*(?:Skip|Cancel))?(?:\s*[·│,]\s*ctrl\+[a-z0-9]+\s+[a-zA-Z]+)*$/i.test(trimmed) || /^(?:↑\/↓|↑|↓|\^|v)\s*(?:\/|\s+)?Navigate(?:\s*[·│,]\s*(?:esc|<esc>)?\s*(?:Skip|Cancel))?$/i.test(trimmed) || /^press\s+<?(?:enter|return|space)>?\s+to\s+select$/i.test(trimmed) || /^space\s+to\s+select$/i.test(trimmed) || /^(?:\(?use\s+arrow\s+keys(?:\s+to\s+navigate)?\)?)$/i.test(trimmed) || /^(?:[\u2800-\u28FF]+(?:\s*waiting(?:\.\.\.)?)?|waiting(?:\.\.\.)?)$/i.test(trimmed);
4222
+ return /^(?:esc|<esc>)\s+(?:to\s+)?(?:cancel|skip)(?:\s*[·│,]\s*(?:enter|<enter>)\s+(?:to\s+)?(?:select|submit(?:\s+all)?))?(?:\s*[·│,]\s*ctrl\+[a-z0-9]+\s+[a-zA-Z]+)*$/i.test(trimmed) || /^(?:enter|<enter>)\s+(?:to\s+)?(?:select|submit(?:\s+all)?)(?:\s*[·│,]\s*(?:esc|<esc>)\s+(?:to\s+)?(?:cancel|skip|back))?(?:\s*[·│,]\s*ctrl\+[a-z0-9]+\s+[a-zA-Z]+)*$/i.test(trimmed) || /^(?:space|<space>)\s+(?:to\s+)?(?:select|toggle)(?:\s*[·│,]\s*(?:enter|<enter>)\s+(?:to\s+)?(?:select|submit(?:\s+all)?))?(?:\s*[·│,]\s*(?:esc|<esc>)\s+(?:to\s+)?(?:cancel|skip|back))?$/i.test(trimmed) || /^(?:↑\/↓|↑|↓|\^|v)\s*(?:\/|\s+)?Navigate(?:\s*[·│,]\s*(?:(?:→|->)\s*Next|(?:←|<-)\s*Back))*(?:\s*[·│,]\s*(?:enter|<enter>)?\s*(?:Select|Submit(?:\s+All)?|to select))(?:\s*[·│,]\s*(?:esc|<esc>)?\s*(?:Skip|Cancel|Back))?(?:\s*[·│,]\s*(?:(?:→|->)\s*Next|(?:←|<-)\s*Back))*(?:\s*[·│,]\s*ctrl\+[a-z0-9]+\s+[a-zA-Z]+)*$/i.test(trimmed) || /^(?:↑\/↓|↑|↓|\^|v)\s*(?:\/|\s+)?Navigate(?:\s*[·│,]\s*(?:esc|<esc>)?\s*(?:Skip|Cancel|Back))?$/i.test(trimmed) || /^(?:←|<-)\s*Back\s*[·│,]\s*(?:enter|<enter>)\s+(?:to\s+)?(?:select|submit(?:\s+all)?)(?:\s*[·│,]\s*(?:esc|<esc>)\s+(?:to\s+)?(?:cancel|skip))?$/i.test(trimmed) || /^press\s+<?(?:enter|return|space)>?\s+to\s+select$/i.test(trimmed) || /^space\s+to\s+select$/i.test(trimmed) || /^(?:\(?use\s+arrow\s+keys(?:\s+to\s+navigate)?\)?)$/i.test(trimmed) || /^(?:[\u2800-\u28FF]+(?:\s*waiting(?:\.\.\.)?)?|waiting(?:\.\.\.)?)$/i.test(trimmed);
4209
4223
  }
4210
4224
  function extractQuestionHeader(snapshot) {
4211
4225
  const stripped = snapshot.replace(ANSI_ESCAPE_REGEX, "");
@@ -4231,7 +4245,6 @@ function hashPromptSnapshot(snapshot) {
4231
4245
  }
4232
4246
  return (0, import_crypto.createHash)("sha256").update(normalized).digest("hex");
4233
4247
  }
4234
- var ANSI_ESCAPE_REGEX = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
4235
4248
  function escapeShellArg(value) {
4236
4249
  return value.replace(/'/g, `'\\''`);
4237
4250
  }
@@ -4247,7 +4260,7 @@ function isQuestionUISnapshot(snapshot) {
4247
4260
 
4248
4261
  // src/approval-detector.ts
4249
4262
  var import_events3 = require("events");
4250
- var import_uuid = require("uuid");
4263
+ var import_uuid2 = require("uuid");
4251
4264
 
4252
4265
  // src/prompt-parser.ts
4253
4266
  var import_crypto2 = require("crypto");
@@ -4276,17 +4289,17 @@ function parseApprovalSnapshot(snapshot) {
4276
4289
  return parseApprovalUISnapshot(stripped, snapshot);
4277
4290
  }
4278
4291
  function buildApprovalSemanticKey(candidate) {
4279
- const normalize = (value) => value.replace(/\s+/g, " ").trim();
4292
+ const normalize = (value) => (value ?? "").replace(/\s+/g, " ").trim();
4280
4293
  const payload = {
4281
4294
  kind: candidate.kind,
4282
4295
  headerText: normalize(candidate.headerText),
4283
4296
  body: normalize(candidate.body),
4284
- options: candidate.options.map((option) => ({
4297
+ options: (candidate.options ?? []).map((option) => ({
4285
4298
  number: option.number,
4286
4299
  text: normalize(option.text)
4287
4300
  })),
4288
4301
  submitMap: Object.fromEntries(
4289
- Object.entries(candidate.submitMap).sort(([left], [right]) => Number(left) - Number(right)).map(([number, keys]) => [number, [...keys]])
4302
+ Object.entries(candidate.submitMap ?? {}).sort(([left], [right]) => Number(left) - Number(right)).map(([number, keys]) => [number, [...keys]])
4290
4303
  )
4291
4304
  };
4292
4305
  return (0, import_crypto2.createHash)("sha256").update(JSON.stringify(payload)).digest("hex");
@@ -4376,33 +4389,8 @@ function parseQuestionSnapshot(stripped, originalSnapshot, header) {
4376
4389
  const options = parseOptions(belowHeader);
4377
4390
  if (options.length < 2) return null;
4378
4391
  const normalizedHeader = header.fullLine.replace(/^[❯>\s*•-]+\s*/, "").trim();
4379
- const headerHasArrowHint = /\((?:Use\s+arrow\s+keys|Use\s+\S+\s+keys)[^)]*\)/i.test(normalizedHeader);
4380
- let footerHasArrowHint = false;
4381
- let footerHasCtrlRReview = false;
4382
- const startLine = headerIdx >= 0 ? headerIdx + 1 : 0;
4383
- let hasSeenOptions = false;
4384
- for (let i = startLine; i < lines.length; i++) {
4385
- if (lineInFence[i]) continue;
4386
- const trimmed = lines[i].trim();
4387
- if (!trimmed) continue;
4388
- if (hasSeenOptions && isResumedTurnSignal(trimmed)) break;
4389
- if (/^[>\s❯]*[1-9][.)]\s+\S/.test(trimmed)) {
4390
- hasSeenOptions = true;
4391
- }
4392
- const isFooter = isQuestionFooterLine(trimmed) || /^(?:ctrl\+[a-z0-9]+\s+[a-zA-Z]+|\(?use\s+arrow\s+keys(?:\s+to\s+navigate)?\)?)$/i.test(trimmed);
4393
- if (isFooter) {
4394
- if (/(?:↑\/↓|↑|↓|\^|v)\s*(?:\/|\s+)?Navigate\b/i.test(trimmed) || /\(?use\s+arrow\s+keys(?:\s+to\s+navigate)?\)?/i.test(trimmed)) {
4395
- footerHasArrowHint = true;
4396
- }
4397
- if (/ctrl\+r\s+Review\b/i.test(trimmed)) {
4398
- footerHasCtrlRReview = true;
4399
- }
4400
- }
4401
- }
4402
- const hasArrowKeyHint = headerHasArrowHint || footerHasArrowHint;
4403
- const hasWriteInOption = options.some((opt) => /^\s*write-?in\b/i.test(opt.text));
4404
- const hasCtrlRReviewFooter = footerHasCtrlRReview;
4405
- const isBaseNumberedWizard = /^Question\s+\d+\/\d+:/i.test(normalizedHeader) && !hasArrowKeyHint && !hasWriteInOption && !hasCtrlRReviewFooter;
4392
+ const isMultiSelect = /\bselect\s+(?:all|multiple)\b/i.test(normalizedHeader) || /\bselect\s+(?:all|multiple)\b/i.test(belowHeader) || /(?:<space>|\bspace)\s+(?:to\s+)?(?:select|toggle)\b/i.test(belowHeader) || /\bctrl\+a\s+select\s+all\b/i.test(belowHeader);
4393
+ const isBaseNumberedWizard = /^Question\s+\d+\/\d+:/i.test(normalizedHeader) && !isMultiSelect;
4406
4394
  const submitMap = isBaseNumberedWizard ? buildQuestionSubmitMap(options) : {};
4407
4395
  const body = extractBodyBetweenHeaderAndFirstOption(belowHeader);
4408
4396
  return {
@@ -4465,12 +4453,47 @@ function buildApprovalSubmitMap(options) {
4465
4453
  function buildQuestionSubmitMap(options) {
4466
4454
  const map = {};
4467
4455
  for (const opt of options) {
4468
- if (!/^\s*write-?in\b/i.test(opt.text)) {
4469
- map[opt.number] = [opt.number, "Enter"];
4470
- }
4456
+ map[opt.number] = [opt.number];
4471
4457
  }
4472
4458
  return map;
4473
4459
  }
4460
+ function isWriteInInputActive(paneText, expectedHeader, expectedStep) {
4461
+ if (!paneText) return false;
4462
+ if (detectActivePromptKind(paneText) !== "question") return false;
4463
+ const stripped = paneText.replace(ANSI_ESCAPE_REGEX2, "");
4464
+ const lines = stripped.trimEnd().split("\n");
4465
+ const windowStart = Math.max(0, lines.length - 30);
4466
+ let headerIdx = -1;
4467
+ for (let i = lines.length - 1; i >= windowStart; i--) {
4468
+ if (isQuestionHeaderLine(lines[i])) {
4469
+ headerIdx = i;
4470
+ break;
4471
+ }
4472
+ }
4473
+ if (headerIdx < 0) return false;
4474
+ const rawHeader = lines[headerIdx];
4475
+ const extracted = extractQuestionHeader(rawHeader);
4476
+ if (!extracted) return false;
4477
+ if (expectedHeader) {
4478
+ const expectedExtracted = extractQuestionHeader(expectedHeader);
4479
+ const expectedBody = (expectedExtracted ? expectedExtracted.body : expectedHeader).replace(/^[❯>\s*•-]+\s*/, "").trim().toLowerCase();
4480
+ const paneBody = extracted.body.replace(/^[❯>\s*•-]+\s*/, "").trim().toLowerCase();
4481
+ if (paneBody !== expectedBody) {
4482
+ return false;
4483
+ }
4484
+ }
4485
+ const expectedStepVal = expectedStep || (expectedHeader ? expectedHeader.match(/\bQuestion\s+(\d+(?:[\/:of\s]+\d+)?):/i)?.[1] : void 0);
4486
+ if (expectedStepVal) {
4487
+ const stepMatch = extracted.fullLine.match(/\bQuestion\s+(\d+(?:[\/:of\s]+\d+)?):/i);
4488
+ const paneStep = stepMatch ? stepMatch[1] : null;
4489
+ if (!paneStep || paneStep !== expectedStepVal) {
4490
+ return false;
4491
+ }
4492
+ }
4493
+ const searchLines = lines.slice(headerIdx);
4494
+ const text = searchLines.join("\n");
4495
+ return /Your answer:/i.test(text) && /enter\s+Submit(?:\s+All)?\s*[·│,]\s*esc\s+Back/i.test(text);
4496
+ }
4474
4497
  function extractIdentity(headerText) {
4475
4498
  if (!headerText) return {};
4476
4499
  const trimmed = headerText.trim();
@@ -4561,1428 +4584,2096 @@ function stripAnsi(s) {
4561
4584
  return s.replace(ANSI_ESCAPE_REGEX2, "");
4562
4585
  }
4563
4586
 
4564
- // src/approval-detector.ts
4565
- var DEFAULT_PROMPT_TTL_MS = 6e4;
4566
- var PENDING_CALL_TTL_MS = 5 * 6e4;
4567
- var SWEEP_INTERVAL_MS = 3e4;
4568
- var PASS3_MIN_IDENTITY_LEN = 4;
4569
- var PENDING_CANDIDATE_BUFFER_MS = 5e3;
4570
- var ApprovalDetector = class extends import_events3.EventEmitter {
4571
- constructor(options = {}) {
4572
- super();
4573
- this.pendingCalls = /* @__PURE__ */ new Map();
4574
- this.pendingPrompts = /* @__PURE__ */ new Map();
4575
- /** Track recently-resolved promptIds so a duplicate candidate-emit
4576
- * doesn't re-fire INTERACTIVE_PROMPT. Maps promptId → ms-epoch when
4577
- * resolved. Pruned by sweep. */
4578
- this.resolvedPrompts = /* @__PURE__ */ new Map();
4579
- /** Pre-transcript candidate buffer keyed by `${conversationId}|${paneHash}`
4580
- * (composite key prevents collision when two conversations on the same
4581
- * tmux pane produce identical paneHashes — agy R2 Stage 2 round-3 MED).
4582
- * When a candidate arrives with no matching pending call, we park it
4583
- * here for pendingCandidateBufferMs and re-try the match each time
4584
- * registerPendingCall fires. */
4585
- this.pendingCandidates = /* @__PURE__ */ new Map();
4586
- this.sweepTimer = null;
4587
- // ─── Pane-only prompt emission (v9.1 — bypasses pending-call match) ─────
4588
- /** Set of pane-snapshot hashes we've already emitted INTERACTIVE_PROMPT
4589
- * for. Belt-and-suspenders dedupe against the case where paneObserver
4590
- * somehow re-fires `prompt-candidate` with the same snapshot bytes
4591
- * for the same prompt. The primary dedupe for cosmetic redraws of a
4592
- * single approval (spinner ticks, cursor blink, partial repaints)
4593
- * lives in tmux-pane-observer's debounce window — by the time a
4594
- * candidate reaches this layer, the pane has settled. */
4595
- this.paneOnlyEmittedHashes = /* @__PURE__ */ new Set();
4596
- this.promptTtlMs = options.promptTtlMs ?? DEFAULT_PROMPT_TTL_MS;
4597
- this.pendingCallTtlMs = options.pendingCallTtlMs ?? PENDING_CALL_TTL_MS;
4598
- this.sweepIntervalMs = options.sweepIntervalMs ?? SWEEP_INTERVAL_MS;
4599
- this.pendingCandidateBufferMs = options.pendingCandidateBufferMs ?? PENDING_CANDIDATE_BUFFER_MS;
4600
- }
4601
- start() {
4602
- if (this.sweepTimer) return;
4603
- this.sweepTimer = setInterval(() => this.sweep(), this.sweepIntervalMs);
4604
- if (this.sweepTimer.unref) this.sweepTimer.unref();
4605
- logger.info("Approval detector started", {
4606
- promptTtlMs: this.promptTtlMs,
4607
- pendingCallTtlMs: this.pendingCallTtlMs
4608
- });
4609
- }
4610
- async stop() {
4611
- if (this.sweepTimer) {
4612
- clearInterval(this.sweepTimer);
4613
- this.sweepTimer = null;
4614
- }
4615
- this.pendingCalls.clear();
4616
- this.pendingPrompts.clear();
4617
- this.resolvedPrompts.clear();
4618
- this.pendingCandidates.clear();
4619
- this.paneOnlyEmittedHashes.clear();
4620
- logger.info("Approval detector stopped");
4621
- }
4622
- /**
4623
- * Emit INTERACTIVE_PROMPT directly from a pane snapshot, WITHOUT
4624
- * requiring a matching PendingToolCall from a transcript PLANNER_RESPONSE.
4625
- *
4626
- * Background (v9.1, 2026-05-21): agy does NOT write tool_call entries
4627
- * to the transcript BEFORE rendering the approval UI for shell
4628
- * commands. It only writes them AFTER the user approves. So the
4629
- * transcript-based pending-call matching path deadlocks for shell
4630
- * commands: paneObserver sees the UI, but no pending call exists →
4631
- * candidate buffered → user can't approve from mobile (no prompt
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)
4587
+ // src/event-mapper.ts
4588
+ var import_uuid = require("uuid");
4589
+ var import_codevibe_core5 = require("@quantiya/codevibe-core");
4590
+ var CONTENT_MAX = 150 * 1024;
4591
+ var TOOL_OUTPUT_MAX = 50 * 1024;
4592
+ var TOOL_DIFF_MAX = 100 * 1024;
4593
+ function mapTranscriptEvent(emit, sessionId, notBeforeMs) {
4594
+ const { event } = emit;
4595
+ const base = {
4596
+ sessionId,
4597
+ source: import_codevibe_core5.EventSource.DESKTOP
4598
+ };
4599
+ const orderingKey = `${sessionId}:${emit.conversationId}`;
4600
+ const nextTimestamp = () => (0, import_codevibe_core5.prepareEventTimestamp)({ orderingKey, agentClock: event.created_at, notBeforeMs });
4601
+ const baseMetadata = {
4602
+ step_index: event.step_index,
4603
+ agy_source: event.source,
4604
+ agy_status: event.status,
4605
+ agy_created_at: event.created_at,
4606
+ agy_type: event.type,
4607
+ agy_conversation_id: emit.conversationId
4608
+ };
4609
+ if (event.source === "SYSTEM") {
4610
+ if (event.type === "CONVERSATION_HISTORY" || event.type === "SYSTEM_MESSAGE") {
4611
+ logger.debug("Dropping internal transcript event", {
4612
+ type: event.type,
4613
+ stepIndex: event.step_index
4649
4614
  });
4650
- return null;
4615
+ return { events: [], pendingCallRegistrations: [] };
4651
4616
  }
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;
4617
+ if (event.type !== "ERROR_MESSAGE") {
4618
+ logger.warn("Dropping unknown SYSTEM-source transcript event (safety)", {
4619
+ type: event.type,
4620
+ stepIndex: event.step_index
4621
+ });
4622
+ return { events: [], pendingCallRegistrations: [] };
4656
4623
  }
4657
- const syntheticCall = {
4658
- conversationId,
4659
- intentStepIndex: matchingCall?.intentStepIndex ?? -1,
4660
- // sentinel: "no transcript origin"
4661
- toolCallIndex: matchingCall?.toolCallIndex ?? 0,
4662
- intentByteOffset: matchingCall?.intentByteOffset ?? 0,
4663
- // Best-effort tool-type inference from the candidate. For shell
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
4624
+ }
4625
+ if (event.type === "CONVERSATION_HISTORY" || event.type === "SYSTEM_MESSAGE") {
4626
+ logger.debug("Dropping internal transcript event (non-SYSTEM source)", {
4627
+ type: event.type,
4628
+ source: event.source,
4629
+ stepIndex: event.step_index
4704
4630
  });
4705
- this.emit("pending-prompt", state);
4706
- return state;
4631
+ return { events: [], pendingCallRegistrations: [] };
4707
4632
  }
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
4633
+ if (event.type === "USER_INPUT") {
4634
+ const cleaned = stripUserRequestWrapper(event.content || "");
4635
+ return {
4636
+ events: [
4637
+ {
4638
+ ...base,
4639
+ timestamp: nextTimestamp(),
4640
+ type: import_codevibe_core5.EventType.USER_PROMPT,
4641
+ content: truncate(cleaned, CONTENT_MAX),
4642
+ metadata: { ...baseMetadata }
4643
+ }
4644
+ ],
4645
+ pendingCallRegistrations: []
4646
+ };
4647
+ }
4648
+ if (event.type === "PLANNER_RESPONSE") {
4649
+ const events = [];
4650
+ const pendingCallRegistrations = [];
4651
+ if (typeof event.thinking === "string" && event.thinking.trim().length > 0) {
4652
+ events.push({
4653
+ ...base,
4654
+ timestamp: nextTimestamp(),
4655
+ type: import_codevibe_core5.EventType.REASONING,
4656
+ content: truncate(event.thinking, CONTENT_MAX),
4657
+ metadata: { ...baseMetadata }
4721
4658
  });
4722
- return;
4723
4659
  }
4724
- this.pendingCalls.set(key, call);
4725
- logger.debug("Pending tool call registered", {
4726
- key,
4727
- toolType: call.toolType,
4728
- command: call.command,
4729
- filePath: call.filePath
4730
- });
4731
- this.drainBufferedCandidates(call.conversationId);
4732
- }
4733
- drainBufferedCandidates(conversationId) {
4734
- if (this.pendingCandidates.size === 0) return;
4735
- const now = Date.now();
4736
- for (const [key, buffered] of this.pendingCandidates.entries()) {
4737
- if (buffered.conversationId !== conversationId) continue;
4738
- if (now - buffered.queuedAt > this.pendingCandidateBufferMs) {
4739
- this.pendingCandidates.delete(key);
4740
- continue;
4741
- }
4742
- const state = this.matchCandidateInternal(buffered.candidate, buffered.conversationId);
4743
- if (state) {
4744
- this.pendingCandidates.delete(key);
4745
- logger.info("Drained buffered candidate after pending-call arrived", {
4746
- key,
4747
- ageMs: now - buffered.queuedAt
4660
+ if (typeof event.content === "string" && event.content.trim().length > 0) {
4661
+ events.push({
4662
+ ...base,
4663
+ timestamp: nextTimestamp(),
4664
+ type: import_codevibe_core5.EventType.ASSISTANT_RESPONSE,
4665
+ content: truncate(event.content, CONTENT_MAX),
4666
+ metadata: { ...baseMetadata }
4667
+ });
4668
+ }
4669
+ const toolCalls = event.tool_calls;
4670
+ if (Array.isArray(toolCalls)) {
4671
+ for (let i = 0; i < toolCalls.length; i++) {
4672
+ const tc = toolCalls[i];
4673
+ if (!tc || typeof tc !== "object") continue;
4674
+ const name = tc.name;
4675
+ if (typeof name !== "string") continue;
4676
+ const args = tc.args;
4677
+ pendingCallRegistrations.push({
4678
+ intentStepIndex: event.step_index,
4679
+ toolCallIndex: i,
4680
+ intentByteOffset: emit.byteOffset,
4681
+ toolType: hookToolNameToTranscriptType(name),
4682
+ conversationId: emit.conversationId,
4683
+ command: extractCommandFromArgs(name, args),
4684
+ filePath: extractFilePathFromArgs(name, args),
4685
+ questionText: extractQuestionFromArgs(name, args),
4686
+ questionOptions: extractQuestionOptionsFromArgs(name, args),
4687
+ cwd: extractCwdFromArgs(args),
4688
+ rawToolName: name,
4689
+ startedAt: Date.now(),
4690
+ intentCreatedAt: event.created_at
4748
4691
  });
4749
4692
  }
4750
4693
  }
4694
+ return { events, pendingCallRegistrations };
4751
4695
  }
4752
- /** Composite key for the pre-transcript buffer. Scoping by
4753
- * conversationId prevents the collision case where two simultaneous
4754
- * conversations on the same tmux pane (contract-discouraged but not
4755
- * contract-impossible) produce identical paneHashes — without
4756
- * conversation scoping, conv-B's candidate would be silently dropped
4757
- * at the same-key dedupe check. (agy R2 Stage 2 round-3 MED finding
4758
- * 2026-05-20.) */
4759
- candidateKey(conversationId, paneHash) {
4760
- return `${conversationId}|${paneHash}`;
4696
+ if (event.type === "ERROR_MESSAGE") {
4697
+ const content = stripTimestampPrefix(event.content || "Agy error");
4698
+ return {
4699
+ events: [
4700
+ {
4701
+ ...base,
4702
+ timestamp: nextTimestamp(),
4703
+ type: import_codevibe_core5.EventType.NOTIFICATION,
4704
+ content: truncate(content, CONTENT_MAX),
4705
+ metadata: {
4706
+ ...baseMetadata,
4707
+ severity: "error"
4708
+ }
4709
+ }
4710
+ ],
4711
+ pendingCallRegistrations: []
4712
+ };
4761
4713
  }
4762
- /**
4763
- * Clear a pending call when its matching RESULT event arrives in the
4764
- * transcript. event-mapper.ts calls this from its tool-result handlers.
4765
- *
4766
- * The result event's step_index = intentStepIndex + toolCallIndex + 1
4767
- * per agy R2 v2 (the deterministic correlation formula). We try to
4768
- * match by (conversationId, expected step_index, toolType) — when one
4769
- * matches, clear it. If multiple pending calls match (rollback edge),
4770
- * clear the earliest by intentStepIndex.
4771
- */
4772
- clearOnResultEvent(args) {
4773
- let matched = null;
4774
- let matchedKey = null;
4775
- for (const [key, call] of this.pendingCalls.entries()) {
4776
- if (call.conversationId !== args.conversationId) continue;
4777
- if (args.toolType !== "GENERIC" && call.toolType !== args.toolType) continue;
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;
4714
+ const toolName = transcriptTypeToFriendlyToolName(event.type);
4715
+ const isErrorStatus = event.status === "ERROR";
4716
+ const rawContent = stripTimestampPrefix(event.content || "");
4717
+ const truncatedContent = truncate(rawContent, TOOL_OUTPUT_MAX);
4718
+ return {
4719
+ events: [
4720
+ {
4721
+ ...base,
4722
+ timestamp: nextTimestamp(),
4723
+ type: import_codevibe_core5.EventType.TOOL_USE,
4724
+ content: formatToolResultContent(event.type, toolName, truncatedContent, isErrorStatus),
4725
+ metadata: {
4726
+ ...baseMetadata,
4727
+ tool_name: toolName,
4728
+ tool_output: truncatedContent,
4729
+ success: !isErrorStatus
4785
4730
  }
4786
- } else if (!matched || call.intentStepIndex < matched.intentStepIndex) {
4787
- matched = call;
4788
- matchedKey = key;
4789
4731
  }
4732
+ ],
4733
+ pendingCallRegistrations: []
4734
+ };
4735
+ }
4736
+ function stripUserRequestWrapper(content) {
4737
+ const match = content.match(/<USER_REQUEST>([\s\S]*?)<\/USER_REQUEST>/);
4738
+ if (match && match[1] !== void 0) {
4739
+ return match[1].trim();
4740
+ }
4741
+ return content.trim();
4742
+ }
4743
+ function stripTimestampPrefix(content) {
4744
+ return content.replace(
4745
+ /^Created At:[^\n]*\nCompleted At:[^\n]*\n+(?:\s*\n)?/,
4746
+ ""
4747
+ ).trim();
4748
+ }
4749
+ function transcriptTypeToFriendlyToolName(type) {
4750
+ switch (type) {
4751
+ case "RUN_COMMAND":
4752
+ return "Bash";
4753
+ case "LIST_DIRECTORY":
4754
+ return "LS";
4755
+ case "GREP_SEARCH":
4756
+ return "Grep";
4757
+ case "VIEW_FILE":
4758
+ return "Read";
4759
+ case "CODE_ACTION":
4760
+ return "Edit";
4761
+ case "SEARCH_WEB":
4762
+ return "WebSearch";
4763
+ case "GENERIC":
4764
+ return "Other";
4765
+ default:
4766
+ return "Other";
4767
+ }
4768
+ }
4769
+ function hookToolNameToTranscriptType(hookName) {
4770
+ const upper = hookName.toUpperCase();
4771
+ switch (upper) {
4772
+ case "LIST_DIR":
4773
+ return "LIST_DIRECTORY";
4774
+ case "REPLACE_FILE_CONTENT":
4775
+ case "MULTI_REPLACE_FILE_CONTENT":
4776
+ case "WRITE_TO_FILE":
4777
+ return "CODE_ACTION";
4778
+ case "GENERATE_IMAGE":
4779
+ case "READ_URL_CONTENT":
4780
+ case "INVOKE_SUBAGENT":
4781
+ case "DEFINE_SUBAGENT":
4782
+ case "MANAGE_SUBAGENTS":
4783
+ case "SEND_MESSAGE":
4784
+ case "LIST_PERMISSIONS":
4785
+ case "MANAGE_TASK":
4786
+ case "SCHEDULE":
4787
+ case "ASK_PERMISSION":
4788
+ return "GENERIC";
4789
+ case "ASK_QUESTION":
4790
+ return "ASK_QUESTION";
4791
+ default:
4792
+ return upper;
4793
+ }
4794
+ }
4795
+ function extractCommandFromArgs(toolName, args) {
4796
+ if (!args || typeof args !== "object") return void 0;
4797
+ const a = args;
4798
+ for (const key of ["CommandLine", "Command", "command", "cmd", "exec"]) {
4799
+ const v = a[key];
4800
+ if (typeof v === "string" && v.trim().length > 0) {
4801
+ return stripWrappingQuotes2(v.trim());
4790
4802
  }
4791
- if (matched && matchedKey) {
4792
- matched.resultStepIndex = args.resultStepIndex;
4793
- this.pendingCalls.delete(matchedKey);
4794
- if (matched.promptId) {
4795
- this.resolvedPrompts.set(matched.promptId, Date.now());
4796
- this.pendingPrompts.delete(matched.promptId);
4803
+ }
4804
+ return void 0;
4805
+ }
4806
+ function extractFilePathFromArgs(toolName, args) {
4807
+ if (!args || typeof args !== "object") return void 0;
4808
+ const a = args;
4809
+ for (const key of [
4810
+ "TargetFile",
4811
+ "FilePath",
4812
+ "file_path",
4813
+ "filePath",
4814
+ "path",
4815
+ "DirectoryPath",
4816
+ "directory",
4817
+ "Directory"
4818
+ ]) {
4819
+ const v = a[key];
4820
+ if (typeof v === "string" && v.trim().length > 0) {
4821
+ return stripWrappingQuotes2(v.trim());
4822
+ }
4823
+ }
4824
+ return void 0;
4825
+ }
4826
+ function extractQuestionFromArgs(toolName, args) {
4827
+ if (!args || typeof args !== "object") return void 0;
4828
+ const tn = (toolName || "").toLowerCase().trim();
4829
+ const isQuestionTool = tn === "ask_question" || tn === "question" || tn === "ask";
4830
+ if (!isQuestionTool) return void 0;
4831
+ const a = args;
4832
+ if (typeof a.question === "string" && a.question.trim().length > 0) {
4833
+ return stripWrappingQuotes2(a.question.trim());
4834
+ }
4835
+ if (typeof a.prompt === "string" && a.prompt.trim().length > 0) {
4836
+ return stripWrappingQuotes2(a.prompt.trim());
4837
+ }
4838
+ let questions = a.questions;
4839
+ if (typeof questions === "string") {
4840
+ try {
4841
+ questions = JSON.parse(questions.trim());
4842
+ } catch {
4843
+ }
4844
+ }
4845
+ if (Array.isArray(questions) && questions.length > 0) {
4846
+ const first = questions[0];
4847
+ if (typeof first === "string" && first.trim().length > 0) {
4848
+ return stripWrappingQuotes2(first.trim());
4849
+ }
4850
+ if (first && typeof first === "object") {
4851
+ const q = first.question;
4852
+ if (typeof q === "string" && q.trim().length > 0) {
4853
+ return stripWrappingQuotes2(q.trim());
4797
4854
  }
4798
- logger.debug("Pending tool call cleared on result event", {
4799
- intentStepIndex: matched.intentStepIndex,
4800
- toolCallIndex: matched.toolCallIndex,
4801
- resultStepIndex: args.resultStepIndex,
4802
- toolType: args.toolType
4803
- });
4804
4855
  }
4805
- return matched;
4806
4856
  }
4807
- // ─── Candidate matching (called from tmux-pane-observer's prompt-candidate) ─
4808
- /**
4809
- * Cross-reference a parsed ApprovalCandidate against the pending-call
4810
- * list. Returns the matched PendingPromptState (which the MCP server
4811
- * uses to create the INTERACTIVE_PROMPT event), or null if no match
4812
- * (stale scrollback).
4813
- *
4814
- * Match algorithm:
4815
- * 1. Filter pendingCalls by candidate.command (if set) — exact match
4816
- * against PendingToolCall.command.
4817
- * 2. Fallback: filter by candidate.filePath (if set) — exact match
4818
- * against PendingToolCall.filePath.
4819
- * 3. If multiple candidates match (rare), prefer the earliest
4820
- * intentStepIndex.
4821
- * 4. If a match exists AND it already has a promptId AND that
4822
- * promptId is in resolvedPrompts → null (don't re-emit).
4823
- * 5. If a match exists AND has a promptId BUT NOT resolved → return
4824
- * the existing PendingPromptState (idempotent re-emit on the same
4825
- * promptId for a duplicate candidate hash).
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
- });
4857
+ return void 0;
4858
+ }
4859
+ function extractQuestionOptionsFromArgs(toolName, args) {
4860
+ if (!args || typeof args !== "object") return void 0;
4861
+ const tn = (toolName || "").toLowerCase().trim();
4862
+ const isQuestionTool = tn === "ask_question" || tn === "question" || tn === "ask";
4863
+ if (!isQuestionTool) return void 0;
4864
+ const a = args;
4865
+ let questions = a.questions;
4866
+ if (typeof questions === "string") {
4867
+ try {
4868
+ questions = JSON.parse(questions.trim());
4869
+ } catch {
4847
4870
  }
4848
- return null;
4849
4871
  }
4850
- /** Internal match logic — does NOT buffer on miss. Used both by the
4851
- * public matchCandidate (which buffers on miss) and by
4852
- * drainBufferedCandidates (which doesn't re-buffer; misses just stay
4853
- * in the buffer until their TTL expires). */
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;
4864
- }
4865
- if (matched.promptId && this.pendingPrompts.has(matched.promptId)) {
4866
- return this.pendingPrompts.get(matched.promptId) ?? null;
4872
+ if (Array.isArray(questions) && questions.length > 0) {
4873
+ const first = questions[0];
4874
+ if (first && typeof first === "object" && Array.isArray(first.options)) {
4875
+ const rawOptions = first.options;
4876
+ const result = [];
4877
+ for (let i = 0; i < rawOptions.length; i++) {
4878
+ const opt = rawOptions[i];
4879
+ if (typeof opt === "string" && opt.trim().length > 0) {
4880
+ result.push({ number: String(i + 1), text: stripWrappingQuotes2(opt.trim()) });
4881
+ } else if (opt && typeof opt === "object" && typeof opt.text === "string") {
4882
+ result.push({
4883
+ number: String(opt.number ?? i + 1),
4884
+ text: stripWrappingQuotes2(String(opt.text).trim())
4885
+ });
4886
+ }
4887
+ }
4888
+ if (result.length > 0) return result;
4867
4889
  }
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
4890
  }
4895
- /** Resolve a pending prompt — called from MCP server when mobile sends
4896
- * the option-number reply with metadata.promptId. */
4897
- /**
4898
- * Called from the MCP server when paneObserver emits 'prompt-cleared'
4899
- * — i.e. the approval UI disappeared from the pane (desktop user
4900
- * picked an option, agy timed out the prompt, or the dispatcher
4901
- * answered). Resolves ALL outstanding pane-only pending prompts and
4902
- * releases the paneOnly dedup hashes so a subsequent approval can
4903
- * fresh-emit. Without this, mobile prompt state dangles until TTL
4904
- * AND the next identical approval is forever-suppressed by stale
4905
- * dedup. (Stage 2 R1 MED finding 2026-05-21.)
4906
- *
4907
- * `emittedBeforeMs` (F1, Stage 2 R1-F2): a desktop input that waited in the
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 [];
4915
- }
4916
- const now = Date.now();
4917
- const cleared = [];
4918
- let retained = false;
4919
- for (const [promptId, state] of this.pendingPrompts.entries()) {
4920
- if (emittedBeforeMs !== void 0 && state.emittedAt >= emittedBeforeMs) {
4921
- retained = true;
4922
- continue;
4891
+ if (Array.isArray(a.options) || typeof a.options === "string") {
4892
+ let topOptions = a.options;
4893
+ if (typeof topOptions === "string") {
4894
+ try {
4895
+ topOptions = JSON.parse(topOptions.trim());
4896
+ } catch {
4923
4897
  }
4924
- this.resolvedPrompts.set(promptId, now);
4925
- cleared.push(promptId);
4926
4898
  }
4927
- if (!retained) {
4928
- this.pendingPrompts.clear();
4929
- this.paneOnlyEmittedHashes.clear();
4930
- } else {
4931
- for (const promptId of cleared) {
4932
- const state = this.pendingPrompts.get(promptId);
4933
- if (state?.paneHash) this.paneOnlyEmittedHashes.delete(state.paneHash);
4934
- this.pendingPrompts.delete(promptId);
4899
+ if (Array.isArray(topOptions)) {
4900
+ const result = [];
4901
+ for (let i = 0; i < topOptions.length; i++) {
4902
+ const opt = topOptions[i];
4903
+ if (typeof opt === "string" && opt.trim().length > 0) {
4904
+ result.push({ number: String(i + 1), text: stripWrappingQuotes2(opt.trim()) });
4905
+ } else if (opt && typeof opt === "object" && typeof opt.text === "string") {
4906
+ result.push({
4907
+ number: String(opt.number ?? i + 1),
4908
+ text: stripWrappingQuotes2(String(opt.text).trim())
4909
+ });
4910
+ }
4935
4911
  }
4912
+ if (result.length > 0) return result;
4936
4913
  }
4937
- if (cleared.length > 0) {
4938
- logger.info("Pane prompt cleared \u2014 resolved pending prompts", {
4939
- count: cleared.length
4940
- });
4941
- }
4942
- return cleared;
4943
4914
  }
4944
- resolvePrompt(promptId) {
4945
- const state = this.pendingPrompts.get(promptId);
4946
- if (!state) return null;
4947
- this.resolvedPrompts.set(promptId, Date.now());
4948
- this.pendingPrompts.delete(promptId);
4949
- this.paneOnlyEmittedHashes.clear();
4950
- return state;
4915
+ return void 0;
4916
+ }
4917
+ function decodeBoolean(val) {
4918
+ if (typeof val === "boolean") return val;
4919
+ if (typeof val === "string") {
4920
+ const s = val.trim().toLowerCase();
4921
+ return s === "true" || s === "1" || s === "yes";
4951
4922
  }
4952
- /**
4953
- * Roll back a pending prompt WITHOUT marking it as resolved. Used by
4954
- * the MCP server when the INTERACTIVE_PROMPT createEvent fails — the
4955
- * prompt was never delivered to mobile, so a future tmux pane
4956
- * candidate for the same pending tool-call should be eligible to
4957
- * re-emit. `resolvePrompt` is the WRONG call there because it adds
4958
- * the promptId to `resolvedPrompts`, which the stale-scrollback guard
4959
- * uses to suppress re-emit forever. This method instead detaches the
4960
- * promptId from the pending call + removes from pendingPrompts +
4961
- * leaves resolvedPrompts UNTOUCHED so the fresh-emit path runs on
4962
- * the next match. (agy R2 Stage 2 round-2 MED finding 2026-05-20.)
4963
- */
4964
- rollbackPrompt(promptId) {
4965
- const state = this.pendingPrompts.get(promptId);
4966
- if (!state) return null;
4967
- this.pendingPrompts.delete(promptId);
4968
- if (state.pendingCall.promptId === promptId) {
4969
- state.pendingCall.promptId = void 0;
4923
+ if (typeof val === "number") return val !== 0;
4924
+ return false;
4925
+ }
4926
+ function extractAllQuestionsFromArgs(toolName, args) {
4927
+ if (!args || typeof args !== "object") return void 0;
4928
+ const tn = (toolName || "").toLowerCase().trim();
4929
+ const isQuestionTool = tn === "ask_question" || tn === "question" || tn === "ask";
4930
+ if (!isQuestionTool) return void 0;
4931
+ const a = args;
4932
+ let questions = a.questions;
4933
+ if (typeof questions === "string") {
4934
+ try {
4935
+ questions = JSON.parse(questions.trim());
4936
+ } catch {
4970
4937
  }
4971
- for (const call of this.pendingCalls.values()) {
4972
- if (call.matchedPromptId === promptId) {
4973
- call.matchedPromptId = void 0;
4938
+ }
4939
+ const items = [];
4940
+ if (Array.isArray(questions) && questions.length > 0) {
4941
+ for (const qItem of questions) {
4942
+ if (typeof qItem === "string" && qItem.trim().length > 0) {
4943
+ items.push({ question: stripWrappingQuotes2(qItem.trim()), options: [] });
4944
+ } else if (qItem && typeof qItem === "object") {
4945
+ const qObj = qItem;
4946
+ const qText = typeof qObj.question === "string" ? stripWrappingQuotes2(qObj.question.trim()) : "Question";
4947
+ const opts = [];
4948
+ if (Array.isArray(qObj.options)) {
4949
+ for (let i = 0; i < qObj.options.length; i++) {
4950
+ const opt = qObj.options[i];
4951
+ if (typeof opt === "string" && opt.trim().length > 0) {
4952
+ opts.push({ number: String(i + 1), text: stripWrappingQuotes2(opt.trim()) });
4953
+ } else if (opt && typeof opt === "object" && typeof opt.text === "string") {
4954
+ opts.push({
4955
+ number: String(opt.number ?? i + 1),
4956
+ text: stripWrappingQuotes2(String(opt.text).trim())
4957
+ });
4958
+ }
4959
+ }
4960
+ }
4961
+ const isMulti = decodeBoolean(qObj.is_multi_select ?? qObj.isMultiSelect);
4962
+ items.push({ question: qText, options: opts, isMultiSelect: isMulti });
4974
4963
  }
4975
4964
  }
4976
- this.paneOnlyEmittedHashes.clear();
4977
- return state;
4978
- }
4979
- /** Returns the current pending-prompt state by id, or null if expired /
4980
- * unknown. Used by prompt-responder for the active-prompt-identity
4981
- * guard right before send-keys. */
4982
- getPendingPrompt(promptId) {
4983
- return this.pendingPrompts.get(promptId) ?? null;
4984
- }
4985
- getPendingPromptsForConversation(conversationId) {
4986
- const pending = Array.from(this.pendingPrompts.values());
4987
- return conversationId ? pending.filter((state) => state.conversationId === conversationId) : pending;
4988
- }
4989
- // ─── Read-only accessors ────────────────────────────────────────────────
4990
- getPendingCalls() {
4991
- return Array.from(this.pendingCalls.values());
4965
+ } else if (typeof a.question === "string" || typeof a.prompt === "string") {
4966
+ const qText = stripWrappingQuotes2(String(a.question ?? a.prompt).trim());
4967
+ const opts = extractQuestionOptionsFromArgs(toolName, args) ?? [];
4968
+ const isMulti = decodeBoolean(a.is_multi_select ?? a.isMultiSelect);
4969
+ items.push({ question: qText, options: opts, isMultiSelect: isMulti });
4992
4970
  }
4993
- getActivePromptCount() {
4994
- return this.pendingPrompts.size;
4971
+ return items.length > 0 ? items : void 0;
4972
+ }
4973
+ function extractCwdFromArgs(args) {
4974
+ if (!args || typeof args !== "object") return void 0;
4975
+ const a = args;
4976
+ for (const key of ["Cwd", "cwd", "workingDirectory", "WorkingDirectory"]) {
4977
+ const v = a[key];
4978
+ if (typeof v === "string" && v.trim().length > 0) {
4979
+ return stripWrappingQuotes2(v.trim());
4980
+ }
4995
4981
  }
4996
- // ─── Internals ──────────────────────────────────────────────────────────
4997
- callKey(call) {
4998
- return `${call.conversationId}|${call.intentStepIndex}|${call.toolCallIndex}|${call.intentByteOffset}`;
4982
+ return void 0;
4983
+ }
4984
+ function stripWrappingQuotes2(s) {
4985
+ if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
4986
+ return s.slice(1, -1);
4999
4987
  }
5000
- findMatchingPendingCall(candidate, conversationId) {
5001
- if (candidate.kind === "question") {
5002
- const questionCalls = Array.from(this.pendingCalls.values()).filter((c) => c.conversationId === conversationId).filter((c) => c.resultStepIndex === void 0).filter((c) => {
5003
- const associatedPromptId = c.matchedPromptId || c.promptId;
5004
- if (!associatedPromptId) return true;
5005
- return !this.resolvedPrompts.has(associatedPromptId) && !this.pendingPrompts.has(associatedPromptId);
5006
- }).filter(
5007
- (c) => c.toolType === "ASK_QUESTION" || c.rawToolName?.toLowerCase() === "ask_question"
5008
- ).sort((a, b) => a.intentStepIndex - b.intentStepIndex);
5009
- if (questionCalls.length > 0 && candidate.headerText) {
5010
- const normHeader = candidate.headerText.toLowerCase().trim();
5011
- const textMatch = questionCalls.find((c) => {
5012
- if (!c.questionText) return false;
5013
- const normQ = c.questionText.toLowerCase().trim();
5014
- return normHeader === normQ || normHeader.includes(normQ) || normQ.includes(normHeader);
5015
- });
5016
- if (textMatch) return textMatch;
5017
- }
5018
- return null;
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];
5039
- }
5040
- return null;
4988
+ return s;
4989
+ }
4990
+ function formatToolResultContent(agyType, toolName, output, isError) {
4991
+ const prefix = isError ? "\u26A0 " : "";
4992
+ const firstLine = output.split("\n", 1)[0]?.trim();
4993
+ switch (agyType) {
4994
+ case "RUN_COMMAND":
4995
+ return `${prefix}Bash: ${firstLine || "command executed"}`;
4996
+ case "LIST_DIRECTORY":
4997
+ return `${prefix}LS: ${firstLine || "directory listed"}`;
4998
+ case "GREP_SEARCH":
4999
+ return `${prefix}Grep: ${firstLine || "search executed"}`;
5000
+ case "VIEW_FILE":
5001
+ return `${prefix}Read: ${firstLine || "file read"}`;
5002
+ case "CODE_ACTION":
5003
+ return `${prefix}Edit: ${firstLine || "file edited"}`;
5004
+ case "SEARCH_WEB":
5005
+ return `${prefix}WebSearch: ${firstLine || "search executed"}`;
5006
+ case "GENERIC":
5007
+ return `${prefix}${toolName}: ${firstLine || "tool executed"}`;
5008
+ default:
5009
+ return `${prefix}${toolName} (${agyType}): ${firstLine || "unknown event"}`;
5041
5010
  }
5042
- sweep() {
5043
- const now = Date.now();
5044
- for (const [key, call] of this.pendingCalls.entries()) {
5045
- if (now - call.startedAt > this.pendingCallTtlMs) {
5046
- this.pendingCalls.delete(key);
5047
- if (call.promptId) {
5048
- this.pendingPrompts.delete(call.promptId);
5049
- this.resolvedPrompts.set(call.promptId, now);
5050
- }
5051
- logger.warn("Pending tool call expired (no matching result event)", {
5052
- intentStepIndex: call.intentStepIndex,
5053
- toolCallIndex: call.toolCallIndex,
5054
- command: call.command,
5055
- filePath: call.filePath,
5056
- ageMs: now - call.startedAt
5057
- });
5058
- this.emit("pending-call-expired", call);
5059
- }
5060
- }
5061
- for (const [promptId, resolvedAt] of this.resolvedPrompts.entries()) {
5062
- if (now - resolvedAt > 5 * 6e4) {
5063
- this.resolvedPrompts.delete(promptId);
5064
- }
5011
+ }
5012
+ function truncate(s, maxBytes) {
5013
+ if (!s) return "";
5014
+ if (Buffer.byteLength(s, "utf-8") <= maxBytes) return s;
5015
+ let lo = 0;
5016
+ let hi = s.length;
5017
+ while (lo < hi) {
5018
+ const mid = lo + hi + 1 >>> 1;
5019
+ if (Buffer.byteLength(s.slice(0, mid), "utf-8") <= maxBytes - 32) {
5020
+ lo = mid;
5021
+ } else {
5022
+ hi = mid - 1;
5065
5023
  }
5066
- for (const [key, buffered] of this.pendingCandidates.entries()) {
5067
- if (now - buffered.queuedAt > this.pendingCandidateBufferMs) {
5068
- this.pendingCandidates.delete(key);
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
- });
5024
+ }
5025
+ return s.slice(0, lo) + ` [...truncated ${s.length - lo} chars]`;
5026
+ }
5027
+
5028
+ // src/approval-detector.ts
5029
+ var DEFAULT_PROMPT_TTL_MS = 6e4;
5030
+ var PENDING_CALL_TTL_MS = 5 * 6e4;
5031
+ var SWEEP_INTERVAL_MS = 3e4;
5032
+ var PASS3_MIN_IDENTITY_LEN = 4;
5033
+ var PENDING_CANDIDATE_BUFFER_MS = 5e3;
5034
+ function normalizeQuestionText(q) {
5035
+ if (!q) return "";
5036
+ return q.normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
5037
+ }
5038
+ function questionsMatch(hookText, paneText) {
5039
+ const h = normalizeQuestionText(hookText);
5040
+ const p = normalizeQuestionText(paneText);
5041
+ if (!h || !p) return false;
5042
+ return h === p;
5043
+ }
5044
+ function correlatesQuestionInvocation(call, delivery) {
5045
+ if (!call) return false;
5046
+ const tn = (delivery.toolName ?? "").toLowerCase().trim();
5047
+ if (!["ask_question", "question", "ask"].includes(tn)) {
5048
+ return false;
5049
+ }
5050
+ const hasPromptStep = call.intentStepIndex !== void 0 && call.intentStepIndex >= 0;
5051
+ const hasDeliveryStep = delivery.stepIdx !== void 0 && delivery.stepIdx >= 0;
5052
+ if (hasPromptStep && !hasDeliveryStep) {
5053
+ return false;
5054
+ }
5055
+ if (hasPromptStep && hasDeliveryStep) {
5056
+ const stepDiff = delivery.stepIdx - call.intentStepIndex;
5057
+ if (stepDiff < 0 || stepDiff > 1) return false;
5058
+ } else {
5059
+ if (!delivery.toolArgs) return false;
5060
+ }
5061
+ if (delivery.toolArgs) {
5062
+ const completedQ = extractQuestionFromArgs(delivery.toolName ?? "", delivery.toolArgs);
5063
+ const allCompleted = extractAllQuestionsFromArgs(delivery.toolName ?? "", delivery.toolArgs);
5064
+ if (completedQ || allCompleted && allCompleted.length > 0) {
5065
+ const matchesCurrent = completedQ ? questionsMatch(call.questionText, completedQ) : false;
5066
+ const matchesStructured = call.structuredQuestions?.some(
5067
+ (sq) => completedQ && questionsMatch(sq.question, completedQ) || allCompleted?.some((ac) => questionsMatch(sq.question, ac.question))
5068
+ ) ?? false;
5069
+ const matchesAllCompleted = allCompleted?.some(
5070
+ (ac) => questionsMatch(call.questionText, ac.question)
5071
+ ) ?? false;
5072
+ if (!matchesCurrent && !matchesStructured && !matchesAllCompleted) {
5073
+ return false;
5074
5074
  }
5075
+ } else if (!hasPromptStep || !hasDeliveryStep) {
5076
+ return false;
5075
5077
  }
5076
5078
  }
5077
- };
5078
- function containsAsWord(haystack, needle) {
5079
- const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5080
- return new RegExp(`(^|[\\s"'\`])${escaped}([\\s"'\`]|$)`).test(haystack);
5079
+ return true;
5081
5080
  }
5082
-
5083
- // src/prompt-responder.ts
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 {
5081
+ var ApprovalDetector = class extends import_events3.EventEmitter {
5090
5082
  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
- );
5099
- }
5083
+ super();
5084
+ this.pendingCalls = /* @__PURE__ */ new Map();
5085
+ this.pendingPrompts = /* @__PURE__ */ new Map();
5086
+ /** Track recently-resolved promptIds so a duplicate candidate-emit
5087
+ * doesn't re-fire INTERACTIVE_PROMPT. Maps promptId → ms-epoch when
5088
+ * resolved. Pruned by sweep. */
5089
+ this.resolvedPrompts = /* @__PURE__ */ new Map();
5090
+ /** Pre-transcript candidate buffer keyed by `${conversationId}|${paneHash}`
5091
+ * (composite key prevents collision when two conversations on the same
5092
+ * tmux pane produce identical paneHashes — agy R2 Stage 2 round-3 MED).
5093
+ * When a candidate arrives with no matching pending call, we park it
5094
+ * here for pendingCandidateBufferMs and re-try the match each time
5095
+ * registerPendingCall fires. */
5096
+ this.pendingCandidates = /* @__PURE__ */ new Map();
5097
+ /** Authoritative live wizard invocations keyed by conversationId (Codex S2-Codex-R6-F15).
5098
+ * Retains invocation identity and structured questions independently of transcript TTL sweeps. */
5099
+ this.liveWizardInvocations = /* @__PURE__ */ new Map();
5100
+ this.sweepTimer = null;
5101
+ // ─── Pane-only prompt emission (v9.1 — bypasses pending-call match) ─────
5102
+ /** Set of pane-snapshot hashes we've already emitted INTERACTIVE_PROMPT
5103
+ * for. Belt-and-suspenders dedupe against the case where paneObserver
5104
+ * somehow re-fires `prompt-candidate` with the same snapshot bytes
5105
+ * for the same prompt. The primary dedupe for cosmetic redraws of a
5106
+ * single approval (spinner ticks, cursor blink, partial repaints)
5107
+ * lives in tmux-pane-observer's debounce window — by the time a
5108
+ * candidate reaches this layer, the pane has settled. */
5109
+ this.paneOnlyEmittedHashes = /* @__PURE__ */ new Set();
5110
+ this.promptTtlMs = options.promptTtlMs ?? DEFAULT_PROMPT_TTL_MS;
5111
+ this.pendingCallTtlMs = options.pendingCallTtlMs ?? PENDING_CALL_TTL_MS;
5112
+ this.sweepIntervalMs = options.sweepIntervalMs ?? SWEEP_INTERVAL_MS;
5113
+ this.pendingCandidateBufferMs = options.pendingCandidateBufferMs ?? PENDING_CANDIDATE_BUFFER_MS;
5100
5114
  }
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" };
5115
+ start() {
5116
+ if (this.sweepTimer) return;
5117
+ this.sweepTimer = setInterval(() => this.sweep(), this.sweepIntervalMs);
5118
+ if (this.sweepTimer.unref) this.sweepTimer.unref();
5119
+ logger.info("Approval detector started", {
5120
+ promptTtlMs: this.promptTtlMs,
5121
+ pendingCallTtlMs: this.pendingCallTtlMs
5122
+ });
5123
+ }
5124
+ async stop() {
5125
+ if (this.sweepTimer) {
5126
+ clearInterval(this.sweepTimer);
5127
+ this.sweepTimer = null;
5110
5128
  }
5111
- try {
5112
- if (text === CODEVIBE_AGY_ESCAPE_INPUT) {
5113
- await this.sendKey(target, "Escape");
5114
- } else {
5115
- await this.typeLiteral(target, text);
5116
- await delay(ENTER_DELAY_MS);
5117
- await this.sendKey(target, "Enter");
5129
+ this.pendingCalls.clear();
5130
+ this.pendingPrompts.clear();
5131
+ this.resolvedPrompts.clear();
5132
+ this.pendingCandidates.clear();
5133
+ this.paneOnlyEmittedHashes.clear();
5134
+ this.liveWizardInvocations.clear();
5135
+ logger.info("Approval detector stopped");
5136
+ }
5137
+ /** Explicitly retire a live wizard invocation upon completion (PostToolUse / Stop). (Codex S2-Codex-R6-F15) */
5138
+ retireLiveWizard(conversationId) {
5139
+ const live = this.liveWizardInvocations.get(conversationId);
5140
+ if (live) {
5141
+ if (live.promptId) {
5142
+ this.resolvedPrompts.set(live.promptId, Date.now());
5118
5143
  }
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
- };
5144
+ const callKey = this.callKey(live.call);
5145
+ const call = this.pendingCalls.get(callKey);
5146
+ if (call) {
5147
+ call.resultStepIndex = call.intentStepIndex;
5148
+ }
5149
+ this.liveWizardInvocations.delete(conversationId);
5150
+ }
5151
+ }
5152
+ /** Explicitly retire a live wizard invocation matching a completed tool delivery (PostToolUse) (Codex S2-Codex-R6-F15, Claude R8-F6).
5153
+ * Runs synchronously before any async backend awaits, using the shared invocation correlation predicate. */
5154
+ retireLiveWizardMatching(conversationId, delivery) {
5155
+ const live = this.liveWizardInvocations.get(conversationId);
5156
+ if (!live) return false;
5157
+ if (!correlatesQuestionInvocation(live.call, delivery)) {
5158
+ return false;
5159
+ }
5160
+ if (live.promptId) {
5161
+ this.resolvedPrompts.set(live.promptId, Date.now());
5162
+ }
5163
+ const callKey = this.callKey(live.call);
5164
+ const call = this.pendingCalls.get(callKey);
5165
+ if (call) {
5166
+ call.resultStepIndex = call.intentStepIndex;
5131
5167
  }
5168
+ this.liveWizardInvocations.delete(conversationId);
5169
+ logger.info("Retired live wizard invocation matching completed tool", {
5170
+ conversationId,
5171
+ toolName: delivery.toolName,
5172
+ stepIdx: delivery.stepIdx,
5173
+ intentStepIndex: live.call.intentStepIndex
5174
+ });
5175
+ return true;
5132
5176
  }
5133
5177
  /**
5134
- * Resolve a pending approval prompt with the mobile-user's chosen
5135
- * option number. Uses the submitMap from the PendingPromptState to
5136
- * translate option-number → terminal keystrokes (digit alone for agy's
5137
- * 4-numbered UI; future Y/N prompts would use 'y'/'n'/'Escape').
5178
+ * Emit INTERACTIVE_PROMPT directly from a pane snapshot, WITHOUT
5179
+ * requiring a matching PendingToolCall from a transcript PLANNER_RESPONSE.
5138
5180
  *
5139
- * Active-prompt-identity guard (DESIGN.md §3.4):
5140
- * 1. Detector check: is `promptId` still in pendingPrompts? If not
5141
- * (expired or already-resolved), drop with `prompt-expired`.
5142
- * 2. Pane-observer check: probeApprovalUIActive() — does the pane
5143
- * STILL show an approval UI whose header matches our pending
5144
- * call's identity? If active-UI is gone OR header differs,
5145
- * drop with `prompt-superseded`.
5146
- * 3. submitMap lookup: is `optionNumber` a valid key? If not (e.g.
5147
- * mobile sent "5" for a 4-option prompt), drop with
5148
- * `invalid-option`.
5149
- * 4. Send the keystrokes via tmux send-keys.
5181
+ * Background (v9.1, 2026-05-21): agy does NOT write tool_call entries
5182
+ * to the transcript BEFORE rendering the approval UI for shell
5183
+ * commands. It only writes them AFTER the user approves. So the
5184
+ * transcript-based pending-call matching path deadlocks for shell
5185
+ * commands: paneObserver sees the UI, but no pending call exists →
5186
+ * candidate buffered → user can't approve from mobile (no prompt
5187
+ * shown) → no transcript write → buffer expires.
5188
+ *
5189
+ * Solution: pane is authoritative for "approval UI is showing right
5190
+ * now." Skip the pending-call requirement and emit directly. The
5191
+ * parsed candidate has everything needed (command, options, submitMap).
5192
+ *
5193
+ * Synthesizes a minimal PendingToolCall from the candidate so the
5194
+ * existing handlePendingPrompt code path doesn't need to branch on
5195
+ * `pendingCall: null`.
5196
+ *
5197
+ * Returns the emitted state, or null if dedupe (already emitted for
5198
+ * this paneHash) or disabled.
5150
5199
  */
5151
- async sendApprovalReply(promptId, optionNumber) {
5152
- const target = this.resolveTmuxTarget();
5153
- if (!target) {
5154
- return { ok: false, reason: "no-tmux-target" };
5200
+ emitPaneOnlyPrompt(candidate, conversationId, forcedPromptId) {
5201
+ if (this.paneOnlyEmittedHashes.has(candidate.paneHash)) {
5202
+ logger.debug("Skipping pane-only re-emit for duplicate paneHash", {
5203
+ paneHash: candidate.paneHash.substring(0, 16)
5204
+ });
5205
+ return null;
5155
5206
  }
5156
- if (this.detector) {
5157
- const state = this.detector.getPendingPrompt(promptId);
5158
- if (!state) {
5159
- logger.warn("Mobile reply for unknown / expired promptId", { promptId, optionNumber });
5160
- return {
5161
- ok: false,
5162
- reason: "prompt-expired",
5163
- details: `promptId ${promptId} is not in pendingPrompts`
5207
+ if (candidate.kind === "question") {
5208
+ const paneQuestionText = candidate.headerText || candidate.fullHeaderLine;
5209
+ const candidateSemanticKey = buildApprovalSemanticKey(candidate);
5210
+ const liveWizard2 = this.liveWizardInvocations.get(conversationId);
5211
+ if (liveWizard2 && liveWizard2.call.structuredQuestions && liveWizard2.call.structuredQuestions.length > 0) {
5212
+ const sqs = liveWizard2.call.structuredQuestions;
5213
+ const currentActiveIdx = liveWizard2.activeStepIndex ?? 0;
5214
+ const scoreStep = (stepIdx) => {
5215
+ if (stepIdx < 0 || stepIdx >= sqs.length) return { score: -1, textMatch: false };
5216
+ const targetQ = sqs[stepIdx];
5217
+ const textMatch = questionsMatch(targetQ.question, paneQuestionText);
5218
+ if (!textMatch) return { score: -1, textMatch: false };
5219
+ const qOptTexts = (targetQ.options || []).map((o) => (typeof o === "string" ? o : o.text).toLowerCase().trim());
5220
+ const cOptTexts = (candidate.options || []).map((o) => (typeof o === "string" ? o : o.text).toLowerCase().trim());
5221
+ if (cOptTexts.length > 0 && qOptTexts.length > 0) {
5222
+ const hasTrailingWriteIn = cOptTexts.length === qOptTexts.length + 1 && /^\s*write-?in\b/i.test(cOptTexts[cOptTexts.length - 1]);
5223
+ const cEffectiveTexts = hasTrailingWriteIn ? cOptTexts.slice(0, -1) : cOptTexts;
5224
+ const matchedQCount = qOptTexts.filter((q) => cEffectiveTexts.includes(q)).length;
5225
+ const matchedCCount = cEffectiveTexts.filter((c) => qOptTexts.includes(c)).length;
5226
+ const qRatio = matchedQCount / qOptTexts.length;
5227
+ if (cEffectiveTexts.length !== qOptTexts.length || matchedQCount !== qOptTexts.length || matchedCCount !== cEffectiveTexts.length) {
5228
+ return { score: -1, textMatch: true };
5229
+ }
5230
+ const posMatches = qOptTexts.filter((q, idx) => idx < cEffectiveTexts.length && cEffectiveTexts[idx] === q).length;
5231
+ const posRatio = posMatches / qOptTexts.length;
5232
+ const score = qRatio * 0.5 + posRatio * 0.5;
5233
+ return { score, textMatch: true };
5234
+ }
5235
+ if (cOptTexts.length === 0 && qOptTexts.length === 0) {
5236
+ return { score: 1, textMatch: true };
5237
+ }
5238
+ return { score: -1, textMatch: true };
5164
5239
  };
5165
- }
5166
- if (this.paneObserver) {
5167
- if (!state.paneSemanticKey) {
5168
- return {
5169
- ok: false,
5170
- reason: "prompt-probe-failed",
5171
- details: "pending prompt has no exact semantic identity"
5172
- };
5240
+ const allScores = sqs.map((_, idx) => ({ stepIdx: idx, ...scoreStep(idx) })).filter((res) => res.score > 0);
5241
+ let bestStepIdx = -1;
5242
+ if (allScores.length > 0) {
5243
+ const maxScore = Math.max(...allScores.map((s) => s.score));
5244
+ const topSteps = allScores.filter((s) => Math.abs(s.score - maxScore) < 1e-3);
5245
+ if (topSteps.length > 1) {
5246
+ logger.warn("Ambiguous pane options match: multiple wizard steps tied with score", {
5247
+ maxScore,
5248
+ tiedSteps: topSteps.map((s) => s.stepIdx)
5249
+ });
5250
+ bestStepIdx = -1;
5251
+ } else {
5252
+ bestStepIdx = topSteps[0].stepIdx;
5253
+ }
5173
5254
  }
5174
- }
5175
- const keys = state.submitMap[optionNumber];
5176
- if (!keys || keys.length === 0) {
5177
- return {
5178
- ok: false,
5179
- reason: "invalid-option",
5180
- details: `option '${optionNumber}' not in submitMap (valid: ${Object.keys(state.submitMap).join(",")})`
5181
- };
5182
- }
5183
- try {
5184
- for (const key of keys) {
5185
- if (!this.detector || !this.paneObserver) {
5186
- return {
5187
- ok: false,
5188
- reason: "prompt-probe-failed",
5189
- details: "approval responder is missing its detector or pane observer"
5255
+ if (bestStepIdx === -1) {
5256
+ logger.debug("Pane candidate did not match any step of active live wizard", {
5257
+ paneQuestionText,
5258
+ currentActiveIdx,
5259
+ totalSteps: sqs.length
5260
+ });
5261
+ return null;
5262
+ }
5263
+ const targetStepQ = sqs[bestStepIdx];
5264
+ if (targetStepQ?.isMultiSelect) {
5265
+ candidate.submitMap = {};
5266
+ }
5267
+ const isAdvancement = bestStepIdx !== currentActiveIdx;
5268
+ if (!isAdvancement) {
5269
+ const existing = [...this.pendingPrompts.values()].find(
5270
+ (p) => p.conversationId === conversationId && p.kind === "question"
5271
+ );
5272
+ if (existing) {
5273
+ const targetQ = existing.pendingCall?.structuredQuestions?.[existing.wizardStepIndex ?? 0];
5274
+ const isAuthoritativeMultiSelect = Boolean(targetQ?.isMultiSelect);
5275
+ const isInitialUpgrade = existing.isHookPrompt && !isAuthoritativeMultiSelect && Object.keys(existing.submitMap || {}).length === 0 && Object.keys(candidate.submitMap || {}).length > 0;
5276
+ if (isInitialUpgrade) {
5277
+ existing.submitMap = candidate.submitMap;
5278
+ if (candidate.options && candidate.options.length > 0) {
5279
+ existing.paneOptions = candidate.options;
5280
+ }
5281
+ existing.paneSemanticKey = candidateSemanticKey;
5282
+ existing.paneHash = candidate.paneHash;
5283
+ if (candidate.headerText) existing.matchedPaneHeader = candidate.headerText;
5284
+ if (candidate.fullHeaderLine) existing.paneDisplayHeader = candidate.fullHeaderLine;
5285
+ if (candidate.body) existing.body = candidate.body;
5286
+ if (candidate.paneHash) this.paneOnlyEmittedHashes.add(candidate.paneHash);
5287
+ logger.info("Upgraded hook-driven question prompt to actionable from pane observer", {
5288
+ promptId: existing.promptId,
5289
+ optionsCount: existing.paneOptions?.length ?? 0
5290
+ });
5291
+ this.emit("prompt-upgraded", existing);
5292
+ return existing;
5293
+ }
5294
+ const areControlsMutated = (existingOpts, candidateOpts) => {
5295
+ if (!existingOpts || !candidateOpts) return false;
5296
+ if (existingOpts.length !== candidateOpts.length) return true;
5297
+ return !existingOpts.every((o, idx) => o.number === candidateOpts[idx].number && (o.text || "").trim() === (candidateOpts[idx].text || "").trim());
5190
5298
  };
5299
+ const isControlDelta = Boolean(
5300
+ existing.paneOptions && existing.paneOptions.length > 0 && candidate.options && candidate.options.length > 0 && areControlsMutated(existing.paneOptions, candidate.options)
5301
+ );
5302
+ if (isControlDelta) {
5303
+ logger.info("Control delta observed on active question step; resolving old prompt and re-emitting", {
5304
+ oldPromptId: existing.promptId,
5305
+ oldSemanticKey: existing.paneSemanticKey,
5306
+ newSemanticKey: candidateSemanticKey
5307
+ });
5308
+ this.resolvePrompt(existing.promptId, { advanceWizard: false });
5309
+ this.emit("prompt-resolved", existing.promptId);
5310
+ } else {
5311
+ if (!existing.paneSemanticKey) {
5312
+ existing.paneSemanticKey = candidateSemanticKey;
5313
+ }
5314
+ if (candidate.body) existing.body = candidate.body;
5315
+ if (candidate.paneHash) this.paneOnlyEmittedHashes.add(candidate.paneHash);
5316
+ return null;
5317
+ }
5191
5318
  }
5192
- if (this.detector.getPendingPrompt(promptId) !== state) {
5193
- return { ok: false, reason: "prompt-expired" };
5319
+ } else {
5320
+ liveWizard2.activeStepIndex = bestStepIdx;
5321
+ for (const p of [...this.pendingPrompts.values()]) {
5322
+ if (p.conversationId === conversationId && p.kind === "question") {
5323
+ logger.info("Resolving older question prompt on wizard step progression", {
5324
+ oldPromptId: p.promptId,
5325
+ fromStep: currentActiveIdx,
5326
+ toStep: bestStepIdx
5327
+ });
5328
+ this.resolvePrompt(p.promptId, { advanceWizard: false });
5329
+ this.emit("prompt-resolved", p.promptId);
5330
+ }
5194
5331
  }
5195
- let liveSemanticKey = null;
5196
- try {
5197
- const snapshot = await this.paneObserver.captureSnapshot();
5198
- const live = parseApprovalSnapshot(snapshot);
5199
- if (!live) {
5200
- if (detectActivePromptKind(snapshot) === null) {
5201
- this.detector.resolvePrompt(promptId);
5202
- return {
5203
- ok: false,
5204
- reason: "prompt-superseded",
5205
- details: "the approval chooser is no longer active"
5206
- };
5332
+ }
5333
+ } else {
5334
+ for (const p of [...this.pendingPrompts.values()]) {
5335
+ if (p.conversationId === conversationId && p.kind === "question") {
5336
+ const hookQuestionText = p.pendingCall?.questionText || p.matchedPaneHeader || p.paneDisplayHeader;
5337
+ const isSameQuestion = questionsMatch(hookQuestionText, paneQuestionText);
5338
+ if (isSameQuestion) {
5339
+ const targetQ = p.pendingCall?.structuredQuestions?.[p.wizardStepIndex ?? 0];
5340
+ const isAuthoritativeMultiSelect = Boolean(targetQ?.isMultiSelect);
5341
+ const isInitialUpgrade = p.isHookPrompt && !isAuthoritativeMultiSelect && Object.keys(p.submitMap || {}).length === 0 && Object.keys(candidate.submitMap || {}).length > 0;
5342
+ const isControlDelta = !isInitialUpgrade && Boolean(
5343
+ p.paneSemanticKey && candidateSemanticKey !== p.paneSemanticKey
5344
+ );
5345
+ if (!isControlDelta) {
5346
+ if (isInitialUpgrade) {
5347
+ p.submitMap = candidate.submitMap;
5348
+ if (candidate.options && candidate.options.length > 0) {
5349
+ p.paneOptions = candidate.options;
5350
+ }
5351
+ p.paneSemanticKey = candidateSemanticKey;
5352
+ p.paneHash = candidate.paneHash;
5353
+ if (candidate.headerText) p.matchedPaneHeader = candidate.headerText;
5354
+ if (candidate.fullHeaderLine) p.paneDisplayHeader = candidate.fullHeaderLine;
5355
+ if (candidate.body) p.body = candidate.body;
5356
+ if (candidate.paneHash) this.paneOnlyEmittedHashes.add(candidate.paneHash);
5357
+ logger.info("Upgraded hook-driven question prompt to actionable from pane observer", {
5358
+ promptId: p.promptId,
5359
+ optionsCount: p.paneOptions?.length ?? 0
5360
+ });
5361
+ this.emit("prompt-upgraded", p);
5362
+ return p;
5363
+ }
5364
+ if (!p.paneSemanticKey) {
5365
+ p.paneSemanticKey = candidateSemanticKey;
5366
+ }
5367
+ if (candidate.paneHash) this.paneOnlyEmittedHashes.add(candidate.paneHash);
5368
+ return null;
5207
5369
  }
5208
- return {
5209
- ok: false,
5210
- reason: "prompt-probe-failed",
5211
- details: "the active chooser could not be parsed exactly"
5212
- };
5213
5370
  }
5214
- liveSemanticKey = live ? buildApprovalSemanticKey(live) : null;
5215
- } catch (error) {
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
- };
5371
+ this.resolvePrompt(p.promptId);
5372
+ this.emit("prompt-resolved", p.promptId);
5229
5373
  }
5230
- if (isNamedKey(key)) {
5231
- await this.sendKey(target, key);
5374
+ }
5375
+ }
5376
+ }
5377
+ const promptId = forcedPromptId ?? (0, import_uuid2.v4)();
5378
+ const liveWizard = candidate.kind === "question" ? this.liveWizardInvocations.get(conversationId) : void 0;
5379
+ const matchingCall = liveWizard ? liveWizard.call : candidate.kind === "question" ? this.findMatchingPendingCall(candidate, conversationId) : null;
5380
+ if (liveWizard) {
5381
+ liveWizard.promptId = promptId;
5382
+ } else if (matchingCall) {
5383
+ matchingCall.matchedPromptId = promptId;
5384
+ }
5385
+ const syntheticCall = {
5386
+ conversationId,
5387
+ intentStepIndex: matchingCall?.intentStepIndex ?? -1,
5388
+ // sentinel: "no transcript origin"
5389
+ toolCallIndex: matchingCall?.toolCallIndex ?? 0,
5390
+ intentByteOffset: matchingCall?.intentByteOffset ?? 0,
5391
+ // Best-effort tool-type inference from the candidate. For shell
5392
+ // commands → RUN_COMMAND. For edit-style headers with a file
5393
+ // path → CODE_ACTION. For questions → ASK_QUESTION. Default: GENERIC.
5394
+ toolType: matchingCall?.toolType ?? guessToolTypeFromIdentity(candidate),
5395
+ command: matchingCall?.command ?? candidate.command,
5396
+ filePath: matchingCall?.filePath ?? candidate.filePath,
5397
+ rawToolName: matchingCall?.rawToolName ?? (candidate.kind === "question" ? "ask_question" : candidate.command ?? candidate.filePath ?? "unknown"),
5398
+ questionText: candidate.kind === "question" && candidate.headerText ? candidate.headerText : matchingCall?.questionText,
5399
+ // NOTE (Codex R3-F1 / Claude R3-F2): Pane options are authoritative ground truth
5400
+ // that directly correspond to desktop keystrokes (submitMap). Transcript options
5401
+ // serve solely as fallback when pane options cannot be parsed.
5402
+ questionOptions: candidate.kind === "question" && candidate.options && candidate.options.length > 0 ? candidate.options : matchingCall?.questionOptions,
5403
+ structuredQuestions: matchingCall?.structuredQuestions,
5404
+ promptId,
5405
+ startedAt: matchingCall?.startedAt ?? Date.now(),
5406
+ intentCreatedAt: matchingCall?.intentCreatedAt ?? (/* @__PURE__ */ new Date()).toISOString()
5407
+ };
5408
+ const isHookCall = Boolean(
5409
+ matchingCall?.structuredQuestions && matchingCall.structuredQuestions.length > 0
5410
+ );
5411
+ const state = {
5412
+ promptId,
5413
+ conversationId,
5414
+ pendingCall: syntheticCall,
5415
+ submitMap: candidate.submitMap,
5416
+ matchedPaneHeader: candidate.headerText,
5417
+ paneDisplayHeader: candidate.fullHeaderLine,
5418
+ body: candidate.body,
5419
+ paneOptions: candidate.options,
5420
+ paneSemanticKey: buildApprovalSemanticKey(candidate),
5421
+ kind: candidate.kind,
5422
+ isHookPrompt: isHookCall,
5423
+ // Carry hook ownership forward so pane redraws do not drop it! (Codex S2-Codex-R6-F15)
5424
+ wizardStepIndex: liveWizard ? liveWizard.activeStepIndex : void 0,
5425
+ emittedAt: Date.now(),
5426
+ ttlMs: this.promptTtlMs,
5427
+ // E1 (§6a-2b / F5) — carry the consumed pane hash so an emit-failure
5428
+ // rollback can reset the observer's lastPromptHash and let the unchanged
5429
+ // live pane re-emit.
5430
+ paneHash: candidate.paneHash
5431
+ };
5432
+ this.pendingPrompts.set(promptId, state);
5433
+ this.paneOnlyEmittedHashes.add(candidate.paneHash);
5434
+ logger.info("Emitting pane-only INTERACTIVE_PROMPT", {
5435
+ promptId,
5436
+ command: candidate.command,
5437
+ filePath: candidate.filePath,
5438
+ optionsCount: candidate.options.length
5439
+ });
5440
+ this.emit("pending-prompt", state);
5441
+ return state;
5442
+ }
5443
+ /**
5444
+ * Emit an INTERACTIVE_PROMPT for a question tool call directly from the PreToolUse hook.
5445
+ * This guarantees prompt delivery to mobile without relying on fragile TUI terminal screen-scraping.
5446
+ */
5447
+ emitHookQuestionPrompt(call, forcedPromptId) {
5448
+ const questionText = call.questionText ?? "Question";
5449
+ if (!call.questionOptions && call.structuredQuestions && call.structuredQuestions[0]?.options) {
5450
+ call.questionOptions = call.structuredQuestions[0].options;
5451
+ }
5452
+ for (const [id, p] of this.pendingPrompts.entries()) {
5453
+ if (p.conversationId === call.conversationId && p.kind === "question") {
5454
+ const existingText = p.pendingCall?.questionText || p.matchedPaneHeader || p.paneDisplayHeader;
5455
+ const isSameInvocation = !p.pendingCall || p.pendingCall.intentStepIndex === -1 || call.intentStepIndex === -1 || p.pendingCall.intentStepIndex === call.intentStepIndex;
5456
+ if (isSameInvocation && questionsMatch(existingText, questionText)) {
5457
+ if (p.pendingCall) {
5458
+ p.pendingCall.structuredQuestions = call.structuredQuestions;
5459
+ if (call.intentStepIndex !== -1) {
5460
+ p.pendingCall.intentStepIndex = call.intentStepIndex;
5461
+ }
5462
+ p.pendingCall.rawToolName = call.rawToolName;
5463
+ p.pendingCall.toolType = call.toolType;
5232
5464
  } else {
5233
- await this.typeLiteral(target, key);
5465
+ p.pendingCall = call;
5466
+ }
5467
+ const isHookMultiSelect = Boolean(
5468
+ call.isMultiSelect || call.structuredQuestions && call.structuredQuestions[0]?.isMultiSelect
5469
+ );
5470
+ if (isHookMultiSelect && Object.keys(p.submitMap || {}).length > 0) {
5471
+ p.submitMap = {};
5472
+ logger.info("emitHookQuestionPrompt: revoked actionable submitMap because incoming hook is multi-select", {
5473
+ promptId: p.promptId
5474
+ });
5475
+ this.emit("prompt-upgraded", p);
5476
+ }
5477
+ p.isHookPrompt = true;
5478
+ call.matchedPromptId = p.promptId;
5479
+ if (call.structuredQuestions && call.structuredQuestions.length > 0) {
5480
+ this.liveWizardInvocations.set(call.conversationId, {
5481
+ call,
5482
+ activeStepIndex: 0,
5483
+ promptId: p.promptId,
5484
+ registeredAt: Date.now()
5485
+ });
5234
5486
  }
5487
+ logger.info("emitHookQuestionPrompt: reusing existing matching prompt", {
5488
+ promptId: p.promptId,
5489
+ isActionable: Object.keys(p.submitMap || {}).length > 0
5490
+ });
5491
+ return p;
5235
5492
  }
5236
- this.detector.resolvePrompt(promptId);
5237
- logger.info("Sent approval reply via tmux", {
5238
- promptId,
5239
- optionNumber,
5240
- keysSent: keys
5493
+ logger.info("Replacing older question prompt with new hook question", {
5494
+ oldPromptId: id,
5495
+ oldStep: p.pendingCall?.intentStepIndex,
5496
+ newStep: call.intentStepIndex,
5497
+ conversationId: call.conversationId
5241
5498
  });
5242
- return { ok: true };
5243
- } catch (error) {
5244
- return {
5245
- ok: false,
5246
- reason: "tmux-failed",
5247
- details: error instanceof Error ? error.message : String(error)
5248
- };
5499
+ this.resolvePrompt(id);
5500
+ this.emit("prompt-resolved", id);
5249
5501
  }
5250
5502
  }
5251
- try {
5252
- await this.typeLiteral(target, optionNumber);
5253
- return { ok: true };
5254
- } catch (error) {
5255
- return {
5256
- ok: false,
5257
- reason: "tmux-failed",
5258
- details: error instanceof Error ? error.message : String(error)
5259
- };
5503
+ const promptId = forcedPromptId ?? (0, import_uuid2.v4)();
5504
+ this.registerPendingCall(call);
5505
+ call.matchedPromptId = promptId;
5506
+ const state = {
5507
+ promptId,
5508
+ conversationId: call.conversationId,
5509
+ pendingCall: call,
5510
+ submitMap: {},
5511
+ matchedPaneHeader: call.questionText ?? "Question",
5512
+ paneDisplayHeader: call.questionText ?? "Question",
5513
+ body: void 0,
5514
+ paneOptions: call.questionOptions,
5515
+ paneSemanticKey: buildApprovalSemanticKey({
5516
+ kind: "question",
5517
+ headerText: call.questionText ?? "Question",
5518
+ fullHeaderLine: call.questionText ?? "Question",
5519
+ options: call.questionOptions ?? [],
5520
+ submitMap: {},
5521
+ body: "",
5522
+ paneHash: ""
5523
+ }),
5524
+ kind: "question",
5525
+ isHookPrompt: true,
5526
+ wizardStepIndex: 0,
5527
+ emittedAt: Date.now(),
5528
+ ttlMs: this.promptTtlMs
5529
+ };
5530
+ if (call.structuredQuestions && call.structuredQuestions.length > 0) {
5531
+ this.liveWizardInvocations.set(call.conversationId, {
5532
+ call,
5533
+ activeStepIndex: 0,
5534
+ promptId,
5535
+ registeredAt: Date.now()
5536
+ });
5260
5537
  }
5538
+ this.pendingPrompts.set(promptId, state);
5539
+ logger.info("Emitting hook-driven INTERACTIVE_PROMPT for question", {
5540
+ promptId,
5541
+ conversationId: call.conversationId,
5542
+ questionText: call.questionText,
5543
+ optionsCount: call.questionOptions?.length ?? 0
5544
+ });
5545
+ this.emit("pending-prompt", state);
5546
+ return state;
5261
5547
  }
5262
- async sendFreeFormWhenNoApprovalActive(text, beforeType) {
5263
- const target = this.resolveTmuxTarget();
5264
- if (!target) return { ok: false, reason: "no-tmux-target" };
5265
- if (!this.paneObserver) {
5266
- return {
5267
- ok: false,
5268
- reason: "prompt-probe-failed",
5269
- details: "pane observer is required for safe free-form input"
5270
- };
5548
+ // ─── Pending-call registration (called from event-mapper hook) ──────────
5549
+ /**
5550
+ * Register a tool-call intent from a PLANNER_RESPONSE.tool_calls[].
5551
+ * Indexed by composite key so agy rollback (which can replay
5552
+ * step_index values) doesn't cause collisions.
5553
+ */
5554
+ registerPendingCall(call) {
5555
+ const key = this.callKey(call);
5556
+ if (this.pendingCalls.has(key)) {
5557
+ logger.debug("Pending call already registered, skipping", {
5558
+ key,
5559
+ intentStepIndex: call.intentStepIndex,
5560
+ toolType: call.toolType
5561
+ });
5562
+ return;
5271
5563
  }
5272
- const chooserIsAbsent = async () => {
5273
- const probe = await this.paneObserver.probeApprovalUIActive();
5274
- if (!probe.probeSucceeded) {
5275
- return { ok: false, reason: "prompt-probe-failed" };
5276
- }
5277
- if (probe.active) {
5278
- return { ok: false, reason: "prompt-superseded" };
5564
+ this.pendingCalls.set(key, call);
5565
+ logger.debug("Pending tool call registered", {
5566
+ key,
5567
+ toolType: call.toolType,
5568
+ command: call.command,
5569
+ filePath: call.filePath
5570
+ });
5571
+ this.drainBufferedCandidates(call.conversationId);
5572
+ }
5573
+ drainBufferedCandidates(conversationId) {
5574
+ if (this.pendingCandidates.size === 0) return;
5575
+ const now = Date.now();
5576
+ for (const [key, buffered] of this.pendingCandidates.entries()) {
5577
+ if (buffered.conversationId !== conversationId) continue;
5578
+ if (now - buffered.queuedAt > this.pendingCandidateBufferMs) {
5579
+ this.pendingCandidates.delete(key);
5580
+ continue;
5279
5581
  }
5280
- return null;
5281
- };
5282
- let preparationFailed = false;
5283
- try {
5284
- const beforeText = await chooserIsAbsent();
5285
- if (beforeText) return beforeText;
5286
- if (beforeType) {
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;
5582
+ const state = this.matchCandidateInternal(buffered.candidate, buffered.conversationId);
5583
+ if (state) {
5584
+ this.pendingCandidates.delete(key);
5585
+ logger.info("Drained buffered candidate after pending-call arrived", {
5586
+ key,
5587
+ ageMs: now - buffered.queuedAt
5588
+ });
5295
5589
  }
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
- }
5310
- }
5311
- /** Dismiss whichever approval UI is active without binding to a stale promptId. */
5312
- async dismissActivePrompt() {
5313
- const target = this.resolveTmuxTarget();
5314
- if (!target) return { ok: false, reason: "no-tmux-target" };
5315
- if (!this.paneObserver) {
5316
- return { ok: false, reason: "prompt-probe-failed" };
5317
- }
5318
- try {
5319
- const before = await this.paneObserver.probeApprovalUIActive();
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
5590
  }
5368
5591
  }
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);
5592
+ /** Composite key for the pre-transcript buffer. Scoping by
5593
+ * conversationId prevents the collision case where two simultaneous
5594
+ * conversations on the same tmux pane (contract-discouraged but not
5595
+ * contract-impossible) produce identical paneHashes — without
5596
+ * conversation scoping, conv-B's candidate would be silently dropped
5597
+ * at the same-key dedupe check. (agy R2 Stage 2 round-3 MED finding
5598
+ * 2026-05-20.) */
5599
+ candidateKey(conversationId, paneHash) {
5600
+ return `${conversationId}|${paneHash}`;
5433
5601
  }
5434
5602
  /**
5435
- * Called by event-mapper / MCP server immediately BEFORE forwarding a
5436
- * USER_PROMPT to AppSync. If the echo matches a recently-tracked mobile
5437
- * prompt, returns the match metadata + CONSUMES the tracked entry (so a
5438
- * second similar echo doesn't suppress an actual second mobile prompt).
5439
- * Returns null when no match → caller proceeds with the AppSync emit.
5603
+ * Clear a pending call when its matching RESULT event arrives in the
5604
+ * transcript. event-mapper.ts calls this from its tool-result handlers.
5605
+ *
5606
+ * The result event's step_index = intentStepIndex + toolCallIndex + 1
5607
+ * per agy R2 v2 (the deterministic correlation formula). We try to
5608
+ * match by (conversationId, expected step_index, toolType) — when one
5609
+ * matches, clear it. If multiple pending calls match (rollback edge),
5610
+ * clear the earliest by intentStepIndex.
5440
5611
  */
5441
- consumeIfDuplicate(sessionId, echo) {
5442
- this.sweep();
5443
- const normalizedEcho = this.normalize(echo);
5444
- if (!normalizedEcho) return null;
5445
- let match = null;
5446
- const entries = this.validEntries(sessionId).filter((entry) => {
5447
- if (!match) {
5448
- const matchType = this.matchType(entry.normalized, normalizedEcho);
5449
- if (matchType) {
5450
- match = {
5451
- matchType,
5452
- originalLength: entry.normalized.length,
5453
- echoLength: normalizedEcho.length
5454
- };
5455
- return false;
5612
+ clearOnResultEvent(args) {
5613
+ let matched = null;
5614
+ let matchedKey = null;
5615
+ for (const [key, call] of this.pendingCalls.entries()) {
5616
+ if (call.conversationId !== args.conversationId) continue;
5617
+ if (args.toolType !== "GENERIC" && call.toolType !== args.toolType) continue;
5618
+ const expected = call.intentStepIndex + call.toolCallIndex + 1;
5619
+ if (expected !== args.resultStepIndex) continue;
5620
+ if (typeof args.resultByteOffset === "number") {
5621
+ if (call.intentByteOffset >= args.resultByteOffset) continue;
5622
+ if (!matched || call.intentByteOffset > matched.intentByteOffset) {
5623
+ matched = call;
5624
+ matchedKey = key;
5456
5625
  }
5626
+ } else if (!matched || call.intentStepIndex < matched.intentStepIndex) {
5627
+ matched = call;
5628
+ matchedKey = key;
5457
5629
  }
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
5630
  }
5471
- return null;
5472
- }
5473
- // ─── Internals ──────────────────────────────────────────────────────────
5474
- sweep() {
5475
- const cutoff = this.now() - this.expiryMs;
5476
- for (const [sessId, entries] of this.pendingBySession.entries()) {
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);
5631
+ if (matched && matchedKey) {
5632
+ matched.resultStepIndex = args.resultStepIndex;
5633
+ this.pendingCalls.delete(matchedKey);
5634
+ if (matched.promptId) {
5635
+ this.resolvedPrompts.set(matched.promptId, Date.now());
5636
+ this.pendingPrompts.delete(matched.promptId);
5482
5637
  }
5638
+ logger.debug("Pending tool call cleared on result event", {
5639
+ intentStepIndex: matched.intentStepIndex,
5640
+ toolCallIndex: matched.toolCallIndex,
5641
+ resultStepIndex: args.resultStepIndex,
5642
+ toolType: args.toolType
5643
+ });
5483
5644
  }
5645
+ return matched;
5484
5646
  }
5485
- validEntries(sessionId) {
5486
- const cutoff = this.now() - this.expiryMs;
5487
- return (this.pendingBySession.get(sessionId) || []).filter((entry) => entry.timestamp >= cutoff);
5488
- }
5489
- replaceEntries(sessionId, entries) {
5490
- if (entries.length > 0) {
5491
- this.pendingBySession.set(sessionId, entries);
5647
+ // ─── Candidate matching (called from tmux-pane-observer's prompt-candidate) ─
5648
+ /**
5649
+ * Cross-reference a parsed ApprovalCandidate against the pending-call
5650
+ * list. Returns the matched PendingPromptState (which the MCP server
5651
+ * uses to create the INTERACTIVE_PROMPT event), or null if no match
5652
+ * (stale scrollback).
5653
+ *
5654
+ * Match algorithm:
5655
+ * 1. Filter pendingCalls by candidate.command (if set) — exact match
5656
+ * against PendingToolCall.command.
5657
+ * 2. Fallback: filter by candidate.filePath (if set) — exact match
5658
+ * against PendingToolCall.filePath.
5659
+ * 3. If multiple candidates match (rare), prefer the earliest
5660
+ * intentStepIndex.
5661
+ * 4. If a match exists AND it already has a promptId AND that
5662
+ * promptId is in resolvedPrompts → null (don't re-emit).
5663
+ * 5. If a match exists AND has a promptId BUT NOT resolved → return
5664
+ * the existing PendingPromptState (idempotent re-emit on the same
5665
+ * promptId for a duplicate candidate hash).
5666
+ * 6. Otherwise: create a fresh promptId + PendingPromptState, attach
5667
+ * to the matched call, store in pendingPrompts, return it.
5668
+ */
5669
+ matchCandidate(candidate, conversationId) {
5670
+ const state = this.matchCandidateInternal(candidate, conversationId);
5671
+ if (state) return state;
5672
+ const existing = this.pendingCandidates.get(this.candidateKey(conversationId, candidate.paneHash));
5673
+ if (existing) {
5674
+ logger.debug("Candidate already buffered (same paneHash)", { paneHash: this.candidateKey(conversationId, candidate.paneHash) });
5492
5675
  } else {
5493
- this.pendingBySession.delete(sessionId);
5494
- }
5495
- }
5496
- matchType(original, echo) {
5497
- if (original === echo) {
5498
- return "exact";
5499
- }
5500
- const longEnough = echo.length >= this.minFuzzyEchoLength;
5501
- const enoughOfOriginal = echo.length / original.length >= this.minFuzzyEchoRatio;
5502
- if (longEnough && enoughOfOriginal && original.endsWith(echo)) {
5503
- return "suffix";
5676
+ this.pendingCandidates.set(this.candidateKey(conversationId, candidate.paneHash), {
5677
+ candidate,
5678
+ conversationId,
5679
+ queuedAt: Date.now()
5680
+ });
5681
+ logger.debug("No pending tool-call matched \u2014 buffering candidate for pre-transcript window", {
5682
+ candidateCommand: candidate.command,
5683
+ candidateFilePath: candidate.filePath,
5684
+ pendingCount: this.pendingCalls.size,
5685
+ bufferSize: this.pendingCandidates.size
5686
+ });
5504
5687
  }
5505
5688
  return null;
5506
5689
  }
5507
- normalize(text) {
5508
- return text.replace(/\s+/g, " ").trim();
5509
- }
5510
- };
5511
-
5512
- // src/daemon-telemetry.ts
5513
- var crypto2 = __toESM(require("crypto"));
5514
- var fs6 = __toESM(require("fs"));
5515
- var https = __toESM(require("https"));
5516
- var os6 = __toESM(require("os"));
5517
- var path7 = __toESM(require("path"));
5518
- var MEASUREMENT_ID = "G-GS74YEQTB8";
5519
- var API_SECRET = "lAfOF6OxRzSQ-NsLBRjhAg";
5520
- var GA4_HOSTNAME = "www.google-analytics.com";
5521
- var GA4_PATH = `/mp/collect?measurement_id=${MEASUREMENT_ID}&api_secret=${API_SECRET}`;
5522
- var BEACON_TOTAL_TIMEOUT_MS = 800;
5523
- function readPluginVersion() {
5524
- try {
5525
- const pkgPath = path7.resolve(__dirname, "..", "package.json");
5526
- const raw = fs6.readFileSync(pkgPath, "utf-8");
5527
- const pkg = JSON.parse(raw);
5528
- if (typeof pkg.version === "string" && pkg.version.length > 0 && pkg.version.length < 30) {
5529
- return pkg.version;
5690
+ /** Internal match logic — does NOT buffer on miss. Used both by the
5691
+ * public matchCandidate (which buffers on miss) and by
5692
+ * drainBufferedCandidates (which doesn't re-buffer; misses just stay
5693
+ * in the buffer until their TTL expires). */
5694
+ matchCandidateInternal(candidate, conversationId) {
5695
+ const matched = this.findMatchingPendingCall(candidate, conversationId);
5696
+ if (!matched) {
5697
+ return null;
5530
5698
  }
5531
- } catch {
5532
- }
5533
- return "unknown";
5534
- }
5535
- var PLUGIN_VERSION = readPluginVersion();
5536
- function getClientId() {
5537
- const uid = typeof process.getuid === "function" ? process.getuid() : 0;
5538
- return crypto2.createHash("sha256").update(`${os6.hostname()}-${uid}`).digest("hex").substring(0, 36);
5539
- }
5540
- function sanitizeErrorMessage(msg) {
5541
- if (!msg) return "";
5542
- const homedir5 = os6.homedir();
5543
- let out = msg.replace(/[\n\r\t]/g, " ");
5544
- if (homedir5 && homedir5.length > 0) {
5545
- const safeHome = homedir5.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5546
- out = out.replace(new RegExp(safeHome, "g"), "~");
5547
- }
5548
- 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, "");
5549
- return out.trim().substring(0, 100);
5550
- }
5551
- function fireDaemonBeacon(name, params) {
5552
- return new Promise((resolve2) => {
5553
- let req;
5554
- let settled = false;
5555
- const settle = () => {
5556
- if (settled) return;
5557
- settled = true;
5558
- clearTimeout(overall);
5559
- resolve2();
5699
+ if (matched.promptId && this.resolvedPrompts.has(matched.promptId)) {
5700
+ logger.debug("Skipping re-emit for already-resolved prompt", {
5701
+ promptId: matched.promptId
5702
+ });
5703
+ return null;
5704
+ }
5705
+ if (matched.promptId && this.pendingPrompts.has(matched.promptId)) {
5706
+ return this.pendingPrompts.get(matched.promptId) ?? null;
5707
+ }
5708
+ const promptId = (0, import_uuid2.v4)();
5709
+ matched.promptId = promptId;
5710
+ const state = {
5711
+ promptId,
5712
+ conversationId,
5713
+ pendingCall: matched,
5714
+ submitMap: candidate.submitMap,
5715
+ matchedPaneHeader: candidate.headerText,
5716
+ paneDisplayHeader: candidate.fullHeaderLine,
5717
+ body: candidate.body,
5718
+ paneOptions: candidate.options,
5719
+ paneSemanticKey: buildApprovalSemanticKey(candidate),
5720
+ kind: candidate.kind,
5721
+ emittedAt: Date.now(),
5722
+ ttlMs: this.promptTtlMs
5560
5723
  };
5561
- const overall = setTimeout(() => {
5562
- try {
5563
- req?.destroy();
5564
- } catch {
5565
- }
5566
- settle();
5567
- }, BEACON_TOTAL_TIMEOUT_MS);
5568
- if (typeof overall.unref === "function") {
5569
- overall.unref();
5724
+ this.pendingPrompts.set(promptId, state);
5725
+ logger.info("Emitting INTERACTIVE_PROMPT for matched candidate", {
5726
+ promptId,
5727
+ command: matched.command,
5728
+ filePath: matched.filePath,
5729
+ intentStepIndex: matched.intentStepIndex,
5730
+ toolCallIndex: matched.toolCallIndex
5731
+ });
5732
+ this.emit("pending-prompt", state);
5733
+ return state;
5734
+ }
5735
+ /** Resolve a pending prompt — called from MCP server when mobile sends
5736
+ * the option-number reply with metadata.promptId. */
5737
+ /**
5738
+ * Called from the MCP server when paneObserver emits 'prompt-cleared'
5739
+ * — i.e. the approval UI disappeared from the pane (desktop user
5740
+ * picked an option, agy timed out the prompt, or the dispatcher
5741
+ * answered). Resolves ALL outstanding pane-only pending prompts and
5742
+ * releases the paneOnly dedup hashes so a subsequent approval can
5743
+ * fresh-emit. Without this, mobile prompt state dangles until TTL
5744
+ * AND the next identical approval is forever-suppressed by stale
5745
+ * dedup. (Stage 2 R1 MED finding 2026-05-21.)
5746
+ *
5747
+ * `emittedBeforeMs` (F1, Stage 2 R1-F2): a desktop input that waited in the
5748
+ * routing hold proves only that prompts raised BEFORE it was typed are gone.
5749
+ * A prompt emitted after that moment stays pending, and so does its dedup
5750
+ * hash (clearing it would re-emit the still-visible chooser).
5751
+ */
5752
+ onPanePromptCleared(emittedBeforeMs) {
5753
+ if (this.pendingPrompts.size === 0 && this.paneOnlyEmittedHashes.size === 0) {
5754
+ return [];
5570
5755
  }
5571
- try {
5572
- const sanitizedParams = {};
5573
- for (const [key, value] of Object.entries(params)) {
5574
- if (typeof value === "string") {
5575
- sanitizedParams[key] = sanitizeErrorMessage(value);
5576
- } else {
5577
- sanitizedParams[key] = value;
5578
- }
5756
+ const now = Date.now();
5757
+ const cleared = [];
5758
+ let retained = false;
5759
+ for (const [promptId, state] of this.pendingPrompts.entries()) {
5760
+ if (emittedBeforeMs !== void 0 && state.emittedAt >= emittedBeforeMs) {
5761
+ retained = true;
5762
+ continue;
5579
5763
  }
5580
- const payload = JSON.stringify({
5581
- client_id: getClientId(),
5582
- events: [
5583
- {
5584
- name,
5585
- params: {
5586
- agent: "antigravity",
5587
- plugin_version: PLUGIN_VERSION,
5588
- platform: process.platform,
5589
- source: process.env.CODEVIBE_TELEMETRY_SOURCE || "production",
5590
- ...sanitizedParams
5591
- }
5592
- }
5593
- ]
5594
- });
5595
- req = https.request(
5596
- {
5597
- hostname: GA4_HOSTNAME,
5598
- path: GA4_PATH,
5599
- method: "POST",
5600
- headers: { "Content-Type": "application/json" },
5601
- timeout: BEACON_TOTAL_TIMEOUT_MS
5602
- },
5603
- (res) => {
5604
- res.resume();
5605
- res.on("end", settle);
5606
- res.on("close", settle);
5607
- res.on("error", settle);
5608
- }
5609
- );
5610
- req.on("error", settle);
5611
- req.on("timeout", () => {
5612
- try {
5613
- req?.destroy();
5614
- } catch {
5615
- }
5616
- settle();
5617
- });
5618
- req.on("close", settle);
5619
- req.write(payload);
5620
- req.end();
5621
- } catch {
5622
- settle();
5764
+ if (state.isHookPrompt) {
5765
+ retained = true;
5766
+ continue;
5767
+ }
5768
+ this.resolvedPrompts.set(promptId, now);
5769
+ cleared.push(promptId);
5623
5770
  }
5624
- });
5625
- }
5626
-
5627
- // src/event-mapper.ts
5628
- var import_uuid2 = require("uuid");
5629
- var import_codevibe_core5 = require("@quantiya/codevibe-core");
5630
- var CONTENT_MAX = 150 * 1024;
5631
- var TOOL_OUTPUT_MAX = 50 * 1024;
5632
- var TOOL_DIFF_MAX = 100 * 1024;
5633
- function mapTranscriptEvent(emit, sessionId, notBeforeMs) {
5634
- const { event } = emit;
5635
- const base = {
5636
- sessionId,
5637
- source: import_codevibe_core5.EventSource.DESKTOP
5638
- };
5639
- const orderingKey = `${sessionId}:${emit.conversationId}`;
5640
- const nextTimestamp = () => (0, import_codevibe_core5.prepareEventTimestamp)({ orderingKey, agentClock: event.created_at, notBeforeMs });
5641
- const baseMetadata = {
5642
- step_index: event.step_index,
5643
- agy_source: event.source,
5644
- agy_status: event.status,
5645
- agy_created_at: event.created_at,
5646
- agy_type: event.type,
5647
- agy_conversation_id: emit.conversationId
5648
- };
5649
- if (event.source === "SYSTEM") {
5650
- if (event.type === "CONVERSATION_HISTORY" || event.type === "SYSTEM_MESSAGE") {
5651
- logger.debug("Dropping internal transcript event", {
5652
- type: event.type,
5653
- stepIndex: event.step_index
5654
- });
5655
- return { events: [], pendingCallRegistrations: [] };
5771
+ if (!retained) {
5772
+ this.pendingPrompts.clear();
5773
+ this.paneOnlyEmittedHashes.clear();
5774
+ } else {
5775
+ for (const promptId of cleared) {
5776
+ const state = this.pendingPrompts.get(promptId);
5777
+ if (state?.paneHash) this.paneOnlyEmittedHashes.delete(state.paneHash);
5778
+ this.pendingPrompts.delete(promptId);
5779
+ }
5656
5780
  }
5657
- if (event.type !== "ERROR_MESSAGE") {
5658
- logger.warn("Dropping unknown SYSTEM-source transcript event (safety)", {
5659
- type: event.type,
5660
- stepIndex: event.step_index
5781
+ if (cleared.length > 0) {
5782
+ logger.info("Pane prompt cleared \u2014 resolved pending prompts", {
5783
+ count: cleared.length
5661
5784
  });
5662
- return { events: [], pendingCallRegistrations: [] };
5663
5785
  }
5786
+ return cleared;
5664
5787
  }
5665
- if (event.type === "CONVERSATION_HISTORY" || event.type === "SYSTEM_MESSAGE") {
5666
- logger.debug("Dropping internal transcript event (non-SYSTEM source)", {
5667
- type: event.type,
5668
- source: event.source,
5669
- stepIndex: event.step_index
5670
- });
5671
- return { events: [], pendingCallRegistrations: [] };
5788
+ resolvePrompt(promptId, options) {
5789
+ const state = this.pendingPrompts.get(promptId);
5790
+ if (!state) return null;
5791
+ this.resolvedPrompts.set(promptId, Date.now());
5792
+ this.pendingPrompts.delete(promptId);
5793
+ const advanceWizard = options?.advanceWizard !== false;
5794
+ const liveWizard = this.liveWizardInvocations.get(state.conversationId);
5795
+ if (liveWizard && liveWizard.promptId === promptId) {
5796
+ if (advanceWizard) {
5797
+ const maxStep = (liveWizard.call.structuredQuestions?.length ?? 1) - 1;
5798
+ liveWizard.activeStepIndex = Math.min(liveWizard.activeStepIndex + 1, maxStep);
5799
+ }
5800
+ liveWizard.promptId = "";
5801
+ }
5802
+ this.paneOnlyEmittedHashes.clear();
5803
+ return state;
5672
5804
  }
5673
- if (event.type === "USER_INPUT") {
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
- };
5805
+ /**
5806
+ * Roll back a pending prompt WITHOUT marking it as resolved. Used by
5807
+ * the MCP server when the INTERACTIVE_PROMPT createEvent fails — the
5808
+ * prompt was never delivered to mobile, so a future tmux pane
5809
+ * candidate for the same pending tool-call should be eligible to
5810
+ * re-emit. `resolvePrompt` is the WRONG call there because it adds
5811
+ * the promptId to `resolvedPrompts`, which the stale-scrollback guard
5812
+ * uses to suppress re-emit forever. This method instead detaches the
5813
+ * promptId from the pending call + removes from pendingPrompts +
5814
+ * leaves resolvedPrompts UNTOUCHED so the fresh-emit path runs on
5815
+ * the next match. (agy R2 Stage 2 round-2 MED finding 2026-05-20.)
5816
+ */
5817
+ rollbackPrompt(promptId) {
5818
+ const state = this.pendingPrompts.get(promptId);
5819
+ if (!state) return null;
5820
+ this.pendingPrompts.delete(promptId);
5821
+ if (state.pendingCall.promptId === promptId) {
5822
+ state.pendingCall.promptId = void 0;
5823
+ }
5824
+ for (const call of this.pendingCalls.values()) {
5825
+ if (call.matchedPromptId === promptId) {
5826
+ call.matchedPromptId = void 0;
5827
+ }
5828
+ }
5829
+ this.paneOnlyEmittedHashes.clear();
5830
+ return state;
5687
5831
  }
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
- });
5832
+ /**
5833
+ * Rekey a pending prompt in the detector's internal map and associated pending calls.
5834
+ * Keeps map keys and state.promptId synchronized when a server layer adopts an admission identity.
5835
+ */
5836
+ rekeyPrompt(oldPromptId, newPromptId) {
5837
+ if (oldPromptId === newPromptId) return;
5838
+ const state = this.pendingPrompts.get(oldPromptId);
5839
+ if (!state) return;
5840
+ this.pendingPrompts.delete(oldPromptId);
5841
+ state.promptId = newPromptId;
5842
+ if (state.pendingCall) {
5843
+ if (state.pendingCall.promptId === oldPromptId) state.pendingCall.promptId = newPromptId;
5844
+ if (state.pendingCall.matchedPromptId === oldPromptId) state.pendingCall.matchedPromptId = newPromptId;
5699
5845
  }
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
- });
5846
+ for (const call of this.pendingCalls.values()) {
5847
+ if (call.promptId === oldPromptId) call.promptId = newPromptId;
5848
+ if (call.matchedPromptId === oldPromptId) call.matchedPromptId = newPromptId;
5708
5849
  }
5709
- const toolCalls = event.tool_calls;
5710
- if (Array.isArray(toolCalls)) {
5711
- for (let i = 0; i < toolCalls.length; i++) {
5712
- const tc = toolCalls[i];
5713
- if (!tc || typeof tc !== "object") continue;
5714
- const name = tc.name;
5715
- if (typeof name !== "string") continue;
5716
- const args = tc.args;
5717
- pendingCallRegistrations.push({
5718
- intentStepIndex: event.step_index,
5719
- toolCallIndex: i,
5720
- intentByteOffset: emit.byteOffset,
5721
- toolType: hookToolNameToTranscriptType(name),
5722
- conversationId: emit.conversationId,
5723
- command: extractCommandFromArgs(name, args),
5724
- filePath: extractFilePathFromArgs(name, args),
5725
- questionText: extractQuestionFromArgs(name, args),
5726
- questionOptions: extractQuestionOptionsFromArgs(name, args),
5727
- cwd: extractCwdFromArgs(args),
5728
- rawToolName: name,
5729
- startedAt: Date.now(),
5730
- intentCreatedAt: event.created_at
5850
+ this.pendingPrompts.set(newPromptId, state);
5851
+ }
5852
+ /** Returns the current pending-prompt state by id, or null if expired /
5853
+ * unknown. Used by prompt-responder for the active-prompt-identity
5854
+ * guard right before send-keys. */
5855
+ getPendingPrompt(promptId) {
5856
+ return this.pendingPrompts.get(promptId) ?? null;
5857
+ }
5858
+ getPendingPromptsForConversation(conversationId) {
5859
+ const pending = Array.from(this.pendingPrompts.values());
5860
+ return conversationId ? pending.filter((state) => state.conversationId === conversationId) : pending;
5861
+ }
5862
+ // ─── Read-only accessors ────────────────────────────────────────────────
5863
+ getPendingCalls() {
5864
+ return Array.from(this.pendingCalls.values());
5865
+ }
5866
+ getActivePromptCount() {
5867
+ return this.pendingPrompts.size;
5868
+ }
5869
+ // ─── Internals ──────────────────────────────────────────────────────────
5870
+ callKey(call) {
5871
+ return `${call.conversationId}|${call.intentStepIndex}|${call.toolCallIndex}|${call.intentByteOffset}`;
5872
+ }
5873
+ findMatchingPendingCall(candidate, conversationId) {
5874
+ if (candidate.kind === "question") {
5875
+ const questionCalls = Array.from(this.pendingCalls.values()).filter((c) => c.conversationId === conversationId).filter((c) => c.resultStepIndex === void 0).filter((c) => {
5876
+ if (c.structuredQuestions && candidate.headerText) {
5877
+ const matchesStructured = c.structuredQuestions.some((sq) => questionsMatch(sq.question, candidate.headerText));
5878
+ if (matchesStructured) return true;
5879
+ }
5880
+ const associatedPromptId = c.matchedPromptId || c.promptId;
5881
+ if (!associatedPromptId) return true;
5882
+ return !this.resolvedPrompts.has(associatedPromptId) && !this.pendingPrompts.has(associatedPromptId);
5883
+ }).filter(
5884
+ (c) => c.toolType === "ASK_QUESTION" || ["ask_question", "question", "ask"].includes(c.rawToolName?.toLowerCase() ?? "")
5885
+ ).sort((a, b) => (b.intentStepIndex ?? 0) - (a.intentStepIndex ?? 0));
5886
+ if (questionCalls.length > 0 && candidate.headerText) {
5887
+ const textMatch = questionCalls.find((c) => {
5888
+ if (!c.questionText) return false;
5889
+ if (questionsMatch(c.questionText, candidate.headerText)) return true;
5890
+ if (c.structuredQuestions) {
5891
+ return c.structuredQuestions.some((sq) => questionsMatch(sq.question, candidate.headerText));
5892
+ }
5893
+ return false;
5731
5894
  });
5895
+ if (textMatch) return textMatch;
5732
5896
  }
5897
+ return null;
5733
5898
  }
5734
- return { events, pendingCallRegistrations };
5735
- }
5736
- if (event.type === "ERROR_MESSAGE") {
5737
- const content = stripTimestampPrefix(event.content || "Agy error");
5738
- return {
5739
- events: [
5740
- {
5741
- ...base,
5742
- timestamp: nextTimestamp(),
5743
- type: import_codevibe_core5.EventType.NOTIFICATION,
5744
- content: truncate(content, CONTENT_MAX),
5745
- metadata: {
5746
- ...baseMetadata,
5747
- severity: "error"
5748
- }
5749
- }
5750
- ],
5751
- pendingCallRegistrations: []
5752
- };
5899
+ if (candidate.command) {
5900
+ 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);
5901
+ if (matches.length > 0) return matches[0];
5902
+ }
5903
+ if (candidate.filePath) {
5904
+ 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);
5905
+ if (matches.length > 0) return matches[0];
5906
+ }
5907
+ const allFields = (candidate.command ?? "") + " " + (candidate.filePath ?? "");
5908
+ if (allFields.trim()) {
5909
+ const matches = Array.from(this.pendingCalls.values()).filter((c) => c.conversationId === conversationId).filter((c) => {
5910
+ const cmd = (c.command ?? "").trim();
5911
+ const fp = (c.filePath ?? "").trim();
5912
+ if (!cmd && !fp) return false;
5913
+ const cmdOk = cmd.length >= PASS3_MIN_IDENTITY_LEN && containsAsWord(allFields, cmd);
5914
+ const fpOk = fp.length >= PASS3_MIN_IDENTITY_LEN && containsAsWord(allFields, fp);
5915
+ return cmdOk || fpOk;
5916
+ }).sort((a, b) => a.intentStepIndex - b.intentStepIndex);
5917
+ if (matches.length > 0) return matches[0];
5918
+ }
5919
+ return null;
5753
5920
  }
5754
- const toolName = transcriptTypeToFriendlyToolName(event.type);
5755
- const isErrorStatus = event.status === "ERROR";
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
5921
+ sweep() {
5922
+ const now = Date.now();
5923
+ for (const [key, call] of this.pendingCalls.entries()) {
5924
+ if (now - call.startedAt > this.pendingCallTtlMs) {
5925
+ this.pendingCalls.delete(key);
5926
+ if (call.promptId) {
5927
+ this.pendingPrompts.delete(call.promptId);
5928
+ this.resolvedPrompts.set(call.promptId, now);
5770
5929
  }
5930
+ logger.warn("Pending tool call expired (no matching result event)", {
5931
+ intentStepIndex: call.intentStepIndex,
5932
+ toolCallIndex: call.toolCallIndex,
5933
+ command: call.command,
5934
+ filePath: call.filePath,
5935
+ ageMs: now - call.startedAt
5936
+ });
5937
+ this.emit("pending-call-expired", call);
5771
5938
  }
5772
- ],
5773
- pendingCallRegistrations: []
5774
- };
5775
- }
5776
- function stripUserRequestWrapper(content) {
5777
- const match = content.match(/<USER_REQUEST>([\s\S]*?)<\/USER_REQUEST>/);
5778
- if (match && match[1] !== void 0) {
5779
- return match[1].trim();
5939
+ }
5940
+ for (const [promptId, resolvedAt] of this.resolvedPrompts.entries()) {
5941
+ if (now - resolvedAt > 5 * 6e4) {
5942
+ this.resolvedPrompts.delete(promptId);
5943
+ }
5944
+ }
5945
+ for (const [key, buffered] of this.pendingCandidates.entries()) {
5946
+ if (now - buffered.queuedAt > this.pendingCandidateBufferMs) {
5947
+ this.pendingCandidates.delete(key);
5948
+ logger.debug("Buffered candidate expired without ever matching a pending call", {
5949
+ key,
5950
+ candidateCommand: buffered.candidate.command,
5951
+ ageMs: now - buffered.queuedAt
5952
+ });
5953
+ }
5954
+ }
5780
5955
  }
5781
- return content.trim();
5782
- }
5783
- function stripTimestampPrefix(content) {
5784
- return content.replace(
5785
- /^Created At:[^\n]*\nCompleted At:[^\n]*\n+(?:\s*\n)?/,
5786
- ""
5787
- ).trim();
5956
+ };
5957
+ function containsAsWord(haystack, needle) {
5958
+ const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5959
+ return new RegExp(`(^|[\\s"'\`])${escaped}([\\s"'\`]|$)`).test(haystack);
5788
5960
  }
5789
- function transcriptTypeToFriendlyToolName(type) {
5790
- switch (type) {
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";
5961
+
5962
+ // src/prompt-responder.ts
5963
+ var import_child_process2 = require("child_process");
5964
+ var import_util2 = require("util");
5965
+ var execAsync2 = (0, import_util2.promisify)(import_child_process2.exec);
5966
+ var ENTER_DELAY_MS = 100;
5967
+ var CODEVIBE_AGY_ESCAPE_INPUT = "__CODEVIBE_AGY_KEY_ESCAPE__";
5968
+ var PromptResponder = class {
5969
+ constructor(options = {}) {
5970
+ this.tmuxTargetOverride = options.tmuxTarget;
5971
+ this.execFn = options.execFn ?? (async (cmd) => execAsync2(cmd));
5972
+ this.paneObserver = options.paneObserver ?? null;
5973
+ this.detector = options.detector ?? null;
5974
+ if (this.detector && !this.paneObserver) {
5975
+ throw new Error(
5976
+ "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."
5977
+ );
5978
+ }
5807
5979
  }
5808
- }
5809
- function hookToolNameToTranscriptType(hookName) {
5810
- const upper = hookName.toUpperCase();
5811
- switch (upper) {
5812
- case "LIST_DIR":
5813
- return "LIST_DIRECTORY";
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;
5980
+ /**
5981
+ * Send free-form text to the agy session. The text is typed (via
5982
+ * `send-keys -l`) then Enter is sent after a short delay so agy's
5983
+ * input handler has time to process the buffer.
5984
+ */
5985
+ async sendFreeForm(text) {
5986
+ const target = this.resolveTmuxTarget();
5987
+ if (!target) {
5988
+ return { ok: false, reason: "no-tmux-target" };
5989
+ }
5990
+ try {
5991
+ if (text === CODEVIBE_AGY_ESCAPE_INPUT) {
5992
+ await this.sendKey(target, "Escape");
5993
+ } else {
5994
+ await this.typeLiteral(target, text);
5995
+ await delay(ENTER_DELAY_MS);
5996
+ await this.sendKey(target, "Enter");
5997
+ }
5998
+ logger.info("Sent free-form input to agy", {
5999
+ target,
6000
+ textLength: text.length
6001
+ });
6002
+ return { ok: true };
6003
+ } catch (error) {
6004
+ logger.error("tmux send-keys failed for free-form input", { target, error });
6005
+ return {
6006
+ ok: false,
6007
+ reason: "tmux-failed",
6008
+ details: error instanceof Error ? error.message : String(error)
6009
+ };
6010
+ }
5833
6011
  }
5834
- }
5835
- function extractCommandFromArgs(toolName, args) {
5836
- if (!args || typeof args !== "object") return void 0;
5837
- const a = args;
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());
6012
+ /**
6013
+ * Resolve a pending approval prompt with the mobile-user's chosen
6014
+ * option number and optional follow-up text for write-in questions.
6015
+ * Uses the submitMap from the PendingPromptState to translate
6016
+ * option-number → terminal keystrokes.
6017
+ *
6018
+ * Active-prompt-identity guard (DESIGN.md §3.4):
6019
+ * 1. Detector check: is `promptId` still in pendingPrompts? If not
6020
+ * (expired or already-resolved), drop with `prompt-expired`.
6021
+ * 2. submitMap lookup: is `optionNumber` a valid key? If not, drop with `invalid-option`.
6022
+ * If `followUpText` is provided, verify option is Write-in; otherwise drop with `follow-up-not-allowed`.
6023
+ * If bare Write-in option has no text, drop with `write-in-text-required`.
6024
+ * 3. Pane-observer check: captureSnapshot() — does the pane STILL show
6025
+ * an approval UI matching our pending prompt's semantic key?
6026
+ * 4. Send option keystrokes via tmux send-keys.
6027
+ * 5. If write-in followUpText is present: wait 250ms, verify pane state, type literal text, send Enter.
6028
+ */
6029
+ async sendApprovalReply(promptId, optionNumber, followUpText, beforeTypeText) {
6030
+ const target = this.resolveTmuxTarget();
6031
+ if (!target) {
6032
+ return { ok: false, reason: "no-tmux-target" };
6033
+ }
6034
+ if (this.detector) {
6035
+ const state = this.detector.getPendingPrompt(promptId);
6036
+ if (!state) {
6037
+ logger.warn("Mobile reply for unknown / expired promptId", { promptId, optionNumber });
6038
+ return {
6039
+ ok: false,
6040
+ reason: "prompt-expired",
6041
+ details: `promptId ${promptId} is not in pendingPrompts`
6042
+ };
6043
+ }
6044
+ const keys = state.submitMap[optionNumber];
6045
+ if (!keys || keys.length === 0) {
6046
+ return {
6047
+ ok: false,
6048
+ reason: "invalid-option",
6049
+ details: `option '${optionNumber}' not in submitMap (valid: ${Object.keys(state.submitMap).join(",")})`
6050
+ };
6051
+ }
6052
+ const isQuestionPrompt = state.kind === "question";
6053
+ const expectedStep = (state.paneDisplayHeader || "").match(/\bQuestion\s+(\d+(?:[\/:of\s]+\d+)?):/i)?.[1];
6054
+ const matchingOpt = state.paneOptions?.find((o) => String(o.number) === String(optionNumber));
6055
+ const isWriteInOption = matchingOpt ? /^\s*write-?in\b/i.test(matchingOpt.text) : false;
6056
+ if (isQuestionPrompt) {
6057
+ if (state.paneOptions && state.paneOptions.length > 0 && !matchingOpt) {
6058
+ return {
6059
+ ok: false,
6060
+ reason: "invalid-option",
6061
+ details: `option '${optionNumber}' not found in advertised options`,
6062
+ deliveryPhase: "none"
6063
+ };
6064
+ }
6065
+ if (isWriteInOption && (!followUpText || followUpText.trim().length === 0)) {
6066
+ return {
6067
+ ok: false,
6068
+ reason: "write-in-text-required",
6069
+ details: 'Please provide your write-in text along with the option number (e.g. "4, your response")',
6070
+ deliveryPhase: "none"
6071
+ };
6072
+ }
6073
+ if (followUpText && followUpText.trim().length > 0 && !isWriteInOption) {
6074
+ return {
6075
+ ok: false,
6076
+ reason: "follow-up-not-allowed",
6077
+ details: `Option '${optionNumber}' does not accept follow-up text`,
6078
+ deliveryPhase: "none"
6079
+ };
6080
+ }
6081
+ }
6082
+ if (this.paneObserver) {
6083
+ if (!state.paneSemanticKey) {
6084
+ return {
6085
+ ok: false,
6086
+ reason: "prompt-probe-failed",
6087
+ details: "pending prompt has no exact semantic identity",
6088
+ deliveryPhase: "none"
6089
+ };
6090
+ }
6091
+ }
6092
+ let deliveryPhase = "none";
6093
+ try {
6094
+ for (const key of keys) {
6095
+ if (!this.detector || !this.paneObserver) {
6096
+ return {
6097
+ ok: false,
6098
+ reason: "prompt-probe-failed",
6099
+ details: "approval responder is missing its detector or pane observer",
6100
+ deliveryPhase
6101
+ };
6102
+ }
6103
+ const currentPrompt = this.detector.getPendingPrompt(promptId);
6104
+ if (currentPrompt !== state || !currentPrompt.submitMap || !currentPrompt.submitMap[optionNumber]) {
6105
+ return {
6106
+ ok: false,
6107
+ reason: "prompt-expired",
6108
+ details: "option is no longer actionable on active prompt (controls revoked or updated)",
6109
+ deliveryPhase
6110
+ };
6111
+ }
6112
+ let liveSemanticKey = null;
6113
+ try {
6114
+ const snapshot = await this.paneObserver.captureSnapshot();
6115
+ const postCapturePrompt = this.detector.getPendingPrompt(promptId);
6116
+ if (postCapturePrompt !== state || !postCapturePrompt.submitMap || !postCapturePrompt.submitMap[optionNumber]) {
6117
+ return {
6118
+ ok: false,
6119
+ reason: "prompt-expired",
6120
+ details: "option is no longer actionable on active prompt (controls revoked or updated)",
6121
+ deliveryPhase
6122
+ };
6123
+ }
6124
+ if (isQuestionPrompt && isWriteInInputActive(snapshot, state.matchedPaneHeader, expectedStep)) {
6125
+ return {
6126
+ ok: false,
6127
+ reason: "write-in-already-active",
6128
+ details: "write-in input field is already open on desktop; finish or cancel on desktop before selecting an option",
6129
+ deliveryPhase: "none"
6130
+ };
6131
+ }
6132
+ const live = parseApprovalSnapshot(snapshot);
6133
+ if (!live) {
6134
+ if (detectActivePromptKind(snapshot) === null) {
6135
+ this.detector.resolvePrompt(promptId);
6136
+ return {
6137
+ ok: false,
6138
+ reason: "prompt-superseded",
6139
+ details: "the approval chooser is no longer active",
6140
+ deliveryPhase
6141
+ };
6142
+ }
6143
+ return {
6144
+ ok: false,
6145
+ reason: "prompt-probe-failed",
6146
+ details: "the active chooser could not be parsed exactly",
6147
+ deliveryPhase
6148
+ };
6149
+ }
6150
+ liveSemanticKey = live ? buildApprovalSemanticKey(live) : null;
6151
+ } catch (error) {
6152
+ return {
6153
+ ok: false,
6154
+ reason: "prompt-probe-failed",
6155
+ details: error instanceof Error ? error.message : String(error),
6156
+ deliveryPhase
6157
+ };
6158
+ }
6159
+ if (liveSemanticKey !== state.paneSemanticKey) {
6160
+ this.detector.resolvePrompt(promptId);
6161
+ return {
6162
+ ok: false,
6163
+ reason: "prompt-superseded",
6164
+ details: "active chooser semantics differ from the mobile prompt",
6165
+ deliveryPhase
6166
+ };
6167
+ }
6168
+ deliveryPhase = "option_attempted";
6169
+ if (isNamedKey(key)) {
6170
+ await this.sendKey(target, key);
6171
+ } else {
6172
+ await this.typeLiteral(target, key);
6173
+ }
6174
+ deliveryPhase = "option_sent";
6175
+ await delay(50);
6176
+ }
6177
+ if (isQuestionPrompt && isWriteInOption && followUpText && followUpText.trim().length > 0) {
6178
+ let writeInReady = false;
6179
+ if (this.paneObserver) {
6180
+ for (let probeAttempt = 0; probeAttempt < 10; probeAttempt++) {
6181
+ await delay(100);
6182
+ try {
6183
+ const liveSnap = await this.paneObserver.captureSnapshot();
6184
+ if (liveSnap && isWriteInInputActive(liveSnap, state.matchedPaneHeader, expectedStep)) {
6185
+ writeInReady = true;
6186
+ break;
6187
+ }
6188
+ } catch {
6189
+ }
6190
+ }
6191
+ if (!writeInReady) {
6192
+ return {
6193
+ ok: false,
6194
+ reason: "prompt-probe-failed",
6195
+ details: "terminal pane failed to transition to write-in input line",
6196
+ deliveryPhase
6197
+ };
6198
+ }
6199
+ } else {
6200
+ await delay(250);
6201
+ }
6202
+ if (beforeTypeText) {
6203
+ try {
6204
+ await beforeTypeText();
6205
+ } catch (err) {
6206
+ return {
6207
+ ok: false,
6208
+ reason: "tmux-failed",
6209
+ details: err instanceof Error ? err.message : String(err),
6210
+ deliveryPhase: "option_sent"
6211
+ // Do not advance phase if hook throws (Claude R4-N2)
6212
+ };
6213
+ }
6214
+ }
6215
+ const postHookPrompt = this.detector.getPendingPrompt(promptId);
6216
+ if (postHookPrompt !== state || !postHookPrompt.submitMap || !postHookPrompt.submitMap[optionNumber]) {
6217
+ return { ok: false, reason: "prompt-expired", deliveryPhase: "option_sent" };
6218
+ }
6219
+ if (this.paneObserver) {
6220
+ try {
6221
+ const postHookSnap = await this.paneObserver.captureSnapshot();
6222
+ if (!postHookSnap || !isWriteInInputActive(postHookSnap, state.matchedPaneHeader, expectedStep)) {
6223
+ return {
6224
+ ok: false,
6225
+ reason: "prompt-probe-failed",
6226
+ details: "write-in input field disappeared during callback before typing",
6227
+ deliveryPhase: "option_sent"
6228
+ };
6229
+ }
6230
+ } catch (err) {
6231
+ return {
6232
+ ok: false,
6233
+ reason: "prompt-probe-failed",
6234
+ details: err instanceof Error ? err.message : String(err),
6235
+ deliveryPhase: "option_sent"
6236
+ };
6237
+ }
6238
+ const postSnapPrompt1 = this.detector.getPendingPrompt(promptId);
6239
+ if (postSnapPrompt1 !== state || !postSnapPrompt1.submitMap || !postSnapPrompt1.submitMap[optionNumber]) {
6240
+ return { ok: false, reason: "prompt-expired", deliveryPhase: "option_sent" };
6241
+ }
6242
+ }
6243
+ deliveryPhase = "text_attempted";
6244
+ const cleanText = followUpText.replace(/[\r\n]+/g, " ").trim();
6245
+ await this.typeLiteral(target, cleanText);
6246
+ deliveryPhase = "text_typed";
6247
+ await delay(50);
6248
+ if (this.paneObserver) {
6249
+ try {
6250
+ const preEnterSnap = await this.paneObserver.captureSnapshot();
6251
+ if (!preEnterSnap || !isWriteInInputActive(preEnterSnap, state.matchedPaneHeader, expectedStep)) {
6252
+ return {
6253
+ ok: false,
6254
+ reason: "prompt-probe-failed",
6255
+ details: "write-in input field disappeared before confirmation",
6256
+ deliveryPhase: "text_typed"
6257
+ };
6258
+ }
6259
+ } catch (err) {
6260
+ return {
6261
+ ok: false,
6262
+ reason: "prompt-probe-failed",
6263
+ details: err instanceof Error ? err.message : String(err),
6264
+ deliveryPhase: "text_typed"
6265
+ };
6266
+ }
6267
+ }
6268
+ const postSnapPrompt = this.detector.getPendingPrompt(promptId);
6269
+ if (postSnapPrompt !== state || !postSnapPrompt.submitMap || !postSnapPrompt.submitMap[optionNumber]) {
6270
+ return { ok: false, reason: "prompt-expired", deliveryPhase: "text_typed" };
6271
+ }
6272
+ deliveryPhase = "enter_attempted";
6273
+ await this.sendKey(target, "Enter");
6274
+ deliveryPhase = "completed";
6275
+ }
6276
+ this.detector.resolvePrompt(promptId);
6277
+ logger.info("Sent approval reply via tmux", {
6278
+ promptId,
6279
+ optionNumber,
6280
+ keysSent: keys,
6281
+ hasFollowUpText: Boolean(followUpText),
6282
+ deliveryPhase
6283
+ });
6284
+ return { ok: true, deliveryPhase: "completed" };
6285
+ } catch (error) {
6286
+ return {
6287
+ ok: false,
6288
+ reason: "tmux-failed",
6289
+ details: error instanceof Error ? error.message : String(error),
6290
+ deliveryPhase
6291
+ };
6292
+ }
6293
+ }
6294
+ try {
6295
+ await this.typeLiteral(target, optionNumber);
6296
+ await delay(50);
6297
+ await this.sendKey(target, "Enter");
6298
+ if (followUpText && followUpText.trim().length > 0) {
6299
+ await delay(250);
6300
+ const cleanText = followUpText.replace(/[\r\n]+/g, " ").trim();
6301
+ await this.typeLiteral(target, cleanText);
6302
+ await delay(50);
6303
+ await this.sendKey(target, "Enter");
6304
+ }
6305
+ return { ok: true };
6306
+ } catch (error) {
6307
+ return {
6308
+ ok: false,
6309
+ reason: "tmux-failed",
6310
+ details: error instanceof Error ? error.message : String(error)
6311
+ };
6312
+ }
6313
+ }
6314
+ async sendFreeFormWhenNoApprovalActive(text, beforeType) {
6315
+ const target = this.resolveTmuxTarget();
6316
+ if (!target) return { ok: false, reason: "no-tmux-target" };
6317
+ if (!this.paneObserver) {
6318
+ return {
6319
+ ok: false,
6320
+ reason: "prompt-probe-failed",
6321
+ details: "pane observer is required for safe free-form input"
6322
+ };
6323
+ }
6324
+ const chooserIsAbsent = async () => {
6325
+ const probe = await this.paneObserver.probeApprovalUIActive();
6326
+ if (!probe.probeSucceeded) {
6327
+ return { ok: false, reason: "prompt-probe-failed" };
6328
+ }
6329
+ if (probe.active) {
6330
+ return { ok: false, reason: "prompt-superseded" };
6331
+ }
6332
+ return null;
6333
+ };
6334
+ let preparationFailed = false;
6335
+ try {
6336
+ const beforeText = await chooserIsAbsent();
6337
+ if (beforeText) return beforeText;
6338
+ if (beforeType) {
6339
+ try {
6340
+ await beforeType();
6341
+ } catch (error) {
6342
+ preparationFailed = true;
6343
+ throw error;
6344
+ }
6345
+ const afterPreparation = await chooserIsAbsent();
6346
+ if (afterPreparation) return afterPreparation;
6347
+ }
6348
+ await this.typeLiteral(target, text);
6349
+ await delay(ENTER_DELAY_MS);
6350
+ const beforeEnter = await chooserIsAbsent();
6351
+ if (beforeEnter) return beforeEnter;
6352
+ await this.sendKey(target, "Enter");
6353
+ return { ok: true };
6354
+ } catch (error) {
6355
+ if (preparationFailed) throw error;
6356
+ return {
6357
+ ok: false,
6358
+ reason: "tmux-failed",
6359
+ details: error instanceof Error ? error.message : String(error)
6360
+ };
6361
+ }
6362
+ }
6363
+ /** Dismiss whichever approval UI is active without binding to a stale promptId. */
6364
+ async dismissActivePrompt() {
6365
+ const target = this.resolveTmuxTarget();
6366
+ if (!target) return { ok: false, reason: "no-tmux-target" };
6367
+ if (!this.paneObserver) {
6368
+ return { ok: false, reason: "prompt-probe-failed" };
6369
+ }
6370
+ try {
6371
+ const before = await this.paneObserver.probeApprovalUIActive();
6372
+ if (!before.probeSucceeded) return { ok: false, reason: "prompt-probe-failed" };
6373
+ if (!before.active) return { ok: true };
6374
+ if (before.ambiguous) {
6375
+ return {
6376
+ ok: false,
6377
+ reason: "ambiguous-prompt-active",
6378
+ details: "masked/ambiguous prompt cannot be dismissed via Escape from mobile"
6379
+ };
6380
+ }
6381
+ if (before.kind === "question") {
6382
+ let isActionableWizard = false;
6383
+ try {
6384
+ const snapshot = await this.paneObserver.captureSnapshot();
6385
+ if (snapshot) {
6386
+ const parsed = parseApprovalSnapshot(snapshot);
6387
+ if (parsed && parsed.kind === "question" && Object.keys(parsed.submitMap || {}).length > 0) {
6388
+ isActionableWizard = true;
6389
+ }
6390
+ }
6391
+ } catch {
6392
+ }
6393
+ if (!isActionableWizard) {
6394
+ return {
6395
+ ok: false,
6396
+ reason: "display-only-question-active",
6397
+ details: "active question prompt cannot be dismissed via Escape from mobile"
6398
+ };
6399
+ }
6400
+ }
6401
+ await this.sendKey(target, "Escape");
6402
+ await delay(250);
6403
+ const after = await this.paneObserver.probeApprovalUIActive();
6404
+ if (!after.probeSucceeded) return { ok: false, reason: "prompt-probe-failed" };
6405
+ if (after.active) {
6406
+ return {
6407
+ ok: false,
6408
+ reason: "prompt-superseded",
6409
+ details: "approval chooser remained active after Escape"
6410
+ };
6411
+ }
6412
+ return { ok: true };
6413
+ } catch (error) {
6414
+ return {
6415
+ ok: false,
6416
+ reason: "tmux-failed",
6417
+ details: error instanceof Error ? error.message : String(error)
6418
+ };
5842
6419
  }
5843
6420
  }
5844
- return void 0;
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
- }
6421
+ // ─── Tmux primitives ────────────────────────────────────────────────────
6422
+ async typeLiteral(target, text) {
6423
+ const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, "\\$").replace(/`/g, "\\`");
6424
+ const safeTarget = escapeShellArg2(target);
6425
+ const cmd = `tmux send-keys -t '${safeTarget}' -l "${escaped}"`;
6426
+ await this.execFn(cmd);
5863
6427
  }
5864
- return void 0;
6428
+ async sendKey(target, key) {
6429
+ const safeTarget = escapeShellArg2(target);
6430
+ const cmd = `tmux send-keys -t '${safeTarget}' ${key}`;
6431
+ await this.execFn(cmd);
6432
+ }
6433
+ resolveTmuxTarget() {
6434
+ return this.tmuxTargetOverride ?? process.env.CODEVIBE_AGY_TMUX_TARGET ?? null;
6435
+ }
6436
+ };
6437
+ function escapeShellArg2(value) {
6438
+ return value.replace(/'/g, `'\\''`);
5865
6439
  }
5866
- function extractQuestionFromArgs(toolName, args) {
5867
- if (!args || typeof args !== "object") return void 0;
5868
- const tn = (toolName || "").toLowerCase().trim();
5869
- const isQuestionTool = tn === "ask_question" || tn === "question" || tn === "ask";
5870
- if (!isQuestionTool) return void 0;
5871
- const a = args;
5872
- if (typeof a.question === "string" && a.question.trim().length > 0) {
5873
- return stripWrappingQuotes2(a.question.trim());
6440
+ function isNamedKey(key) {
6441
+ return /^[A-Z][A-Za-z0-9]+$/.test(key);
6442
+ }
6443
+ function delay(ms) {
6444
+ return new Promise((r) => setTimeout(r, ms));
6445
+ }
6446
+
6447
+ // src/mobile-prompt-dedupe.ts
6448
+ var DEFAULT_EXPIRY_MS = 15e3;
6449
+ var DEFAULT_MIN_FUZZY_ECHO_LENGTH = 16;
6450
+ var DEFAULT_MIN_FUZZY_ECHO_RATIO = 0.35;
6451
+ var MobilePromptDeduper = class {
6452
+ constructor(options = {}) {
6453
+ this.pendingBySession = /* @__PURE__ */ new Map();
6454
+ this.expiryMs = options.expiryMs ?? DEFAULT_EXPIRY_MS;
6455
+ this.minFuzzyEchoLength = options.minFuzzyEchoLength ?? DEFAULT_MIN_FUZZY_ECHO_LENGTH;
6456
+ this.minFuzzyEchoRatio = options.minFuzzyEchoRatio ?? DEFAULT_MIN_FUZZY_ECHO_RATIO;
6457
+ this.now = options.now ?? Date.now;
5874
6458
  }
5875
- if (typeof a.prompt === "string" && a.prompt.trim().length > 0) {
5876
- return stripWrappingQuotes2(a.prompt.trim());
6459
+ /** Called by the MCP server immediately BEFORE prompt-responder
6460
+ * performs the tmux send-keys. Records the normalized text + timestamp
6461
+ * for future echo-suppression. */
6462
+ track(sessionId, prompt) {
6463
+ this.sweep();
6464
+ const normalized = this.normalize(prompt);
6465
+ if (!normalized) return;
6466
+ const entries = this.validEntries(sessionId);
6467
+ entries.push({ normalized, timestamp: this.now() });
6468
+ this.pendingBySession.set(sessionId, entries);
5877
6469
  }
5878
- let questions = a.questions;
5879
- if (typeof questions === "string") {
5880
- try {
5881
- questions = JSON.parse(questions.trim());
5882
- } catch {
5883
- }
6470
+ /** Symmetric helper for callers that want to retract a tracked entry
6471
+ * WITHOUT consuming it via dedupe (e.g. responder bailed before send). */
6472
+ forget(sessionId, prompt) {
6473
+ this.sweep();
6474
+ const normalized = this.normalize(prompt);
6475
+ if (!normalized) return;
6476
+ let removed = false;
6477
+ const entries = this.validEntries(sessionId).filter((entry) => {
6478
+ if (!removed && entry.normalized === normalized) {
6479
+ removed = true;
6480
+ return false;
6481
+ }
6482
+ return true;
6483
+ });
6484
+ this.replaceEntries(sessionId, entries);
5884
6485
  }
5885
- if (Array.isArray(questions) && questions.length > 0) {
5886
- const first = questions[0];
5887
- if (typeof first === "string" && first.trim().length > 0) {
5888
- return stripWrappingQuotes2(first.trim());
6486
+ /**
6487
+ * Called by event-mapper / MCP server immediately BEFORE forwarding a
6488
+ * USER_PROMPT to AppSync. If the echo matches a recently-tracked mobile
6489
+ * prompt, returns the match metadata + CONSUMES the tracked entry (so a
6490
+ * second similar echo doesn't suppress an actual second mobile prompt).
6491
+ * Returns null when no match → caller proceeds with the AppSync emit.
6492
+ */
6493
+ consumeIfDuplicate(sessionId, echo) {
6494
+ this.sweep();
6495
+ const normalizedEcho = this.normalize(echo);
6496
+ if (!normalizedEcho) return null;
6497
+ let match = null;
6498
+ const entries = this.validEntries(sessionId).filter((entry) => {
6499
+ if (!match) {
6500
+ const matchType = this.matchType(entry.normalized, normalizedEcho);
6501
+ if (matchType) {
6502
+ match = {
6503
+ matchType,
6504
+ originalLength: entry.normalized.length,
6505
+ echoLength: normalizedEcho.length
6506
+ };
6507
+ return false;
6508
+ }
6509
+ }
6510
+ return true;
6511
+ });
6512
+ this.replaceEntries(sessionId, entries);
6513
+ return match;
6514
+ }
6515
+ /** Inspect without spending the token before durable native admission. */
6516
+ peekIfDuplicate(sessionId, echo) {
6517
+ const normalized = this.normalize(echo);
6518
+ if (!normalized) return null;
6519
+ for (const entry of this.validEntries(sessionId)) {
6520
+ const matchType = this.matchType(entry.normalized, normalized);
6521
+ if (matchType) return { matchType, originalLength: entry.normalized.length, echoLength: normalized.length };
5889
6522
  }
5890
- if (first && typeof first === "object") {
5891
- const q = first.question;
5892
- if (typeof q === "string" && q.trim().length > 0) {
5893
- return stripWrappingQuotes2(q.trim());
6523
+ return null;
6524
+ }
6525
+ // ─── Internals ──────────────────────────────────────────────────────────
6526
+ sweep() {
6527
+ const cutoff = this.now() - this.expiryMs;
6528
+ for (const [sessId, entries] of this.pendingBySession.entries()) {
6529
+ const valid = entries.filter((entry) => entry.timestamp >= cutoff);
6530
+ if (valid.length > 0) {
6531
+ this.pendingBySession.set(sessId, valid);
6532
+ } else {
6533
+ this.pendingBySession.delete(sessId);
5894
6534
  }
5895
6535
  }
5896
6536
  }
5897
- return void 0;
5898
- }
5899
- function extractQuestionOptionsFromArgs(toolName, args) {
5900
- if (!args || typeof args !== "object") return void 0;
5901
- const tn = (toolName || "").toLowerCase().trim();
5902
- const isQuestionTool = tn === "ask_question" || tn === "question" || tn === "ask";
5903
- if (!isQuestionTool) return void 0;
5904
- const a = args;
5905
- let questions = a.questions;
5906
- if (typeof questions === "string") {
5907
- try {
5908
- questions = JSON.parse(questions.trim());
5909
- } catch {
6537
+ validEntries(sessionId) {
6538
+ const cutoff = this.now() - this.expiryMs;
6539
+ return (this.pendingBySession.get(sessionId) || []).filter((entry) => entry.timestamp >= cutoff);
6540
+ }
6541
+ replaceEntries(sessionId, entries) {
6542
+ if (entries.length > 0) {
6543
+ this.pendingBySession.set(sessionId, entries);
6544
+ } else {
6545
+ this.pendingBySession.delete(sessionId);
5910
6546
  }
5911
6547
  }
5912
- if (Array.isArray(questions) && questions.length > 0) {
5913
- const first = questions[0];
5914
- if (first && typeof first === "object" && Array.isArray(first.options)) {
5915
- const rawOptions = first.options;
5916
- const result = [];
5917
- for (let i = 0; i < rawOptions.length; i++) {
5918
- const opt = rawOptions[i];
5919
- if (typeof opt === "string" && opt.trim().length > 0) {
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;
6548
+ matchType(original, echo) {
6549
+ if (original === echo) {
6550
+ return "exact";
5929
6551
  }
6552
+ const longEnough = echo.length >= this.minFuzzyEchoLength;
6553
+ const enoughOfOriginal = echo.length / original.length >= this.minFuzzyEchoRatio;
6554
+ if (longEnough && enoughOfOriginal && original.endsWith(echo)) {
6555
+ return "suffix";
6556
+ }
6557
+ return null;
5930
6558
  }
5931
- return void 0;
5932
- }
5933
- function extractCwdFromArgs(args) {
5934
- if (!args || typeof args !== "object") return void 0;
5935
- const a = args;
5936
- for (const key of ["Cwd", "cwd", "workingDirectory", "WorkingDirectory"]) {
5937
- const v = a[key];
5938
- if (typeof v === "string" && v.trim().length > 0) {
5939
- return stripWrappingQuotes2(v.trim());
6559
+ normalize(text) {
6560
+ return text.replace(/\s+/g, " ").trim();
6561
+ }
6562
+ };
6563
+
6564
+ // src/daemon-telemetry.ts
6565
+ var crypto2 = __toESM(require("crypto"));
6566
+ var fs6 = __toESM(require("fs"));
6567
+ var https = __toESM(require("https"));
6568
+ var os6 = __toESM(require("os"));
6569
+ var path7 = __toESM(require("path"));
6570
+ var MEASUREMENT_ID = "G-GS74YEQTB8";
6571
+ var API_SECRET = "lAfOF6OxRzSQ-NsLBRjhAg";
6572
+ var GA4_HOSTNAME = "www.google-analytics.com";
6573
+ var GA4_PATH = `/mp/collect?measurement_id=${MEASUREMENT_ID}&api_secret=${API_SECRET}`;
6574
+ var BEACON_TOTAL_TIMEOUT_MS = 800;
6575
+ function readPluginVersion() {
6576
+ try {
6577
+ const pkgPath = path7.resolve(__dirname, "..", "package.json");
6578
+ const raw = fs6.readFileSync(pkgPath, "utf-8");
6579
+ const pkg = JSON.parse(raw);
6580
+ if (typeof pkg.version === "string" && pkg.version.length > 0 && pkg.version.length < 30) {
6581
+ return pkg.version;
5940
6582
  }
6583
+ } catch {
5941
6584
  }
5942
- return void 0;
6585
+ return "unknown";
5943
6586
  }
5944
- function stripWrappingQuotes2(s) {
5945
- if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
5946
- return s.slice(1, -1);
5947
- }
5948
- return s;
6587
+ var PLUGIN_VERSION = readPluginVersion();
6588
+ function getClientId() {
6589
+ const uid = typeof process.getuid === "function" ? process.getuid() : 0;
6590
+ return crypto2.createHash("sha256").update(`${os6.hostname()}-${uid}`).digest("hex").substring(0, 36);
5949
6591
  }
5950
- function formatToolResultContent(agyType, toolName, output, isError) {
5951
- const prefix = isError ? "\u26A0 " : "";
5952
- const firstLine = output.split("\n", 1)[0]?.trim();
5953
- switch (agyType) {
5954
- case "RUN_COMMAND":
5955
- return `${prefix}Bash: ${firstLine || "command executed"}`;
5956
- case "LIST_DIRECTORY":
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"}`;
6592
+ function sanitizeErrorMessage(msg) {
6593
+ if (!msg) return "";
6594
+ const homedir5 = os6.homedir();
6595
+ let out = msg.replace(/[\n\r\t]/g, " ");
6596
+ if (homedir5 && homedir5.length > 0) {
6597
+ const safeHome = homedir5.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6598
+ out = out.replace(new RegExp(safeHome, "g"), "~");
5970
6599
  }
6600
+ 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, "");
6601
+ return out.trim().substring(0, 100);
5971
6602
  }
5972
- function truncate(s, maxBytes) {
5973
- if (!s) return "";
5974
- if (Buffer.byteLength(s, "utf-8") <= maxBytes) return s;
5975
- let lo = 0;
5976
- let hi = s.length;
5977
- while (lo < hi) {
5978
- const mid = lo + hi + 1 >>> 1;
5979
- if (Buffer.byteLength(s.slice(0, mid), "utf-8") <= maxBytes - 32) {
5980
- lo = mid;
5981
- } else {
5982
- hi = mid - 1;
6603
+ function fireDaemonBeacon(name, params) {
6604
+ return new Promise((resolve2) => {
6605
+ let req;
6606
+ let settled = false;
6607
+ const settle = () => {
6608
+ if (settled) return;
6609
+ settled = true;
6610
+ clearTimeout(overall);
6611
+ resolve2();
6612
+ };
6613
+ const overall = setTimeout(() => {
6614
+ try {
6615
+ req?.destroy();
6616
+ } catch {
6617
+ }
6618
+ settle();
6619
+ }, BEACON_TOTAL_TIMEOUT_MS);
6620
+ if (typeof overall.unref === "function") {
6621
+ overall.unref();
5983
6622
  }
5984
- }
5985
- return s.slice(0, lo) + ` [...truncated ${s.length - lo} chars]`;
6623
+ try {
6624
+ const sanitizedParams = {};
6625
+ for (const [key, value] of Object.entries(params)) {
6626
+ if (typeof value === "string") {
6627
+ sanitizedParams[key] = sanitizeErrorMessage(value);
6628
+ } else {
6629
+ sanitizedParams[key] = value;
6630
+ }
6631
+ }
6632
+ const payload = JSON.stringify({
6633
+ client_id: getClientId(),
6634
+ events: [
6635
+ {
6636
+ name,
6637
+ params: {
6638
+ agent: "antigravity",
6639
+ plugin_version: PLUGIN_VERSION,
6640
+ platform: process.platform,
6641
+ source: process.env.CODEVIBE_TELEMETRY_SOURCE || "production",
6642
+ ...sanitizedParams
6643
+ }
6644
+ }
6645
+ ]
6646
+ });
6647
+ req = https.request(
6648
+ {
6649
+ hostname: GA4_HOSTNAME,
6650
+ path: GA4_PATH,
6651
+ method: "POST",
6652
+ headers: { "Content-Type": "application/json" },
6653
+ timeout: BEACON_TOTAL_TIMEOUT_MS
6654
+ },
6655
+ (res) => {
6656
+ res.resume();
6657
+ res.on("end", settle);
6658
+ res.on("close", settle);
6659
+ res.on("error", settle);
6660
+ }
6661
+ );
6662
+ req.on("error", settle);
6663
+ req.on("timeout", () => {
6664
+ try {
6665
+ req?.destroy();
6666
+ } catch {
6667
+ }
6668
+ settle();
6669
+ });
6670
+ req.on("close", settle);
6671
+ req.write(payload);
6672
+ req.end();
6673
+ } catch {
6674
+ settle();
6675
+ }
6676
+ });
5986
6677
  }
5987
6678
 
5988
6679
  // src/tool-activity-integration.ts
@@ -6437,6 +7128,20 @@ var McpServer = class _McpServer {
6437
7128
  * bounded by TTL. (Codex Stage 2 R1 v12 HIGH.) */
6438
7129
  this.observedMainConversationIds = /* @__PURE__ */ new Set();
6439
7130
  this.hookRegistrationCache = null;
7131
+ /** Tracks generation numbers per conversation to invalidate in-flight async PreToolUse callbacks upon turn transition, Stop, or PostToolUse. (Codex S2-Codex-F6) */
7132
+ this.conversationGenerations = /* @__PURE__ */ new Map();
7133
+ /** Tracks turn transition and Stop generations per conversation to distinguish turn boundaries from tool completions. (Codex S2-Codex-F6, S2-Codex-R12-F2) */
7134
+ this.conversationTurnGenerations = /* @__PURE__ */ new Map();
7135
+ /** Monotonic sequence counter for hook lifecycle events (PreToolUse arrival, PostToolUse completion, Stop). (Claude R13-F2, Codex S2-Codex-R13-F1) */
7136
+ this.hookEventSeq = 0;
7137
+ /** Tracks Stop sequence per conversation to invalidate in-flight async bootstrap across stop events. (Codex S2-Codex-F6, Claude R13-N1) */
7138
+ this.conversationStopSeq = /* @__PURE__ */ new Map();
7139
+ /** 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) */
7140
+ this.conversationCompletedPostDeliveries = /* @__PURE__ */ new Map();
7141
+ /** Tracks the latest turn initialNumSteps to filter stale in-flight or held deliveries from earlier turns. (Codex S2-Codex-F6) */
7142
+ this.conversationInitialNumSteps = /* @__PURE__ */ new Map();
7143
+ /** Bounded buffer for PreToolUse deliveries arriving while conversation routing is in 'hold'. (Codex S2-Codex-F6) */
7144
+ this.heldHookDeliveries = /* @__PURE__ */ new Map();
6440
7145
  this.started = false;
6441
7146
  /** Monotonically-increasing counter incremented on every start(). Captured
6442
7147
  * by async callbacks at entry; mismatch after an await means stop()→start()
@@ -6563,8 +7268,16 @@ var McpServer = class _McpServer {
6563
7268
  isClaimedElsewhere: (conversationId) => this.claims.isClaimedElsewhere(conversationId),
6564
7269
  otherInvokedAt: (conversationId) => this.claims.otherInvokedAt(conversationId),
6565
7270
  readTranscriptFirstType: (conversationId) => this.readConversationFirstType(conversationId),
6566
- onRelease: (emits, decision) => {
7271
+ onRelease: (emits, decision, convId) => {
6567
7272
  for (const emit of emits) this.dispatchTranscriptEmit(emit, decision);
7273
+ const targetConvId = convId ?? (emits.length > 0 ? emits[0].conversationId : void 0);
7274
+ if (targetConvId) {
7275
+ const turnStep = emits.find((e) => e.event?.type === "USER_INPUT")?.event?.step_index;
7276
+ const minStep = this.conversationInitialNumSteps.get(targetConvId) ?? (typeof turnStep === "number" ? turnStep : void 0);
7277
+ void this.drainHeldHookDeliveries(targetConvId, decision, minStep).catch((err) => {
7278
+ logger.warn("Failed draining held hook deliveries onRelease", { error: String(err) });
7279
+ });
7280
+ }
6568
7281
  },
6569
7282
  isModelOutput: (emit) => emit.event?.source === "MODEL",
6570
7283
  turnStep: (emit) => emit.event?.type === "USER_INPUT" ? typeof emit.event.step_index === "number" ? emit.event.step_index : -1 : null,
@@ -6572,6 +7285,7 @@ var McpServer = class _McpServer {
6572
7285
  onLog: (message, data) => logger.info(message, data),
6573
7286
  holdMs: options.conversationHoldMs
6574
7287
  });
7288
+ this.conversationHoldMs = options.conversationHoldMs;
6575
7289
  this.httpApi.setHandlers({
6576
7290
  pushEvent: (p) => this.handleHttpPushEvent(p),
6577
7291
  getActiveSession: () => this.getAnySession(),
@@ -6581,6 +7295,9 @@ var McpServer = class _McpServer {
6581
7295
  static {
6582
7296
  this.E1_TTL_REFRESH_MS = 5 * 60 * 1e3;
6583
7297
  }
7298
+ static {
7299
+ this.MAX_HELD_CONVERSATIONS = 50;
7300
+ }
6584
7301
  // ─── Lifecycle ──────────────────────────────────────────────────────────
6585
7302
  async start() {
6586
7303
  if (this.started) throw new Error("McpServer.start() called twice");
@@ -6668,6 +7385,18 @@ var McpServer = class _McpServer {
6668
7385
  logger.error("handlePendingPrompt failed", { error: String(err) });
6669
7386
  });
6670
7387
  });
7388
+ this.addListener(this.approvalDetector, "prompt-upgraded", (state) => {
7389
+ void this.handlePendingPrompt(state).catch((err) => {
7390
+ logger.error("handlePendingPrompt failed on prompt-upgraded", { error: String(err) });
7391
+ });
7392
+ });
7393
+ this.addListener(this.approvalDetector, "prompt-resolved", (promptId) => {
7394
+ const session = this.session;
7395
+ if (!session) return;
7396
+ void this.resolveE1PluginApproval(session.sessionId, promptId).catch((err) => {
7397
+ logger.warn("resolveE1PluginApproval failed on prompt-resolved", { promptId, error: String(err) });
7398
+ });
7399
+ });
6671
7400
  this.launchSessionPromise = this.createLaunchSession();
6672
7401
  await this.launchSessionPromise;
6673
7402
  if (!this.started) {
@@ -6812,6 +7541,12 @@ var McpServer = class _McpServer {
6812
7541
  await this.boundedTeardownStep("paneObserver.stop", () => this.paneObserver.stop());
6813
7542
  await this.boundedTeardownStep("approvalDetector.stop", () => this.approvalDetector.stop());
6814
7543
  await this.boundedTeardownStep("httpApi.stop", () => this.httpApi.stop());
7544
+ for (const [, list] of this.heldHookDeliveries) {
7545
+ for (const entry of list) {
7546
+ clearTimeout(entry.timer);
7547
+ }
7548
+ }
7549
+ this.heldHookDeliveries.clear();
6815
7550
  this.removeOrphanedRuntimeDir();
6816
7551
  try {
6817
7552
  this.appSyncClient.cleanupSubscriptions();
@@ -7163,16 +7898,38 @@ var McpServer = class _McpServer {
7163
7898
  ));
7164
7899
  }
7165
7900
  // ─── agy hooks (F1) ─────────────────────────────────────────────────────
7901
+ dropHeldHookDeliveries(conversationId, reason) {
7902
+ const list = this.heldHookDeliveries.get(conversationId);
7903
+ if (!list) return;
7904
+ for (const entry of list) {
7905
+ clearTimeout(entry.timer);
7906
+ }
7907
+ this.heldHookDeliveries.delete(conversationId);
7908
+ logger.info("Dropped held hook deliveries", { conversationId, reason, count: list.length });
7909
+ }
7166
7910
  /**
7167
7911
  * One hook delivery from OUR agy (agy-hook.js only posts here when the
7168
7912
  * launcher's inline CODEVIBE_AGY_HOOK_OWNER names this daemon's runtime
7169
7913
  * dir). Updates routing state synchronously; network work runs afterwards so
7170
7914
  * the HTTP reply — which agy is waiting for — goes out at once.
7171
7915
  */
7172
- handleAgyHook(delivery) {
7916
+ async handleAgyHook(delivery) {
7173
7917
  if (!this.started) return;
7174
7918
  const { conversationId } = delivery;
7175
7919
  if (delivery.event === "PreInvocation") {
7920
+ const isTurnStart = delivery.invocationNum === void 0 || delivery.invocationNum === 0;
7921
+ const wasAlreadyAdmitted = this.router.roleOf(conversationId) === "main" || this.observedMainConversationIds.has(conversationId);
7922
+ if (isTurnStart && wasAlreadyAdmitted) {
7923
+ this.conversationGenerations.set(conversationId, (this.conversationGenerations.get(conversationId) ?? 0) + 1);
7924
+ this.conversationTurnGenerations.set(conversationId, (this.conversationTurnGenerations.get(conversationId) ?? 0) + 1);
7925
+ this.approvalDetector.retireLiveWizard(conversationId);
7926
+ this.resolveLingeringQuestionPrompts(conversationId);
7927
+ this.dropHeldHookDeliveries(conversationId, "turn-transition");
7928
+ this.conversationCompletedPostDeliveries.delete(conversationId);
7929
+ }
7930
+ if (delivery.initialNumSteps !== void 0) {
7931
+ this.conversationInitialNumSteps.set(conversationId, delivery.initialNumSteps);
7932
+ }
7176
7933
  const previousBound = this.router.getBound();
7177
7934
  const { role, bound } = this.router.onPreInvocation({
7178
7935
  conversationId,
@@ -7181,6 +7938,9 @@ var McpServer = class _McpServer {
7181
7938
  });
7182
7939
  if (role === "main") {
7183
7940
  this.observedMainConversationIds.add(conversationId);
7941
+ if (!wasAlreadyAdmitted) {
7942
+ await this.drainHeldHookDeliveries(conversationId, "process", delivery.initialNumSteps);
7943
+ }
7184
7944
  } else {
7185
7945
  this.observedMainConversationIds.delete(conversationId);
7186
7946
  }
@@ -7193,10 +7953,264 @@ var McpServer = class _McpServer {
7193
7953
  }
7194
7954
  return;
7195
7955
  }
7196
- this.router.onPostToolUse(conversationId);
7956
+ if (delivery.event === "Stop") {
7957
+ this.conversationGenerations.set(conversationId, (this.conversationGenerations.get(conversationId) ?? 0) + 1);
7958
+ this.conversationTurnGenerations.set(conversationId, (this.conversationTurnGenerations.get(conversationId) ?? 0) + 1);
7959
+ this.conversationStopSeq.set(conversationId, ++this.hookEventSeq);
7960
+ this.dropHeldHookDeliveries(conversationId, "stop-hook");
7961
+ this.conversationCompletedPostDeliveries.delete(conversationId);
7962
+ this.approvalDetector.retireLiveWizard(conversationId);
7963
+ this.resolveLingeringQuestionPrompts(conversationId);
7964
+ return;
7965
+ }
7966
+ if (delivery.event === "PreToolUse") {
7967
+ void this.handlePreToolUse(delivery).catch((error) => {
7968
+ logger.warn("PreToolUse hook handling failed", { error: String(error) });
7969
+ });
7970
+ return;
7971
+ }
7972
+ const tn = (delivery.toolName ?? "").toLowerCase().trim();
7973
+ const isQuestionTool = ["ask_question", "question", "ask"].includes(tn);
7974
+ if (isQuestionTool) {
7975
+ this.conversationGenerations.set(delivery.conversationId, (this.conversationGenerations.get(delivery.conversationId) ?? 0) + 1);
7976
+ let list = this.conversationCompletedPostDeliveries.get(delivery.conversationId);
7977
+ if (!list) {
7978
+ list = [];
7979
+ this.conversationCompletedPostDeliveries.set(delivery.conversationId, list);
7980
+ }
7981
+ list.push({ toolName: delivery.toolName, stepIdx: delivery.stepIdx, toolArgs: delivery.toolArgs, seq: ++this.hookEventSeq });
7982
+ this.dropHeldHookDeliveries(delivery.conversationId, "post-tool-use-completion");
7983
+ this.approvalDetector.retireLiveWizardMatching(delivery.conversationId, delivery);
7984
+ }
7197
7985
  void this.resolvePromptsForCompletedTool(delivery).catch((error) => {
7198
7986
  logger.warn("PostToolUse prompt resolution failed", { error: String(error) });
7199
7987
  });
7988
+ this.router.onPostToolUse(conversationId);
7989
+ }
7990
+ resolveLingeringQuestionPrompts(conversationId) {
7991
+ const lingeringPrompts = this.approvalDetector.getPendingPromptsForConversation(conversationId).filter((p) => p.kind === "question");
7992
+ for (const p of lingeringPrompts) {
7993
+ this.approvalDetector.resolvePrompt(p.promptId);
7994
+ if (this.session) {
7995
+ void this.resolveE1PluginApproval(this.session.sessionId, p.promptId).catch(() => {
7996
+ });
7997
+ }
7998
+ }
7999
+ }
8000
+ async drainHeldHookDeliveries(conversationId, decision, initialNumSteps) {
8001
+ const list = this.heldHookDeliveries.get(conversationId);
8002
+ if (!list || list.length === 0) return;
8003
+ for (const entry of list) {
8004
+ clearTimeout(entry.timer);
8005
+ }
8006
+ this.heldHookDeliveries.delete(conversationId);
8007
+ if (decision === "drop") {
8008
+ logger.info("Disposing held hook deliveries for foreign conversation", {
8009
+ conversationId,
8010
+ count: list.length
8011
+ });
8012
+ return;
8013
+ }
8014
+ logger.info("Replaying held hook deliveries after affirmative admission", {
8015
+ conversationId,
8016
+ count: list.length,
8017
+ decision
8018
+ });
8019
+ const now = Date.now();
8020
+ const HELD_TTL_MS = 3e4;
8021
+ const capturedGen = this.conversationGenerations.get(conversationId) ?? 0;
8022
+ for (const entry of list) {
8023
+ if (now - entry.receivedAt > HELD_TTL_MS) {
8024
+ logger.info("Discarding expired held hook delivery", {
8025
+ conversationId,
8026
+ toolName: entry.delivery.toolName,
8027
+ ageMs: now - entry.receivedAt
8028
+ });
8029
+ continue;
8030
+ }
8031
+ if (initialNumSteps !== void 0 && entry.delivery.stepIdx !== void 0 && entry.delivery.stepIdx < initialNumSteps - 1) {
8032
+ logger.info("Discarding held delivery from prior step before initialNumSteps", {
8033
+ conversationId,
8034
+ stepIdx: entry.delivery.stepIdx,
8035
+ initialNumSteps
8036
+ });
8037
+ continue;
8038
+ }
8039
+ if (capturedGen !== (this.conversationGenerations.get(conversationId) ?? 0)) {
8040
+ logger.info("Aborting drain of held hook delivery due to generation advance", {
8041
+ conversationId
8042
+ });
8043
+ break;
8044
+ }
8045
+ try {
8046
+ await this.handlePreToolUse(entry.delivery);
8047
+ } catch (err) {
8048
+ logger.warn("Failed replaying held hook delivery", { error: String(err) });
8049
+ }
8050
+ }
8051
+ }
8052
+ async handlePreToolUse(delivery) {
8053
+ if (!this.started || !delivery.toolName) return;
8054
+ logger.info("PreToolUse hook delivery received", {
8055
+ conversationId: delivery.conversationId,
8056
+ toolName: delivery.toolName,
8057
+ stepIdx: delivery.stepIdx
8058
+ });
8059
+ const tn = delivery.toolName.toLowerCase().trim();
8060
+ if (tn === "ask_question" || tn === "question" || tn === "ask") {
8061
+ const capturedTurnGen = this.conversationTurnGenerations.get(delivery.conversationId) ?? 0;
8062
+ const startedSeq = ++this.hookEventSeq;
8063
+ await this.ensureLaunchSession();
8064
+ if (!this.session && !this.sessionDisabled && !this.launchKeyUnavailable) {
8065
+ for (let attempt = 0; attempt < 2 && !this.session; attempt++) {
8066
+ await this.ensureLaunchSession();
8067
+ if (this.sessionDisabled || this.launchKeyUnavailable) break;
8068
+ if (!this.session) await new Promise((r) => setTimeout(r, 150));
8069
+ }
8070
+ }
8071
+ if (this.sessionDisabled || this.launchKeyUnavailable) {
8072
+ logger.warn("PreToolUse dropped due to terminal session state", {
8073
+ conversationId: delivery.conversationId
8074
+ });
8075
+ return;
8076
+ }
8077
+ const completedPosts = this.conversationCompletedPostDeliveries.get(delivery.conversationId) ?? [];
8078
+ const qText = extractQuestionFromArgs(delivery.toolName ?? "ask_question", delivery.toolArgs ?? {});
8079
+ const structQuestions = extractAllQuestionsFromArgs(delivery.toolName ?? "ask_question", delivery.toolArgs ?? {});
8080
+ const candidateCall = {
8081
+ intentStepIndex: delivery.stepIdx,
8082
+ questionText: qText,
8083
+ structuredQuestions: structQuestions,
8084
+ rawToolName: delivery.toolName ?? "ask_question"
8085
+ };
8086
+ const isAlreadyCompleted = completedPosts.some(
8087
+ (post) => post.seq > startedSeq && correlatesQuestionInvocation(candidateCall, post)
8088
+ );
8089
+ if (isAlreadyCompleted) {
8090
+ logger.info("PreToolUse already completed during async bootstrap await", {
8091
+ conversationId: delivery.conversationId,
8092
+ stepIdx: delivery.stepIdx
8093
+ });
8094
+ return;
8095
+ }
8096
+ const stoppedSeq = this.conversationStopSeq.get(delivery.conversationId) ?? 0;
8097
+ if (stoppedSeq > startedSeq) {
8098
+ logger.info("PreToolUse invalidated by Stop during async bootstrap await", {
8099
+ conversationId: delivery.conversationId,
8100
+ stepIdx: delivery.stepIdx
8101
+ });
8102
+ return;
8103
+ }
8104
+ const currentTurnGen = this.conversationTurnGenerations.get(delivery.conversationId) ?? 0;
8105
+ if (capturedTurnGen !== currentTurnGen) {
8106
+ const minStep2 = this.conversationInitialNumSteps.get(delivery.conversationId);
8107
+ const isCurrentTurn = minStep2 !== void 0 && delivery.stepIdx !== void 0 && delivery.stepIdx >= minStep2 - 1;
8108
+ if (!isCurrentTurn) {
8109
+ logger.info("PreToolUse invalidated by turn transition during async bootstrap await", {
8110
+ conversationId: delivery.conversationId,
8111
+ capturedTurnGen,
8112
+ currentTurnGen,
8113
+ stepIdx: delivery.stepIdx,
8114
+ minStep: minStep2
8115
+ });
8116
+ return;
8117
+ }
8118
+ logger.info("PreToolUse correlated with admitted current turn across bootstrap await", {
8119
+ conversationId: delivery.conversationId,
8120
+ stepIdx: delivery.stepIdx,
8121
+ minStep: minStep2
8122
+ });
8123
+ }
8124
+ const minStep = this.conversationInitialNumSteps.get(delivery.conversationId);
8125
+ if (minStep !== void 0 && delivery.stepIdx !== void 0 && delivery.stepIdx < minStep - 1) {
8126
+ logger.info("PreToolUse dropped as stale prior step before initialNumSteps", {
8127
+ conversationId: delivery.conversationId,
8128
+ stepIdx: delivery.stepIdx,
8129
+ minStep
8130
+ });
8131
+ return;
8132
+ }
8133
+ const decision = this.router.route(delivery.conversationId);
8134
+ if (decision === "drop") {
8135
+ logger.debug("PreToolUse dropped for conversation outside session", {
8136
+ conversationId: delivery.conversationId
8137
+ });
8138
+ return;
8139
+ }
8140
+ if (decision === "hold") {
8141
+ if (!this.heldHookDeliveries.has(delivery.conversationId) && this.heldHookDeliveries.size >= _McpServer.MAX_HELD_CONVERSATIONS) {
8142
+ const oldestConv = this.heldHookDeliveries.keys().next().value;
8143
+ if (oldestConv) {
8144
+ this.dropHeldHookDeliveries(oldestConv, "global-capacity-exceeded");
8145
+ }
8146
+ }
8147
+ let held = this.heldHookDeliveries.get(delivery.conversationId);
8148
+ if (!held) {
8149
+ held = [];
8150
+ this.heldHookDeliveries.set(delivery.conversationId, held);
8151
+ }
8152
+ if (held.length < 10) {
8153
+ const timer = setTimeout(() => {
8154
+ this.dropHeldHookDeliveries(delivery.conversationId, "hold-timeout");
8155
+ }, this.conversationHoldMs ?? 3e4);
8156
+ held.push({ delivery, receivedAt: Date.now(), timer });
8157
+ }
8158
+ this.registerHeldConversationBeforeFirstHook(delivery.conversationId);
8159
+ return;
8160
+ }
8161
+ const structuredQuestions = extractAllQuestionsFromArgs(delivery.toolName, delivery.toolArgs);
8162
+ const questionText = extractQuestionFromArgs(delivery.toolName, delivery.toolArgs);
8163
+ const questionOptions = extractQuestionOptionsFromArgs(delivery.toolName, delivery.toolArgs);
8164
+ logger.info("PreToolUse received ask_question hook", {
8165
+ conversationId: delivery.conversationId,
8166
+ questionText: questionText ? questionText.slice(0, 100) : null,
8167
+ optionCount: questionOptions ? questionOptions.length : 0,
8168
+ structuredQuestionsCount: structuredQuestions ? structuredQuestions.length : 0
8169
+ });
8170
+ const pendingCall = {
8171
+ intentStepIndex: delivery.stepIdx ?? -1,
8172
+ toolCallIndex: 0,
8173
+ intentByteOffset: 0,
8174
+ toolType: "ASK_QUESTION",
8175
+ conversationId: delivery.conversationId,
8176
+ rawToolName: delivery.toolName,
8177
+ questionText,
8178
+ questionOptions,
8179
+ structuredQuestions,
8180
+ startedAt: Date.now(),
8181
+ intentCreatedAt: (/* @__PURE__ */ new Date()).toISOString()
8182
+ };
8183
+ this.approvalDetector.registerPendingCall(pendingCall);
8184
+ this.emitQuestionPromptFromHook(delivery.conversationId, pendingCall);
8185
+ }
8186
+ }
8187
+ emitQuestionPromptFromHook(conversationId, pendingCall) {
8188
+ const decision = this.router.route(conversationId);
8189
+ if (decision !== "process" && decision !== "legacy") {
8190
+ logger.debug("emitQuestionPromptFromHook skipped for non-admitted conversation", { conversationId, decision });
8191
+ return;
8192
+ }
8193
+ let session = this.getSessionByConversation(conversationId);
8194
+ if (!session && this.session) {
8195
+ if (decision === "process" || decision === "legacy" && this.legacyAcceptsConversation(conversationId)) {
8196
+ this.observedMainConversationIds.add(conversationId);
8197
+ session = this.session;
8198
+ }
8199
+ }
8200
+ if (!session) {
8201
+ logger.warn("PreToolUse ask_question hook fired but conversation not owned or no session known", {
8202
+ conversationId,
8203
+ decision
8204
+ });
8205
+ return;
8206
+ }
8207
+ const e1rec = this.newE1Raise(session.sessionId, false);
8208
+ const emitted = this.approvalDetector.emitHookQuestionPrompt(pendingCall, e1rec.promptId);
8209
+ if (!emitted) {
8210
+ this.e1PromptRaises.delete(e1rec.promptId);
8211
+ } else if (emitted.promptId !== e1rec.promptId) {
8212
+ this.e1PromptRaises.delete(e1rec.promptId);
8213
+ }
7200
8214
  }
7201
8215
  /**
7202
8216
  * A tool our agy just ran was blocked on its approval prompt, so a pending
@@ -7206,8 +8220,14 @@ var McpServer = class _McpServer {
7206
8220
  async resolvePromptsForCompletedTool(delivery) {
7207
8221
  const session = this.session;
7208
8222
  if (!session) return;
7209
- const pending = this.approvalDetector.getPendingPromptsForConversation();
7210
- const matched = pending.filter((state) => promptMatchesCompletedTool(state, delivery.toolName, delivery.toolArgs));
8223
+ const pending = this.approvalDetector.getPendingPromptsForConversation(delivery.conversationId);
8224
+ const matched = pending.filter((state) => {
8225
+ if (state.conversationId !== delivery.conversationId) return false;
8226
+ if (state.kind === "question") {
8227
+ return correlatesQuestionInvocation(state.pendingCall, delivery);
8228
+ }
8229
+ return promptMatchesCompletedTool(state, delivery.toolName, delivery.toolArgs);
8230
+ });
7211
8231
  logger.info("PostToolUse checked pending prompts", {
7212
8232
  conversationId: delivery.conversationId,
7213
8233
  toolName: delivery.toolName ?? null,
@@ -8235,11 +9255,14 @@ var McpServer = class _McpServer {
8235
9255
  const e1rec = this.newE1Raise(session.sessionId, isActionable);
8236
9256
  const emitted = this.approvalDetector.emitPaneOnlyPrompt(parsed, activeConvId, e1rec.promptId);
8237
9257
  if (emitted) {
9258
+ if (emitted.promptId !== e1rec.promptId) {
9259
+ this.e1PromptRaises.delete(e1rec.promptId);
9260
+ }
8238
9261
  if (cand.snapshotHash) {
8239
- this.e1ContentKeyIndex.set(`${session.sessionId}::${cand.snapshotHash}`, e1rec.promptId);
9262
+ this.e1ContentKeyIndex.set(`${session.sessionId}::${cand.snapshotHash}`, emitted.promptId);
8240
9263
  }
8241
9264
  logger.info("Pane prompt identity", {
8242
- promptId: e1rec.promptId,
9265
+ promptId: emitted.promptId,
8243
9266
  conversationId: activeConvId,
8244
9267
  commandDigest: commandDigest(parsed.command)
8245
9268
  });
@@ -8283,8 +9306,16 @@ var McpServer = class _McpServer {
8283
9306
  }
8284
9307
  const isDisplayOnly = Object.keys(state.submitMap || {}).length === 0;
8285
9308
  const e1existing = this.e1PromptRaises.get(state.promptId);
8286
- const e1rec = e1existing ? e1existing.record : this.newE1Raise(session.sessionId, !isDisplayOnly);
8287
- if (!e1existing) state.promptId = e1rec.promptId;
9309
+ let e1rec;
9310
+ if (e1existing) {
9311
+ e1rec = e1existing.record;
9312
+ e1rec.mobileActionable = !isDisplayOnly;
9313
+ } else {
9314
+ e1rec = this.newE1Raise(session.sessionId, !isDisplayOnly);
9315
+ const oldPromptId = state.promptId;
9316
+ state.promptId = e1rec.promptId;
9317
+ this.approvalDetector.rekeyPrompt(oldPromptId, e1rec.promptId);
9318
+ }
8288
9319
  const resolvedOptions = state.paneOptions && state.paneOptions.length > 0 ? state.paneOptions : state.pendingCall?.questionOptions && state.pendingCall.questionOptions.length > 0 ? state.pendingCall.questionOptions : void 0;
8289
9320
  const optionsArr = isDisplayOnly ? [] : resolvedOptions ? resolvedOptions.map((o) => ({ number: o.number, text: o.text })) : Object.keys(state.submitMap).map((n) => ({ number: n }));
8290
9321
  const diffParsed = parseFencedDiffFromBody(
@@ -8317,12 +9348,29 @@ var McpServer = class _McpServer {
8317
9348
  file_path: filePath,
8318
9349
  options: optionsArr,
8319
9350
  ...toolInput ? { tool_input: toolInput } : {},
8320
- ...state.kind === "question" && resolvedOptions && resolvedOptions.length > 0 ? {
8321
- questions: [{
8322
- question: state.pendingCall?.questionText || state.matchedPaneHeader || state.paneDisplayHeader || "Question",
8323
- options: resolvedOptions,
8324
- ...isDisplayOnly ? { instructions: "Answer this question in your desktop terminal" } : {}
8325
- }]
9351
+ ...state.kind === "question" ? {
9352
+ questions: (() => {
9353
+ if (!state.pendingCall?.structuredQuestions || state.pendingCall.structuredQuestions.length === 0) {
9354
+ return resolvedOptions && resolvedOptions.length > 0 ? [{
9355
+ question: state.pendingCall?.questionText || state.matchedPaneHeader || state.paneDisplayHeader || "Question",
9356
+ options: resolvedOptions,
9357
+ ...isDisplayOnly ? { instructions: "Answer this question in your desktop terminal" } : {}
9358
+ }] : void 0;
9359
+ }
9360
+ const activeQuestionText = state.matchedPaneHeader || state.paneDisplayHeader || state.pendingCall?.questionText || "";
9361
+ let activeIdx = state.wizardStepIndex !== void 0 ? state.wizardStepIndex : state.pendingCall.structuredQuestions.findIndex((sq) => questionsMatch(sq.question, activeQuestionText));
9362
+ if (activeIdx < 0 || activeIdx >= state.pendingCall.structuredQuestions.length) activeIdx = 0;
9363
+ const activeQ = state.pendingCall.structuredQuestions[activeIdx] ?? {
9364
+ question: activeQuestionText || "Question",
9365
+ options: resolvedOptions || []
9366
+ };
9367
+ return [{
9368
+ question: activeQ.question,
9369
+ options: resolvedOptions && resolvedOptions.length > 0 ? resolvedOptions : activeQ.options,
9370
+ ...activeQ.isMultiSelect !== void 0 ? { is_multi_select: activeQ.isMultiSelect } : {},
9371
+ ...isDisplayOnly ? { instructions: "Answer this question in your desktop terminal" } : {}
9372
+ }];
9373
+ })()
8326
9374
  } : {}
8327
9375
  } : { promptId: state.promptId };
8328
9376
  const content = state.paneDisplayHeader || state.matchedPaneHeader || state.pendingCall?.command || state.pendingCall?.filePath || "approval requested";
@@ -8545,22 +9593,119 @@ var McpServer = class _McpServer {
8545
9593
  }
8546
9594
  }
8547
9595
  await this.trackDurableMobileEcho(session, evt, intent.option, "primary");
8548
- const optionResult = await this.promptResponder.sendApprovalReply(
8549
- activePrompt.promptId,
8550
- intent.option
8551
- );
8552
- if (!optionResult.ok) {
8553
- if (!(0, import_codevibe_core7.requiresSyncInputDeliveryReceipt)(evt)) this.mobileDeduper.forget(session.sessionId, intent.option);
9596
+ const isQuestionPrompt = activePrompt.kind === "question";
9597
+ if (isQuestionPrompt) {
9598
+ let armedWriteInEcho = false;
9599
+ const optionResult = intent.followUpText ? await this.promptResponder.sendApprovalReply(
9600
+ activePrompt.promptId,
9601
+ intent.option,
9602
+ intent.followUpText,
9603
+ async () => {
9604
+ armedWriteInEcho = true;
9605
+ await this.trackDurableMobileEcho(session, evt, intent.followUpText, "free_form");
9606
+ }
9607
+ ) : await this.promptResponder.sendApprovalReply(
9608
+ activePrompt.promptId,
9609
+ intent.option
9610
+ );
9611
+ if (!optionResult.ok) {
9612
+ if (optionResult.reason === "write-in-text-required") {
9613
+ await this.emitPromptSafetyNotification(
9614
+ session,
9615
+ optionResult.details || 'Please provide your write-in text along with the option number (e.g. "4, your response").'
9616
+ );
9617
+ } else if (optionResult.reason === "write-in-already-active") {
9618
+ await this.emitPromptSafetyNotification(
9619
+ session,
9620
+ optionResult.details || "Write-in input field is already open on desktop; finish or cancel on desktop before selecting an option."
9621
+ );
9622
+ } else if (optionResult.reason === "follow-up-not-allowed") {
9623
+ await this.emitPromptSafetyNotification(
9624
+ session,
9625
+ optionResult.details || `Option ${intent.option} does not accept follow-up text.`
9626
+ );
9627
+ } else {
9628
+ const phase = optionResult.deliveryPhase ?? "none";
9629
+ let message;
9630
+ if (phase !== "none") {
9631
+ message = "Your answer was partially entered on desktop. Please complete or cancel the prompt directly on desktop.";
9632
+ } else if (optionResult.reason === "prompt-expired" || optionResult.reason === "prompt-superseded") {
9633
+ message = "The question prompt on desktop has expired or changed. Please review on desktop.";
9634
+ } else if (optionResult.reason === "prompt-probe-failed") {
9635
+ message = "Write-in input field was not ready on desktop. Please try again or respond on desktop.";
9636
+ } else if (optionResult.reason === "no-tmux-target" || optionResult.reason === "tmux-failed") {
9637
+ message = optionResult.details || "Desktop terminal session is unreachable. Please respond directly on desktop.";
9638
+ } else if (optionResult.reason === "display-only-question-active") {
9639
+ message = "This question requires desktop input. Please respond directly on desktop.";
9640
+ } else if (optionResult.reason === "invalid-option") {
9641
+ message = optionResult.details || `Option '${intent.option}' is not valid for the current question.`;
9642
+ } else {
9643
+ message = optionResult.details || `Failed to submit response to question prompt (${optionResult.reason}).`;
9644
+ }
9645
+ await this.emitPromptSafetyNotification(session, message);
9646
+ }
9647
+ if (!(0, import_codevibe_core7.requiresSyncInputDeliveryReceipt)(evt)) {
9648
+ const phase = optionResult.deliveryPhase ?? "none";
9649
+ if (phase === "none") {
9650
+ this.mobileDeduper.forget(session.sessionId, intent.option);
9651
+ if (armedWriteInEcho && intent.followUpText) {
9652
+ this.mobileDeduper.forget(session.sessionId, intent.followUpText);
9653
+ }
9654
+ } else if (phase === "option_sent" && armedWriteInEcho && intent.followUpText) {
9655
+ this.mobileDeduper.forget(session.sessionId, intent.followUpText);
9656
+ }
9657
+ }
9658
+ return;
9659
+ }
9660
+ await this.resolveE1PluginApproval(session.sessionId, activePrompt.promptId);
9661
+ await this.markExecuted(evt);
8554
9662
  return;
8555
- }
8556
- await this.resolveE1PluginApproval(session.sessionId, activePrompt.promptId);
8557
- if (!intent.followUpText) {
9663
+ } else {
9664
+ const optionResult = await this.promptResponder.sendApprovalReply(
9665
+ activePrompt.promptId,
9666
+ intent.option
9667
+ );
9668
+ if (!optionResult.ok) {
9669
+ if (!(0, import_codevibe_core7.requiresSyncInputDeliveryReceipt)(evt)) {
9670
+ const phase = optionResult.deliveryPhase ?? "none";
9671
+ if (phase === "none") {
9672
+ this.mobileDeduper.forget(session.sessionId, intent.option);
9673
+ }
9674
+ }
9675
+ return;
9676
+ }
9677
+ await this.resolveE1PluginApproval(session.sessionId, activePrompt.promptId);
9678
+ if (intent.followUpText) {
9679
+ await new Promise((r) => setTimeout(r, 250));
9680
+ const freeFormResult = await this.promptResponder.sendFreeFormWhenNoApprovalActive(
9681
+ intent.followUpText,
9682
+ () => this.trackDurableMobileEcho(session, evt, intent.followUpText, "free_form")
9683
+ );
9684
+ if (!freeFormResult.ok) {
9685
+ if (!(0, import_codevibe_core7.requiresSyncInputDeliveryReceipt)(evt)) {
9686
+ this.mobileDeduper.forget(session.sessionId, intent.followUpText);
9687
+ }
9688
+ logger.warn("Follow-up text send failed for approval", { reason: freeFormResult.reason });
9689
+ return;
9690
+ }
9691
+ }
8558
9692
  await this.markExecuted(evt);
8559
9693
  return;
8560
9694
  }
8561
- promptContent = intent.followUpText;
8562
- await new Promise((resolve2) => setTimeout(resolve2, 250));
8563
9695
  } else {
9696
+ const activeQuestionPrompt = pendingPrompts.find((p) => p.kind === "question");
9697
+ if (activeQuestionPrompt) {
9698
+ const isDisplayOnly = Object.keys(activeQuestionPrompt.submitMap || {}).length === 0;
9699
+ logger.warn("Non-option input received while an interactive question prompt is active; rejecting dismissal", {
9700
+ sessionId: session.sessionId,
9701
+ promptId: activeQuestionPrompt.promptId,
9702
+ isDisplayOnly,
9703
+ content: promptContent.slice(0, 64)
9704
+ });
9705
+ const message = isDisplayOnly ? "The active terminal question prompt is display-only on mobile. Please select an option directly in your terminal." : "An interactive question prompt is active in your terminal. Please select one of the advertised options or answer directly in your terminal.";
9706
+ await this.emitPromptSafetyNotification(session, message);
9707
+ return;
9708
+ }
8564
9709
  const hasDisplayOnlyPrompt = pendingPrompts.some(
8565
9710
  (p) => Object.keys(p.submitMap || {}).length === 0
8566
9711
  );
@@ -9230,6 +10375,7 @@ var __testing = {
9230
10375
  0 && (module.exports = {
9231
10376
  ApprovalDetector,
9232
10377
  McpServer,
10378
+ PromptResponder,
9233
10379
  SessionNotFoundError,
9234
10380
  StoredAuthFailureError,
9235
10381
  TERMINAL_SHUTDOWN_SIGNALS,