@quantiya/codevibe-antigravity-plugin 2.0.85 → 2.0.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -45,7 +45,7 @@ install the unified `codevibe --agent` interface yet.
45
45
  - **macOS, Linux, or WSL Ubuntu** — Windows without WSL is not supported
46
46
  - **Node.js** 22.0.0+
47
47
  - **tmux** — `brew install tmux` on macOS, `apt install tmux` on Linux/WSL
48
- - **Antigravity CLI (`agy`)** — install from [Google's documentation](https://antigravity.google/docs)
48
+ - **Antigravity CLI (`agy`) 1.4.3+** with `--log-file` advertised by `agy --help` — install from [Google's documentation](https://antigravity.google/docs). Companion checks this before creating a session and tells unsupported clients to update; direct `agy` is unchanged.
49
49
 
50
50
  > Antigravity CLI is distributed by Google as a binary; it is not on npm. After installing `agy`, run `codevibe --agent agy` and the plugin handles the rest.
51
51
 
@@ -66,7 +66,7 @@ All agy sessions on a computer write to the same folders. CodeVibe tells them ap
66
66
 
67
67
  On each launch, `codevibe --agent agy` registers one named entry, `codevibe-antigravity`, in `~/.gemini/config/hooks.json`, and keeps the hook scripts in `~/.codevibe/codevibe-agy/hooks/`:
68
68
  - Other entries in that file are left as they are.
69
- - A file that is not valid JSON, or is a symlink, is never changed. CodeVibe then falls back to agy's log files, which cannot tell concurrent sessions apart.
69
+ - A file that is not valid JSON, or is a symlink, is never changed. Without hooks, CodeVibe uses only this launch's pinned native log and a `USER_INPUT`-first main transcript; unknown ownership waits, and `SYSTEM_MESSAGE`-first subagents are excluded.
70
70
  - For other agy sessions on the machine, `PreToolUse` immediately permits execution with `{"decision":"allow"}`, and other hook events are skipped.
71
71
  - To switch it off, add `"enabled": false` to the `codevibe-antigravity` entry. CodeVibe leaves a switched-off entry alone. Deleting the entry only lasts until the next `codevibe --agent agy` launch, which writes it again. After uninstalling CodeVibe you can delete the entry; see INSTALLATION.md.
72
72
 
@@ -6,6 +6,6 @@
6
6
  "implementation": {
7
7
  "architecture": "transcript+tmux+approval-heuristic",
8
8
  "schema_version": "v1.0",
9
- "note": "Monitors ~/.gemini/antigravity-cli/brain/<UUID>/.system_generated/logs/transcript.jsonl and the tmux pane. Where agy JSON hooks fire (agy 1.3.1+, hook gate on), the companion launcher registers a `codevibe-antigravity` entry in ~/.gemini/config/hooks.json (PreInvocation + PostToolUse) so each session routes only its own conversations; otherwise it falls back to agy's CLI log."
9
+ "note": "Requires native agy 1.4.3+ advertising --log-file; checked before Companion session creation. Monitors ~/.gemini/antigravity-cli/brain/<UUID>/.system_generated/logs/transcript.jsonl and the tmux pane. Own agy JSON hooks identify main conversations. Without hooks, ownership requires this launch's pinned native log and a USER_INPUT-first main transcript; SYSTEM_MESSAGE-first subagents and shared/newest logs are excluded. Unknown ownership defers sync."
10
10
  }
11
11
  }
package/dist/server.js CHANGED
@@ -32,6 +32,7 @@ var server_exports = {};
32
32
  __export(server_exports, {
33
33
  ApprovalDetector: () => ApprovalDetector,
34
34
  McpServer: () => McpServer,
35
+ PromptResponder: () => PromptResponder,
35
36
  SessionNotFoundError: () => SessionNotFoundError,
36
37
  StoredAuthFailureError: () => StoredAuthFailureError,
37
38
  TERMINAL_SHUTDOWN_SIGNALS: () => TERMINAL_SHUTDOWN_SIGNALS,
@@ -789,6 +790,20 @@ var TranscriptTailer = class extends import_events.EventEmitter {
789
790
  getActiveConversations() {
790
791
  return Array.from(this.activeConversations);
791
792
  }
793
+ /** Retry a deferred native read after THIS launcher's hook proves ownership.
794
+ * No offset reset or legacy fallback. Join an in-flight read first so a late
795
+ * hook cannot lose its wakeup in that read's rejection/finally cleanup. */
796
+ retryOwnedConversation(conversationId) {
797
+ if (!this.admission || !this.isWatching || !CONVERSATION_DIR_REGEX.test(conversationId)) return;
798
+ const generation = this.readGeneration, filePath = this.transcriptPathFor(conversationId);
799
+ const pending = this.activeReads.get(filePath) ?? Promise.resolve();
800
+ void pending.then(() => {
801
+ if (!this.isWatching || generation !== this.readGeneration) return;
802
+ if (!fs2.existsSync(filePath)) return;
803
+ this.ensureOffset(filePath);
804
+ void this.scheduleReadNewLines(filePath, conversationId);
805
+ }).catch((error) => logger.warn("Owned transcript wakeup failed", { conversationId, error: String(error) }));
806
+ }
792
807
  // ─── follower event handlers ─────────────────────────────────────────────
793
808
  onFileAdded(filePath) {
794
809
  if (this.isShuttingDown) return;
@@ -1233,6 +1248,10 @@ var TranscriptTailer = class extends import_events.EventEmitter {
1233
1248
  }
1234
1249
  const generation = this.readGeneration;
1235
1250
  const read = this.drainReadQueue(filePath, conversationId, generation).catch((error) => {
1251
+ if (error?.code === "SYNC_SOURCE_OWNERSHIP_PENDING") {
1252
+ logger.debug("Transcript waiting for its own hook or process-bound log", { conversationId });
1253
+ return;
1254
+ }
1236
1255
  logger.error("Transcript read chain failed (non-fatal) \u2014 next change event retries", {
1237
1256
  filePath,
1238
1257
  conversationId,
@@ -2924,6 +2943,7 @@ function isComposerBoxAt(lines, idx) {
2924
2943
  }
2925
2944
  return false;
2926
2945
  }
2946
+ var ANSI_ESCAPE_REGEX = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
2927
2947
  function isPaneInputSafetyBlocked(input) {
2928
2948
  const rawText = Array.isArray(input) ? input.join("\n") : input;
2929
2949
  if (!rawText) return { blocked: false, kind: null };
@@ -3093,9 +3113,10 @@ function isPaneInputSafetyBlocked(input) {
3093
3113
  return { blocked: false, kind: null };
3094
3114
  }
3095
3115
  function looksLikePromptDelta(chunk) {
3116
+ const text = chunk.includes("\x1B") ? chunk.replace(ANSI_ESCAPE_REGEX, "") : chunk;
3096
3117
  return (
3097
3118
  // Approval UI signatures
3098
- /Requesting permission for:/i.test(chunk) || /Do you want to proceed\?/i.test(chunk) || /\besc to cancel\b/i.test(chunk) || /^\s*[1-9]\. (?:Yes|No)\b/im.test(chunk) || /\[(?:y\/n|Y\/n|y\/N)\]/.test(chunk) || /^\s*(?:[❯>]\s*)?\?\s+(?!for\s+(?:shortcuts|help|commands))\S/im.test(chunk) || /Question\b(?:\s+\d+(?:[\/:of\s]+\d+)?)?:/i.test(chunk) || /\benter\s+(?:to\s+)?select\b/i.test(chunk) || /\b(?:navigate|use\s+arrow\s+keys)\b/i.test(chunk) || /^File access\s*$/im.test(chunk) || /^\s*Allow access to this file\?\s*$/im.test(chunk) || /(?:Yes, allow access|No, deny access)/i.test(chunk) || /(?:↑\/↓\s+Navigate|ctrl\+r\s+Review)/i.test(chunk) || /Our servers are experiencing/i.test(chunk) || /Error ID:\s*[0-9a-fA-F-]*/i.test(chunk) || /(?:⚠️|⚠\uFE0F?)\s+.*(?:high[- ]traffic|try again in a minute|try again later|stream was interrupted|stream interrupted|overloaded|unexpected error|quota\b|upgrade your subscription|increase your limits|resets in\s+\d|credits balance is too low|out of credits|rate[- ]limit|capacity exhausted|exhausted your quota|internal error|currently unreachable|network issue connecting|authentication expired|authentication issue|terminated due to error|invalid output|invalid tool call)/i.test(chunk)
3119
+ /Requesting permission for:/i.test(chunk) || /Do you want to proceed\?/i.test(chunk) || /\besc to cancel\b/i.test(text) || /^\s*[1-9]\. (?:Yes|No)\b/im.test(text) || /\[(?:y\/n|Y\/n|y\/N)\]/.test(chunk) || /^\s*(?:[❯>]\s*)?\?\s+(?!for\s+(?:shortcuts|help|commands))\S/im.test(text) || /Question\b(?:\s+\d+(?:[\/:of\s]+\d+)?)?:/i.test(text) || /\benter\s+(?:to\s+)?(?:select|submit(?:\s+all)?|next)\b/i.test(text) || /\b(?:navigate|use\s+arrow\s+keys)\b/i.test(text) || /\b\d+[\/:of\s]+\d+:\s+\S/i.test(text) || /\bSubmit(?:\s+All)?\b/i.test(text) || /(?:^|[\s·|│])(?:←|<-)\s*Back\b/i.test(text) || /^File access\s*$/im.test(text) || /^\s*Allow access to this file\?\s*$/im.test(text) || /(?:Yes, allow access|No, deny access)/i.test(text) || /(?:↑\/↓\s+Navigate|ctrl\+r\s+Review)/i.test(text) || /Our servers are experiencing/i.test(text) || /Error ID:\s*[0-9a-fA-F-]*/i.test(text) || /(?:⚠️|⚠\uFE0F?)\s+.*(?:high[- ]traffic|try again in a minute|try again later|stream was interrupted|stream interrupted|overloaded|unexpected error|quota\b|upgrade your subscription|increase your limits|resets in\s+\d|credits balance is too low|out of credits|rate[- ]limit|capacity exhausted|exhausted your quota|internal error|currently unreachable|network issue connecting|authentication expired|authentication issue|terminated due to error|invalid output|invalid tool call)/i.test(text)
3099
3120
  );
3100
3121
  }
3101
3122
  function isNonSystemErrorWarningLine(trimmed) {
@@ -4216,7 +4237,7 @@ function isQuestionFooterLine(line) {
4216
4237
  }
4217
4238
  if (/^[>\s❯]*[1-9][.)]\s+\S/.test(trimmed)) return false;
4218
4239
  if (isQuestionHeaderLine(trimmed)) return false;
4219
- return /^(?:esc|<esc>)\s+(?:to\s+)?(?:cancel|skip)(?:\s*[·│,]\s*(?:enter|<enter>)\s+(?:to\s+)?select)?(?:\s*[·│,]\s*ctrl\+[a-z0-9]+\s+[a-zA-Z]+)*$/i.test(trimmed) || /^(?:enter|<enter>)\s+(?:to\s+)?select(?:\s*[·│,]\s*(?:esc|<esc>)\s+(?:to\s+)?(?:cancel|skip))?(?:\s*[·│,]\s*ctrl\+[a-z0-9]+\s+[a-zA-Z]+)*$/i.test(trimmed) || /^(?:↑\/↓|↑|↓|\^|v)\s*(?:\/|\s+)?Navigate(?:\s*[·│,]\s*(?:enter|<enter>)?\s*(?:Select|to select))(?:\s*[·│,]\s*(?:esc|<esc>)?\s*(?:Skip|Cancel))?(?:\s*[·│,]\s*ctrl\+[a-z0-9]+\s+[a-zA-Z]+)*$/i.test(trimmed) || /^(?:↑\/↓|↑|↓|\^|v)\s*(?:\/|\s+)?Navigate(?:\s*[·│,]\s*(?:esc|<esc>)?\s*(?:Skip|Cancel))?$/i.test(trimmed) || /^press\s+<?(?:enter|return|space)>?\s+to\s+select$/i.test(trimmed) || /^space\s+to\s+select$/i.test(trimmed) || /^(?:\(?use\s+arrow\s+keys(?:\s+to\s+navigate)?\)?)$/i.test(trimmed) || /^(?:[\u2800-\u28FF]+(?:\s*waiting(?:\.\.\.)?)?|waiting(?:\.\.\.)?)$/i.test(trimmed);
4240
+ return /^(?:esc|<esc>)\s+(?:to\s+)?(?:cancel|skip)(?:\s*[·│,]\s*(?:enter|<enter>)\s+(?:to\s+)?(?:select|submit(?:\s+all)?))?(?:\s*[·│,]\s*ctrl\+[a-z0-9]+\s+[a-zA-Z]+)*$/i.test(trimmed) || /^(?:enter|<enter>)\s+(?:to\s+)?(?:select|submit(?:\s+all)?)(?:\s*[·│,]\s*(?:esc|<esc>)\s+(?:to\s+)?(?:cancel|skip|back))?(?:\s*[·│,]\s*ctrl\+[a-z0-9]+\s+[a-zA-Z]+)*$/i.test(trimmed) || /^(?:space|<space>)\s+(?:to\s+)?(?:select|toggle)(?:\s*[·│,]\s*(?:enter|<enter>)\s+(?:to\s+)?(?:select|submit(?:\s+all)?))?(?:\s*[·│,]\s*(?:esc|<esc>)\s+(?:to\s+)?(?:cancel|skip|back))?$/i.test(trimmed) || /^(?:↑\/↓|↑|↓|\^|v)\s*(?:\/|\s+)?Navigate(?:\s*[·│,]\s*(?:(?:→|->)\s*Next|(?:←|<-)\s*Back))*(?:\s*[·│,]\s*(?:enter|<enter>)?\s*(?:Select|Submit(?:\s+All)?|to select))(?:\s*[·│,]\s*(?:esc|<esc>)?\s*(?:Skip|Cancel|Back))?(?:\s*[·│,]\s*(?:(?:→|->)\s*Next|(?:←|<-)\s*Back))*(?:\s*[·│,]\s*ctrl\+[a-z0-9]+\s+[a-zA-Z]+)*$/i.test(trimmed) || /^(?:↑\/↓|↑|↓|\^|v)\s*(?:\/|\s+)?Navigate(?:\s*[·│,]\s*(?:esc|<esc>)?\s*(?:Skip|Cancel|Back))?$/i.test(trimmed) || /^(?:←|<-)\s*Back\s*[·│,]\s*(?:enter|<enter>)\s+(?:to\s+)?(?:select|submit(?:\s+all)?)(?:\s*[·│,]\s*(?:esc|<esc>)\s+(?:to\s+)?(?:cancel|skip))?$/i.test(trimmed) || /^press\s+<?(?:enter|return|space)>?\s+to\s+select$/i.test(trimmed) || /^space\s+to\s+select$/i.test(trimmed) || /^(?:\(?use\s+arrow\s+keys(?:\s+to\s+navigate)?\)?)$/i.test(trimmed) || /^(?:[\u2800-\u28FF]+(?:\s*waiting(?:\.\.\.)?)?|waiting(?:\.\.\.)?)$/i.test(trimmed);
4220
4241
  }
4221
4242
  function extractQuestionHeader(snapshot) {
4222
4243
  const stripped = snapshot.replace(ANSI_ESCAPE_REGEX, "");
@@ -4242,7 +4263,6 @@ function hashPromptSnapshot(snapshot) {
4242
4263
  }
4243
4264
  return (0, import_crypto.createHash)("sha256").update(normalized).digest("hex");
4244
4265
  }
4245
- var ANSI_ESCAPE_REGEX = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
4246
4266
  function escapeShellArg(value) {
4247
4267
  return value.replace(/'/g, `'\\''`);
4248
4268
  }
@@ -4387,33 +4407,8 @@ function parseQuestionSnapshot(stripped, originalSnapshot, header) {
4387
4407
  const options = parseOptions(belowHeader);
4388
4408
  if (options.length < 2) return null;
4389
4409
  const normalizedHeader = header.fullLine.replace(/^[❯>\s*•-]+\s*/, "").trim();
4390
- const headerHasArrowHint = /\((?:Use\s+arrow\s+keys|Use\s+\S+\s+keys)[^)]*\)/i.test(normalizedHeader);
4391
- let footerHasArrowHint = false;
4392
- let footerHasCtrlRReview = false;
4393
- const startLine = headerIdx >= 0 ? headerIdx + 1 : 0;
4394
- let hasSeenOptions = false;
4395
- for (let i = startLine; i < lines.length; i++) {
4396
- if (lineInFence[i]) continue;
4397
- const trimmed = lines[i].trim();
4398
- if (!trimmed) continue;
4399
- if (hasSeenOptions && isResumedTurnSignal(trimmed)) break;
4400
- if (/^[>\s❯]*[1-9][.)]\s+\S/.test(trimmed)) {
4401
- hasSeenOptions = true;
4402
- }
4403
- const isFooter = isQuestionFooterLine(trimmed) || /^(?:ctrl\+[a-z0-9]+\s+[a-zA-Z]+|\(?use\s+arrow\s+keys(?:\s+to\s+navigate)?\)?)$/i.test(trimmed);
4404
- if (isFooter) {
4405
- if (/(?:↑\/↓|↑|↓|\^|v)\s*(?:\/|\s+)?Navigate\b/i.test(trimmed) || /\(?use\s+arrow\s+keys(?:\s+to\s+navigate)?\)?/i.test(trimmed)) {
4406
- footerHasArrowHint = true;
4407
- }
4408
- if (/ctrl\+r\s+Review\b/i.test(trimmed)) {
4409
- footerHasCtrlRReview = true;
4410
- }
4411
- }
4412
- }
4413
- const hasArrowKeyHint = headerHasArrowHint || footerHasArrowHint;
4414
- const hasWriteInOption = options.some((opt) => /^\s*write-?in\b/i.test(opt.text));
4415
- const hasCtrlRReviewFooter = footerHasCtrlRReview;
4416
- const isBaseNumberedWizard = /^Question\s+\d+\/\d+:/i.test(normalizedHeader) && !hasArrowKeyHint && !hasWriteInOption && !hasCtrlRReviewFooter;
4410
+ const isMultiSelect = /\bselect\s+(?:all|multiple)\b/i.test(normalizedHeader) || /\bselect\s+(?:all|multiple)\b/i.test(belowHeader) || /(?:<space>|\bspace)\s+(?:to\s+)?(?:select|toggle)\b/i.test(belowHeader) || /\bctrl\+a\s+select\s+all\b/i.test(belowHeader);
4411
+ const isBaseNumberedWizard = /^Question\s+\d+\/\d+:/i.test(normalizedHeader) && !isMultiSelect;
4417
4412
  const submitMap = isBaseNumberedWizard ? buildQuestionSubmitMap(options) : {};
4418
4413
  const body = extractBodyBetweenHeaderAndFirstOption(belowHeader);
4419
4414
  return {
@@ -4476,12 +4471,47 @@ function buildApprovalSubmitMap(options) {
4476
4471
  function buildQuestionSubmitMap(options) {
4477
4472
  const map = {};
4478
4473
  for (const opt of options) {
4479
- if (!/^\s*write-?in\b/i.test(opt.text)) {
4480
- map[opt.number] = [opt.number, "Enter"];
4481
- }
4474
+ map[opt.number] = [opt.number];
4482
4475
  }
4483
4476
  return map;
4484
4477
  }
4478
+ function isWriteInInputActive(paneText, expectedHeader, expectedStep) {
4479
+ if (!paneText) return false;
4480
+ if (detectActivePromptKind(paneText) !== "question") return false;
4481
+ const stripped = paneText.replace(ANSI_ESCAPE_REGEX2, "");
4482
+ const lines = stripped.trimEnd().split("\n");
4483
+ const windowStart = Math.max(0, lines.length - 30);
4484
+ let headerIdx = -1;
4485
+ for (let i = lines.length - 1; i >= windowStart; i--) {
4486
+ if (isQuestionHeaderLine(lines[i])) {
4487
+ headerIdx = i;
4488
+ break;
4489
+ }
4490
+ }
4491
+ if (headerIdx < 0) return false;
4492
+ const rawHeader = lines[headerIdx];
4493
+ const extracted = extractQuestionHeader(rawHeader);
4494
+ if (!extracted) return false;
4495
+ if (expectedHeader) {
4496
+ const expectedExtracted = extractQuestionHeader(expectedHeader);
4497
+ const expectedBody = (expectedExtracted ? expectedExtracted.body : expectedHeader).replace(/^[❯>\s*•-]+\s*/, "").trim().toLowerCase();
4498
+ const paneBody = extracted.body.replace(/^[❯>\s*•-]+\s*/, "").trim().toLowerCase();
4499
+ if (paneBody !== expectedBody) {
4500
+ return false;
4501
+ }
4502
+ }
4503
+ const expectedStepVal = expectedStep || (expectedHeader ? expectedHeader.match(/\bQuestion\s+(\d+(?:[\/:of\s]+\d+)?):/i)?.[1] : void 0);
4504
+ if (expectedStepVal) {
4505
+ const stepMatch = extracted.fullLine.match(/\bQuestion\s+(\d+(?:[\/:of\s]+\d+)?):/i);
4506
+ const paneStep = stepMatch ? stepMatch[1] : null;
4507
+ if (!paneStep || paneStep !== expectedStepVal) {
4508
+ return false;
4509
+ }
4510
+ }
4511
+ const searchLines = lines.slice(headerIdx);
4512
+ const text = searchLines.join("\n");
4513
+ return /Your answer:/i.test(text) && /enter\s+Submit(?:\s+All)?\s*[·│,]\s*esc\s+Back/i.test(text);
4514
+ }
4485
4515
  function extractIdentity(headerText) {
4486
4516
  if (!headerText) return {};
4487
4517
  const trimmed = headerText.trim();
@@ -5207,13 +5237,15 @@ var ApprovalDetector = class extends import_events3.EventEmitter {
5207
5237
  const qOptTexts = (targetQ.options || []).map((o) => (typeof o === "string" ? o : o.text).toLowerCase().trim());
5208
5238
  const cOptTexts = (candidate.options || []).map((o) => (typeof o === "string" ? o : o.text).toLowerCase().trim());
5209
5239
  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;
5240
+ const hasTrailingWriteIn = cOptTexts.length === qOptTexts.length + 1 && /^\s*write-?in\b/i.test(cOptTexts[cOptTexts.length - 1]);
5241
+ const cEffectiveTexts = hasTrailingWriteIn ? cOptTexts.slice(0, -1) : cOptTexts;
5242
+ const matchedQCount = qOptTexts.filter((q) => cEffectiveTexts.includes(q)).length;
5243
+ const matchedCCount = cEffectiveTexts.filter((c) => qOptTexts.includes(c)).length;
5212
5244
  const qRatio = matchedQCount / qOptTexts.length;
5213
- if (cOptTexts.length !== qOptTexts.length || matchedQCount !== qOptTexts.length || matchedCCount !== cOptTexts.length) {
5245
+ if (cEffectiveTexts.length !== qOptTexts.length || matchedQCount !== qOptTexts.length || matchedCCount !== cEffectiveTexts.length) {
5214
5246
  return { score: -1, textMatch: true };
5215
5247
  }
5216
- const posMatches = qOptTexts.filter((q, idx) => idx < cOptTexts.length && cOptTexts[idx] === q).length;
5248
+ const posMatches = qOptTexts.filter((q, idx) => idx < cEffectiveTexts.length && cEffectiveTexts[idx] === q).length;
5217
5249
  const posRatio = posMatches / qOptTexts.length;
5218
5250
  const score = qRatio * 0.5 + posRatio * 0.5;
5219
5251
  return { score, textMatch: true };
@@ -5246,13 +5278,19 @@ var ApprovalDetector = class extends import_events3.EventEmitter {
5246
5278
  });
5247
5279
  return null;
5248
5280
  }
5281
+ const targetStepQ = sqs[bestStepIdx];
5282
+ if (targetStepQ?.isMultiSelect) {
5283
+ candidate.submitMap = {};
5284
+ }
5249
5285
  const isAdvancement = bestStepIdx !== currentActiveIdx;
5250
5286
  if (!isAdvancement) {
5251
5287
  const existing = [...this.pendingPrompts.values()].find(
5252
5288
  (p) => p.conversationId === conversationId && p.kind === "question"
5253
5289
  );
5254
5290
  if (existing) {
5255
- const isInitialUpgrade = existing.isHookPrompt && Object.keys(existing.submitMap || {}).length === 0 && Object.keys(candidate.submitMap || {}).length > 0;
5291
+ const targetQ = existing.pendingCall?.structuredQuestions?.[existing.wizardStepIndex ?? 0];
5292
+ const isAuthoritativeMultiSelect = Boolean(targetQ?.isMultiSelect);
5293
+ const isInitialUpgrade = existing.isHookPrompt && !isAuthoritativeMultiSelect && Object.keys(existing.submitMap || {}).length === 0 && Object.keys(candidate.submitMap || {}).length > 0;
5256
5294
  if (isInitialUpgrade) {
5257
5295
  existing.submitMap = candidate.submitMap;
5258
5296
  if (candidate.options && candidate.options.length > 0) {
@@ -5271,12 +5309,30 @@ var ApprovalDetector = class extends import_events3.EventEmitter {
5271
5309
  this.emit("prompt-upgraded", existing);
5272
5310
  return existing;
5273
5311
  }
5274
- if (!existing.paneSemanticKey) {
5275
- existing.paneSemanticKey = candidateSemanticKey;
5312
+ const areControlsMutated = (existingOpts, candidateOpts) => {
5313
+ if (!existingOpts || !candidateOpts) return false;
5314
+ if (existingOpts.length !== candidateOpts.length) return true;
5315
+ return !existingOpts.every((o, idx) => o.number === candidateOpts[idx].number && (o.text || "").trim() === (candidateOpts[idx].text || "").trim());
5316
+ };
5317
+ const isControlDelta = Boolean(
5318
+ existing.paneOptions && existing.paneOptions.length > 0 && candidate.options && candidate.options.length > 0 && areControlsMutated(existing.paneOptions, candidate.options)
5319
+ );
5320
+ if (isControlDelta) {
5321
+ logger.info("Control delta observed on active question step; resolving old prompt and re-emitting", {
5322
+ oldPromptId: existing.promptId,
5323
+ oldSemanticKey: existing.paneSemanticKey,
5324
+ newSemanticKey: candidateSemanticKey
5325
+ });
5326
+ this.resolvePrompt(existing.promptId, { advanceWizard: false });
5327
+ this.emit("prompt-resolved", existing.promptId);
5328
+ } else {
5329
+ if (!existing.paneSemanticKey) {
5330
+ existing.paneSemanticKey = candidateSemanticKey;
5331
+ }
5332
+ if (candidate.body) existing.body = candidate.body;
5333
+ if (candidate.paneHash) this.paneOnlyEmittedHashes.add(candidate.paneHash);
5334
+ return null;
5276
5335
  }
5277
- if (candidate.body) existing.body = candidate.body;
5278
- if (candidate.paneHash) this.paneOnlyEmittedHashes.add(candidate.paneHash);
5279
- return null;
5280
5336
  }
5281
5337
  } else {
5282
5338
  liveWizard2.activeStepIndex = bestStepIdx;
@@ -5298,12 +5354,14 @@ var ApprovalDetector = class extends import_events3.EventEmitter {
5298
5354
  const hookQuestionText = p.pendingCall?.questionText || p.matchedPaneHeader || p.paneDisplayHeader;
5299
5355
  const isSameQuestion = questionsMatch(hookQuestionText, paneQuestionText);
5300
5356
  if (isSameQuestion) {
5301
- const isInitialUpgrade = p.isHookPrompt && Object.keys(p.submitMap || {}).length === 0 && Object.keys(candidate.submitMap || {}).length > 0;
5357
+ const targetQ = p.pendingCall?.structuredQuestions?.[p.wizardStepIndex ?? 0];
5358
+ const isAuthoritativeMultiSelect = Boolean(targetQ?.isMultiSelect);
5359
+ const isInitialUpgrade = p.isHookPrompt && !isAuthoritativeMultiSelect && Object.keys(p.submitMap || {}).length === 0 && Object.keys(candidate.submitMap || {}).length > 0;
5302
5360
  const isControlDelta = !isInitialUpgrade && Boolean(
5303
5361
  p.paneSemanticKey && candidateSemanticKey !== p.paneSemanticKey
5304
5362
  );
5305
5363
  if (!isControlDelta) {
5306
- if (p.isHookPrompt && Object.keys(p.submitMap || {}).length === 0 && Object.keys(candidate.submitMap || {}).length > 0) {
5364
+ if (isInitialUpgrade) {
5307
5365
  p.submitMap = candidate.submitMap;
5308
5366
  if (candidate.options && candidate.options.length > 0) {
5309
5367
  p.paneOptions = candidate.options;
@@ -5424,6 +5482,16 @@ var ApprovalDetector = class extends import_events3.EventEmitter {
5424
5482
  } else {
5425
5483
  p.pendingCall = call;
5426
5484
  }
5485
+ const isHookMultiSelect = Boolean(
5486
+ call.isMultiSelect || call.structuredQuestions && call.structuredQuestions[0]?.isMultiSelect
5487
+ );
5488
+ if (isHookMultiSelect && Object.keys(p.submitMap || {}).length > 0) {
5489
+ p.submitMap = {};
5490
+ logger.info("emitHookQuestionPrompt: revoked actionable submitMap because incoming hook is multi-select", {
5491
+ promptId: p.promptId
5492
+ });
5493
+ this.emit("prompt-upgraded", p);
5494
+ }
5427
5495
  p.isHookPrompt = true;
5428
5496
  call.matchedPromptId = p.promptId;
5429
5497
  if (call.structuredQuestions && call.structuredQuestions.length > 0) {
@@ -5961,23 +6029,22 @@ var PromptResponder = class {
5961
6029
  }
5962
6030
  /**
5963
6031
  * 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').
6032
+ * option number and optional follow-up text for write-in questions.
6033
+ * Uses the submitMap from the PendingPromptState to translate
6034
+ * option-number → terminal keystrokes.
5967
6035
  *
5968
6036
  * Active-prompt-identity guard (DESIGN.md §3.4):
5969
6037
  * 1. Detector check: is `promptId` still in pendingPrompts? If not
5970
6038
  * (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.
6039
+ * 2. submitMap lookup: is `optionNumber` a valid key? If not, drop with `invalid-option`.
6040
+ * If `followUpText` is provided, verify option is Write-in; otherwise drop with `follow-up-not-allowed`.
6041
+ * If bare Write-in option has no text, drop with `write-in-text-required`.
6042
+ * 3. Pane-observer check: captureSnapshot() — does the pane STILL show
6043
+ * an approval UI matching our pending prompt's semantic key?
6044
+ * 4. Send option keystrokes via tmux send-keys.
6045
+ * 5. If write-in followUpText is present: wait 250ms, verify pane state, type literal text, send Enter.
5979
6046
  */
5980
- async sendApprovalReply(promptId, optionNumber) {
6047
+ async sendApprovalReply(promptId, optionNumber, followUpText, beforeTypeText) {
5981
6048
  const target = this.resolveTmuxTarget();
5982
6049
  if (!target) {
5983
6050
  return { ok: false, reason: "no-tmux-target" };
@@ -5992,15 +6059,6 @@ var PromptResponder = class {
5992
6059
  details: `promptId ${promptId} is not in pendingPrompts`
5993
6060
  };
5994
6061
  }
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
6062
  const keys = state.submitMap[optionNumber];
6005
6063
  if (!keys || keys.length === 0) {
6006
6064
  return {
@@ -6009,21 +6067,86 @@ var PromptResponder = class {
6009
6067
  details: `option '${optionNumber}' not in submitMap (valid: ${Object.keys(state.submitMap).join(",")})`
6010
6068
  };
6011
6069
  }
6070
+ const isQuestionPrompt = state.kind === "question";
6071
+ const expectedStep = (state.paneDisplayHeader || "").match(/\bQuestion\s+(\d+(?:[\/:of\s]+\d+)?):/i)?.[1];
6072
+ const matchingOpt = state.paneOptions?.find((o) => String(o.number) === String(optionNumber));
6073
+ const isWriteInOption = matchingOpt ? /^\s*write-?in\b/i.test(matchingOpt.text) : false;
6074
+ if (isQuestionPrompt) {
6075
+ if (state.paneOptions && state.paneOptions.length > 0 && !matchingOpt) {
6076
+ return {
6077
+ ok: false,
6078
+ reason: "invalid-option",
6079
+ details: `option '${optionNumber}' not found in advertised options`,
6080
+ deliveryPhase: "none"
6081
+ };
6082
+ }
6083
+ if (isWriteInOption && (!followUpText || followUpText.trim().length === 0)) {
6084
+ return {
6085
+ ok: false,
6086
+ reason: "write-in-text-required",
6087
+ details: 'Please provide your write-in text along with the option number (e.g. "4, your response")',
6088
+ deliveryPhase: "none"
6089
+ };
6090
+ }
6091
+ if (followUpText && followUpText.trim().length > 0 && !isWriteInOption) {
6092
+ return {
6093
+ ok: false,
6094
+ reason: "follow-up-not-allowed",
6095
+ details: `Option '${optionNumber}' does not accept follow-up text`,
6096
+ deliveryPhase: "none"
6097
+ };
6098
+ }
6099
+ }
6100
+ if (this.paneObserver) {
6101
+ if (!state.paneSemanticKey) {
6102
+ return {
6103
+ ok: false,
6104
+ reason: "prompt-probe-failed",
6105
+ details: "pending prompt has no exact semantic identity",
6106
+ deliveryPhase: "none"
6107
+ };
6108
+ }
6109
+ }
6110
+ let deliveryPhase = "none";
6012
6111
  try {
6013
6112
  for (const key of keys) {
6014
6113
  if (!this.detector || !this.paneObserver) {
6015
6114
  return {
6016
6115
  ok: false,
6017
6116
  reason: "prompt-probe-failed",
6018
- details: "approval responder is missing its detector or pane observer"
6117
+ details: "approval responder is missing its detector or pane observer",
6118
+ deliveryPhase
6019
6119
  };
6020
6120
  }
6021
- if (this.detector.getPendingPrompt(promptId) !== state) {
6022
- return { ok: false, reason: "prompt-expired" };
6121
+ const currentPrompt = this.detector.getPendingPrompt(promptId);
6122
+ if (currentPrompt !== state || !currentPrompt.submitMap || !currentPrompt.submitMap[optionNumber]) {
6123
+ return {
6124
+ ok: false,
6125
+ reason: "prompt-expired",
6126
+ details: "option is no longer actionable on active prompt (controls revoked or updated)",
6127
+ deliveryPhase
6128
+ };
6023
6129
  }
6024
6130
  let liveSemanticKey = null;
6025
6131
  try {
6026
6132
  const snapshot = await this.paneObserver.captureSnapshot();
6133
+ const postCapturePrompt = this.detector.getPendingPrompt(promptId);
6134
+ if (postCapturePrompt !== state || !postCapturePrompt.submitMap || !postCapturePrompt.submitMap[optionNumber]) {
6135
+ return {
6136
+ ok: false,
6137
+ reason: "prompt-expired",
6138
+ details: "option is no longer actionable on active prompt (controls revoked or updated)",
6139
+ deliveryPhase
6140
+ };
6141
+ }
6142
+ if (isQuestionPrompt && isWriteInInputActive(snapshot, state.matchedPaneHeader, expectedStep)) {
6143
+ return {
6144
+ ok: false,
6145
+ reason: "write-in-already-active",
6146
+ details: "write-in input field is already open on desktop; finish or cancel on desktop before selecting an option",
6147
+ deliveryPhase: "none"
6148
+ };
6149
+ }
6027
6150
  const live = parseApprovalSnapshot(snapshot);
6028
6151
  if (!live) {
6029
6152
  if (detectActivePromptKind(snapshot) === null) {
@@ -6031,13 +6154,15 @@ var PromptResponder = class {
6031
6154
  return {
6032
6155
  ok: false,
6033
6156
  reason: "prompt-superseded",
6034
- details: "the approval chooser is no longer active"
6157
+ details: "the approval chooser is no longer active",
6158
+ deliveryPhase
6035
6159
  };
6036
6160
  }
6037
6161
  return {
6038
6162
  ok: false,
6039
6163
  reason: "prompt-probe-failed",
6040
- details: "the active chooser could not be parsed exactly"
6164
+ details: "the active chooser could not be parsed exactly",
6165
+ deliveryPhase
6041
6166
  };
6042
6167
  }
6043
6168
  liveSemanticKey = live ? buildApprovalSemanticKey(live) : null;
@@ -6045,7 +6170,8 @@ var PromptResponder = class {
6045
6170
  return {
6046
6171
  ok: false,
6047
6172
  reason: "prompt-probe-failed",
6048
- details: error instanceof Error ? error.message : String(error)
6173
+ details: error instanceof Error ? error.message : String(error),
6174
+ deliveryPhase
6049
6175
  };
6050
6176
  }
6051
6177
  if (liveSemanticKey !== state.paneSemanticKey) {
@@ -6053,32 +6179,147 @@ var PromptResponder = class {
6053
6179
  return {
6054
6180
  ok: false,
6055
6181
  reason: "prompt-superseded",
6056
- details: "active chooser semantics differ from the mobile prompt"
6182
+ details: "active chooser semantics differ from the mobile prompt",
6183
+ deliveryPhase
6057
6184
  };
6058
6185
  }
6186
+ deliveryPhase = "option_attempted";
6059
6187
  if (isNamedKey(key)) {
6060
6188
  await this.sendKey(target, key);
6061
6189
  } else {
6062
6190
  await this.typeLiteral(target, key);
6063
6191
  }
6192
+ deliveryPhase = "option_sent";
6193
+ await delay(50);
6194
+ }
6195
+ if (isQuestionPrompt && isWriteInOption && followUpText && followUpText.trim().length > 0) {
6196
+ let writeInReady = false;
6197
+ if (this.paneObserver) {
6198
+ for (let probeAttempt = 0; probeAttempt < 10; probeAttempt++) {
6199
+ await delay(100);
6200
+ try {
6201
+ const liveSnap = await this.paneObserver.captureSnapshot();
6202
+ if (liveSnap && isWriteInInputActive(liveSnap, state.matchedPaneHeader, expectedStep)) {
6203
+ writeInReady = true;
6204
+ break;
6205
+ }
6206
+ } catch {
6207
+ }
6208
+ }
6209
+ if (!writeInReady) {
6210
+ return {
6211
+ ok: false,
6212
+ reason: "prompt-probe-failed",
6213
+ details: "terminal pane failed to transition to write-in input line",
6214
+ deliveryPhase
6215
+ };
6216
+ }
6217
+ } else {
6218
+ await delay(250);
6219
+ }
6220
+ if (beforeTypeText) {
6221
+ try {
6222
+ await beforeTypeText();
6223
+ } catch (err) {
6224
+ return {
6225
+ ok: false,
6226
+ reason: "tmux-failed",
6227
+ details: err instanceof Error ? err.message : String(err),
6228
+ deliveryPhase: "option_sent"
6229
+ // Do not advance phase if hook throws (Claude R4-N2)
6230
+ };
6231
+ }
6232
+ }
6233
+ const postHookPrompt = this.detector.getPendingPrompt(promptId);
6234
+ if (postHookPrompt !== state || !postHookPrompt.submitMap || !postHookPrompt.submitMap[optionNumber]) {
6235
+ return { ok: false, reason: "prompt-expired", deliveryPhase: "option_sent" };
6236
+ }
6237
+ if (this.paneObserver) {
6238
+ try {
6239
+ const postHookSnap = await this.paneObserver.captureSnapshot();
6240
+ if (!postHookSnap || !isWriteInInputActive(postHookSnap, state.matchedPaneHeader, expectedStep)) {
6241
+ return {
6242
+ ok: false,
6243
+ reason: "prompt-probe-failed",
6244
+ details: "write-in input field disappeared during callback before typing",
6245
+ deliveryPhase: "option_sent"
6246
+ };
6247
+ }
6248
+ } catch (err) {
6249
+ return {
6250
+ ok: false,
6251
+ reason: "prompt-probe-failed",
6252
+ details: err instanceof Error ? err.message : String(err),
6253
+ deliveryPhase: "option_sent"
6254
+ };
6255
+ }
6256
+ const postSnapPrompt1 = this.detector.getPendingPrompt(promptId);
6257
+ if (postSnapPrompt1 !== state || !postSnapPrompt1.submitMap || !postSnapPrompt1.submitMap[optionNumber]) {
6258
+ return { ok: false, reason: "prompt-expired", deliveryPhase: "option_sent" };
6259
+ }
6260
+ }
6261
+ deliveryPhase = "text_attempted";
6262
+ const cleanText = followUpText.replace(/[\r\n]+/g, " ").trim();
6263
+ await this.typeLiteral(target, cleanText);
6264
+ deliveryPhase = "text_typed";
6265
+ await delay(50);
6266
+ if (this.paneObserver) {
6267
+ try {
6268
+ const preEnterSnap = await this.paneObserver.captureSnapshot();
6269
+ if (!preEnterSnap || !isWriteInInputActive(preEnterSnap, state.matchedPaneHeader, expectedStep)) {
6270
+ return {
6271
+ ok: false,
6272
+ reason: "prompt-probe-failed",
6273
+ details: "write-in input field disappeared before confirmation",
6274
+ deliveryPhase: "text_typed"
6275
+ };
6276
+ }
6277
+ } catch (err) {
6278
+ return {
6279
+ ok: false,
6280
+ reason: "prompt-probe-failed",
6281
+ details: err instanceof Error ? err.message : String(err),
6282
+ deliveryPhase: "text_typed"
6283
+ };
6284
+ }
6285
+ }
6286
+ const postSnapPrompt = this.detector.getPendingPrompt(promptId);
6287
+ if (postSnapPrompt !== state || !postSnapPrompt.submitMap || !postSnapPrompt.submitMap[optionNumber]) {
6288
+ return { ok: false, reason: "prompt-expired", deliveryPhase: "text_typed" };
6289
+ }
6290
+ deliveryPhase = "enter_attempted";
6291
+ await this.sendKey(target, "Enter");
6292
+ deliveryPhase = "completed";
6064
6293
  }
6065
6294
  this.detector.resolvePrompt(promptId);
6066
6295
  logger.info("Sent approval reply via tmux", {
6067
6296
  promptId,
6068
6297
  optionNumber,
6069
- keysSent: keys
6298
+ keysSent: keys,
6299
+ hasFollowUpText: Boolean(followUpText),
6300
+ deliveryPhase
6070
6301
  });
6071
- return { ok: true };
6302
+ return { ok: true, deliveryPhase: "completed" };
6072
6303
  } catch (error) {
6073
6304
  return {
6074
6305
  ok: false,
6075
6306
  reason: "tmux-failed",
6076
- details: error instanceof Error ? error.message : String(error)
6307
+ details: error instanceof Error ? error.message : String(error),
6308
+ deliveryPhase
6077
6309
  };
6078
6310
  }
6079
6311
  }
6080
6312
  try {
6081
6313
  await this.typeLiteral(target, optionNumber);
6314
+ await delay(50);
6315
+ await this.sendKey(target, "Enter");
6316
+ if (followUpText && followUpText.trim().length > 0) {
6317
+ await delay(250);
6318
+ const cleanText = followUpText.replace(/[\r\n]+/g, " ").trim();
6319
+ await this.typeLiteral(target, cleanText);
6320
+ await delay(50);
6321
+ await this.sendKey(target, "Enter");
6322
+ }
6082
6323
  return { ok: true };
6083
6324
  } catch (error) {
6084
6325
  return {
@@ -6795,6 +7036,7 @@ ${commandErrorText(error, "stdout")}`;
6795
7036
  }
6796
7037
  var McpServer = class _McpServer {
6797
7038
  constructor(options) {
7039
+ this.lastNativeLogConversation = null;
6798
7040
  /** Per-session causal floor for transcript-event timestamps emitted right
6799
7041
  * after a MOBILE prompt. The mobile USER_PROMPT is created by the iOS app
6800
7042
  * (real ms clock) and never flows through prepareEventTimestamp, while the
@@ -6987,6 +7229,15 @@ var McpServer = class _McpServer {
6987
7229
  this.tmuxTarget = options.tmuxTarget ?? process.env.CODEVIBE_AGY_TMUX_TARGET ?? null;
6988
7230
  this.wrapperPid = options.wrapperPid ?? null;
6989
7231
  this.cliLogPath = options.cliLogPath ?? null;
7232
+ this.nativeCliLogPath = options.nativeCliLogPath ?? null;
7233
+ this.nativeCliLogIdentity = null;
7234
+ if (this.nativeCliLogPath) {
7235
+ const st = fs7.lstatSync(this.nativeCliLogPath);
7236
+ if (!st.isFile() || st.isSymbolicLink() || st.nlink !== 1 || st.uid !== process.getuid?.()) {
7237
+ throw Error("AGY_NATIVE_LOG_NOT_OWNED");
7238
+ }
7239
+ this.nativeCliLogIdentity = { dev: st.dev, ino: st.ino };
7240
+ }
6990
7241
  this.launchSettleTimeoutMs = options.launchSettleTimeoutMs ?? LAUNCH_SETTLE_TIMEOUT_MS;
6991
7242
  this.appSyncClient = options.appSyncClient ?? new import_codevibe_core7.AppSyncClient();
6992
7243
  this.toolActivity = options.toolActivityIntegration === void 0 ? new AgyToolActivityIntegration({
@@ -7614,13 +7865,14 @@ var McpServer = class _McpServer {
7614
7865
  }
7615
7866
  }
7616
7867
  /**
7617
- * The pre-F1 rule, now only the fallback for conversations no hook has
7618
- * claimed when hooks do not fire (see conversation-router.ts): the newest
7619
- * agy CLI log names the active conversation. F1 amendment: a conversation
7620
- * another live CodeVibe daemon has claimed is never "the active one" here,
7621
- * so another session's busy log cannot make us drop our own turn.
7868
+ * Production launchers supply a pinned native log. Hookless routing admits
7869
+ * only its positively identified main transcript. The unbound pre-F1 rule
7870
+ * below remains for legacy/test callers, not durable source allocation.
7622
7871
  */
7623
7872
  legacyAcceptsConversation(conversationId) {
7873
+ if (this.nativeCliLogPath) {
7874
+ return !this.claims.isClaimedElsewhere(conversationId) && this.boundNativeLogConversation() === conversationId;
7875
+ }
7624
7876
  if (process.env.JEST_WORKER_ID && !this.cliLogPath) {
7625
7877
  return true;
7626
7878
  }
@@ -7634,6 +7886,38 @@ var McpServer = class _McpServer {
7634
7886
  }
7635
7887
  return activeId === conversationId;
7636
7888
  }
7889
+ /** Never resolve the shared cli.log symlink or scan for another process's log. */
7890
+ boundNativeLogConversation() {
7891
+ if (!this.nativeCliLogPath || !this.nativeCliLogIdentity) return null;
7892
+ let fd;
7893
+ try {
7894
+ fd = fs7.openSync(this.nativeCliLogPath, fs7.constants.O_RDONLY | fs7.constants.O_NOFOLLOW);
7895
+ const st = fs7.fstatSync(fd), expected = this.nativeCliLogIdentity;
7896
+ if (!st.isFile() || st.nlink !== 1 || st.dev !== expected.dev || st.ino !== expected.ino) return null;
7897
+ const matches = [...fs7.readFileSync(fd, "utf8").matchAll(/(?:Created conversation|Streaming conversation|Starting conversation update stream for)\s+([0-9a-fA-F-]{36})/g)];
7898
+ const checked = /* @__PURE__ */ new Set();
7899
+ for (let i = matches.length - 1; i >= 0; i--) {
7900
+ const conversationId = matches[i][1];
7901
+ if (checked.has(conversationId)) continue;
7902
+ checked.add(conversationId);
7903
+ if (this.router.roleOf(conversationId) !== "subagent" && !this.claims.isClaimedElsewhere(conversationId) && this.readConversationFirstType(conversationId) === "USER_INPUT") return conversationId;
7904
+ }
7905
+ return null;
7906
+ } catch {
7907
+ return null;
7908
+ } finally {
7909
+ if (fd !== void 0) fs7.closeSync(fd);
7910
+ }
7911
+ }
7912
+ refreshNativeLogOwnership() {
7913
+ const conversationId = this.boundNativeLogConversation();
7914
+ if (!conversationId || conversationId === this.lastNativeLogConversation) return;
7915
+ this.lastNativeLogConversation = conversationId;
7916
+ if (this.router.roleOf(conversationId) !== "subagent" && !this.claims.isClaimedElsewhere(conversationId)) {
7917
+ logger.info("Agy launch log identified its native conversation", { conversationId, ownership: "process-bound-log" });
7918
+ this.transcriptTailer.retryOwnedConversation(conversationId);
7919
+ }
7920
+ }
7637
7921
  /**
7638
7922
  * F1 moved the runtime dir under ~/.codevibe, so it outlives reboots. The
7639
7923
  * wrapper's EXIT trap removes it, except when the outer terminal closed while
@@ -7644,6 +7928,12 @@ var McpServer = class _McpServer {
7644
7928
  removeOrphanedRuntimeDir() {
7645
7929
  if (!this.runtimeDir || this.wrapperPid === null || isPidAlive(this.wrapperPid)) return;
7646
7930
  try {
7931
+ if (this.nativeCliLogPath && this.nativeCliLogIdentity) {
7932
+ const st = fs7.lstatSync(this.nativeCliLogPath);
7933
+ if (st.isFile() && !st.isSymbolicLink() && st.nlink === 1 && st.dev === this.nativeCliLogIdentity.dev && st.ino === this.nativeCliLogIdentity.ino) {
7934
+ fs7.unlinkSync(this.nativeCliLogPath);
7935
+ }
7936
+ }
7647
7937
  fs7.rmdirSync(this.runtimeDir);
7648
7938
  logger.info("Removed runtime dir left by a closed terminal", { runtimeDir: this.runtimeDir });
7649
7939
  } catch (error) {
@@ -7715,6 +8005,7 @@ var McpServer = class _McpServer {
7715
8005
  });
7716
8006
  if (role === "main") {
7717
8007
  this.observedMainConversationIds.add(conversationId);
8008
+ this.transcriptTailer.retryOwnedConversation(conversationId);
7718
8009
  if (!wasAlreadyAdmitted) {
7719
8010
  await this.drainHeldHookDeliveries(conversationId, "process", delivery.initialNumSteps);
7720
8011
  }
@@ -7763,6 +8054,7 @@ var McpServer = class _McpServer {
7763
8054
  logger.warn("PostToolUse prompt resolution failed", { error: String(error) });
7764
8055
  });
7765
8056
  this.router.onPostToolUse(conversationId);
8057
+ if (this.router.roleOf(conversationId) === "main") this.transcriptTailer.retryOwnedConversation(conversationId);
7766
8058
  }
7767
8059
  resolveLingeringQuestionPrompts(conversationId) {
7768
8060
  const lingeringPrompts = this.approvalDetector.getPendingPromptsForConversation(conversationId).filter((p) => p.kind === "question");
@@ -8188,8 +8480,9 @@ var McpServer = class _McpServer {
8188
8480
  const selection = await this.selectNativeSource(session);
8189
8481
  if (!this.started || this.session !== session) throw Error("SYNC_SOURCE_OWNER_CHANGED");
8190
8482
  if (selection.protocol === "LEGACY") return null;
8191
- if (!this.legacyAcceptsConversation(source.conversationId) || this.router.roleOf(source.conversationId) === "subagent") {
8192
- throw Error("SYNC_SOURCE_OWNERSHIP_UNAVAILABLE");
8483
+ const ownsConversation = () => !this.claims.isClaimedElsewhere(source.conversationId) && (this.router.roleOf(source.conversationId) === "main" || this.router.roleOf(source.conversationId) === void 0 && this.boundNativeLogConversation() === source.conversationId);
8484
+ if (!ownsConversation()) {
8485
+ throw Object.assign(Error("SYNC_SOURCE_OWNERSHIP_UNAVAILABLE"), { code: "SYNC_SOURCE_OWNERSHIP_PENDING" });
8193
8486
  }
8194
8487
  if (!this.toolActivity?.isActive(session.sessionId)) {
8195
8488
  if (this.nativeToolStart && this.nativeToolStart.session !== session) throw Error("SYNC_SOURCE_OWNER_CHANGED");
@@ -8203,7 +8496,12 @@ var McpServer = class _McpServer {
8203
8496
  }
8204
8497
  await this.nativeToolStart.pending;
8205
8498
  }
8206
- if (!this.toolActivity?.isActive(session.sessionId) || this.session !== session || !this.started) throw Error("SYNC_SOURCE_OWNERSHIP_UNAVAILABLE");
8499
+ if (!ownsConversation()) {
8500
+ throw Object.assign(Error("SYNC_SOURCE_OWNERSHIP_UNAVAILABLE"), { code: "SYNC_SOURCE_OWNERSHIP_PENDING" });
8501
+ }
8502
+ if (!this.toolActivity?.isActive(session.sessionId) || this.session !== session || !this.started) {
8503
+ throw Error("SYNC_SOURCE_OWNERSHIP_UNAVAILABLE");
8504
+ }
8207
8505
  const streamSha256 = crypto3.createHash("sha256").update(JSON.stringify([source.filePath, source.conversationId])).digest("hex");
8208
8506
  const key = `${selection.sessionScope.sessionId}:${selection.sessionScope.sessionGenerationId}:${streamSha256}`;
8209
8507
  const existing = this.nativeSources.get(key);
@@ -8247,7 +8545,7 @@ var McpServer = class _McpServer {
8247
8545
  this.assertNativeOwner(owner);
8248
8546
  if ((await owner.cursor.open(0)).byteOffset >= source.endOffset) return;
8249
8547
  const admission = { outputs: [null, null] };
8250
- await this.handleTranscriptEmit(emit, "legacy", admission);
8548
+ await this.handleTranscriptEmit(emit, "process", admission);
8251
8549
  this.assertNativeOwner(owner);
8252
8550
  const echoFrame = {
8253
8551
  streamSha256: owner.streamSha256,
@@ -9060,7 +9358,7 @@ var McpServer = class _McpServer {
9060
9358
  if (bound && this.observedMainConversationIds.has(bound)) return bound;
9061
9359
  if (this.observedMainConversationIds.size === 0) return null;
9062
9360
  const cliLogPath = this.cliLogPath ?? path8.join(os7.homedir(), ".gemini", "antigravity-cli", "cli.log");
9063
- const cliActive = getActiveConversationFromCliLog(cliLogPath);
9361
+ const cliActive = this.nativeCliLogPath ? this.boundNativeLogConversation() : getActiveConversationFromCliLog(cliLogPath);
9064
9362
  if (cliActive && this.observedMainConversationIds.has(cliActive)) {
9065
9363
  return cliActive;
9066
9364
  }
@@ -9086,9 +9384,7 @@ var McpServer = class _McpServer {
9086
9384
  let e1rec;
9087
9385
  if (e1existing) {
9088
9386
  e1rec = e1existing.record;
9089
- if (!isDisplayOnly && !e1rec.mobileActionable) {
9090
- e1rec.mobileActionable = true;
9091
- }
9387
+ e1rec.mobileActionable = !isDisplayOnly;
9092
9388
  } else {
9093
9389
  e1rec = this.newE1Raise(session.sessionId, !isDisplayOnly);
9094
9390
  const oldPromptId = state.promptId;
@@ -9372,22 +9668,119 @@ var McpServer = class _McpServer {
9372
9668
  }
9373
9669
  }
9374
9670
  await this.trackDurableMobileEcho(session, evt, intent.option, "primary");
9375
- const optionResult = await this.promptResponder.sendApprovalReply(
9376
- activePrompt.promptId,
9377
- intent.option
9378
- );
9379
- if (!optionResult.ok) {
9380
- if (!(0, import_codevibe_core7.requiresSyncInputDeliveryReceipt)(evt)) this.mobileDeduper.forget(session.sessionId, intent.option);
9671
+ const isQuestionPrompt = activePrompt.kind === "question";
9672
+ if (isQuestionPrompt) {
9673
+ let armedWriteInEcho = false;
9674
+ const optionResult = intent.followUpText ? await this.promptResponder.sendApprovalReply(
9675
+ activePrompt.promptId,
9676
+ intent.option,
9677
+ intent.followUpText,
9678
+ async () => {
9679
+ armedWriteInEcho = true;
9680
+ await this.trackDurableMobileEcho(session, evt, intent.followUpText, "free_form");
9681
+ }
9682
+ ) : await this.promptResponder.sendApprovalReply(
9683
+ activePrompt.promptId,
9684
+ intent.option
9685
+ );
9686
+ if (!optionResult.ok) {
9687
+ if (optionResult.reason === "write-in-text-required") {
9688
+ await this.emitPromptSafetyNotification(
9689
+ session,
9690
+ optionResult.details || 'Please provide your write-in text along with the option number (e.g. "4, your response").'
9691
+ );
9692
+ } else if (optionResult.reason === "write-in-already-active") {
9693
+ await this.emitPromptSafetyNotification(
9694
+ session,
9695
+ optionResult.details || "Write-in input field is already open on desktop; finish or cancel on desktop before selecting an option."
9696
+ );
9697
+ } else if (optionResult.reason === "follow-up-not-allowed") {
9698
+ await this.emitPromptSafetyNotification(
9699
+ session,
9700
+ optionResult.details || `Option ${intent.option} does not accept follow-up text.`
9701
+ );
9702
+ } else {
9703
+ const phase = optionResult.deliveryPhase ?? "none";
9704
+ let message;
9705
+ if (phase !== "none") {
9706
+ message = "Your answer was partially entered on desktop. Please complete or cancel the prompt directly on desktop.";
9707
+ } else if (optionResult.reason === "prompt-expired" || optionResult.reason === "prompt-superseded") {
9708
+ message = "The question prompt on desktop has expired or changed. Please review on desktop.";
9709
+ } else if (optionResult.reason === "prompt-probe-failed") {
9710
+ message = "Write-in input field was not ready on desktop. Please try again or respond on desktop.";
9711
+ } else if (optionResult.reason === "no-tmux-target" || optionResult.reason === "tmux-failed") {
9712
+ message = optionResult.details || "Desktop terminal session is unreachable. Please respond directly on desktop.";
9713
+ } else if (optionResult.reason === "display-only-question-active") {
9714
+ message = "This question requires desktop input. Please respond directly on desktop.";
9715
+ } else if (optionResult.reason === "invalid-option") {
9716
+ message = optionResult.details || `Option '${intent.option}' is not valid for the current question.`;
9717
+ } else {
9718
+ message = optionResult.details || `Failed to submit response to question prompt (${optionResult.reason}).`;
9719
+ }
9720
+ await this.emitPromptSafetyNotification(session, message);
9721
+ }
9722
+ if (!(0, import_codevibe_core7.requiresSyncInputDeliveryReceipt)(evt)) {
9723
+ const phase = optionResult.deliveryPhase ?? "none";
9724
+ if (phase === "none") {
9725
+ this.mobileDeduper.forget(session.sessionId, intent.option);
9726
+ if (armedWriteInEcho && intent.followUpText) {
9727
+ this.mobileDeduper.forget(session.sessionId, intent.followUpText);
9728
+ }
9729
+ } else if (phase === "option_sent" && armedWriteInEcho && intent.followUpText) {
9730
+ this.mobileDeduper.forget(session.sessionId, intent.followUpText);
9731
+ }
9732
+ }
9733
+ return;
9734
+ }
9735
+ await this.resolveE1PluginApproval(session.sessionId, activePrompt.promptId);
9736
+ await this.markExecuted(evt);
9381
9737
  return;
9382
- }
9383
- await this.resolveE1PluginApproval(session.sessionId, activePrompt.promptId);
9384
- if (!intent.followUpText) {
9738
+ } else {
9739
+ const optionResult = await this.promptResponder.sendApprovalReply(
9740
+ activePrompt.promptId,
9741
+ intent.option
9742
+ );
9743
+ if (!optionResult.ok) {
9744
+ if (!(0, import_codevibe_core7.requiresSyncInputDeliveryReceipt)(evt)) {
9745
+ const phase = optionResult.deliveryPhase ?? "none";
9746
+ if (phase === "none") {
9747
+ this.mobileDeduper.forget(session.sessionId, intent.option);
9748
+ }
9749
+ }
9750
+ return;
9751
+ }
9752
+ await this.resolveE1PluginApproval(session.sessionId, activePrompt.promptId);
9753
+ if (intent.followUpText) {
9754
+ await new Promise((r) => setTimeout(r, 250));
9755
+ const freeFormResult = await this.promptResponder.sendFreeFormWhenNoApprovalActive(
9756
+ intent.followUpText,
9757
+ () => this.trackDurableMobileEcho(session, evt, intent.followUpText, "free_form")
9758
+ );
9759
+ if (!freeFormResult.ok) {
9760
+ if (!(0, import_codevibe_core7.requiresSyncInputDeliveryReceipt)(evt)) {
9761
+ this.mobileDeduper.forget(session.sessionId, intent.followUpText);
9762
+ }
9763
+ logger.warn("Follow-up text send failed for approval", { reason: freeFormResult.reason });
9764
+ return;
9765
+ }
9766
+ }
9385
9767
  await this.markExecuted(evt);
9386
9768
  return;
9387
9769
  }
9388
- promptContent = intent.followUpText;
9389
- await new Promise((resolve2) => setTimeout(resolve2, 250));
9390
9770
  } else {
9771
+ const activeQuestionPrompt = pendingPrompts.find((p) => p.kind === "question");
9772
+ if (activeQuestionPrompt) {
9773
+ const isDisplayOnly = Object.keys(activeQuestionPrompt.submitMap || {}).length === 0;
9774
+ logger.warn("Non-option input received while an interactive question prompt is active; rejecting dismissal", {
9775
+ sessionId: session.sessionId,
9776
+ promptId: activeQuestionPrompt.promptId,
9777
+ isDisplayOnly,
9778
+ content: promptContent.slice(0, 64)
9779
+ });
9780
+ const message = isDisplayOnly ? "The active terminal question prompt is display-only on mobile. Please select an option directly in your terminal." : "An interactive question prompt is active in your terminal. Please select one of the advertised options or answer directly in your terminal.";
9781
+ await this.emitPromptSafetyNotification(session, message);
9782
+ return;
9783
+ }
9391
9784
  const hasDisplayOnlyPrompt = pendingPrompts.some(
9392
9785
  (p) => Object.keys(p.submitMap || {}).length === 0
9393
9786
  );
@@ -9596,6 +9989,7 @@ var McpServer = class _McpServer {
9596
9989
  const state = await this.tmuxSessionState(tmuxTarget);
9597
9990
  if (state === "alive") {
9598
9991
  this.tmuxLifecycleObservedAlive = true;
9992
+ this.refreshNativeLogOwnership();
9599
9993
  return;
9600
9994
  }
9601
9995
  const ownershipEstablished = this.tmuxLifecycleObservedAlive || Date.now() >= this.tmuxLifecycleInitDeadlineMs;
@@ -9923,6 +10317,7 @@ async function main() {
9923
10317
  bearerToken,
9924
10318
  preferredPort: args.port,
9925
10319
  runtimeDir: args.runtimeDir,
10320
+ nativeCliLogPath: args.runtimeDir ? path8.join(args.runtimeDir, "native-cli.log") : void 0,
9926
10321
  tmuxTarget: process.env.CODEVIBE_AGY_TMUX_TARGET ?? void 0,
9927
10322
  wrapperPid: process.env.CODEVIBE_AGY_WRAPPER_PID ? parseInt(process.env.CODEVIBE_AGY_WRAPPER_PID, 10) : void 0
9928
10323
  });
@@ -10057,6 +10452,7 @@ var __testing = {
10057
10452
  0 && (module.exports = {
10058
10453
  ApprovalDetector,
10059
10454
  McpServer,
10455
+ PromptResponder,
10060
10456
  SessionNotFoundError,
10061
10457
  StoredAuthFailureError,
10062
10458
  TERMINAL_SHUTDOWN_SIGNALS,
@@ -50,6 +50,7 @@
50
50
  # Companion limitations:
51
51
  # - Requires tmux. Print mode (`--print` / `-p`) is NOT supported.
52
52
  # - Requires Node.js 22 or newer for the MCP server.
53
+ # - Requires agy 1.4.3+ advertising --log-file for launch ownership.
53
54
  #
54
55
 
55
56
  set -e
@@ -182,6 +183,37 @@ case "${1:-}" in
182
183
  ;;
183
184
  esac
184
185
 
186
+ # Companion owns the log used as native transcript ownership proof. Do not
187
+ # silently override a user's log or let a second flag redirect that proof.
188
+ for _CV_ARG in "$@"; do
189
+ case "$_CV_ARG" in
190
+ --log-file|--log-file=*|-log-file|-log-file=*)
191
+ echo 'Error: CodeVibe Companion owns --log-file for session isolation. Run agy directly to use a custom log.' >&2
192
+ exit 2
193
+ ;;
194
+ esac
195
+ done
196
+ unset _CV_ARG
197
+
198
+ # Require the tested native contract before installer, runtime, or session work.
199
+ # Metadata passthrough above and direct `agy` use remain unchanged.
200
+ require_native_sync_support() {
201
+ local version="$1" binary="$2" help major minor patch
202
+ if [[ "$version" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
203
+ major=$((10#${BASH_REMATCH[1]}))
204
+ minor=$((10#${BASH_REMATCH[2]}))
205
+ patch=$((10#${BASH_REMATCH[3]}))
206
+ if (( major > 1 || (major == 1 && (minor > 4 || (minor == 4 && patch >= 3))) )); then
207
+ if help="$("$binary" --help 2>&1)" &&
208
+ [[ "$help" =~ (^|[[:space:]])--log-file([=[:space:]]|$) ]]; then
209
+ return 0
210
+ fi
211
+ fi
212
+ fi
213
+ echo 'Error: CodeVibe Companion requires Antigravity CLI 1.4.3+ with --log-file support. Update agy, then retry. Direct agy use is unchanged.' >&2
214
+ return 1
215
+ }
216
+
185
217
  # ─── Environment probes ───────────────────────────────────────────────
186
218
  _CV_NODE_VER="missing"
187
219
  command -v node >/dev/null 2>&1 && _CV_NODE_VER="$(node -v 2>/dev/null | cv_sanitize)"
@@ -191,7 +223,7 @@ command -v tmux >/dev/null 2>&1 && _CV_TMUX_VER="$(tmux -V 2>/dev/null | cv_sani
191
223
  [ -z "$_CV_TMUX_VER" ] && _CV_TMUX_VER="unknown"
192
224
  _CV_AGY_BIN="$(resolve_agy_bin)"
193
225
  _CV_AGY_VER="missing"
194
- [ -n "$_CV_AGY_BIN" ] && _CV_AGY_VER="$("$_CV_AGY_BIN" --version 2>/dev/null | cv_sanitize)"
226
+ [ -n "$_CV_AGY_BIN" ] && _CV_AGY_VER="$(cv_sanitize "$("$_CV_AGY_BIN" --version 2>/dev/null)")"
195
227
  [ -z "$_CV_AGY_VER" ] && _CV_AGY_VER="unknown"
196
228
  _CV_OS_VER="$(uname -s | cv_sanitize)"
197
229
  [ -z "$_CV_OS_VER" ] && _CV_OS_VER="unknown"
@@ -262,6 +294,11 @@ if [ -z "$_CV_AGY_BIN" ] || ! [ -f "$_CV_AGY_BIN" ] || ! [ -x "$_CV_AGY_BIN" ];
262
294
  exit 1
263
295
  fi
264
296
 
297
+ if ! require_native_sync_support "$_CV_AGY_VER" "$_CV_AGY_BIN"; then
298
+ cv_failed "agy_sync_contract_unsupported"
299
+ exit 2
300
+ fi
301
+
265
302
  if ! command -v node &> /dev/null; then
266
303
  echo "Error: Node.js is required but not installed."
267
304
  echo "Install Node.js 22+ from: https://nodejs.org"
@@ -348,6 +385,14 @@ rm -f "$RUNTIME_DIR/conn.json" "$RUNTIME_DIR/claims.json"
348
385
  # Also tighten the runtime base — best-effort, no fatal on failure.
349
386
  chmod 0700 "$RUNTIME_BASE" 2>/dev/null || true
350
387
 
388
+ # The native process and daemon share only this launch's fresh regular log.
389
+ # Refuse a stale PID-reuse file rather than trusting its conversation identity.
390
+ NATIVE_CLI_LOG="$RUNTIME_DIR/native-cli.log"
391
+ if ! (umask 077; set -o noclobber; : > "$NATIVE_CLI_LOG"); then
392
+ echo 'Error: this Companion runtime already contains a native log; refusing ambiguous ownership.' >&2
393
+ exit 1
394
+ fi
395
+
351
396
  log "Wrapper PID=$WRAPPER_PID; token=$(printf '%s' "$BEARER_TOKEN" | cut -c1-8)...; port=$MCP_PORT; runtime=$RUNTIME_DIR"
352
397
 
353
398
  # Export env vars consumed by the MCP server + any in-tree tooling.
@@ -481,7 +526,8 @@ escaped_exit_file="$(escape_sq "$_CV_AGY_EXIT_FILE")"
481
526
  escaped_conn_file="$(escape_sq "$RUNTIME_DIR/conn.json")"
482
527
  escaped_mcp_log="$(escape_sq "$MCP_LOG_FILE")"
483
528
  escaped_node_bin="$(escape_sq "$(command -v node)")"
484
- AGY_CMD="'$escaped_bin'"
529
+ escaped_native_log="$(escape_sq "$NATIVE_CLI_LOG")"
530
+ AGY_CMD="'$escaped_bin' --log-file '$escaped_native_log'"
485
531
  for arg in "$@"; do
486
532
  escaped_arg="$(escape_sq "$arg")"
487
533
  AGY_CMD="$AGY_CMD '$escaped_arg'"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quantiya/codevibe-antigravity-plugin",
3
- "version": "2.0.85",
3
+ "version": "2.0.87",
4
4
  "description": "Control Antigravity CLI from your iPhone and Android — real-time sync, approve file edits, send prompts by voice. Part of CodeVibe.",
5
5
  "main": "dist/server.js",
6
6
  "codevibe": {
@@ -46,7 +46,7 @@
46
46
  "node": ">=22.0.0"
47
47
  },
48
48
  "dependencies": {
49
- "@quantiya/codevibe-core": "^2.0.75",
49
+ "@quantiya/codevibe-core": "^2.0.76",
50
50
  "chokidar": "^5.0.0",
51
51
  "dotenv": "^16.6.1",
52
52
  "express": "^5.1.0",