@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.
Files changed (2) hide show
  1. package/dist/server.js +772 -49
  2. 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 (lastApprovalLineIdx >= 0 && lastQuestionLineIdx >= 0) break;
1462
+ if (lastFileAccessLineIdx < 0 && fileAccessRe.test(lines[i])) {
1463
+ lastFileAccessLineIdx = i;
1464
+ }
1465
+ if (lastApprovalLineIdx >= 0 && lastQuestionLineIdx >= 0 && lastFileAccessLineIdx >= 0) break;
1421
1466
  }
1422
- if (lastApprovalLineIdx < 0 && lastQuestionLineIdx < 0) return null;
1423
- if (lastQuestionLineIdx > lastApprovalLineIdx) return "question";
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
- paneOptions: candidate.options
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
- const probe = await this.paneObserver.probeApprovalUIActive();
2214
- if (!probe.active) {
2215
- this.detector.resolvePrompt(promptId);
2306
+ if (!state.paneSemanticKey) {
2216
2307
  return {
2217
2308
  ok: false,
2218
- reason: "prompt-superseded",
2219
- details: "approval UI vanished from pane (likely user approved on desktop)"
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
- if (!this.session) return;
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) return;
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.approvalDetector.emitPaneOnlyPrompt(parsed, activeConvId);
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.sendFreeForm(promptContent);
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.9",
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.9",
51
+ "@quantiya/codevibe-core": "2.0.10",
52
52
  "chokidar": "^5.0.0",
53
53
  "dotenv": "^16.6.1",
54
54
  "express": "^5.1.0",