@quantiya/codevibe-antigravity-plugin 2.0.9 → 2.0.11
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 +772 -49
- package/package.json +2 -2
package/dist/server.js
CHANGED
|
@@ -1121,13 +1121,16 @@ var TmuxPaneObserver = class _TmuxPaneObserver extends import_events2.EventEmitt
|
|
|
1121
1121
|
if (kind === "question") {
|
|
1122
1122
|
const q = extractQuestionHeader(snapshot);
|
|
1123
1123
|
headerText = q ? q.body : null;
|
|
1124
|
+
} else if (kind === "file_access") {
|
|
1125
|
+
const fileAccess = extractFileAccessPrompt(snapshot);
|
|
1126
|
+
headerText = fileAccess ? fileAccess.identity : null;
|
|
1124
1127
|
} else if (kind === "approval") {
|
|
1125
1128
|
headerText = extractHeader(snapshot);
|
|
1126
1129
|
}
|
|
1127
1130
|
}
|
|
1128
|
-
return { active, headerText };
|
|
1131
|
+
return { active, headerText, probeSucceeded: true };
|
|
1129
1132
|
} catch {
|
|
1130
|
-
return { active: false, headerText: null };
|
|
1133
|
+
return { active: false, headerText: null, probeSucceeded: false };
|
|
1131
1134
|
}
|
|
1132
1135
|
}
|
|
1133
1136
|
// ─── tmux pipe-pane plumbing ─────────────────────────────────────────────
|
|
@@ -1251,6 +1254,20 @@ var TmuxPaneObserver = class _TmuxPaneObserver extends import_events2.EventEmitt
|
|
|
1251
1254
|
this.currentApprovalHeader = extractHeader(candidate.snapshot);
|
|
1252
1255
|
this.emit("prompt-candidate", candidate);
|
|
1253
1256
|
}
|
|
1257
|
+
/**
|
|
1258
|
+
* E1 (§6a-2b / F5) — clear the dedup `lastPromptHash` IF it still equals `hash`,
|
|
1259
|
+
* so an UNCHANGED live pane re-emits a fresh 'prompt-candidate'. Called by the
|
|
1260
|
+
* server when an emit-failure rollback discarded the durable record: without this
|
|
1261
|
+
* the observer's `lastPromptHash` still matches the unchanged pane, so
|
|
1262
|
+
* processFileChanges short-circuits (the `snapshotHash === this.lastPromptHash`
|
|
1263
|
+
* guard) and the live prompt is never re-observed = under-nag. The equality guard
|
|
1264
|
+
* targets the EXACT consumed hash so a newer prompt's hash is never clobbered.
|
|
1265
|
+
*/
|
|
1266
|
+
resetPromptHash(hash) {
|
|
1267
|
+
if (this.lastPromptHash === hash) {
|
|
1268
|
+
this.lastPromptHash = null;
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1254
1271
|
/** Cancel any in-flight debounce timer + drop the pending candidate.
|
|
1255
1272
|
* Called from stop() so a wrapper exit / restart doesn't fire a
|
|
1256
1273
|
* prompt for a torn-down observer. Also bumps debounceCycleId so
|
|
@@ -1374,7 +1391,7 @@ var TmuxPaneObserver = class _TmuxPaneObserver extends import_events2.EventEmitt
|
|
|
1374
1391
|
function looksLikePromptDelta(chunk) {
|
|
1375
1392
|
return (
|
|
1376
1393
|
// Approval UI signatures
|
|
1377
|
-
/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)
|
|
1394
|
+
/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)
|
|
1378
1395
|
);
|
|
1379
1396
|
}
|
|
1380
1397
|
function looksLikePromptSnapshot(snapshot) {
|
|
@@ -1388,8 +1405,31 @@ function looksLikePromptSnapshot(snapshot) {
|
|
|
1388
1405
|
const hasQuestionOptions = /^[>\s]+[1-9]\. \S/im.test(recent);
|
|
1389
1406
|
const hasQuestionFooter = /\benter Select\b/i.test(recent);
|
|
1390
1407
|
const isQuestion = hasQuestionHeader && hasQuestionOptions && hasQuestionFooter;
|
|
1408
|
+
const hasFileAccessHeader = /^File access\s*$/im.test(recent);
|
|
1409
|
+
const hasFileAccessQuestion = /Allow access to this file\?/i.test(recent);
|
|
1410
|
+
const isFileAccess = hasFileAccessHeader && hasFileAccessQuestion && hasApprovalOptions && hasApprovalFooter;
|
|
1391
1411
|
const legacyYN = /\[(?:y\/n|Y\/n|y\/N)\]/.test(recent) && /\b(?:apply|approve|allow|continue|proceed|run|execute|confirm)\b/i.test(recent);
|
|
1392
|
-
return legacyYN || isApproval || isQuestion;
|
|
1412
|
+
return legacyYN || isApproval || isQuestion || isFileAccess;
|
|
1413
|
+
}
|
|
1414
|
+
function extractFileAccessPrompt(snapshot) {
|
|
1415
|
+
const stripped = snapshot.replace(ANSI_ESCAPE_REGEX, "");
|
|
1416
|
+
const lines = stripped.split("\n");
|
|
1417
|
+
let headingIndex = -1;
|
|
1418
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
1419
|
+
if (/^\s*File access\s*$/i.test(lines[index])) {
|
|
1420
|
+
headingIndex = index;
|
|
1421
|
+
break;
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
if (headingIndex < 0) return null;
|
|
1425
|
+
const block = lines.slice(headingIndex, headingIndex + 20);
|
|
1426
|
+
const questionIndex = block.findIndex((line) => /Allow access to this file\?/i.test(line));
|
|
1427
|
+
if (questionIndex < 0) return null;
|
|
1428
|
+
const readLine = block.slice(0, questionIndex + 1).find((line) => /^\s*Read:\s*\S/i.test(line));
|
|
1429
|
+
const identity = readLine?.replace(/^\s*Read:\s*/i, "").trim() || "non-workspace file access";
|
|
1430
|
+
const fullLine = block[questionIndex].trim();
|
|
1431
|
+
const body = block.slice(1, questionIndex).join("\n").trim();
|
|
1432
|
+
return { identity, fullLine, body };
|
|
1393
1433
|
}
|
|
1394
1434
|
function extractHeader(snapshot) {
|
|
1395
1435
|
const stripped = snapshot.replace(ANSI_ESCAPE_REGEX, "");
|
|
@@ -1408,8 +1448,10 @@ function detectActivePromptKind(snapshot) {
|
|
|
1408
1448
|
const lines = stripped.split("\n");
|
|
1409
1449
|
const approvalRe = /Requesting permission for:/i;
|
|
1410
1450
|
const questionRe = /Question \d+\/\d+:/i;
|
|
1451
|
+
const fileAccessRe = /^\s*File access\s*$/i;
|
|
1411
1452
|
let lastApprovalLineIdx = -1;
|
|
1412
1453
|
let lastQuestionLineIdx = -1;
|
|
1454
|
+
let lastFileAccessLineIdx = -1;
|
|
1413
1455
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1414
1456
|
if (lastApprovalLineIdx < 0 && approvalRe.test(lines[i])) {
|
|
1415
1457
|
lastApprovalLineIdx = i;
|
|
@@ -1417,10 +1459,15 @@ function detectActivePromptKind(snapshot) {
|
|
|
1417
1459
|
if (lastQuestionLineIdx < 0 && questionRe.test(lines[i])) {
|
|
1418
1460
|
lastQuestionLineIdx = i;
|
|
1419
1461
|
}
|
|
1420
|
-
if (
|
|
1462
|
+
if (lastFileAccessLineIdx < 0 && fileAccessRe.test(lines[i])) {
|
|
1463
|
+
lastFileAccessLineIdx = i;
|
|
1464
|
+
}
|
|
1465
|
+
if (lastApprovalLineIdx >= 0 && lastQuestionLineIdx >= 0 && lastFileAccessLineIdx >= 0) break;
|
|
1421
1466
|
}
|
|
1422
|
-
|
|
1423
|
-
if (
|
|
1467
|
+
const latest = Math.max(lastApprovalLineIdx, lastQuestionLineIdx, lastFileAccessLineIdx);
|
|
1468
|
+
if (latest < 0) return null;
|
|
1469
|
+
if (latest === lastFileAccessLineIdx) return "file_access";
|
|
1470
|
+
if (latest === lastQuestionLineIdx) return "question";
|
|
1424
1471
|
return "approval";
|
|
1425
1472
|
}
|
|
1426
1473
|
function extractQuestionHeader(snapshot) {
|
|
@@ -1454,10 +1501,17 @@ var import_events3 = require("events");
|
|
|
1454
1501
|
var import_uuid = require("uuid");
|
|
1455
1502
|
|
|
1456
1503
|
// src/prompt-parser.ts
|
|
1504
|
+
var import_crypto2 = require("crypto");
|
|
1457
1505
|
function parseApprovalSnapshot(snapshot) {
|
|
1458
1506
|
if (!snapshot) return null;
|
|
1459
1507
|
const stripped = stripAnsi(snapshot);
|
|
1460
1508
|
const kind = detectActivePromptKind(stripped);
|
|
1509
|
+
if (kind === "file_access") {
|
|
1510
|
+
const fileAccess = extractFileAccessPrompt(stripped);
|
|
1511
|
+
if (fileAccess) {
|
|
1512
|
+
return parseFileAccessSnapshot(stripped, snapshot, fileAccess);
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1461
1515
|
if (kind === "question") {
|
|
1462
1516
|
const questionHeader = extractQuestionHeader(stripped);
|
|
1463
1517
|
if (questionHeader) {
|
|
@@ -1466,6 +1520,49 @@ function parseApprovalSnapshot(snapshot) {
|
|
|
1466
1520
|
}
|
|
1467
1521
|
return parseApprovalUISnapshot(stripped, snapshot);
|
|
1468
1522
|
}
|
|
1523
|
+
function buildApprovalSemanticKey(candidate) {
|
|
1524
|
+
const normalize = (value) => value.replace(/\s+/g, " ").trim();
|
|
1525
|
+
const payload = {
|
|
1526
|
+
kind: candidate.kind,
|
|
1527
|
+
headerText: normalize(candidate.headerText),
|
|
1528
|
+
body: normalize(candidate.body),
|
|
1529
|
+
options: candidate.options.map((option) => ({
|
|
1530
|
+
number: option.number,
|
|
1531
|
+
text: normalize(option.text)
|
|
1532
|
+
})),
|
|
1533
|
+
submitMap: Object.fromEntries(
|
|
1534
|
+
Object.entries(candidate.submitMap).sort(([left], [right]) => Number(left) - Number(right)).map(([number, keys]) => [number, [...keys]])
|
|
1535
|
+
)
|
|
1536
|
+
};
|
|
1537
|
+
return (0, import_crypto2.createHash)("sha256").update(JSON.stringify(payload)).digest("hex");
|
|
1538
|
+
}
|
|
1539
|
+
function hasNegativeApprovalOption(options) {
|
|
1540
|
+
return options.some((option) => /^(?:no|deny|reject|cancel)\b/i.test(option.text.trim()));
|
|
1541
|
+
}
|
|
1542
|
+
function parseFileAccessSnapshot(stripped, originalSnapshot, prompt) {
|
|
1543
|
+
const lines = stripped.split("\n");
|
|
1544
|
+
let questionIndex = -1;
|
|
1545
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
1546
|
+
if (/Allow access to this file\?/i.test(lines[index])) {
|
|
1547
|
+
questionIndex = index;
|
|
1548
|
+
break;
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
const belowQuestion = questionIndex >= 0 ? lines.slice(questionIndex + 1).join("\n") : stripped;
|
|
1552
|
+
const options = parseOptions(belowQuestion);
|
|
1553
|
+
if (options.length < 2 || !hasNegativeApprovalOption(options)) return null;
|
|
1554
|
+
return {
|
|
1555
|
+
kind: "approval",
|
|
1556
|
+
headerText: prompt.identity,
|
|
1557
|
+
fullHeaderLine: prompt.fullLine,
|
|
1558
|
+
command: void 0,
|
|
1559
|
+
filePath: prompt.identity === "non-workspace file access" ? void 0 : prompt.identity,
|
|
1560
|
+
options,
|
|
1561
|
+
submitMap: buildApprovalSubmitMap(options),
|
|
1562
|
+
body: prompt.body,
|
|
1563
|
+
paneHash: hashPromptSnapshot(originalSnapshot)
|
|
1564
|
+
};
|
|
1565
|
+
}
|
|
1469
1566
|
function parseApprovalUISnapshot(stripped, originalSnapshot) {
|
|
1470
1567
|
const headerText = extractHeader(stripped);
|
|
1471
1568
|
if (!headerText) return null;
|
|
@@ -1480,7 +1577,7 @@ function parseApprovalUISnapshot(stripped, originalSnapshot) {
|
|
|
1480
1577
|
}
|
|
1481
1578
|
const belowHeader = headerIdx >= 0 ? lines.slice(headerIdx + 1).join("\n") : stripped;
|
|
1482
1579
|
const options = parseOptions(belowHeader);
|
|
1483
|
-
if (options.length < 2) return null;
|
|
1580
|
+
if (options.length < 2 || !hasNegativeApprovalOption(options)) return null;
|
|
1484
1581
|
const submitMap = buildApprovalSubmitMap(options);
|
|
1485
1582
|
const { command, filePath } = extractIdentity(headerText);
|
|
1486
1583
|
const body = extractBodyBetweenHeaderAndFirstOption(belowHeader);
|
|
@@ -1738,14 +1835,14 @@ var ApprovalDetector = class extends import_events3.EventEmitter {
|
|
|
1738
1835
|
* Returns the emitted state, or null if dedupe (already emitted for
|
|
1739
1836
|
* this paneHash) or disabled.
|
|
1740
1837
|
*/
|
|
1741
|
-
emitPaneOnlyPrompt(candidate, conversationId) {
|
|
1838
|
+
emitPaneOnlyPrompt(candidate, conversationId, forcedPromptId) {
|
|
1742
1839
|
if (this.paneOnlyEmittedHashes.has(candidate.paneHash)) {
|
|
1743
1840
|
logger.debug("Skipping pane-only re-emit for duplicate paneHash", {
|
|
1744
1841
|
paneHash: candidate.paneHash.substring(0, 16)
|
|
1745
1842
|
});
|
|
1746
1843
|
return null;
|
|
1747
1844
|
}
|
|
1748
|
-
const promptId = (0, import_uuid.v4)();
|
|
1845
|
+
const promptId = forcedPromptId ?? (0, import_uuid.v4)();
|
|
1749
1846
|
const syntheticCall = {
|
|
1750
1847
|
conversationId,
|
|
1751
1848
|
intentStepIndex: -1,
|
|
@@ -1771,9 +1868,14 @@ var ApprovalDetector = class extends import_events3.EventEmitter {
|
|
|
1771
1868
|
matchedPaneHeader: candidate.headerText,
|
|
1772
1869
|
paneDisplayHeader: candidate.fullHeaderLine,
|
|
1773
1870
|
body: candidate.body,
|
|
1871
|
+
paneOptions: candidate.options,
|
|
1872
|
+
paneSemanticKey: buildApprovalSemanticKey(candidate),
|
|
1774
1873
|
emittedAt: Date.now(),
|
|
1775
1874
|
ttlMs: this.promptTtlMs,
|
|
1776
|
-
|
|
1875
|
+
// E1 (§6a-2b / F5) — carry the consumed pane hash so an emit-failure
|
|
1876
|
+
// rollback can reset the observer's lastPromptHash and let the unchanged
|
|
1877
|
+
// live pane re-emit.
|
|
1878
|
+
paneHash: candidate.paneHash
|
|
1777
1879
|
};
|
|
1778
1880
|
this.pendingPrompts.set(promptId, state);
|
|
1779
1881
|
this.paneOnlyEmittedHashes.add(candidate.paneHash);
|
|
@@ -1956,6 +2058,8 @@ var ApprovalDetector = class extends import_events3.EventEmitter {
|
|
|
1956
2058
|
matchedPaneHeader: candidate.headerText,
|
|
1957
2059
|
paneDisplayHeader: candidate.fullHeaderLine,
|
|
1958
2060
|
body: candidate.body,
|
|
2061
|
+
paneOptions: candidate.options,
|
|
2062
|
+
paneSemanticKey: buildApprovalSemanticKey(candidate),
|
|
1959
2063
|
emittedAt: Date.now(),
|
|
1960
2064
|
ttlMs: this.promptTtlMs
|
|
1961
2065
|
};
|
|
@@ -2036,6 +2140,10 @@ var ApprovalDetector = class extends import_events3.EventEmitter {
|
|
|
2036
2140
|
getPendingPrompt(promptId) {
|
|
2037
2141
|
return this.pendingPrompts.get(promptId) ?? null;
|
|
2038
2142
|
}
|
|
2143
|
+
getPendingPromptsForConversation(conversationId) {
|
|
2144
|
+
const pending = Array.from(this.pendingPrompts.values());
|
|
2145
|
+
return conversationId ? pending.filter((state) => state.conversationId === conversationId) : pending;
|
|
2146
|
+
}
|
|
2039
2147
|
// ─── Read-only accessors ────────────────────────────────────────────────
|
|
2040
2148
|
getPendingCalls() {
|
|
2041
2149
|
return Array.from(this.pendingCalls.values());
|
|
@@ -2089,21 +2197,6 @@ var ApprovalDetector = class extends import_events3.EventEmitter {
|
|
|
2089
2197
|
this.emit("pending-call-expired", call);
|
|
2090
2198
|
}
|
|
2091
2199
|
}
|
|
2092
|
-
let anyExpired = false;
|
|
2093
|
-
for (const [promptId, state] of this.pendingPrompts.entries()) {
|
|
2094
|
-
if (now - state.emittedAt > state.ttlMs) {
|
|
2095
|
-
this.pendingPrompts.delete(promptId);
|
|
2096
|
-
this.resolvedPrompts.set(promptId, now);
|
|
2097
|
-
anyExpired = true;
|
|
2098
|
-
logger.debug("Pending prompt expired (TTL)", {
|
|
2099
|
-
promptId,
|
|
2100
|
-
ageMs: now - state.emittedAt
|
|
2101
|
-
});
|
|
2102
|
-
}
|
|
2103
|
-
}
|
|
2104
|
-
if (anyExpired) {
|
|
2105
|
-
this.paneOnlyEmittedHashes.clear();
|
|
2106
|
-
}
|
|
2107
2200
|
for (const [promptId, resolvedAt] of this.resolvedPrompts.entries()) {
|
|
2108
2201
|
if (now - resolvedAt > 5 * 6e4) {
|
|
2109
2202
|
this.resolvedPrompts.delete(promptId);
|
|
@@ -2210,22 +2303,11 @@ var PromptResponder = class {
|
|
|
2210
2303
|
};
|
|
2211
2304
|
}
|
|
2212
2305
|
if (this.paneObserver) {
|
|
2213
|
-
|
|
2214
|
-
if (!probe.active) {
|
|
2215
|
-
this.detector.resolvePrompt(promptId);
|
|
2306
|
+
if (!state.paneSemanticKey) {
|
|
2216
2307
|
return {
|
|
2217
2308
|
ok: false,
|
|
2218
|
-
reason: "prompt-
|
|
2219
|
-
details: "
|
|
2220
|
-
};
|
|
2221
|
-
}
|
|
2222
|
-
const expectedHeader = state.matchedPaneHeader;
|
|
2223
|
-
if (expectedHeader && probe.headerText && !sameIdentity(probe.headerText, expectedHeader)) {
|
|
2224
|
-
this.detector.resolvePrompt(promptId);
|
|
2225
|
-
return {
|
|
2226
|
-
ok: false,
|
|
2227
|
-
reason: "prompt-superseded",
|
|
2228
|
-
details: `pane shows '${probe.headerText}' but matched-against header was '${expectedHeader}'`
|
|
2309
|
+
reason: "prompt-probe-failed",
|
|
2310
|
+
details: "pending prompt has no exact semantic identity"
|
|
2229
2311
|
};
|
|
2230
2312
|
}
|
|
2231
2313
|
}
|
|
@@ -2239,6 +2321,51 @@ var PromptResponder = class {
|
|
|
2239
2321
|
}
|
|
2240
2322
|
try {
|
|
2241
2323
|
for (const key of keys) {
|
|
2324
|
+
if (!this.detector || !this.paneObserver) {
|
|
2325
|
+
return {
|
|
2326
|
+
ok: false,
|
|
2327
|
+
reason: "prompt-probe-failed",
|
|
2328
|
+
details: "approval responder is missing its detector or pane observer"
|
|
2329
|
+
};
|
|
2330
|
+
}
|
|
2331
|
+
if (this.detector.getPendingPrompt(promptId) !== state) {
|
|
2332
|
+
return { ok: false, reason: "prompt-expired" };
|
|
2333
|
+
}
|
|
2334
|
+
let liveSemanticKey = null;
|
|
2335
|
+
try {
|
|
2336
|
+
const snapshot = await this.paneObserver.captureSnapshot();
|
|
2337
|
+
const live = parseApprovalSnapshot(snapshot);
|
|
2338
|
+
if (!live) {
|
|
2339
|
+
if (detectActivePromptKind(snapshot) === null) {
|
|
2340
|
+
this.detector.resolvePrompt(promptId);
|
|
2341
|
+
return {
|
|
2342
|
+
ok: false,
|
|
2343
|
+
reason: "prompt-superseded",
|
|
2344
|
+
details: "the approval chooser is no longer active"
|
|
2345
|
+
};
|
|
2346
|
+
}
|
|
2347
|
+
return {
|
|
2348
|
+
ok: false,
|
|
2349
|
+
reason: "prompt-probe-failed",
|
|
2350
|
+
details: "the active chooser could not be parsed exactly"
|
|
2351
|
+
};
|
|
2352
|
+
}
|
|
2353
|
+
liveSemanticKey = live ? buildApprovalSemanticKey(live) : null;
|
|
2354
|
+
} catch (error) {
|
|
2355
|
+
return {
|
|
2356
|
+
ok: false,
|
|
2357
|
+
reason: "prompt-probe-failed",
|
|
2358
|
+
details: error instanceof Error ? error.message : String(error)
|
|
2359
|
+
};
|
|
2360
|
+
}
|
|
2361
|
+
if (liveSemanticKey !== state.paneSemanticKey) {
|
|
2362
|
+
this.detector.resolvePrompt(promptId);
|
|
2363
|
+
return {
|
|
2364
|
+
ok: false,
|
|
2365
|
+
reason: "prompt-superseded",
|
|
2366
|
+
details: "active chooser semantics differ from the mobile prompt"
|
|
2367
|
+
};
|
|
2368
|
+
}
|
|
2242
2369
|
if (isNamedKey(key)) {
|
|
2243
2370
|
await this.sendKey(target, key);
|
|
2244
2371
|
} else {
|
|
@@ -2271,6 +2398,43 @@ var PromptResponder = class {
|
|
|
2271
2398
|
};
|
|
2272
2399
|
}
|
|
2273
2400
|
}
|
|
2401
|
+
async sendFreeFormWhenNoApprovalActive(text) {
|
|
2402
|
+
const target = this.resolveTmuxTarget();
|
|
2403
|
+
if (!target) return { ok: false, reason: "no-tmux-target" };
|
|
2404
|
+
if (!this.paneObserver) {
|
|
2405
|
+
return {
|
|
2406
|
+
ok: false,
|
|
2407
|
+
reason: "prompt-probe-failed",
|
|
2408
|
+
details: "pane observer is required for safe free-form input"
|
|
2409
|
+
};
|
|
2410
|
+
}
|
|
2411
|
+
const chooserIsAbsent = async () => {
|
|
2412
|
+
const probe = await this.paneObserver.probeApprovalUIActive();
|
|
2413
|
+
if (!probe.probeSucceeded) {
|
|
2414
|
+
return { ok: false, reason: "prompt-probe-failed" };
|
|
2415
|
+
}
|
|
2416
|
+
if (probe.active) {
|
|
2417
|
+
return { ok: false, reason: "prompt-superseded" };
|
|
2418
|
+
}
|
|
2419
|
+
return null;
|
|
2420
|
+
};
|
|
2421
|
+
try {
|
|
2422
|
+
const beforeText = await chooserIsAbsent();
|
|
2423
|
+
if (beforeText) return beforeText;
|
|
2424
|
+
await this.typeLiteral(target, text);
|
|
2425
|
+
await delay(ENTER_DELAY_MS);
|
|
2426
|
+
const beforeEnter = await chooserIsAbsent();
|
|
2427
|
+
if (beforeEnter) return beforeEnter;
|
|
2428
|
+
await this.sendKey(target, "Enter");
|
|
2429
|
+
return { ok: true };
|
|
2430
|
+
} catch (error) {
|
|
2431
|
+
return {
|
|
2432
|
+
ok: false,
|
|
2433
|
+
reason: "tmux-failed",
|
|
2434
|
+
details: error instanceof Error ? error.message : String(error)
|
|
2435
|
+
};
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2274
2438
|
// ─── Tmux primitives ────────────────────────────────────────────────────
|
|
2275
2439
|
async typeLiteral(target, text) {
|
|
2276
2440
|
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, "\\$").replace(/`/g, "\\`");
|
|
@@ -2296,9 +2460,6 @@ function isNamedKey(key) {
|
|
|
2296
2460
|
function delay(ms) {
|
|
2297
2461
|
return new Promise((r) => setTimeout(r, ms));
|
|
2298
2462
|
}
|
|
2299
|
-
function sameIdentity(headerFromPane, expected) {
|
|
2300
|
-
return headerFromPane.trim() === expected.trim();
|
|
2301
|
-
}
|
|
2302
2463
|
|
|
2303
2464
|
// src/mobile-prompt-dedupe.ts
|
|
2304
2465
|
var DEFAULT_EXPIRY_MS = 15e3;
|
|
@@ -2816,7 +2977,7 @@ function truncate(s, maxBytes) {
|
|
|
2816
2977
|
// src/server.ts
|
|
2817
2978
|
var MOBILE_PROMPT_FLOOR_RECENCY_MS = 12e4;
|
|
2818
2979
|
var LAUNCH_SETTLE_TIMEOUT_MS = 3e3;
|
|
2819
|
-
var McpServer = class {
|
|
2980
|
+
var McpServer = class _McpServer {
|
|
2820
2981
|
constructor(options) {
|
|
2821
2982
|
/** Per-session causal floor for transcript-event timestamps emitted right
|
|
2822
2983
|
* after a MOBILE prompt. The mobile USER_PROMPT is created by the iOS app
|
|
@@ -2833,6 +2994,59 @@ var McpServer = class {
|
|
|
2833
2994
|
* under this single sessionId. /resume does NOT create a new
|
|
2834
2995
|
* session — same row. */
|
|
2835
2996
|
this.session = null;
|
|
2997
|
+
// E1 (§4/§6a-4) — per-promptId durable raise entries for the agy producer.
|
|
2998
|
+
// Keyed by the E1 promptId (= derive(ownerToken)); each holds the SECRET
|
|
2999
|
+
// ownerToken so a resume/retirement re-raise re-commits the SAME promptId
|
|
3000
|
+
// (WRITE_ROW on the ownerToken=:token branch → no duplicate row, no under-nag).
|
|
3001
|
+
// This in-memory Map is the AUTHORITATIVE E1 ledger. It outlives the detector's
|
|
3002
|
+
// 60s/5min timer (§3.1), so the TTL-refresh, wake, and retirement re-raises
|
|
3003
|
+
// always cover every still-open prompt this daemon raised. Process-restart
|
|
3004
|
+
// recovery is OUT of v1 scope (F1): a daemon crash is handled by disconnect-
|
|
3005
|
+
// suppression + per-row TTL (design §8), and agy re-observes a live pane on
|
|
3006
|
+
// restart — so there is NO disk persistence (removing it also stops writing the
|
|
3007
|
+
// secret ownerToken to a tmp file). agy is single-session, so sessionId is the
|
|
3008
|
+
// one live session's id.
|
|
3009
|
+
this.e1PromptRaises = /* @__PURE__ */ new Map();
|
|
3010
|
+
// E1 (§6a-2b) — session-scoped content-key → promptId index for the
|
|
3011
|
+
// suppressed-prompt COLLAPSE rule. Key = `${sessionId}::${contentKey}`. A
|
|
3012
|
+
// suppression whose content-key already maps to a LIVE ledger record collapses
|
|
3013
|
+
// onto it (over-nag, never under-nag); an ABSENT content-key mints a fresh badge.
|
|
3014
|
+
this.e1ContentKeyIndex = /* @__PURE__ */ new Map();
|
|
3015
|
+
// E1 (§3.1) — periodic liveness TTL co-refresh for the open-prompt ledger.
|
|
3016
|
+
this.e1TtlRefreshTimer = null;
|
|
3017
|
+
// 5 min (7-day TTL)
|
|
3018
|
+
// E1 (§6a-4) — single-flight guard for the retirement recovery re-raise.
|
|
3019
|
+
this.retiredSessionRecoveryPromise = null;
|
|
3020
|
+
// E1 (§6a-4 / F3) — promptIds whose re-raise (retirement re-point or return-
|
|
3021
|
+
// driven full re-raise) exhausted reRaiseOneE1's bounded per-call attempts and
|
|
3022
|
+
// must keep being retried until the backend acknowledges ("the prompt is never
|
|
3023
|
+
// lost"). drainPendingE1ReRaises re-attempts each still-present entry every TTL
|
|
3024
|
+
// tick; reRaiseOneE1 clears an id on ack and re-flags it on another exhaustion.
|
|
3025
|
+
// Without this a failed retirement re-raise would leave the entry present so
|
|
3026
|
+
// refreshE1Ttls finds it → refreshes → returns no aged-out → never re-fires,
|
|
3027
|
+
// leaving the backend row permanently pointed at the RETIRED session = under-nag.
|
|
3028
|
+
this.e1NeedsReRaise = /* @__PURE__ */ new Set();
|
|
3029
|
+
// E1 (§6a-4 / F2) — bounded exponential backoff for the BLOCK-UNTIL-ACK
|
|
3030
|
+
// retirement re-raise (the pre-heartbeat drain in createLaunchSession, which must
|
|
3031
|
+
// ACK before the replacement's heartbeat advertises liveness — design line 93).
|
|
3032
|
+
// The general (bounded, non-blocking) TTL-drain path keeps its shipped linear
|
|
3033
|
+
// 250·attempt backoff. Overridable in tests so a multi-failure block-until-ack
|
|
3034
|
+
// re-raise doesn't wait real seconds.
|
|
3035
|
+
this.e1BlockingReRaiseBackoff = { baseMs: 250, maxMs: 2e3 };
|
|
3036
|
+
// E1 (§6a-4) — DURABLE park for retirement-orphans awaiting re-home (G-1 fix).
|
|
3037
|
+
// When the backend retires the live session, its still-open prompts are
|
|
3038
|
+
// snapshotted here (FULL records — incl. the secret ownerToken + pre-encryption
|
|
3039
|
+
// content) *before* the replacement-launch attempt, so a transient launch failure
|
|
3040
|
+
// can NOT lose them (the pre-fix snapshot was a LOCAL closure var re-raised only
|
|
3041
|
+
// inside the replacement's pre-heartbeat hook — if that launch failed before the
|
|
3042
|
+
// hook ran, the snapshot was lost and the ledger rows stayed pointed at the
|
|
3043
|
+
// RETIRED session = stranded badgeless live prompt). Drained + re-homed onto the
|
|
3044
|
+
// NEXT successfully-launched session (in createLaunchSession, BEFORE its heartbeat
|
|
3045
|
+
// — §F2) and, as a backstop, on every TTL tick — so a retirement whose OWN
|
|
3046
|
+
// replacement launch failed is still recovered by a later normal activity launch.
|
|
3047
|
+
// Keyed by promptId so re-parks dedupe. In-memory only (process-restart recovery
|
|
3048
|
+
// is out of v1 scope, F1); cleared on a full doStop.
|
|
3049
|
+
this.e1RetirementPark = /* @__PURE__ */ new Map();
|
|
2836
3050
|
/** Single subscription for the launch session (v9 — was per-conv Map). */
|
|
2837
3051
|
this.subscription = null;
|
|
2838
3052
|
/** Wrapper-level disable flag (free-tier limit / collision). v9
|
|
@@ -2935,6 +3149,9 @@ var McpServer = class {
|
|
|
2935
3149
|
getActiveSession: () => this.getAnySession()
|
|
2936
3150
|
});
|
|
2937
3151
|
}
|
|
3152
|
+
static {
|
|
3153
|
+
this.E1_TTL_REFRESH_MS = 5 * 60 * 1e3;
|
|
3154
|
+
}
|
|
2938
3155
|
// ─── Lifecycle ──────────────────────────────────────────────────────────
|
|
2939
3156
|
async start() {
|
|
2940
3157
|
if (this.started) throw new Error("McpServer.start() called twice");
|
|
@@ -3126,6 +3343,8 @@ var McpServer = class {
|
|
|
3126
3343
|
logger.warn("appSyncClient.cleanupSubscriptions failed", { error: String(err) });
|
|
3127
3344
|
}
|
|
3128
3345
|
this.session = null;
|
|
3346
|
+
this.stopE1TtlRefresh();
|
|
3347
|
+
this.e1RetirementPark.clear();
|
|
3129
3348
|
this.sessionDisabled = false;
|
|
3130
3349
|
this.launchKeyUnavailable = false;
|
|
3131
3350
|
this.ensureLaunchInFlight = null;
|
|
@@ -3185,6 +3404,16 @@ var McpServer = class {
|
|
|
3185
3404
|
* events under this single sessionId. /resume does NOT create a new
|
|
3186
3405
|
* row. (Codex Stage 2 R1 v5/v6/v7 switch machinery is now dead code,
|
|
3187
3406
|
* removed.)
|
|
3407
|
+
*
|
|
3408
|
+
* E1 (§6a-4 / F2) — AFTER the session + subscription are wired but BEFORE the
|
|
3409
|
+
* heartbeat advertises liveness, this drains the durable retirement park onto
|
|
3410
|
+
* THIS session (see {@link drainE1RetirementPark}). So the open prompts of a
|
|
3411
|
+
* retired session are re-pointed onto their replacement (backend-ACK'd) before the
|
|
3412
|
+
* first `lastHeartbeatAt` write — otherwise the client would see a connected
|
|
3413
|
+
* session whose rows still point at the RETIRED session = under-nag (design line
|
|
3414
|
+
* 93). Running on EVERY successful launch (not just the retirement-recovery
|
|
3415
|
+
* re-bootstrap) is what lets a later normal activity launch recover a retirement
|
|
3416
|
+
* whose own replacement launch had failed transiently (G-1).
|
|
3188
3417
|
*/
|
|
3189
3418
|
async createLaunchSession() {
|
|
3190
3419
|
const gen = this.lifecycleGen;
|
|
@@ -3268,7 +3497,9 @@ var McpServer = class {
|
|
|
3268
3497
|
logger.error("handleMobileEvent failed", { error: String(err) });
|
|
3269
3498
|
});
|
|
3270
3499
|
},
|
|
3271
|
-
(err) => logger.warn("AppSync subscription error", { error: String(err) })
|
|
3500
|
+
(err) => logger.warn("AppSync subscription error", { error: String(err) }),
|
|
3501
|
+
// E1 (§6a-4) — recover open prompts if the backend retires this session.
|
|
3502
|
+
{ onSessionRetired: () => this.recoverRetiredBackendSession(sessionId) }
|
|
3272
3503
|
);
|
|
3273
3504
|
if (gen !== this.lifecycleGen || !this.started) {
|
|
3274
3505
|
try {
|
|
@@ -3282,6 +3513,8 @@ var McpServer = class {
|
|
|
3282
3513
|
} catch (err) {
|
|
3283
3514
|
logger.error("subscribeToEvents failed (non-fatal)", { sessionId, error: String(err) });
|
|
3284
3515
|
}
|
|
3516
|
+
await this.drainE1RetirementPark({ retryUntilAck: true, gen });
|
|
3517
|
+
if (gen !== this.lifecycleGen || !this.started) return;
|
|
3285
3518
|
try {
|
|
3286
3519
|
this.appSyncClient.startHeartbeat(sessionId);
|
|
3287
3520
|
} catch (err) {
|
|
@@ -3444,6 +3677,395 @@ var McpServer = class {
|
|
|
3444
3677
|
isEncrypted: true
|
|
3445
3678
|
};
|
|
3446
3679
|
}
|
|
3680
|
+
// ─── E1 missed-prompt recovery (§4/§6a) — in-memory ledger + raise helpers ───
|
|
3681
|
+
//
|
|
3682
|
+
// The authoritative ledger is the in-memory `e1PromptRaises` Map (see its field
|
|
3683
|
+
// docstring): it outlives the detector's timer, which is all §6a-4 durability
|
|
3684
|
+
// requires. There is deliberately NO disk persistence — process-restart recovery
|
|
3685
|
+
// is out of v1 scope (F1), and persisting the secret ownerToken to a tmp file was
|
|
3686
|
+
// write-only dead code (never read back on startup).
|
|
3687
|
+
/**
|
|
3688
|
+
* Mint a NEW E1 raise for the agy producer: derive a committed promptId from a
|
|
3689
|
+
* fresh secret, stash a durable entry (identity only — content added at
|
|
3690
|
+
* emit-success via {@link stashE1RaiseContent}), start the TTL timer, and return
|
|
3691
|
+
* the record. `mobileActionable=false` for a suppressed prompt (badge-only
|
|
3692
|
+
* NOTIFICATION carrier, no fabricated options).
|
|
3693
|
+
*/
|
|
3694
|
+
newE1Raise(sessionId, mobileActionable, title) {
|
|
3695
|
+
const { record } = (0, import_codevibe_core4.newPromptRaise)({
|
|
3696
|
+
sessionId,
|
|
3697
|
+
producerKind: "PLUGIN_APPROVAL",
|
|
3698
|
+
mobileActionable,
|
|
3699
|
+
agentType: "ANTIGRAVITY",
|
|
3700
|
+
...title !== void 0 && { title }
|
|
3701
|
+
});
|
|
3702
|
+
this.e1PromptRaises.set(record.promptId, { record });
|
|
3703
|
+
if (!this.e1TtlRefreshTimer) this.startE1TtlRefresh();
|
|
3704
|
+
return record;
|
|
3705
|
+
}
|
|
3706
|
+
/**
|
|
3707
|
+
* E1 (§6a-4) — stash the PRE-ENCRYPTION payload on an already-minted raise entry
|
|
3708
|
+
* at emit-success, so a retirement re-raise can re-encrypt it for the
|
|
3709
|
+
* (replacement) session's key and re-emit the SAME actionable prompt. No-op if
|
|
3710
|
+
* the entry is gone (superseded) or the emit aborted.
|
|
3711
|
+
*/
|
|
3712
|
+
stashE1RaiseContent(promptId, contentPlain, metadataPlain) {
|
|
3713
|
+
const entry = this.e1PromptRaises.get(promptId);
|
|
3714
|
+
if (!entry) return;
|
|
3715
|
+
entry.contentPlain = contentPlain;
|
|
3716
|
+
entry.metadataPlain = metadataPlain;
|
|
3717
|
+
}
|
|
3718
|
+
/**
|
|
3719
|
+
* E1 (§6a-2b) — emit the badge-only carrier for a SUPPRESSED prompt (options
|
|
3720
|
+
* couldn't be parsed → mobile must NOT fabricate any). A NOTIFICATION carrying
|
|
3721
|
+
* the raise identity with mobileActionable=false, so the backend's classifyRaise
|
|
3722
|
+
* records/updates the open-prompt row: mobile shows a banner + NO option buttons.
|
|
3723
|
+
* Content is E2E-encrypted; the raise identity rides plaintext top-level.
|
|
3724
|
+
* Fail-closed on a missing session key — never cleartext.
|
|
3725
|
+
*/
|
|
3726
|
+
async emitSuppressedPromptCarrier(session, record) {
|
|
3727
|
+
const bannerText = "\u26A0\uFE0F A prompt is waiting in your desktop terminal, but its options could not be shown here. Please answer it on your desktop.";
|
|
3728
|
+
const input = {
|
|
3729
|
+
sessionId: session.sessionId,
|
|
3730
|
+
type: import_codevibe_core4.EventType.NOTIFICATION,
|
|
3731
|
+
source: import_codevibe_core4.EventSource.DESKTOP,
|
|
3732
|
+
content: bannerText,
|
|
3733
|
+
metadata: { e1SuppressedPrompt: true },
|
|
3734
|
+
...(0, import_codevibe_core4.raiseFieldsFromRecord)(record),
|
|
3735
|
+
notificationText: bannerText,
|
|
3736
|
+
timestamp: (0, import_codevibe_core4.prepareEventTimestamp)({ orderingKey: session.sessionId })
|
|
3737
|
+
};
|
|
3738
|
+
const outbound = this.encryptOutbound(session, input);
|
|
3739
|
+
if (!outbound) return false;
|
|
3740
|
+
await this.appSyncClient.createEvent(outbound);
|
|
3741
|
+
logger.info("E1: emitted suppressed-prompt badge carrier (mobileActionable=false)", {
|
|
3742
|
+
sessionId: session.sessionId,
|
|
3743
|
+
promptId: record.promptId
|
|
3744
|
+
});
|
|
3745
|
+
return true;
|
|
3746
|
+
}
|
|
3747
|
+
/**
|
|
3748
|
+
* E1 (§6a-2b) — badge-only raise for a SUPPRESSED prompt, applying the content-key
|
|
3749
|
+
* COLLAPSE rule. If a LIVE ledger record already covers this content-key in this
|
|
3750
|
+
* session → COLLAPSE (its row keeps the session badge lit; over-nag, never
|
|
3751
|
+
* under-nag). Otherwise mint a fresh mobileActionable:false raise + emit the
|
|
3752
|
+
* NOTIFICATION carrier. A null content-key always mints (can't prove a
|
|
3753
|
+
* re-observation) — never under-nag.
|
|
3754
|
+
*/
|
|
3755
|
+
raiseSuppressedBadge(session, contentKey) {
|
|
3756
|
+
if (contentKey) {
|
|
3757
|
+
const idxKey = `${session.sessionId}::${contentKey}`;
|
|
3758
|
+
const existingId = this.e1ContentKeyIndex.get(idxKey);
|
|
3759
|
+
if (existingId && this.e1PromptRaises.has(existingId)) return;
|
|
3760
|
+
if (existingId) this.e1ContentKeyIndex.delete(idxKey);
|
|
3761
|
+
}
|
|
3762
|
+
const record = this.newE1Raise(session.sessionId, false);
|
|
3763
|
+
if (contentKey) this.e1ContentKeyIndex.set(`${session.sessionId}::${contentKey}`, record.promptId);
|
|
3764
|
+
this.emitSuppressedPromptCarrier(session, record).catch((e) => {
|
|
3765
|
+
logger.error("E1: failed to emit suppressed-prompt badge carrier", {
|
|
3766
|
+
sessionId: session.sessionId,
|
|
3767
|
+
promptId: record.promptId,
|
|
3768
|
+
error: e instanceof Error ? e.message : String(e)
|
|
3769
|
+
});
|
|
3770
|
+
});
|
|
3771
|
+
}
|
|
3772
|
+
/** Snapshot the still-open E1 entries for a session (deep copy of the record). */
|
|
3773
|
+
snapshotOpenE1(sessionId) {
|
|
3774
|
+
return [...this.e1PromptRaises.values()].filter((e) => e.record.sessionId === sessionId).map((e) => ({
|
|
3775
|
+
record: { ...e.record },
|
|
3776
|
+
...e.contentPlain !== void 0 && { contentPlain: e.contentPlain },
|
|
3777
|
+
...e.metadataPlain !== void 0 && { metadataPlain: e.metadataPlain }
|
|
3778
|
+
}));
|
|
3779
|
+
}
|
|
3780
|
+
/**
|
|
3781
|
+
* E1 (§6a-4) — re-raise a snapshot of still-open prompts from a RETIRED session
|
|
3782
|
+
* onto its live REPLACEMENT session, re-committing the SAME promptId + ownerToken
|
|
3783
|
+
* (the backend re-points the row's sessionId → the replacement; no orphan, no
|
|
3784
|
+
* under-nag). Actionable → INTERACTIVE_PROMPT (re-encrypted real options);
|
|
3785
|
+
* badge-only / content-less → NOTIFICATION carrier. Retries transient failures.
|
|
3786
|
+
*/
|
|
3787
|
+
async reRaiseSnapshotE1(snapshot, replacementSession, opts) {
|
|
3788
|
+
if (snapshot.length === 0) return [];
|
|
3789
|
+
logger.info("E1: re-raising open prompts onto replacement session", {
|
|
3790
|
+
replacementSessionId: replacementSession.sessionId,
|
|
3791
|
+
count: snapshot.length
|
|
3792
|
+
});
|
|
3793
|
+
const unacked = [];
|
|
3794
|
+
for (const snap of snapshot) {
|
|
3795
|
+
const entry = {
|
|
3796
|
+
record: { ...snap.record, sessionId: replacementSession.sessionId },
|
|
3797
|
+
...snap.contentPlain !== void 0 && { contentPlain: snap.contentPlain },
|
|
3798
|
+
...snap.metadataPlain !== void 0 && { metadataPlain: snap.metadataPlain }
|
|
3799
|
+
};
|
|
3800
|
+
this.e1PromptRaises.set(entry.record.promptId, entry);
|
|
3801
|
+
const acked = await this.reRaiseOneE1(replacementSession, entry, opts);
|
|
3802
|
+
if (!acked) unacked.push(snap);
|
|
3803
|
+
}
|
|
3804
|
+
if (!this.e1TtlRefreshTimer) this.startE1TtlRefresh();
|
|
3805
|
+
return unacked;
|
|
3806
|
+
}
|
|
3807
|
+
/**
|
|
3808
|
+
* E1 (§6a-4) — drain the DURABLE retirement park onto the current live session:
|
|
3809
|
+
* re-home each parked orphan (record.sessionId → this session) and re-raise it
|
|
3810
|
+
* ({@link reRaiseSnapshotE1} → {@link reRaiseOneE1}, retry-until-ack via
|
|
3811
|
+
* `e1NeedsReRaise`). This is the second half of the G-1 fix: the park survives a
|
|
3812
|
+
* failed replacement launch, so ANY later successful launch (or TTL tick) re-homes
|
|
3813
|
+
* the orphans that the pre-fix closure snapshot would have lost. agy is
|
|
3814
|
+
* single-session, so the re-home target is unambiguously `this.session`.
|
|
3815
|
+
*
|
|
3816
|
+
* The park entries are moved into the authoritative `e1PromptRaises` ledger by
|
|
3817
|
+
* reRaiseSnapshotE1 (which stamps the new sessionId), so the park is CLEARED up
|
|
3818
|
+
* front — the snapshot + clear are synchronous (no yield) so a concurrent drain
|
|
3819
|
+
* can't double-process, and once handed off the ledger + retry-until-ack own the
|
|
3820
|
+
* entries. No-op when the park is empty or no live session exists yet (retry on
|
|
3821
|
+
* the next launch / tick — never lost).
|
|
3822
|
+
*/
|
|
3823
|
+
async drainE1RetirementPark(opts) {
|
|
3824
|
+
if (this.e1RetirementPark.size === 0) return;
|
|
3825
|
+
const session = this.session;
|
|
3826
|
+
if (!session) return;
|
|
3827
|
+
const parked = [...this.e1RetirementPark.values()];
|
|
3828
|
+
this.e1RetirementPark.clear();
|
|
3829
|
+
const unacked = await this.reRaiseSnapshotE1(parked, session, opts);
|
|
3830
|
+
if (unacked.length > 0) {
|
|
3831
|
+
for (const snap of unacked) this.e1RetirementPark.set(snap.record.promptId, snap);
|
|
3832
|
+
logger.info("E1: retirement park drain aborted before ACK \u2014 re-parked for next launch", {
|
|
3833
|
+
sessionId: session.sessionId,
|
|
3834
|
+
retained: unacked.length
|
|
3835
|
+
});
|
|
3836
|
+
}
|
|
3837
|
+
}
|
|
3838
|
+
/**
|
|
3839
|
+
* E1 (§6a-4 / F2) — lifecycle-supersede predicate for the BLOCK-UNTIL-ACK
|
|
3840
|
+
* retirement re-raise. True once the launch generation that started the drain has
|
|
3841
|
+
* been superseded (`gen` mismatch — a stop()→start() ran) OR the daemon has
|
|
3842
|
+
* stopped (`started` is false — a doStop() is in flight). A stopped daemon returns
|
|
3843
|
+
* true even without a `gen`, so the block-until-ack loop can never hang past a
|
|
3844
|
+
* stop. Only the F2 path consults this; the bounded general path ignores it.
|
|
3845
|
+
*/
|
|
3846
|
+
reRaiseSuperseded(gen) {
|
|
3847
|
+
if (!this.started) return true;
|
|
3848
|
+
return gen !== void 0 && gen !== this.lifecycleGen;
|
|
3849
|
+
}
|
|
3850
|
+
/**
|
|
3851
|
+
* E1 (§6a-4 / F2) — sleep up to `ms`, waking ~every 50ms to re-check
|
|
3852
|
+
* {@link reRaiseSuperseded} so a stop()/stop()→start() breaks a block-until-ack
|
|
3853
|
+
* backoff PROMPTLY (within ~50ms) rather than after a full backoff interval.
|
|
3854
|
+
* Returns immediately once superseded/stopped.
|
|
3855
|
+
*/
|
|
3856
|
+
async interruptibleBackoff(ms, gen) {
|
|
3857
|
+
const deadline = Date.now() + ms;
|
|
3858
|
+
while (Date.now() < deadline) {
|
|
3859
|
+
if (this.reRaiseSuperseded(gen)) return;
|
|
3860
|
+
const slice = Math.min(50, deadline - Date.now());
|
|
3861
|
+
if (slice <= 0) return;
|
|
3862
|
+
await new Promise((resolve3) => setTimeout(resolve3, slice));
|
|
3863
|
+
}
|
|
3864
|
+
}
|
|
3865
|
+
/**
|
|
3866
|
+
* Re-emit ONE E1 entry on `session`, retrying transient failures (§6.8).
|
|
3867
|
+
*
|
|
3868
|
+
* Two modes:
|
|
3869
|
+
* - BOUNDED general path (default): a per-call backoff over `MAX_ATTEMPTS`
|
|
3870
|
+
* (linear 250·attempt); on exhaustion it FLAGS the promptId in `e1NeedsReRaise`
|
|
3871
|
+
* (entry retained, F3) so drainPendingE1ReRaises keeps retrying every TTL tick
|
|
3872
|
+
* until the backend acknowledges — "the prompt is never lost" (§6a-4).
|
|
3873
|
+
* - BLOCK-UNTIL-ACK path (`opts.retryUntilAck`, F2 — the pre-heartbeat
|
|
3874
|
+
* retirement drain in createLaunchSession): retries with a bounded EXPONENTIAL
|
|
3875
|
+
* backoff and does NOT give up after `MAX_ATTEMPTS`, so the caller (and thus the
|
|
3876
|
+
* replacement session's heartbeat) waits until the row is re-pointed and
|
|
3877
|
+
* backend-ACK'd (design line 93 — re-point BEFORE advertising liveness). The
|
|
3878
|
+
* ONLY exit other than ACK is LIFECYCLE-SUPERSEDE ({@link reRaiseSuperseded}
|
|
3879
|
+
* on the captured `opts.gen` / `started`), which flags e1NeedsReRaise (entry
|
|
3880
|
+
* retained → the general net repairs it) and bails so a superseded/stopped
|
|
3881
|
+
* lifecycle never advertises liveness for a mis-pointed row. Bounded against a
|
|
3882
|
+
* hang because the re-raise createEvent and the heartbeat hit the SAME backend:
|
|
3883
|
+
* a down backend fails both (no false-connected), an up backend ACKs quickly.
|
|
3884
|
+
*
|
|
3885
|
+
* On ACK it clears the promptId from `e1NeedsReRaise`. A keyless fail-closed return
|
|
3886
|
+
* leaves the flag as-is so a later tick re-attempts once a key resolves.
|
|
3887
|
+
*/
|
|
3888
|
+
async reRaiseOneE1(session, entry, opts) {
|
|
3889
|
+
const rec = entry.record;
|
|
3890
|
+
const actionable = rec.mobileActionable && entry.contentPlain !== void 0;
|
|
3891
|
+
const retryUntilAck = opts?.retryUntilAck === true;
|
|
3892
|
+
const gen = opts?.gen;
|
|
3893
|
+
const MAX_ATTEMPTS = 4;
|
|
3894
|
+
for (let attempt = 1; ; attempt++) {
|
|
3895
|
+
if (retryUntilAck && this.reRaiseSuperseded(gen)) {
|
|
3896
|
+
this.e1NeedsReRaise.add(rec.promptId);
|
|
3897
|
+
logger.warn("E1: block-until-ack re-raise aborted (lifecycle superseded/stopping) \u2014 flagged for retry-until-ack (entry retained)", {
|
|
3898
|
+
sessionId: session.sessionId,
|
|
3899
|
+
promptId: rec.promptId
|
|
3900
|
+
});
|
|
3901
|
+
return false;
|
|
3902
|
+
}
|
|
3903
|
+
try {
|
|
3904
|
+
if (actionable) {
|
|
3905
|
+
const input = {
|
|
3906
|
+
sessionId: session.sessionId,
|
|
3907
|
+
type: import_codevibe_core4.EventType.INTERACTIVE_PROMPT,
|
|
3908
|
+
source: import_codevibe_core4.EventSource.DESKTOP,
|
|
3909
|
+
content: entry.contentPlain,
|
|
3910
|
+
metadata: entry.metadataPlain ?? {},
|
|
3911
|
+
...(0, import_codevibe_core4.raiseFieldsFromRecord)(rec),
|
|
3912
|
+
timestamp: (0, import_codevibe_core4.prepareEventTimestamp)({ orderingKey: session.sessionId })
|
|
3913
|
+
};
|
|
3914
|
+
const outbound = this.encryptOutbound(session, input);
|
|
3915
|
+
if (!outbound) return false;
|
|
3916
|
+
await this.appSyncClient.createEvent(outbound);
|
|
3917
|
+
} else {
|
|
3918
|
+
rec.mobileActionable = false;
|
|
3919
|
+
if (!await this.emitSuppressedPromptCarrier(session, rec)) return false;
|
|
3920
|
+
}
|
|
3921
|
+
this.e1NeedsReRaise.delete(rec.promptId);
|
|
3922
|
+
logger.info("E1: re-raised prompt onto replacement session", {
|
|
3923
|
+
sessionId: session.sessionId,
|
|
3924
|
+
promptId: rec.promptId,
|
|
3925
|
+
actionable
|
|
3926
|
+
});
|
|
3927
|
+
return true;
|
|
3928
|
+
} catch (e) {
|
|
3929
|
+
if (!retryUntilAck && attempt >= MAX_ATTEMPTS) {
|
|
3930
|
+
this.e1NeedsReRaise.add(rec.promptId);
|
|
3931
|
+
logger.error("E1: re-raise failed after bounded attempts \u2014 flagged for retry-until-ack (entry retained)", {
|
|
3932
|
+
sessionId: session.sessionId,
|
|
3933
|
+
promptId: rec.promptId,
|
|
3934
|
+
error: e instanceof Error ? e.message : String(e)
|
|
3935
|
+
});
|
|
3936
|
+
return false;
|
|
3937
|
+
}
|
|
3938
|
+
if (retryUntilAck) {
|
|
3939
|
+
const delay2 = Math.min(
|
|
3940
|
+
this.e1BlockingReRaiseBackoff.baseMs * 2 ** (attempt - 1),
|
|
3941
|
+
this.e1BlockingReRaiseBackoff.maxMs
|
|
3942
|
+
);
|
|
3943
|
+
await this.interruptibleBackoff(delay2, gen);
|
|
3944
|
+
} else {
|
|
3945
|
+
await new Promise((resolve3) => setTimeout(resolve3, 250 * attempt));
|
|
3946
|
+
}
|
|
3947
|
+
}
|
|
3948
|
+
}
|
|
3949
|
+
}
|
|
3950
|
+
/**
|
|
3951
|
+
* E1 (§6a-4 / F3) — retry-until-ack driver. Re-attempts reRaiseOneE1 for each
|
|
3952
|
+
* promptId still flagged in `e1NeedsReRaise` whose ledger entry is present on the
|
|
3953
|
+
* live session; reRaiseOneE1 clears the flag on ACK or re-flags it on another
|
|
3954
|
+
* exhaustion. A flagged promptId whose ledger entry is gone (superseded/resolved)
|
|
3955
|
+
* is dropped. Called at the top of every refreshE1Ttls tick so a failed
|
|
3956
|
+
* retirement re-point is repaired even though the entry is present (so the row-
|
|
3957
|
+
* exists refresh path never returns it as aged-out).
|
|
3958
|
+
*/
|
|
3959
|
+
async drainPendingE1ReRaises() {
|
|
3960
|
+
if (this.e1NeedsReRaise.size === 0) return;
|
|
3961
|
+
const session = this.session;
|
|
3962
|
+
for (const promptId of [...this.e1NeedsReRaise]) {
|
|
3963
|
+
const entry = this.e1PromptRaises.get(promptId);
|
|
3964
|
+
if (!entry) {
|
|
3965
|
+
this.e1NeedsReRaise.delete(promptId);
|
|
3966
|
+
continue;
|
|
3967
|
+
}
|
|
3968
|
+
if (!session || entry.record.sessionId !== session.sessionId) continue;
|
|
3969
|
+
await this.reRaiseOneE1(session, entry);
|
|
3970
|
+
}
|
|
3971
|
+
}
|
|
3972
|
+
/**
|
|
3973
|
+
* E1 (§3.1) — one TTL-refresh tick for the current session's open prompts so a
|
|
3974
|
+
* long-open prompt is never TTL-deleted (under-nag). Aged-out promptIds are
|
|
3975
|
+
* re-materialized via reRaiseOneE1 (return-driven re-raise).
|
|
3976
|
+
*/
|
|
3977
|
+
async refreshE1Ttls() {
|
|
3978
|
+
await this.drainPendingE1ReRaises();
|
|
3979
|
+
await this.drainE1RetirementPark();
|
|
3980
|
+
const session = this.session;
|
|
3981
|
+
if (!session) return;
|
|
3982
|
+
const sid = session.sessionId;
|
|
3983
|
+
const promptIds = [...this.e1PromptRaises.values()].filter((e) => e.record.sessionId === sid).map((e) => e.record.promptId);
|
|
3984
|
+
if (promptIds.length === 0) return;
|
|
3985
|
+
try {
|
|
3986
|
+
const agedOut = await this.appSyncClient.refreshOpenPromptTtl(promptIds);
|
|
3987
|
+
for (const promptId of agedOut) {
|
|
3988
|
+
const entry = this.e1PromptRaises.get(promptId);
|
|
3989
|
+
if (entry && this.session && entry.record.sessionId === this.session.sessionId) {
|
|
3990
|
+
await this.reRaiseOneE1(this.session, entry);
|
|
3991
|
+
}
|
|
3992
|
+
}
|
|
3993
|
+
} catch (e) {
|
|
3994
|
+
logger.warn("E1: TTL-refresh tick failed (non-fatal, retries next tick)", {
|
|
3995
|
+
error: e instanceof Error ? e.message : String(e)
|
|
3996
|
+
});
|
|
3997
|
+
}
|
|
3998
|
+
}
|
|
3999
|
+
/** Start the E1 TTL-refresh timer (idempotent — never stacks). */
|
|
4000
|
+
startE1TtlRefresh() {
|
|
4001
|
+
if (this.e1TtlRefreshTimer) clearInterval(this.e1TtlRefreshTimer);
|
|
4002
|
+
this.e1TtlRefreshTimer = setInterval(() => {
|
|
4003
|
+
void this.refreshE1Ttls();
|
|
4004
|
+
}, _McpServer.E1_TTL_REFRESH_MS);
|
|
4005
|
+
}
|
|
4006
|
+
/** Stop the E1 TTL-refresh timer. */
|
|
4007
|
+
stopE1TtlRefresh() {
|
|
4008
|
+
if (this.e1TtlRefreshTimer) {
|
|
4009
|
+
clearInterval(this.e1TtlRefreshTimer);
|
|
4010
|
+
this.e1TtlRefreshTimer = null;
|
|
4011
|
+
}
|
|
4012
|
+
}
|
|
4013
|
+
/**
|
|
4014
|
+
* E1 (§6a-4) — recover from a backend session RETIREMENT (agy had no such path;
|
|
4015
|
+
* built for E1). The backend retired the live session (RETIRED_SESSION_CANNOT_
|
|
4016
|
+
* REACTIVATE); its open prompts would orphan. Recovery: snapshot the open E1
|
|
4017
|
+
* prompts and PARK them durably (so a transient replacement-launch failure can
|
|
4018
|
+
* NOT lose them — G-1), PARTIALLY tear down the retired session (drop the
|
|
4019
|
+
* singleton + its subscription + heartbeat, but NOT the INACTIVE write — it's
|
|
4020
|
+
* already retired — and keep the daemon + observers running), then re-bootstrap a
|
|
4021
|
+
* FRESH replacement session via ensureLaunchSession (generateLaunchSessionId mints
|
|
4022
|
+
* a unique id, so this never reactivates the retired one). createLaunchSession
|
|
4023
|
+
* drains the park onto the replacement (same promptId+ownerToken → the backend
|
|
4024
|
+
* re-points the row's sessionId; no orphan) BEFORE its heartbeat. If THIS launch
|
|
4025
|
+
* fails, the park is retained and the NEXT successful launch / TTL tick re-homes
|
|
4026
|
+
* it. Single-flight; a stale callback (session already replaced) is a no-op.
|
|
4027
|
+
*/
|
|
4028
|
+
recoverRetiredBackendSession(expectedSessionId) {
|
|
4029
|
+
if (this.retiredSessionRecoveryPromise) return this.retiredSessionRecoveryPromise;
|
|
4030
|
+
if (!this.started) return Promise.resolve();
|
|
4031
|
+
const recovery = (async () => {
|
|
4032
|
+
const old = this.session;
|
|
4033
|
+
if (!old || old.sessionId !== expectedSessionId) return;
|
|
4034
|
+
logger.warn("Backend retired live agy session; creating replacement", {
|
|
4035
|
+
sessionId: expectedSessionId
|
|
4036
|
+
});
|
|
4037
|
+
const openE1Snapshot = this.snapshotOpenE1(expectedSessionId);
|
|
4038
|
+
for (const snap of openE1Snapshot) {
|
|
4039
|
+
this.e1RetirementPark.set(snap.record.promptId, snap);
|
|
4040
|
+
this.e1PromptRaises.delete(snap.record.promptId);
|
|
4041
|
+
}
|
|
4042
|
+
try {
|
|
4043
|
+
this.appSyncClient.stopHeartbeat(old.sessionId);
|
|
4044
|
+
} catch {
|
|
4045
|
+
}
|
|
4046
|
+
if (this.subscription) {
|
|
4047
|
+
try {
|
|
4048
|
+
this.subscription();
|
|
4049
|
+
} catch {
|
|
4050
|
+
}
|
|
4051
|
+
this.subscription = null;
|
|
4052
|
+
}
|
|
4053
|
+
this.session = null;
|
|
4054
|
+
await this.ensureLaunchSession();
|
|
4055
|
+
if (this.session) await this.drainE1RetirementPark();
|
|
4056
|
+
if (!this.session) {
|
|
4057
|
+
logger.error("agy retirement recovery: replacement session not established; open prompts PARKED for the next successful launch / TTL tick", {
|
|
4058
|
+
oldSessionId: expectedSessionId,
|
|
4059
|
+
parked: this.e1RetirementPark.size
|
|
4060
|
+
});
|
|
4061
|
+
return;
|
|
4062
|
+
}
|
|
4063
|
+
})();
|
|
4064
|
+
this.retiredSessionRecoveryPromise = recovery.finally(() => {
|
|
4065
|
+
this.retiredSessionRecoveryPromise = null;
|
|
4066
|
+
});
|
|
4067
|
+
return this.retiredSessionRecoveryPromise;
|
|
4068
|
+
}
|
|
3447
4069
|
/**
|
|
3448
4070
|
* If the inbound mobile event is marked isEncrypted, decrypt content +
|
|
3449
4071
|
* metadata in place. Returns a new shallow-copied Event; never mutates
|
|
@@ -3488,17 +4110,33 @@ var McpServer = class {
|
|
|
3488
4110
|
if (!this.started) return;
|
|
3489
4111
|
if (this.sessionDisabled) return;
|
|
3490
4112
|
await this.ensureLaunchSession();
|
|
3491
|
-
|
|
4113
|
+
const session = this.session;
|
|
4114
|
+
if (!session) {
|
|
4115
|
+
this.paneObserver.resetPromptHash(cand.snapshotHash);
|
|
4116
|
+
return;
|
|
4117
|
+
}
|
|
3492
4118
|
const parsed = parseApprovalSnapshot(cand.snapshot);
|
|
3493
|
-
if (!parsed)
|
|
4119
|
+
if (!parsed) {
|
|
4120
|
+
this.raiseSuppressedBadge(session, cand.snapshotHash);
|
|
4121
|
+
return;
|
|
4122
|
+
}
|
|
3494
4123
|
const activeConvId = this.pickActiveConversationForPrompt();
|
|
3495
4124
|
if (!activeConvId) {
|
|
3496
4125
|
logger.warn("Prompt candidate fired but no active conversation known", {
|
|
3497
4126
|
observedConvCount: this.observedMainConversationIds.size
|
|
3498
4127
|
});
|
|
4128
|
+
this.raiseSuppressedBadge(session, cand.snapshotHash);
|
|
3499
4129
|
return;
|
|
3500
4130
|
}
|
|
3501
|
-
this.
|
|
4131
|
+
const e1rec = this.newE1Raise(session.sessionId, true);
|
|
4132
|
+
const emitted = this.approvalDetector.emitPaneOnlyPrompt(parsed, activeConvId, e1rec.promptId);
|
|
4133
|
+
if (emitted) {
|
|
4134
|
+
if (cand.snapshotHash) {
|
|
4135
|
+
this.e1ContentKeyIndex.set(`${session.sessionId}::${cand.snapshotHash}`, e1rec.promptId);
|
|
4136
|
+
}
|
|
4137
|
+
} else {
|
|
4138
|
+
this.e1PromptRaises.delete(e1rec.promptId);
|
|
4139
|
+
}
|
|
3502
4140
|
}
|
|
3503
4141
|
/**
|
|
3504
4142
|
* Pick the agy conversation UUID that owns the current approval UI.
|
|
@@ -3528,6 +4166,9 @@ var McpServer = class {
|
|
|
3528
4166
|
});
|
|
3529
4167
|
return;
|
|
3530
4168
|
}
|
|
4169
|
+
const e1existing = this.e1PromptRaises.get(state.promptId);
|
|
4170
|
+
const e1rec = e1existing ? e1existing.record : this.newE1Raise(session.sessionId, true);
|
|
4171
|
+
if (!e1existing) state.promptId = e1rec.promptId;
|
|
3531
4172
|
const optionsArr = state.paneOptions ? state.paneOptions.map((o) => ({ number: o.number, text: o.text })) : Object.keys(state.submitMap).map((n) => ({ number: n }));
|
|
3532
4173
|
const diffParsed = parseFencedDiffFromBody(
|
|
3533
4174
|
state.body,
|
|
@@ -3567,6 +4208,9 @@ var McpServer = class {
|
|
|
3567
4208
|
source: import_codevibe_core4.EventSource.DESKTOP,
|
|
3568
4209
|
content,
|
|
3569
4210
|
metadata: optionsForMobile,
|
|
4211
|
+
// E1 (§4) — raise identity top-level: promptId + ownerToken/producerKind/
|
|
4212
|
+
// mobileActionable (the backend's classifyRaise upserts the open-prompt row).
|
|
4213
|
+
...(0, import_codevibe_core4.raiseFieldsFromRecord)(e1rec),
|
|
3570
4214
|
// Per-conversation monotonic timestamp (event-timestamp-ordering fix
|
|
3571
4215
|
// v5.4, 2026-05-24). orderingKey scopes the counter to this conv so
|
|
3572
4216
|
// INTERACTIVE_PROMPT can't be forced to lastMs+1 by a newer event
|
|
@@ -3580,12 +4224,19 @@ var McpServer = class {
|
|
|
3580
4224
|
const outbound = this.encryptOutbound(session, input);
|
|
3581
4225
|
if (!outbound) return;
|
|
3582
4226
|
await this.appSyncClient.createEvent(outbound);
|
|
4227
|
+
this.stashE1RaiseContent(
|
|
4228
|
+
e1rec.promptId,
|
|
4229
|
+
content,
|
|
4230
|
+
optionsForMobile
|
|
4231
|
+
);
|
|
3583
4232
|
} catch (err) {
|
|
3584
4233
|
logger.error("createEvent INTERACTIVE_PROMPT failed", {
|
|
3585
4234
|
promptId: state.promptId,
|
|
3586
4235
|
error: String(err)
|
|
3587
4236
|
});
|
|
3588
4237
|
this.approvalDetector.rollbackPrompt(state.promptId);
|
|
4238
|
+
if (state.paneHash) this.paneObserver.resetPromptHash(state.paneHash);
|
|
4239
|
+
this.e1PromptRaises.delete(state.promptId);
|
|
3589
4240
|
}
|
|
3590
4241
|
}
|
|
3591
4242
|
// ─── Mobile → desktop flow ──────────────────────────────────────────────
|
|
@@ -3734,8 +4385,63 @@ var McpServer = class {
|
|
|
3734
4385
|
});
|
|
3735
4386
|
}
|
|
3736
4387
|
}
|
|
4388
|
+
const pendingPrompts = this.approvalDetector.getPendingPromptsForConversation(
|
|
4389
|
+
session.conversationId
|
|
4390
|
+
);
|
|
4391
|
+
if (pendingPrompts.length === 0) {
|
|
4392
|
+
const probe = await this.paneObserver.probeApprovalUIActive();
|
|
4393
|
+
if (!probe.probeSucceeded) {
|
|
4394
|
+
await this.emitPromptSafetyNotification(
|
|
4395
|
+
session,
|
|
4396
|
+
"Your message was not sent because the desktop approval state could not be verified. Try again or resolve the prompt on the desktop."
|
|
4397
|
+
);
|
|
4398
|
+
return;
|
|
4399
|
+
}
|
|
4400
|
+
if (probe.active) {
|
|
4401
|
+
await this.emitPromptSafetyNotification(
|
|
4402
|
+
session,
|
|
4403
|
+
"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."
|
|
4404
|
+
);
|
|
4405
|
+
return;
|
|
4406
|
+
}
|
|
4407
|
+
}
|
|
4408
|
+
if (pendingPrompts.length > 0) {
|
|
4409
|
+
if (pendingPrompts.length !== 1) {
|
|
4410
|
+
await this.emitPromptSafetyNotification(
|
|
4411
|
+
session,
|
|
4412
|
+
"Your message was not sent because more than one desktop approval is pending. Resolve the approvals and send it again."
|
|
4413
|
+
);
|
|
4414
|
+
return;
|
|
4415
|
+
}
|
|
4416
|
+
const activePrompt = pendingPrompts[0];
|
|
4417
|
+
const rejectOption = activePrompt.paneOptions?.find(
|
|
4418
|
+
(option) => /^(?:no|deny|reject|cancel)\b/i.test(option.text.trim()) && (activePrompt.submitMap[option.number]?.length ?? 0) > 0
|
|
4419
|
+
);
|
|
4420
|
+
if (!rejectOption) {
|
|
4421
|
+
await this.emitPromptSafetyNotification(
|
|
4422
|
+
session,
|
|
4423
|
+
"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."
|
|
4424
|
+
);
|
|
4425
|
+
return;
|
|
4426
|
+
}
|
|
4427
|
+
this.mobileDeduper.track(session.sessionId, rejectOption.number);
|
|
4428
|
+
const rejectResult = await this.promptResponder.sendApprovalReply(
|
|
4429
|
+
activePrompt.promptId,
|
|
4430
|
+
rejectOption.number
|
|
4431
|
+
);
|
|
4432
|
+
if (!rejectResult.ok) {
|
|
4433
|
+
this.mobileDeduper.forget(session.sessionId, rejectOption.number);
|
|
4434
|
+
logger.warn("Reject-before-free-form failed", {
|
|
4435
|
+
promptId: activePrompt.promptId,
|
|
4436
|
+
reason: rejectResult.reason,
|
|
4437
|
+
details: rejectResult.details
|
|
4438
|
+
});
|
|
4439
|
+
return;
|
|
4440
|
+
}
|
|
4441
|
+
await new Promise((resolve3) => setTimeout(resolve3, 250));
|
|
4442
|
+
}
|
|
3737
4443
|
this.mobileDeduper.track(session.sessionId, promptContent);
|
|
3738
|
-
const result = await this.promptResponder.
|
|
4444
|
+
const result = await this.promptResponder.sendFreeFormWhenNoApprovalActive(promptContent);
|
|
3739
4445
|
if (!result.ok) {
|
|
3740
4446
|
this.mobileDeduper.forget(session.sessionId, promptContent);
|
|
3741
4447
|
logger.warn("sendFreeForm failed", { reason: result.reason, details: result.details });
|
|
@@ -3770,6 +4476,23 @@ var McpServer = class {
|
|
|
3770
4476
|
await this.markExecuted(evt);
|
|
3771
4477
|
}
|
|
3772
4478
|
}
|
|
4479
|
+
async emitPromptSafetyNotification(session, content) {
|
|
4480
|
+
const input = {
|
|
4481
|
+
sessionId: session.sessionId,
|
|
4482
|
+
type: import_codevibe_core4.EventType.NOTIFICATION,
|
|
4483
|
+
source: import_codevibe_core4.EventSource.DESKTOP,
|
|
4484
|
+
content,
|
|
4485
|
+
metadata: { promptSafetyBlocked: true },
|
|
4486
|
+
timestamp: (0, import_codevibe_core4.prepareEventTimestamp)({ orderingKey: session.sessionId })
|
|
4487
|
+
};
|
|
4488
|
+
const outbound = this.encryptOutbound(session, input);
|
|
4489
|
+
if (!outbound) return;
|
|
4490
|
+
try {
|
|
4491
|
+
await this.appSyncClient.createEvent(outbound);
|
|
4492
|
+
} catch (error) {
|
|
4493
|
+
logger.warn("Failed to emit prompt-safety notification", { error: String(error) });
|
|
4494
|
+
}
|
|
4495
|
+
}
|
|
3773
4496
|
/**
|
|
3774
4497
|
* Transition a mobile event's deliveryStatus to DELIVERED. Called as
|
|
3775
4498
|
* soon as the plugin receives + decrypts the event and BEFORE the
|
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.11",
|
|
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",
|