@quantiya/codevibe-antigravity-plugin 2.0.10 → 2.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -32,7 +32,9 @@ var server_exports = {};
32
32
  __export(server_exports, {
33
33
  McpServer: () => McpServer,
34
34
  SessionNotFoundError: () => SessionNotFoundError,
35
+ TERMINAL_SHUTDOWN_SIGNALS: () => TERMINAL_SHUTDOWN_SIGNALS,
35
36
  __testing: () => __testing,
37
+ classifyTmuxHasSessionError: () => classifyTmuxHasSessionError,
36
38
  generateLaunchSessionId: () => generateLaunchSessionId,
37
39
  getActiveConversationFromCliLog: () => getActiveConversationFromCliLog,
38
40
  parseMaybeJson: () => parseMaybeJson
@@ -42,6 +44,8 @@ var crypto3 = __toESM(require("crypto"));
42
44
  var path5 = __toESM(require("path"));
43
45
  var fs5 = __toESM(require("fs"));
44
46
  var os5 = __toESM(require("os"));
47
+ var import_child_process3 = require("child_process");
48
+ var import_util3 = require("util");
45
49
  var import_codevibe_core4 = require("@quantiya/codevibe-core");
46
50
 
47
51
  // src/logger.ts
@@ -1121,13 +1125,16 @@ var TmuxPaneObserver = class _TmuxPaneObserver extends import_events2.EventEmitt
1121
1125
  if (kind === "question") {
1122
1126
  const q = extractQuestionHeader(snapshot);
1123
1127
  headerText = q ? q.body : null;
1128
+ } else if (kind === "file_access") {
1129
+ const fileAccess = extractFileAccessPrompt(snapshot);
1130
+ headerText = fileAccess ? fileAccess.identity : null;
1124
1131
  } else if (kind === "approval") {
1125
1132
  headerText = extractHeader(snapshot);
1126
1133
  }
1127
1134
  }
1128
- return { active, headerText };
1135
+ return { active, headerText, probeSucceeded: true };
1129
1136
  } catch {
1130
- return { active: false, headerText: null };
1137
+ return { active: false, headerText: null, probeSucceeded: false };
1131
1138
  }
1132
1139
  }
1133
1140
  // ─── tmux pipe-pane plumbing ─────────────────────────────────────────────
@@ -1388,7 +1395,7 @@ var TmuxPaneObserver = class _TmuxPaneObserver extends import_events2.EventEmitt
1388
1395
  function looksLikePromptDelta(chunk) {
1389
1396
  return (
1390
1397
  // Approval UI signatures
1391
- /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) || /Question \d+\/\d+:/i.test(chunk) || /\benter Select\b/i.test(chunk)
1398
+ /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) || /Question \d+\/\d+:/i.test(chunk) || /\benter Select\b/i.test(chunk) || /^File access\s*$/im.test(chunk) || /Allow access to this file\?/i.test(chunk) || /No, deny access/i.test(chunk)
1392
1399
  );
1393
1400
  }
1394
1401
  function looksLikePromptSnapshot(snapshot) {
@@ -1402,8 +1409,31 @@ function looksLikePromptSnapshot(snapshot) {
1402
1409
  const hasQuestionOptions = /^[>\s]+[1-9]\. \S/im.test(recent);
1403
1410
  const hasQuestionFooter = /\benter Select\b/i.test(recent);
1404
1411
  const isQuestion = hasQuestionHeader && hasQuestionOptions && hasQuestionFooter;
1412
+ const hasFileAccessHeader = /^File access\s*$/im.test(recent);
1413
+ const hasFileAccessQuestion = /Allow access to this file\?/i.test(recent);
1414
+ const isFileAccess = hasFileAccessHeader && hasFileAccessQuestion && hasApprovalOptions && hasApprovalFooter;
1405
1415
  const legacyYN = /\[(?:y\/n|Y\/n|y\/N)\]/.test(recent) && /\b(?:apply|approve|allow|continue|proceed|run|execute|confirm)\b/i.test(recent);
1406
- return legacyYN || isApproval || isQuestion;
1416
+ return legacyYN || isApproval || isQuestion || isFileAccess;
1417
+ }
1418
+ function extractFileAccessPrompt(snapshot) {
1419
+ const stripped = snapshot.replace(ANSI_ESCAPE_REGEX, "");
1420
+ const lines = stripped.split("\n");
1421
+ let headingIndex = -1;
1422
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
1423
+ if (/^\s*File access\s*$/i.test(lines[index])) {
1424
+ headingIndex = index;
1425
+ break;
1426
+ }
1427
+ }
1428
+ if (headingIndex < 0) return null;
1429
+ const block = lines.slice(headingIndex, headingIndex + 20);
1430
+ const questionIndex = block.findIndex((line) => /Allow access to this file\?/i.test(line));
1431
+ if (questionIndex < 0) return null;
1432
+ const readLine = block.slice(0, questionIndex + 1).find((line) => /^\s*Read:\s*\S/i.test(line));
1433
+ const identity = readLine?.replace(/^\s*Read:\s*/i, "").trim() || "non-workspace file access";
1434
+ const fullLine = block[questionIndex].trim();
1435
+ const body = block.slice(1, questionIndex).join("\n").trim();
1436
+ return { identity, fullLine, body };
1407
1437
  }
1408
1438
  function extractHeader(snapshot) {
1409
1439
  const stripped = snapshot.replace(ANSI_ESCAPE_REGEX, "");
@@ -1422,8 +1452,10 @@ function detectActivePromptKind(snapshot) {
1422
1452
  const lines = stripped.split("\n");
1423
1453
  const approvalRe = /Requesting permission for:/i;
1424
1454
  const questionRe = /Question \d+\/\d+:/i;
1455
+ const fileAccessRe = /^\s*File access\s*$/i;
1425
1456
  let lastApprovalLineIdx = -1;
1426
1457
  let lastQuestionLineIdx = -1;
1458
+ let lastFileAccessLineIdx = -1;
1427
1459
  for (let i = lines.length - 1; i >= 0; i--) {
1428
1460
  if (lastApprovalLineIdx < 0 && approvalRe.test(lines[i])) {
1429
1461
  lastApprovalLineIdx = i;
@@ -1431,10 +1463,15 @@ function detectActivePromptKind(snapshot) {
1431
1463
  if (lastQuestionLineIdx < 0 && questionRe.test(lines[i])) {
1432
1464
  lastQuestionLineIdx = i;
1433
1465
  }
1434
- if (lastApprovalLineIdx >= 0 && lastQuestionLineIdx >= 0) break;
1466
+ if (lastFileAccessLineIdx < 0 && fileAccessRe.test(lines[i])) {
1467
+ lastFileAccessLineIdx = i;
1468
+ }
1469
+ if (lastApprovalLineIdx >= 0 && lastQuestionLineIdx >= 0 && lastFileAccessLineIdx >= 0) break;
1435
1470
  }
1436
- if (lastApprovalLineIdx < 0 && lastQuestionLineIdx < 0) return null;
1437
- if (lastQuestionLineIdx > lastApprovalLineIdx) return "question";
1471
+ const latest = Math.max(lastApprovalLineIdx, lastQuestionLineIdx, lastFileAccessLineIdx);
1472
+ if (latest < 0) return null;
1473
+ if (latest === lastFileAccessLineIdx) return "file_access";
1474
+ if (latest === lastQuestionLineIdx) return "question";
1438
1475
  return "approval";
1439
1476
  }
1440
1477
  function extractQuestionHeader(snapshot) {
@@ -1468,10 +1505,17 @@ var import_events3 = require("events");
1468
1505
  var import_uuid = require("uuid");
1469
1506
 
1470
1507
  // src/prompt-parser.ts
1508
+ var import_crypto2 = require("crypto");
1471
1509
  function parseApprovalSnapshot(snapshot) {
1472
1510
  if (!snapshot) return null;
1473
1511
  const stripped = stripAnsi(snapshot);
1474
1512
  const kind = detectActivePromptKind(stripped);
1513
+ if (kind === "file_access") {
1514
+ const fileAccess = extractFileAccessPrompt(stripped);
1515
+ if (fileAccess) {
1516
+ return parseFileAccessSnapshot(stripped, snapshot, fileAccess);
1517
+ }
1518
+ }
1475
1519
  if (kind === "question") {
1476
1520
  const questionHeader = extractQuestionHeader(stripped);
1477
1521
  if (questionHeader) {
@@ -1480,6 +1524,49 @@ function parseApprovalSnapshot(snapshot) {
1480
1524
  }
1481
1525
  return parseApprovalUISnapshot(stripped, snapshot);
1482
1526
  }
1527
+ function buildApprovalSemanticKey(candidate) {
1528
+ const normalize = (value) => value.replace(/\s+/g, " ").trim();
1529
+ const payload = {
1530
+ kind: candidate.kind,
1531
+ headerText: normalize(candidate.headerText),
1532
+ body: normalize(candidate.body),
1533
+ options: candidate.options.map((option) => ({
1534
+ number: option.number,
1535
+ text: normalize(option.text)
1536
+ })),
1537
+ submitMap: Object.fromEntries(
1538
+ Object.entries(candidate.submitMap).sort(([left], [right]) => Number(left) - Number(right)).map(([number, keys]) => [number, [...keys]])
1539
+ )
1540
+ };
1541
+ return (0, import_crypto2.createHash)("sha256").update(JSON.stringify(payload)).digest("hex");
1542
+ }
1543
+ function hasNegativeApprovalOption(options) {
1544
+ return options.some((option) => /^(?:no|deny|reject|cancel)\b/i.test(option.text.trim()));
1545
+ }
1546
+ function parseFileAccessSnapshot(stripped, originalSnapshot, prompt) {
1547
+ const lines = stripped.split("\n");
1548
+ let questionIndex = -1;
1549
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
1550
+ if (/Allow access to this file\?/i.test(lines[index])) {
1551
+ questionIndex = index;
1552
+ break;
1553
+ }
1554
+ }
1555
+ const belowQuestion = questionIndex >= 0 ? lines.slice(questionIndex + 1).join("\n") : stripped;
1556
+ const options = parseOptions(belowQuestion);
1557
+ if (options.length < 2 || !hasNegativeApprovalOption(options)) return null;
1558
+ return {
1559
+ kind: "approval",
1560
+ headerText: prompt.identity,
1561
+ fullHeaderLine: prompt.fullLine,
1562
+ command: void 0,
1563
+ filePath: prompt.identity === "non-workspace file access" ? void 0 : prompt.identity,
1564
+ options,
1565
+ submitMap: buildApprovalSubmitMap(options),
1566
+ body: prompt.body,
1567
+ paneHash: hashPromptSnapshot(originalSnapshot)
1568
+ };
1569
+ }
1483
1570
  function parseApprovalUISnapshot(stripped, originalSnapshot) {
1484
1571
  const headerText = extractHeader(stripped);
1485
1572
  if (!headerText) return null;
@@ -1494,7 +1581,7 @@ function parseApprovalUISnapshot(stripped, originalSnapshot) {
1494
1581
  }
1495
1582
  const belowHeader = headerIdx >= 0 ? lines.slice(headerIdx + 1).join("\n") : stripped;
1496
1583
  const options = parseOptions(belowHeader);
1497
- if (options.length < 2) return null;
1584
+ if (options.length < 2 || !hasNegativeApprovalOption(options)) return null;
1498
1585
  const submitMap = buildApprovalSubmitMap(options);
1499
1586
  const { command, filePath } = extractIdentity(headerText);
1500
1587
  const body = extractBodyBetweenHeaderAndFirstOption(belowHeader);
@@ -1785,9 +1872,10 @@ var ApprovalDetector = class extends import_events3.EventEmitter {
1785
1872
  matchedPaneHeader: candidate.headerText,
1786
1873
  paneDisplayHeader: candidate.fullHeaderLine,
1787
1874
  body: candidate.body,
1875
+ paneOptions: candidate.options,
1876
+ paneSemanticKey: buildApprovalSemanticKey(candidate),
1788
1877
  emittedAt: Date.now(),
1789
1878
  ttlMs: this.promptTtlMs,
1790
- paneOptions: candidate.options,
1791
1879
  // E1 (§6a-2b / F5) — carry the consumed pane hash so an emit-failure
1792
1880
  // rollback can reset the observer's lastPromptHash and let the unchanged
1793
1881
  // live pane re-emit.
@@ -1974,6 +2062,8 @@ var ApprovalDetector = class extends import_events3.EventEmitter {
1974
2062
  matchedPaneHeader: candidate.headerText,
1975
2063
  paneDisplayHeader: candidate.fullHeaderLine,
1976
2064
  body: candidate.body,
2065
+ paneOptions: candidate.options,
2066
+ paneSemanticKey: buildApprovalSemanticKey(candidate),
1977
2067
  emittedAt: Date.now(),
1978
2068
  ttlMs: this.promptTtlMs
1979
2069
  };
@@ -2054,6 +2144,10 @@ var ApprovalDetector = class extends import_events3.EventEmitter {
2054
2144
  getPendingPrompt(promptId) {
2055
2145
  return this.pendingPrompts.get(promptId) ?? null;
2056
2146
  }
2147
+ getPendingPromptsForConversation(conversationId) {
2148
+ const pending = Array.from(this.pendingPrompts.values());
2149
+ return conversationId ? pending.filter((state) => state.conversationId === conversationId) : pending;
2150
+ }
2057
2151
  // ─── Read-only accessors ────────────────────────────────────────────────
2058
2152
  getPendingCalls() {
2059
2153
  return Array.from(this.pendingCalls.values());
@@ -2107,21 +2201,6 @@ var ApprovalDetector = class extends import_events3.EventEmitter {
2107
2201
  this.emit("pending-call-expired", call);
2108
2202
  }
2109
2203
  }
2110
- let anyExpired = false;
2111
- for (const [promptId, state] of this.pendingPrompts.entries()) {
2112
- if (now - state.emittedAt > state.ttlMs) {
2113
- this.pendingPrompts.delete(promptId);
2114
- this.resolvedPrompts.set(promptId, now);
2115
- anyExpired = true;
2116
- logger.debug("Pending prompt expired (TTL)", {
2117
- promptId,
2118
- ageMs: now - state.emittedAt
2119
- });
2120
- }
2121
- }
2122
- if (anyExpired) {
2123
- this.paneOnlyEmittedHashes.clear();
2124
- }
2125
2204
  for (const [promptId, resolvedAt] of this.resolvedPrompts.entries()) {
2126
2205
  if (now - resolvedAt > 5 * 6e4) {
2127
2206
  this.resolvedPrompts.delete(promptId);
@@ -2228,22 +2307,11 @@ var PromptResponder = class {
2228
2307
  };
2229
2308
  }
2230
2309
  if (this.paneObserver) {
2231
- const probe = await this.paneObserver.probeApprovalUIActive();
2232
- if (!probe.active) {
2233
- this.detector.resolvePrompt(promptId);
2234
- return {
2235
- ok: false,
2236
- reason: "prompt-superseded",
2237
- details: "approval UI vanished from pane (likely user approved on desktop)"
2238
- };
2239
- }
2240
- const expectedHeader = state.matchedPaneHeader;
2241
- if (expectedHeader && probe.headerText && !sameIdentity(probe.headerText, expectedHeader)) {
2242
- this.detector.resolvePrompt(promptId);
2310
+ if (!state.paneSemanticKey) {
2243
2311
  return {
2244
2312
  ok: false,
2245
- reason: "prompt-superseded",
2246
- details: `pane shows '${probe.headerText}' but matched-against header was '${expectedHeader}'`
2313
+ reason: "prompt-probe-failed",
2314
+ details: "pending prompt has no exact semantic identity"
2247
2315
  };
2248
2316
  }
2249
2317
  }
@@ -2257,6 +2325,51 @@ var PromptResponder = class {
2257
2325
  }
2258
2326
  try {
2259
2327
  for (const key of keys) {
2328
+ if (!this.detector || !this.paneObserver) {
2329
+ return {
2330
+ ok: false,
2331
+ reason: "prompt-probe-failed",
2332
+ details: "approval responder is missing its detector or pane observer"
2333
+ };
2334
+ }
2335
+ if (this.detector.getPendingPrompt(promptId) !== state) {
2336
+ return { ok: false, reason: "prompt-expired" };
2337
+ }
2338
+ let liveSemanticKey = null;
2339
+ try {
2340
+ const snapshot = await this.paneObserver.captureSnapshot();
2341
+ const live = parseApprovalSnapshot(snapshot);
2342
+ if (!live) {
2343
+ if (detectActivePromptKind(snapshot) === null) {
2344
+ this.detector.resolvePrompt(promptId);
2345
+ return {
2346
+ ok: false,
2347
+ reason: "prompt-superseded",
2348
+ details: "the approval chooser is no longer active"
2349
+ };
2350
+ }
2351
+ return {
2352
+ ok: false,
2353
+ reason: "prompt-probe-failed",
2354
+ details: "the active chooser could not be parsed exactly"
2355
+ };
2356
+ }
2357
+ liveSemanticKey = live ? buildApprovalSemanticKey(live) : null;
2358
+ } catch (error) {
2359
+ return {
2360
+ ok: false,
2361
+ reason: "prompt-probe-failed",
2362
+ details: error instanceof Error ? error.message : String(error)
2363
+ };
2364
+ }
2365
+ if (liveSemanticKey !== state.paneSemanticKey) {
2366
+ this.detector.resolvePrompt(promptId);
2367
+ return {
2368
+ ok: false,
2369
+ reason: "prompt-superseded",
2370
+ details: "active chooser semantics differ from the mobile prompt"
2371
+ };
2372
+ }
2260
2373
  if (isNamedKey(key)) {
2261
2374
  await this.sendKey(target, key);
2262
2375
  } else {
@@ -2289,6 +2402,43 @@ var PromptResponder = class {
2289
2402
  };
2290
2403
  }
2291
2404
  }
2405
+ async sendFreeFormWhenNoApprovalActive(text) {
2406
+ const target = this.resolveTmuxTarget();
2407
+ if (!target) return { ok: false, reason: "no-tmux-target" };
2408
+ if (!this.paneObserver) {
2409
+ return {
2410
+ ok: false,
2411
+ reason: "prompt-probe-failed",
2412
+ details: "pane observer is required for safe free-form input"
2413
+ };
2414
+ }
2415
+ const chooserIsAbsent = async () => {
2416
+ const probe = await this.paneObserver.probeApprovalUIActive();
2417
+ if (!probe.probeSucceeded) {
2418
+ return { ok: false, reason: "prompt-probe-failed" };
2419
+ }
2420
+ if (probe.active) {
2421
+ return { ok: false, reason: "prompt-superseded" };
2422
+ }
2423
+ return null;
2424
+ };
2425
+ try {
2426
+ const beforeText = await chooserIsAbsent();
2427
+ if (beforeText) return beforeText;
2428
+ await this.typeLiteral(target, text);
2429
+ await delay(ENTER_DELAY_MS);
2430
+ const beforeEnter = await chooserIsAbsent();
2431
+ if (beforeEnter) return beforeEnter;
2432
+ await this.sendKey(target, "Enter");
2433
+ return { ok: true };
2434
+ } catch (error) {
2435
+ return {
2436
+ ok: false,
2437
+ reason: "tmux-failed",
2438
+ details: error instanceof Error ? error.message : String(error)
2439
+ };
2440
+ }
2441
+ }
2292
2442
  // ─── Tmux primitives ────────────────────────────────────────────────────
2293
2443
  async typeLiteral(target, text) {
2294
2444
  const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, "\\$").replace(/`/g, "\\`");
@@ -2314,9 +2464,6 @@ function isNamedKey(key) {
2314
2464
  function delay(ms) {
2315
2465
  return new Promise((r) => setTimeout(r, ms));
2316
2466
  }
2317
- function sameIdentity(headerFromPane, expected) {
2318
- return headerFromPane.trim() === expected.trim();
2319
- }
2320
2467
 
2321
2468
  // src/mobile-prompt-dedupe.ts
2322
2469
  var DEFAULT_EXPIRY_MS = 15e3;
@@ -2834,6 +2981,27 @@ function truncate(s, maxBytes) {
2834
2981
  // src/server.ts
2835
2982
  var MOBILE_PROMPT_FLOOR_RECENCY_MS = 12e4;
2836
2983
  var LAUNCH_SETTLE_TIMEOUT_MS = 3e3;
2984
+ var TMUX_LIFECYCLE_POLL_MS = 1e3;
2985
+ var TMUX_LIFECYCLE_INIT_GRACE_MS = 1e4;
2986
+ var SESSION_RETIRE_MAX_ATTEMPTS = 3;
2987
+ var SESSION_RETIRE_RETRY_MS = 100;
2988
+ var execFileAsync = (0, import_util3.promisify)(import_child_process3.execFile);
2989
+ var TERMINAL_SHUTDOWN_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
2990
+ function commandErrorText(error, field) {
2991
+ const value = error?.[field];
2992
+ if (typeof value === "string") return value;
2993
+ if (Buffer.isBuffer(value)) return value.toString("utf8");
2994
+ return "";
2995
+ }
2996
+ function classifyTmuxHasSessionError(error) {
2997
+ if (typeof error?.code !== "number") return "unknown";
2998
+ const output = `${commandErrorText(error, "stderr")}
2999
+ ${commandErrorText(error, "stdout")}`;
3000
+ if (/^can't find session:/m.test(output) || /^no server running on /m.test(output) || /^error connecting to .* \((?:No such file or directory|Connection refused)\)$/m.test(output)) {
3001
+ return "absent";
3002
+ }
3003
+ return "unknown";
3004
+ }
2837
3005
  var McpServer = class _McpServer {
2838
3006
  constructor(options) {
2839
3007
  /** Per-session causal floor for transcript-event timestamps emitted right
@@ -2974,6 +3142,11 @@ var McpServer = class _McpServer {
2974
3142
  this.signalsRegistered = false;
2975
3143
  this.boundSigintHandler = null;
2976
3144
  this.boundSigtermHandler = null;
3145
+ this.boundSighupHandler = null;
3146
+ this.tmuxLifecycleTimer = null;
3147
+ this.tmuxLifecycleCheckInFlight = false;
3148
+ this.tmuxLifecycleObservedAlive = false;
3149
+ this.tmuxLifecycleInitDeadlineMs = Number.POSITIVE_INFINITY;
2977
3150
  if (!options.bearerToken || options.bearerToken.length < 16) {
2978
3151
  throw new Error("McpServer requires a non-trivial bearerToken");
2979
3152
  }
@@ -3013,6 +3186,8 @@ var McpServer = class _McpServer {
3013
3186
  async start() {
3014
3187
  if (this.started) throw new Error("McpServer.start() called twice");
3015
3188
  this.stopPromise = null;
3189
+ this.stopTmuxLifecycleMonitor();
3190
+ this.tmuxLifecycleObservedAlive = false;
3016
3191
  await fireDaemonBeacon("daemon_init_start", {
3017
3192
  step: "init",
3018
3193
  outcome: "ok",
@@ -3033,6 +3208,7 @@ var McpServer = class _McpServer {
3033
3208
  this.lifecycleGen++;
3034
3209
  this.stopRequestedDuringCreate = false;
3035
3210
  this.registerSignalHandlers();
3211
+ this.startTmuxLifecycleMonitor();
3036
3212
  await (0, import_codevibe_core4.registerDeviceEncryptionKey)(this.appSyncClient, logger);
3037
3213
  if (!this.started) return { httpPort: 0 };
3038
3214
  (0, import_codevibe_core4.startDeviceKeyWatcher)(this.appSyncClient, logger);
@@ -3116,15 +3292,18 @@ var McpServer = class _McpServer {
3116
3292
  }
3117
3293
  async doStop() {
3118
3294
  this.started = false;
3119
- if (this.launchSessionPromise) {
3120
- const settled = this.launchSessionPromise.catch(() => void 0);
3295
+ this.stopTmuxLifecycleMonitor();
3296
+ const launchSettlement = this.launchSessionPromise?.catch(() => void 0) ?? null;
3297
+ let mustAwaitLaunchSettlement = false;
3298
+ let hostedRetirementError = null;
3299
+ if (launchSettlement) {
3121
3300
  let timer;
3122
3301
  const timeout = new Promise((resolve3) => {
3123
3302
  timer = setTimeout(resolve3, this.launchSettleTimeoutMs);
3124
3303
  timer.unref?.();
3125
3304
  });
3126
3305
  try {
3127
- await Promise.race([settled, timeout]);
3306
+ await Promise.race([launchSettlement, timeout]);
3128
3307
  } finally {
3129
3308
  if (timer) clearTimeout(timer);
3130
3309
  }
@@ -3136,8 +3315,9 @@ var McpServer = class _McpServer {
3136
3315
  if (deactivated) {
3137
3316
  if (this.pendingLaunchSessionId === pendingId) this.pendingLaunchSessionId = null;
3138
3317
  } else {
3318
+ mustAwaitLaunchSettlement = launchSettlement !== null;
3139
3319
  logger.error(
3140
- "doStop: INACTIVE write for the pending launch row FAILED (row may not exist yet \u2014 create still in flight). Keeping the settlement obligation: if the create resolves in-process its tail deactivates the row; if the process exits first, an ACTIVE row may leak (SIGKILL-class residual) (#638 H4)",
3320
+ "doStop: INACTIVE write for the pending launch row FAILED (row may not exist yet \u2014 create still in flight). Keeping the settlement obligation: if the create resolves in-process its tail deactivates the row; the process remains alive so the delayed create tail can settle it (#638 H4)",
3141
3321
  { sessionId: pendingId }
3142
3322
  );
3143
3323
  }
@@ -3161,16 +3341,11 @@ var McpServer = class _McpServer {
3161
3341
  this.appSyncClient.stopHeartbeat(this.session.sessionId);
3162
3342
  } catch {
3163
3343
  }
3164
- try {
3165
- await this.appSyncClient.updateSession({
3166
- sessionId: this.session.sessionId,
3167
- status: "INACTIVE"
3168
- });
3169
- } catch (err) {
3170
- logger.warn("updateSession INACTIVE failed during shutdown", {
3171
- sessionId: this.session.sessionId,
3172
- error: String(err)
3173
- });
3344
+ const retired = await this.deactivateLaunchRow(this.session.sessionId);
3345
+ if (!retired) {
3346
+ hostedRetirementError = new Error(
3347
+ `Failed to retire hosted Antigravity session ${this.session.sessionId}`
3348
+ );
3174
3349
  }
3175
3350
  }
3176
3351
  this.unregisterSignalHandlers();
@@ -3206,10 +3381,25 @@ var McpServer = class _McpServer {
3206
3381
  this.launchKeyUnavailable = false;
3207
3382
  this.ensureLaunchInFlight = null;
3208
3383
  this.observedMainConversationIds.clear();
3384
+ if (mustAwaitLaunchSettlement && launchSettlement) {
3385
+ await launchSettlement;
3386
+ if (this.pendingLaunchSessionId) {
3387
+ const pendingId = this.pendingLaunchSessionId;
3388
+ const retired = await this.deactivateLaunchRow(pendingId);
3389
+ if (retired) {
3390
+ if (this.pendingLaunchSessionId === pendingId) this.pendingLaunchSessionId = null;
3391
+ } else {
3392
+ hostedRetirementError = new Error(
3393
+ `Failed to settle delayed Antigravity launch session ${pendingId}`
3394
+ );
3395
+ }
3396
+ }
3397
+ }
3209
3398
  await fireDaemonBeacon("daemon_init_step", {
3210
3399
  step: "shutdown",
3211
- outcome: "ok"
3400
+ outcome: hostedRetirementError ? "fail" : "ok"
3212
3401
  });
3402
+ if (hostedRetirementError) throw hostedRetirementError;
3213
3403
  }
3214
3404
  async handleConversationDiscovered(conversationId) {
3215
3405
  if (!this.started) return;
@@ -3244,16 +3434,30 @@ var McpServer = class _McpServer {
3244
3434
  async deactivateLaunchRow(sessionId) {
3245
3435
  try {
3246
3436
  this.appSyncClient.stopHeartbeat(sessionId);
3247
- await this.appSyncClient.updateSession({ sessionId, status: "INACTIVE" });
3248
- logger.info("Marked superseded/interrupted launch session INACTIVE", { sessionId });
3249
- return true;
3250
- } catch (err) {
3251
- logger.warn("Failed to mark superseded/interrupted launch session INACTIVE", {
3437
+ } catch (error) {
3438
+ logger.warn("Failed to stop heartbeat while retiring Antigravity session", {
3252
3439
  sessionId,
3253
- error: String(err)
3440
+ error: error instanceof Error ? error.message : String(error)
3254
3441
  });
3255
- return false;
3256
3442
  }
3443
+ for (let attempt = 1; attempt <= SESSION_RETIRE_MAX_ATTEMPTS; attempt += 1) {
3444
+ try {
3445
+ await this.appSyncClient.updateSession({ sessionId, status: "INACTIVE" });
3446
+ logger.info("Marked superseded/interrupted launch session INACTIVE", { sessionId });
3447
+ return true;
3448
+ } catch (err) {
3449
+ logger.warn("Failed to mark superseded/interrupted launch session INACTIVE", {
3450
+ sessionId,
3451
+ attempt,
3452
+ maxAttempts: SESSION_RETIRE_MAX_ATTEMPTS,
3453
+ error: String(err)
3454
+ });
3455
+ if (attempt < SESSION_RETIRE_MAX_ATTEMPTS) {
3456
+ await new Promise((resolve3) => setTimeout(resolve3, SESSION_RETIRE_RETRY_MS * attempt));
3457
+ }
3458
+ }
3459
+ }
3460
+ return false;
3257
3461
  }
3258
3462
  /**
3259
3463
  * v9 launch-session: create the wrapper-lifetime backend session at
@@ -4242,8 +4446,63 @@ var McpServer = class _McpServer {
4242
4446
  });
4243
4447
  }
4244
4448
  }
4449
+ const pendingPrompts = this.approvalDetector.getPendingPromptsForConversation(
4450
+ session.conversationId
4451
+ );
4452
+ if (pendingPrompts.length === 0) {
4453
+ const probe = await this.paneObserver.probeApprovalUIActive();
4454
+ if (!probe.probeSucceeded) {
4455
+ await this.emitPromptSafetyNotification(
4456
+ session,
4457
+ "Your message was not sent because the desktop approval state could not be verified. Try again or resolve the prompt on the desktop."
4458
+ );
4459
+ return;
4460
+ }
4461
+ if (probe.active) {
4462
+ await this.emitPromptSafetyNotification(
4463
+ session,
4464
+ "Your message was not sent because a desktop approval is active but its options are not yet available on mobile. Resolve it on the desktop and send the message again."
4465
+ );
4466
+ return;
4467
+ }
4468
+ }
4469
+ if (pendingPrompts.length > 0) {
4470
+ if (pendingPrompts.length !== 1) {
4471
+ await this.emitPromptSafetyNotification(
4472
+ session,
4473
+ "Your message was not sent because more than one desktop approval is pending. Resolve the approvals and send it again."
4474
+ );
4475
+ return;
4476
+ }
4477
+ const activePrompt = pendingPrompts[0];
4478
+ const rejectOption = activePrompt.paneOptions?.find(
4479
+ (option) => /^(?:no|deny|reject|cancel)\b/i.test(option.text.trim()) && (activePrompt.submitMap[option.number]?.length ?? 0) > 0
4480
+ );
4481
+ if (!rejectOption) {
4482
+ await this.emitPromptSafetyNotification(
4483
+ session,
4484
+ "Your message was not sent because the active desktop approval has no verified reject option. Resolve it on the desktop and send the message again."
4485
+ );
4486
+ return;
4487
+ }
4488
+ this.mobileDeduper.track(session.sessionId, rejectOption.number);
4489
+ const rejectResult = await this.promptResponder.sendApprovalReply(
4490
+ activePrompt.promptId,
4491
+ rejectOption.number
4492
+ );
4493
+ if (!rejectResult.ok) {
4494
+ this.mobileDeduper.forget(session.sessionId, rejectOption.number);
4495
+ logger.warn("Reject-before-free-form failed", {
4496
+ promptId: activePrompt.promptId,
4497
+ reason: rejectResult.reason,
4498
+ details: rejectResult.details
4499
+ });
4500
+ return;
4501
+ }
4502
+ await new Promise((resolve3) => setTimeout(resolve3, 250));
4503
+ }
4245
4504
  this.mobileDeduper.track(session.sessionId, promptContent);
4246
- const result = await this.promptResponder.sendFreeForm(promptContent);
4505
+ const result = await this.promptResponder.sendFreeFormWhenNoApprovalActive(promptContent);
4247
4506
  if (!result.ok) {
4248
4507
  this.mobileDeduper.forget(session.sessionId, promptContent);
4249
4508
  logger.warn("sendFreeForm failed", { reason: result.reason, details: result.details });
@@ -4278,6 +4537,23 @@ var McpServer = class _McpServer {
4278
4537
  await this.markExecuted(evt);
4279
4538
  }
4280
4539
  }
4540
+ async emitPromptSafetyNotification(session, content) {
4541
+ const input = {
4542
+ sessionId: session.sessionId,
4543
+ type: import_codevibe_core4.EventType.NOTIFICATION,
4544
+ source: import_codevibe_core4.EventSource.DESKTOP,
4545
+ content,
4546
+ metadata: { promptSafetyBlocked: true },
4547
+ timestamp: (0, import_codevibe_core4.prepareEventTimestamp)({ orderingKey: session.sessionId })
4548
+ };
4549
+ const outbound = this.encryptOutbound(session, input);
4550
+ if (!outbound) return;
4551
+ try {
4552
+ await this.appSyncClient.createEvent(outbound);
4553
+ } catch (error) {
4554
+ logger.warn("Failed to emit prompt-safety notification", { error: String(error) });
4555
+ }
4556
+ }
4281
4557
  /**
4282
4558
  * Transition a mobile event's deliveryStatus to DELIVERED. Called as
4283
4559
  * soon as the plugin receives + decrypts the event and BEFORE the
@@ -4360,27 +4636,117 @@ var McpServer = class _McpServer {
4360
4636
  return this.session.sessionId === backendSessionId ? this.session : null;
4361
4637
  }
4362
4638
  // ─── Signal handling ────────────────────────────────────────────────────
4639
+ /** Preserve a supported detach, but retire the daemon when its observed tmux owner ends. */
4640
+ startTmuxLifecycleMonitor() {
4641
+ if (this.tmuxLifecycleTimer || !this.tmuxTarget) return;
4642
+ const tmuxTarget = this.tmuxTarget;
4643
+ this.tmuxLifecycleTimer = setInterval(() => {
4644
+ void this.checkTmuxLifecycle(tmuxTarget);
4645
+ }, TMUX_LIFECYCLE_POLL_MS);
4646
+ this.tmuxLifecycleInitDeadlineMs = Date.now() + TMUX_LIFECYCLE_INIT_GRACE_MS;
4647
+ this.tmuxLifecycleTimer.unref?.();
4648
+ void this.checkTmuxLifecycle(tmuxTarget);
4649
+ }
4650
+ async tmuxSessionState(tmuxTarget) {
4651
+ try {
4652
+ await execFileAsync("tmux", ["has-session", "-t", tmuxTarget]);
4653
+ return "alive";
4654
+ } catch (error) {
4655
+ const state = classifyTmuxHasSessionError(error);
4656
+ if (state === "absent") return state;
4657
+ logger.warn("Could not inspect native Antigravity tmux session; preserving daemon", {
4658
+ tmuxTarget,
4659
+ error: error instanceof Error ? error.message : String(error)
4660
+ });
4661
+ return "unknown";
4662
+ }
4663
+ }
4664
+ async checkTmuxLifecycle(tmuxTarget) {
4665
+ if (!this.started || this.tmuxLifecycleCheckInFlight) return;
4666
+ this.tmuxLifecycleCheckInFlight = true;
4667
+ try {
4668
+ const state = await this.tmuxSessionState(tmuxTarget);
4669
+ if (state === "alive") {
4670
+ this.tmuxLifecycleObservedAlive = true;
4671
+ return;
4672
+ }
4673
+ const ownershipEstablished = this.tmuxLifecycleObservedAlive || Date.now() >= this.tmuxLifecycleInitDeadlineMs;
4674
+ if (state === "unknown" || !ownershipEstablished || !this.started) return;
4675
+ this.stopTmuxLifecycleMonitor();
4676
+ logger.info("Native Antigravity tmux session ended; stopping companion daemon", {
4677
+ tmuxTarget
4678
+ });
4679
+ void this.stop().then(
4680
+ () => process.exit(0),
4681
+ (error) => {
4682
+ logger.error("Failed to stop companion daemon after native session end", {
4683
+ error: error instanceof Error ? error.message : String(error)
4684
+ });
4685
+ process.exit(1);
4686
+ }
4687
+ );
4688
+ } finally {
4689
+ this.tmuxLifecycleCheckInFlight = false;
4690
+ }
4691
+ }
4692
+ stopTmuxLifecycleMonitor() {
4693
+ if (this.tmuxLifecycleTimer) {
4694
+ clearInterval(this.tmuxLifecycleTimer);
4695
+ this.tmuxLifecycleTimer = null;
4696
+ }
4697
+ }
4363
4698
  registerSignalHandlers() {
4364
4699
  if (this.signalsRegistered) return;
4365
4700
  this.signalsRegistered = true;
4366
4701
  const onSignal = (sig) => {
4367
- logger.info(`${sig} received \u2014 shutting down`);
4368
- void this.stop().then(() => process.exit(0)).catch((err) => {
4702
+ logger.info(`${sig} received \u2014 evaluating terminal shutdown`);
4703
+ void this.stopForTerminalSignal(sig).then((stopped) => {
4704
+ if (stopped) process.exit(0);
4705
+ }).catch((err) => {
4369
4706
  logger.error("shutdown error", { error: String(err) });
4370
4707
  process.exit(1);
4371
4708
  });
4372
4709
  };
4373
4710
  this.boundSigintHandler = () => onSignal("SIGINT");
4374
4711
  this.boundSigtermHandler = () => onSignal("SIGTERM");
4375
- process.on("SIGINT", this.boundSigintHandler);
4376
- process.on("SIGTERM", this.boundSigtermHandler);
4712
+ this.boundSighupHandler = () => onSignal("SIGHUP");
4713
+ const handlers = {
4714
+ SIGINT: this.boundSigintHandler,
4715
+ SIGTERM: this.boundSigtermHandler,
4716
+ SIGHUP: this.boundSighupHandler
4717
+ };
4718
+ for (const signal of TERMINAL_SHUTDOWN_SIGNALS) {
4719
+ process.on(signal, handlers[signal]);
4720
+ }
4721
+ }
4722
+ /**
4723
+ * A tmux client detach can hang up the wrapper PTY while Antigravity remains
4724
+ * alive in the bound tmux session. Only treat SIGHUP as terminal when tmux
4725
+ * authoritatively reports that target absent; an unknown result preserves the
4726
+ * daemon until the lifecycle monitor retries.
4727
+ */
4728
+ async stopForTerminalSignal(signal) {
4729
+ if (signal === "SIGHUP" && this.tmuxTarget) {
4730
+ const state = await this.tmuxSessionState(this.tmuxTarget);
4731
+ if (state !== "absent") {
4732
+ logger.info("Ignoring SIGHUP while native Antigravity tmux session remains available", {
4733
+ tmuxTarget: this.tmuxTarget,
4734
+ state
4735
+ });
4736
+ return false;
4737
+ }
4738
+ }
4739
+ await this.stop();
4740
+ return true;
4377
4741
  }
4378
4742
  unregisterSignalHandlers() {
4379
4743
  if (!this.signalsRegistered) return;
4380
4744
  if (this.boundSigintHandler) process.off("SIGINT", this.boundSigintHandler);
4381
4745
  if (this.boundSigtermHandler) process.off("SIGTERM", this.boundSigtermHandler);
4746
+ if (this.boundSighupHandler) process.off("SIGHUP", this.boundSighupHandler);
4382
4747
  this.boundSigintHandler = null;
4383
4748
  this.boundSigtermHandler = null;
4749
+ this.boundSighupHandler = null;
4384
4750
  this.signalsRegistered = false;
4385
4751
  }
4386
4752
  // ─── Lookups (test surface) ────────────────────────────────────────────
@@ -4620,7 +4986,9 @@ var __testing = {
4620
4986
  0 && (module.exports = {
4621
4987
  McpServer,
4622
4988
  SessionNotFoundError,
4989
+ TERMINAL_SHUTDOWN_SIGNALS,
4623
4990
  __testing,
4991
+ classifyTmuxHasSessionError,
4624
4992
  generateLaunchSessionId,
4625
4993
  getActiveConversationFromCliLog,
4626
4994
  parseMaybeJson
@@ -201,6 +201,27 @@ log() {
201
201
  echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG_FILE"
202
202
  }
203
203
 
204
+ tmux_session_state() {
205
+ local target="$1"
206
+ local output
207
+ local rc
208
+ output="$(tmux has-session -t "$target" 2>&1)"
209
+ rc=$?
210
+ if [ "$rc" -eq 0 ]; then
211
+ printf '%s\n' "alive"
212
+ return
213
+ fi
214
+ case "$output" in
215
+ "can't find session:"*|"no server running on "*|"error connecting to "*" (No such file or directory)"|"error connecting to "*" (Connection refused)")
216
+ printf '%s\n' "absent"
217
+ ;;
218
+ *)
219
+ log "WARN: tmux inspection failed for $target; preserving daemon: $output"
220
+ printf '%s\n' "unknown"
221
+ ;;
222
+ esac
223
+ }
224
+
204
225
  # ─── Reject unsupported invocations ───────────────────────────────────
205
226
  # `--print` / `-p` mode bypasses the interactive TUI we depend on. Bail
206
227
  # out with a clear message rather than silently producing no mobile sync.
@@ -326,6 +347,22 @@ cleanup() {
326
347
  local wrapper_exit_code=$?
327
348
  log "Cleanup triggered"
328
349
 
350
+ # Closing or detaching the outer terminal must not disconnect an agy
351
+ # process that is still alive inside tmux. The daemon monitors this exact
352
+ # tmux target and owns full cleanup after the native session disappears.
353
+ if [ "${_CV_TMUX_STARTED:-false}" = "true" ]; then
354
+ local tmux_state
355
+ tmux_state="$(tmux_session_state "$SESSION_NAME")"
356
+ if [ "$tmux_state" = "alive" ]; then
357
+ log "Tmux client detached while Antigravity remains active; leaving daemon running"
358
+ return
359
+ fi
360
+ if [ "$tmux_state" = "unknown" ]; then
361
+ log "Tmux state is ambiguous; preserving daemon and native session"
362
+ return
363
+ fi
364
+ fi
365
+
329
366
  # Fire wrapper_exited telemetry BEFORE killing the server so MCP
330
367
  # logs are intact. cv_failed sets _CV_EXITED on pre-flight failures,
331
368
  # so this block won't double-fire.
@@ -358,21 +395,12 @@ cleanup() {
358
395
  cv_telem "wrapper_exited" "\"exit_code\":$wrapper_exit_code,\"lifetime_seconds\":$lifetime,\"agy_exit_code\":\"$agy_exit\",\"agy_lifetime_seconds\":$agy_lifetime,\"tmux_session_started\":$_CV_TMUX_STARTED,\"agent_invoked\":$_CV_AGENT_INVOKED,\"terminal_outcome\":\"$outcome\""
359
396
  fi
360
397
 
361
- # Stop the MCP server graceful first, then SIGKILL fallback.
362
- # The server's SIGTERM handler marks the session INACTIVE.
398
+ # Stop the MCP server gracefully and wait for product-owned cleanup. Never
399
+ # force-kill here: stop() must finish its hosted INACTIVE obligation before
400
+ # the process exits, even when that takes longer than the old 3s bound.
363
401
  if [ -n "$MCP_PID" ] && kill -0 "$MCP_PID" 2>/dev/null; then
364
402
  log "Stopping MCP server (PID: $MCP_PID)"
365
403
  kill -TERM "$MCP_PID" 2>/dev/null || true
366
- # Wait up to 3s for graceful shutdown.
367
- local i=0
368
- while [ $i -lt 30 ] && kill -0 "$MCP_PID" 2>/dev/null; do
369
- sleep 0.1
370
- i=$((i + 1))
371
- done
372
- if kill -0 "$MCP_PID" 2>/dev/null; then
373
- log "MCP server did not exit cleanly; sending SIGKILL"
374
- kill -KILL "$MCP_PID" 2>/dev/null || true
375
- fi
376
404
  wait "$MCP_PID" 2>/dev/null || true
377
405
  fi
378
406
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quantiya/codevibe-antigravity-plugin",
3
- "version": "2.0.10",
3
+ "version": "2.0.12",
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": {
@@ -48,7 +48,7 @@
48
48
  "node": ">=22.0.0"
49
49
  },
50
50
  "dependencies": {
51
- "@quantiya/codevibe-core": "2.0.9",
51
+ "@quantiya/codevibe-core": "2.0.10",
52
52
  "chokidar": "^5.0.0",
53
53
  "dotenv": "^16.6.1",
54
54
  "express": "^5.1.0",