@quantiya/codevibe-antigravity-plugin 2.0.83 → 2.0.85

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