@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 +436 -68
- package/libexec/companion-launcher +40 -12
- package/package.json +2 -2
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 (
|
|
1466
|
+
if (lastFileAccessLineIdx < 0 && fileAccessRe.test(lines[i])) {
|
|
1467
|
+
lastFileAccessLineIdx = i;
|
|
1468
|
+
}
|
|
1469
|
+
if (lastApprovalLineIdx >= 0 && lastQuestionLineIdx >= 0 && lastFileAccessLineIdx >= 0) break;
|
|
1435
1470
|
}
|
|
1436
|
-
|
|
1437
|
-
if (
|
|
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
|
-
|
|
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-
|
|
2246
|
-
details:
|
|
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
|
-
|
|
3120
|
-
|
|
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([
|
|
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;
|
|
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
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
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
|
-
|
|
3248
|
-
logger.
|
|
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(
|
|
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.
|
|
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
|
|
4368
|
-
void this.
|
|
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
|
-
|
|
4376
|
-
|
|
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
|
|
362
|
-
#
|
|
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.
|
|
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.
|
|
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",
|