@quantiya/codevibe-antigravity-plugin 2.0.9 → 2.0.10

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 +516 -8
  2. package/package.json +1 -1
package/dist/server.js CHANGED
@@ -1251,6 +1251,20 @@ var TmuxPaneObserver = class _TmuxPaneObserver extends import_events2.EventEmitt
1251
1251
  this.currentApprovalHeader = extractHeader(candidate.snapshot);
1252
1252
  this.emit("prompt-candidate", candidate);
1253
1253
  }
1254
+ /**
1255
+ * E1 (§6a-2b / F5) — clear the dedup `lastPromptHash` IF it still equals `hash`,
1256
+ * so an UNCHANGED live pane re-emits a fresh 'prompt-candidate'. Called by the
1257
+ * server when an emit-failure rollback discarded the durable record: without this
1258
+ * the observer's `lastPromptHash` still matches the unchanged pane, so
1259
+ * processFileChanges short-circuits (the `snapshotHash === this.lastPromptHash`
1260
+ * guard) and the live prompt is never re-observed = under-nag. The equality guard
1261
+ * targets the EXACT consumed hash so a newer prompt's hash is never clobbered.
1262
+ */
1263
+ resetPromptHash(hash) {
1264
+ if (this.lastPromptHash === hash) {
1265
+ this.lastPromptHash = null;
1266
+ }
1267
+ }
1254
1268
  /** Cancel any in-flight debounce timer + drop the pending candidate.
1255
1269
  * Called from stop() so a wrapper exit / restart doesn't fire a
1256
1270
  * prompt for a torn-down observer. Also bumps debounceCycleId so
@@ -1738,14 +1752,14 @@ var ApprovalDetector = class extends import_events3.EventEmitter {
1738
1752
  * Returns the emitted state, or null if dedupe (already emitted for
1739
1753
  * this paneHash) or disabled.
1740
1754
  */
1741
- emitPaneOnlyPrompt(candidate, conversationId) {
1755
+ emitPaneOnlyPrompt(candidate, conversationId, forcedPromptId) {
1742
1756
  if (this.paneOnlyEmittedHashes.has(candidate.paneHash)) {
1743
1757
  logger.debug("Skipping pane-only re-emit for duplicate paneHash", {
1744
1758
  paneHash: candidate.paneHash.substring(0, 16)
1745
1759
  });
1746
1760
  return null;
1747
1761
  }
1748
- const promptId = (0, import_uuid.v4)();
1762
+ const promptId = forcedPromptId ?? (0, import_uuid.v4)();
1749
1763
  const syntheticCall = {
1750
1764
  conversationId,
1751
1765
  intentStepIndex: -1,
@@ -1773,7 +1787,11 @@ var ApprovalDetector = class extends import_events3.EventEmitter {
1773
1787
  body: candidate.body,
1774
1788
  emittedAt: Date.now(),
1775
1789
  ttlMs: this.promptTtlMs,
1776
- paneOptions: candidate.options
1790
+ paneOptions: candidate.options,
1791
+ // E1 (§6a-2b / F5) — carry the consumed pane hash so an emit-failure
1792
+ // rollback can reset the observer's lastPromptHash and let the unchanged
1793
+ // live pane re-emit.
1794
+ paneHash: candidate.paneHash
1777
1795
  };
1778
1796
  this.pendingPrompts.set(promptId, state);
1779
1797
  this.paneOnlyEmittedHashes.add(candidate.paneHash);
@@ -2816,7 +2834,7 @@ function truncate(s, maxBytes) {
2816
2834
  // src/server.ts
2817
2835
  var MOBILE_PROMPT_FLOOR_RECENCY_MS = 12e4;
2818
2836
  var LAUNCH_SETTLE_TIMEOUT_MS = 3e3;
2819
- var McpServer = class {
2837
+ var McpServer = class _McpServer {
2820
2838
  constructor(options) {
2821
2839
  /** Per-session causal floor for transcript-event timestamps emitted right
2822
2840
  * after a MOBILE prompt. The mobile USER_PROMPT is created by the iOS app
@@ -2833,6 +2851,59 @@ var McpServer = class {
2833
2851
  * under this single sessionId. /resume does NOT create a new
2834
2852
  * session — same row. */
2835
2853
  this.session = null;
2854
+ // E1 (§4/§6a-4) — per-promptId durable raise entries for the agy producer.
2855
+ // Keyed by the E1 promptId (= derive(ownerToken)); each holds the SECRET
2856
+ // ownerToken so a resume/retirement re-raise re-commits the SAME promptId
2857
+ // (WRITE_ROW on the ownerToken=:token branch → no duplicate row, no under-nag).
2858
+ // This in-memory Map is the AUTHORITATIVE E1 ledger. It outlives the detector's
2859
+ // 60s/5min timer (§3.1), so the TTL-refresh, wake, and retirement re-raises
2860
+ // always cover every still-open prompt this daemon raised. Process-restart
2861
+ // recovery is OUT of v1 scope (F1): a daemon crash is handled by disconnect-
2862
+ // suppression + per-row TTL (design §8), and agy re-observes a live pane on
2863
+ // restart — so there is NO disk persistence (removing it also stops writing the
2864
+ // secret ownerToken to a tmp file). agy is single-session, so sessionId is the
2865
+ // one live session's id.
2866
+ this.e1PromptRaises = /* @__PURE__ */ new Map();
2867
+ // E1 (§6a-2b) — session-scoped content-key → promptId index for the
2868
+ // suppressed-prompt COLLAPSE rule. Key = `${sessionId}::${contentKey}`. A
2869
+ // suppression whose content-key already maps to a LIVE ledger record collapses
2870
+ // onto it (over-nag, never under-nag); an ABSENT content-key mints a fresh badge.
2871
+ this.e1ContentKeyIndex = /* @__PURE__ */ new Map();
2872
+ // E1 (§3.1) — periodic liveness TTL co-refresh for the open-prompt ledger.
2873
+ this.e1TtlRefreshTimer = null;
2874
+ // 5 min (7-day TTL)
2875
+ // E1 (§6a-4) — single-flight guard for the retirement recovery re-raise.
2876
+ this.retiredSessionRecoveryPromise = null;
2877
+ // E1 (§6a-4 / F3) — promptIds whose re-raise (retirement re-point or return-
2878
+ // driven full re-raise) exhausted reRaiseOneE1's bounded per-call attempts and
2879
+ // must keep being retried until the backend acknowledges ("the prompt is never
2880
+ // lost"). drainPendingE1ReRaises re-attempts each still-present entry every TTL
2881
+ // tick; reRaiseOneE1 clears an id on ack and re-flags it on another exhaustion.
2882
+ // Without this a failed retirement re-raise would leave the entry present so
2883
+ // refreshE1Ttls finds it → refreshes → returns no aged-out → never re-fires,
2884
+ // leaving the backend row permanently pointed at the RETIRED session = under-nag.
2885
+ this.e1NeedsReRaise = /* @__PURE__ */ new Set();
2886
+ // E1 (§6a-4 / F2) — bounded exponential backoff for the BLOCK-UNTIL-ACK
2887
+ // retirement re-raise (the pre-heartbeat drain in createLaunchSession, which must
2888
+ // ACK before the replacement's heartbeat advertises liveness — design line 93).
2889
+ // The general (bounded, non-blocking) TTL-drain path keeps its shipped linear
2890
+ // 250·attempt backoff. Overridable in tests so a multi-failure block-until-ack
2891
+ // re-raise doesn't wait real seconds.
2892
+ this.e1BlockingReRaiseBackoff = { baseMs: 250, maxMs: 2e3 };
2893
+ // E1 (§6a-4) — DURABLE park for retirement-orphans awaiting re-home (G-1 fix).
2894
+ // When the backend retires the live session, its still-open prompts are
2895
+ // snapshotted here (FULL records — incl. the secret ownerToken + pre-encryption
2896
+ // content) *before* the replacement-launch attempt, so a transient launch failure
2897
+ // can NOT lose them (the pre-fix snapshot was a LOCAL closure var re-raised only
2898
+ // inside the replacement's pre-heartbeat hook — if that launch failed before the
2899
+ // hook ran, the snapshot was lost and the ledger rows stayed pointed at the
2900
+ // RETIRED session = stranded badgeless live prompt). Drained + re-homed onto the
2901
+ // NEXT successfully-launched session (in createLaunchSession, BEFORE its heartbeat
2902
+ // — §F2) and, as a backstop, on every TTL tick — so a retirement whose OWN
2903
+ // replacement launch failed is still recovered by a later normal activity launch.
2904
+ // Keyed by promptId so re-parks dedupe. In-memory only (process-restart recovery
2905
+ // is out of v1 scope, F1); cleared on a full doStop.
2906
+ this.e1RetirementPark = /* @__PURE__ */ new Map();
2836
2907
  /** Single subscription for the launch session (v9 — was per-conv Map). */
2837
2908
  this.subscription = null;
2838
2909
  /** Wrapper-level disable flag (free-tier limit / collision). v9
@@ -2935,6 +3006,9 @@ var McpServer = class {
2935
3006
  getActiveSession: () => this.getAnySession()
2936
3007
  });
2937
3008
  }
3009
+ static {
3010
+ this.E1_TTL_REFRESH_MS = 5 * 60 * 1e3;
3011
+ }
2938
3012
  // ─── Lifecycle ──────────────────────────────────────────────────────────
2939
3013
  async start() {
2940
3014
  if (this.started) throw new Error("McpServer.start() called twice");
@@ -3126,6 +3200,8 @@ var McpServer = class {
3126
3200
  logger.warn("appSyncClient.cleanupSubscriptions failed", { error: String(err) });
3127
3201
  }
3128
3202
  this.session = null;
3203
+ this.stopE1TtlRefresh();
3204
+ this.e1RetirementPark.clear();
3129
3205
  this.sessionDisabled = false;
3130
3206
  this.launchKeyUnavailable = false;
3131
3207
  this.ensureLaunchInFlight = null;
@@ -3185,6 +3261,16 @@ var McpServer = class {
3185
3261
  * events under this single sessionId. /resume does NOT create a new
3186
3262
  * row. (Codex Stage 2 R1 v5/v6/v7 switch machinery is now dead code,
3187
3263
  * removed.)
3264
+ *
3265
+ * E1 (§6a-4 / F2) — AFTER the session + subscription are wired but BEFORE the
3266
+ * heartbeat advertises liveness, this drains the durable retirement park onto
3267
+ * THIS session (see {@link drainE1RetirementPark}). So the open prompts of a
3268
+ * retired session are re-pointed onto their replacement (backend-ACK'd) before the
3269
+ * first `lastHeartbeatAt` write — otherwise the client would see a connected
3270
+ * session whose rows still point at the RETIRED session = under-nag (design line
3271
+ * 93). Running on EVERY successful launch (not just the retirement-recovery
3272
+ * re-bootstrap) is what lets a later normal activity launch recover a retirement
3273
+ * whose own replacement launch had failed transiently (G-1).
3188
3274
  */
3189
3275
  async createLaunchSession() {
3190
3276
  const gen = this.lifecycleGen;
@@ -3268,7 +3354,9 @@ var McpServer = class {
3268
3354
  logger.error("handleMobileEvent failed", { error: String(err) });
3269
3355
  });
3270
3356
  },
3271
- (err) => logger.warn("AppSync subscription error", { error: String(err) })
3357
+ (err) => logger.warn("AppSync subscription error", { error: String(err) }),
3358
+ // E1 (§6a-4) — recover open prompts if the backend retires this session.
3359
+ { onSessionRetired: () => this.recoverRetiredBackendSession(sessionId) }
3272
3360
  );
3273
3361
  if (gen !== this.lifecycleGen || !this.started) {
3274
3362
  try {
@@ -3282,6 +3370,8 @@ var McpServer = class {
3282
3370
  } catch (err) {
3283
3371
  logger.error("subscribeToEvents failed (non-fatal)", { sessionId, error: String(err) });
3284
3372
  }
3373
+ await this.drainE1RetirementPark({ retryUntilAck: true, gen });
3374
+ if (gen !== this.lifecycleGen || !this.started) return;
3285
3375
  try {
3286
3376
  this.appSyncClient.startHeartbeat(sessionId);
3287
3377
  } catch (err) {
@@ -3444,6 +3534,395 @@ var McpServer = class {
3444
3534
  isEncrypted: true
3445
3535
  };
3446
3536
  }
3537
+ // ─── E1 missed-prompt recovery (§4/§6a) — in-memory ledger + raise helpers ───
3538
+ //
3539
+ // The authoritative ledger is the in-memory `e1PromptRaises` Map (see its field
3540
+ // docstring): it outlives the detector's timer, which is all §6a-4 durability
3541
+ // requires. There is deliberately NO disk persistence — process-restart recovery
3542
+ // is out of v1 scope (F1), and persisting the secret ownerToken to a tmp file was
3543
+ // write-only dead code (never read back on startup).
3544
+ /**
3545
+ * Mint a NEW E1 raise for the agy producer: derive a committed promptId from a
3546
+ * fresh secret, stash a durable entry (identity only — content added at
3547
+ * emit-success via {@link stashE1RaiseContent}), start the TTL timer, and return
3548
+ * the record. `mobileActionable=false` for a suppressed prompt (badge-only
3549
+ * NOTIFICATION carrier, no fabricated options).
3550
+ */
3551
+ newE1Raise(sessionId, mobileActionable, title) {
3552
+ const { record } = (0, import_codevibe_core4.newPromptRaise)({
3553
+ sessionId,
3554
+ producerKind: "PLUGIN_APPROVAL",
3555
+ mobileActionable,
3556
+ agentType: "ANTIGRAVITY",
3557
+ ...title !== void 0 && { title }
3558
+ });
3559
+ this.e1PromptRaises.set(record.promptId, { record });
3560
+ if (!this.e1TtlRefreshTimer) this.startE1TtlRefresh();
3561
+ return record;
3562
+ }
3563
+ /**
3564
+ * E1 (§6a-4) — stash the PRE-ENCRYPTION payload on an already-minted raise entry
3565
+ * at emit-success, so a retirement re-raise can re-encrypt it for the
3566
+ * (replacement) session's key and re-emit the SAME actionable prompt. No-op if
3567
+ * the entry is gone (superseded) or the emit aborted.
3568
+ */
3569
+ stashE1RaiseContent(promptId, contentPlain, metadataPlain) {
3570
+ const entry = this.e1PromptRaises.get(promptId);
3571
+ if (!entry) return;
3572
+ entry.contentPlain = contentPlain;
3573
+ entry.metadataPlain = metadataPlain;
3574
+ }
3575
+ /**
3576
+ * E1 (§6a-2b) — emit the badge-only carrier for a SUPPRESSED prompt (options
3577
+ * couldn't be parsed → mobile must NOT fabricate any). A NOTIFICATION carrying
3578
+ * the raise identity with mobileActionable=false, so the backend's classifyRaise
3579
+ * records/updates the open-prompt row: mobile shows a banner + NO option buttons.
3580
+ * Content is E2E-encrypted; the raise identity rides plaintext top-level.
3581
+ * Fail-closed on a missing session key — never cleartext.
3582
+ */
3583
+ async emitSuppressedPromptCarrier(session, record) {
3584
+ 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.";
3585
+ const input = {
3586
+ sessionId: session.sessionId,
3587
+ type: import_codevibe_core4.EventType.NOTIFICATION,
3588
+ source: import_codevibe_core4.EventSource.DESKTOP,
3589
+ content: bannerText,
3590
+ metadata: { e1SuppressedPrompt: true },
3591
+ ...(0, import_codevibe_core4.raiseFieldsFromRecord)(record),
3592
+ notificationText: bannerText,
3593
+ timestamp: (0, import_codevibe_core4.prepareEventTimestamp)({ orderingKey: session.sessionId })
3594
+ };
3595
+ const outbound = this.encryptOutbound(session, input);
3596
+ if (!outbound) return false;
3597
+ await this.appSyncClient.createEvent(outbound);
3598
+ logger.info("E1: emitted suppressed-prompt badge carrier (mobileActionable=false)", {
3599
+ sessionId: session.sessionId,
3600
+ promptId: record.promptId
3601
+ });
3602
+ return true;
3603
+ }
3604
+ /**
3605
+ * E1 (§6a-2b) — badge-only raise for a SUPPRESSED prompt, applying the content-key
3606
+ * COLLAPSE rule. If a LIVE ledger record already covers this content-key in this
3607
+ * session → COLLAPSE (its row keeps the session badge lit; over-nag, never
3608
+ * under-nag). Otherwise mint a fresh mobileActionable:false raise + emit the
3609
+ * NOTIFICATION carrier. A null content-key always mints (can't prove a
3610
+ * re-observation) — never under-nag.
3611
+ */
3612
+ raiseSuppressedBadge(session, contentKey) {
3613
+ if (contentKey) {
3614
+ const idxKey = `${session.sessionId}::${contentKey}`;
3615
+ const existingId = this.e1ContentKeyIndex.get(idxKey);
3616
+ if (existingId && this.e1PromptRaises.has(existingId)) return;
3617
+ if (existingId) this.e1ContentKeyIndex.delete(idxKey);
3618
+ }
3619
+ const record = this.newE1Raise(session.sessionId, false);
3620
+ if (contentKey) this.e1ContentKeyIndex.set(`${session.sessionId}::${contentKey}`, record.promptId);
3621
+ this.emitSuppressedPromptCarrier(session, record).catch((e) => {
3622
+ logger.error("E1: failed to emit suppressed-prompt badge carrier", {
3623
+ sessionId: session.sessionId,
3624
+ promptId: record.promptId,
3625
+ error: e instanceof Error ? e.message : String(e)
3626
+ });
3627
+ });
3628
+ }
3629
+ /** Snapshot the still-open E1 entries for a session (deep copy of the record). */
3630
+ snapshotOpenE1(sessionId) {
3631
+ return [...this.e1PromptRaises.values()].filter((e) => e.record.sessionId === sessionId).map((e) => ({
3632
+ record: { ...e.record },
3633
+ ...e.contentPlain !== void 0 && { contentPlain: e.contentPlain },
3634
+ ...e.metadataPlain !== void 0 && { metadataPlain: e.metadataPlain }
3635
+ }));
3636
+ }
3637
+ /**
3638
+ * E1 (§6a-4) — re-raise a snapshot of still-open prompts from a RETIRED session
3639
+ * onto its live REPLACEMENT session, re-committing the SAME promptId + ownerToken
3640
+ * (the backend re-points the row's sessionId → the replacement; no orphan, no
3641
+ * under-nag). Actionable → INTERACTIVE_PROMPT (re-encrypted real options);
3642
+ * badge-only / content-less → NOTIFICATION carrier. Retries transient failures.
3643
+ */
3644
+ async reRaiseSnapshotE1(snapshot, replacementSession, opts) {
3645
+ if (snapshot.length === 0) return [];
3646
+ logger.info("E1: re-raising open prompts onto replacement session", {
3647
+ replacementSessionId: replacementSession.sessionId,
3648
+ count: snapshot.length
3649
+ });
3650
+ const unacked = [];
3651
+ for (const snap of snapshot) {
3652
+ const entry = {
3653
+ record: { ...snap.record, sessionId: replacementSession.sessionId },
3654
+ ...snap.contentPlain !== void 0 && { contentPlain: snap.contentPlain },
3655
+ ...snap.metadataPlain !== void 0 && { metadataPlain: snap.metadataPlain }
3656
+ };
3657
+ this.e1PromptRaises.set(entry.record.promptId, entry);
3658
+ const acked = await this.reRaiseOneE1(replacementSession, entry, opts);
3659
+ if (!acked) unacked.push(snap);
3660
+ }
3661
+ if (!this.e1TtlRefreshTimer) this.startE1TtlRefresh();
3662
+ return unacked;
3663
+ }
3664
+ /**
3665
+ * E1 (§6a-4) — drain the DURABLE retirement park onto the current live session:
3666
+ * re-home each parked orphan (record.sessionId → this session) and re-raise it
3667
+ * ({@link reRaiseSnapshotE1} → {@link reRaiseOneE1}, retry-until-ack via
3668
+ * `e1NeedsReRaise`). This is the second half of the G-1 fix: the park survives a
3669
+ * failed replacement launch, so ANY later successful launch (or TTL tick) re-homes
3670
+ * the orphans that the pre-fix closure snapshot would have lost. agy is
3671
+ * single-session, so the re-home target is unambiguously `this.session`.
3672
+ *
3673
+ * The park entries are moved into the authoritative `e1PromptRaises` ledger by
3674
+ * reRaiseSnapshotE1 (which stamps the new sessionId), so the park is CLEARED up
3675
+ * front — the snapshot + clear are synchronous (no yield) so a concurrent drain
3676
+ * can't double-process, and once handed off the ledger + retry-until-ack own the
3677
+ * entries. No-op when the park is empty or no live session exists yet (retry on
3678
+ * the next launch / tick — never lost).
3679
+ */
3680
+ async drainE1RetirementPark(opts) {
3681
+ if (this.e1RetirementPark.size === 0) return;
3682
+ const session = this.session;
3683
+ if (!session) return;
3684
+ const parked = [...this.e1RetirementPark.values()];
3685
+ this.e1RetirementPark.clear();
3686
+ const unacked = await this.reRaiseSnapshotE1(parked, session, opts);
3687
+ if (unacked.length > 0) {
3688
+ for (const snap of unacked) this.e1RetirementPark.set(snap.record.promptId, snap);
3689
+ logger.info("E1: retirement park drain aborted before ACK \u2014 re-parked for next launch", {
3690
+ sessionId: session.sessionId,
3691
+ retained: unacked.length
3692
+ });
3693
+ }
3694
+ }
3695
+ /**
3696
+ * E1 (§6a-4 / F2) — lifecycle-supersede predicate for the BLOCK-UNTIL-ACK
3697
+ * retirement re-raise. True once the launch generation that started the drain has
3698
+ * been superseded (`gen` mismatch — a stop()→start() ran) OR the daemon has
3699
+ * stopped (`started` is false — a doStop() is in flight). A stopped daemon returns
3700
+ * true even without a `gen`, so the block-until-ack loop can never hang past a
3701
+ * stop. Only the F2 path consults this; the bounded general path ignores it.
3702
+ */
3703
+ reRaiseSuperseded(gen) {
3704
+ if (!this.started) return true;
3705
+ return gen !== void 0 && gen !== this.lifecycleGen;
3706
+ }
3707
+ /**
3708
+ * E1 (§6a-4 / F2) — sleep up to `ms`, waking ~every 50ms to re-check
3709
+ * {@link reRaiseSuperseded} so a stop()/stop()→start() breaks a block-until-ack
3710
+ * backoff PROMPTLY (within ~50ms) rather than after a full backoff interval.
3711
+ * Returns immediately once superseded/stopped.
3712
+ */
3713
+ async interruptibleBackoff(ms, gen) {
3714
+ const deadline = Date.now() + ms;
3715
+ while (Date.now() < deadline) {
3716
+ if (this.reRaiseSuperseded(gen)) return;
3717
+ const slice = Math.min(50, deadline - Date.now());
3718
+ if (slice <= 0) return;
3719
+ await new Promise((resolve3) => setTimeout(resolve3, slice));
3720
+ }
3721
+ }
3722
+ /**
3723
+ * Re-emit ONE E1 entry on `session`, retrying transient failures (§6.8).
3724
+ *
3725
+ * Two modes:
3726
+ * - BOUNDED general path (default): a per-call backoff over `MAX_ATTEMPTS`
3727
+ * (linear 250·attempt); on exhaustion it FLAGS the promptId in `e1NeedsReRaise`
3728
+ * (entry retained, F3) so drainPendingE1ReRaises keeps retrying every TTL tick
3729
+ * until the backend acknowledges — "the prompt is never lost" (§6a-4).
3730
+ * - BLOCK-UNTIL-ACK path (`opts.retryUntilAck`, F2 — the pre-heartbeat
3731
+ * retirement drain in createLaunchSession): retries with a bounded EXPONENTIAL
3732
+ * backoff and does NOT give up after `MAX_ATTEMPTS`, so the caller (and thus the
3733
+ * replacement session's heartbeat) waits until the row is re-pointed and
3734
+ * backend-ACK'd (design line 93 — re-point BEFORE advertising liveness). The
3735
+ * ONLY exit other than ACK is LIFECYCLE-SUPERSEDE ({@link reRaiseSuperseded}
3736
+ * on the captured `opts.gen` / `started`), which flags e1NeedsReRaise (entry
3737
+ * retained → the general net repairs it) and bails so a superseded/stopped
3738
+ * lifecycle never advertises liveness for a mis-pointed row. Bounded against a
3739
+ * hang because the re-raise createEvent and the heartbeat hit the SAME backend:
3740
+ * a down backend fails both (no false-connected), an up backend ACKs quickly.
3741
+ *
3742
+ * On ACK it clears the promptId from `e1NeedsReRaise`. A keyless fail-closed return
3743
+ * leaves the flag as-is so a later tick re-attempts once a key resolves.
3744
+ */
3745
+ async reRaiseOneE1(session, entry, opts) {
3746
+ const rec = entry.record;
3747
+ const actionable = rec.mobileActionable && entry.contentPlain !== void 0;
3748
+ const retryUntilAck = opts?.retryUntilAck === true;
3749
+ const gen = opts?.gen;
3750
+ const MAX_ATTEMPTS = 4;
3751
+ for (let attempt = 1; ; attempt++) {
3752
+ if (retryUntilAck && this.reRaiseSuperseded(gen)) {
3753
+ this.e1NeedsReRaise.add(rec.promptId);
3754
+ logger.warn("E1: block-until-ack re-raise aborted (lifecycle superseded/stopping) \u2014 flagged for retry-until-ack (entry retained)", {
3755
+ sessionId: session.sessionId,
3756
+ promptId: rec.promptId
3757
+ });
3758
+ return false;
3759
+ }
3760
+ try {
3761
+ if (actionable) {
3762
+ const input = {
3763
+ sessionId: session.sessionId,
3764
+ type: import_codevibe_core4.EventType.INTERACTIVE_PROMPT,
3765
+ source: import_codevibe_core4.EventSource.DESKTOP,
3766
+ content: entry.contentPlain,
3767
+ metadata: entry.metadataPlain ?? {},
3768
+ ...(0, import_codevibe_core4.raiseFieldsFromRecord)(rec),
3769
+ timestamp: (0, import_codevibe_core4.prepareEventTimestamp)({ orderingKey: session.sessionId })
3770
+ };
3771
+ const outbound = this.encryptOutbound(session, input);
3772
+ if (!outbound) return false;
3773
+ await this.appSyncClient.createEvent(outbound);
3774
+ } else {
3775
+ rec.mobileActionable = false;
3776
+ if (!await this.emitSuppressedPromptCarrier(session, rec)) return false;
3777
+ }
3778
+ this.e1NeedsReRaise.delete(rec.promptId);
3779
+ logger.info("E1: re-raised prompt onto replacement session", {
3780
+ sessionId: session.sessionId,
3781
+ promptId: rec.promptId,
3782
+ actionable
3783
+ });
3784
+ return true;
3785
+ } catch (e) {
3786
+ if (!retryUntilAck && attempt >= MAX_ATTEMPTS) {
3787
+ this.e1NeedsReRaise.add(rec.promptId);
3788
+ logger.error("E1: re-raise failed after bounded attempts \u2014 flagged for retry-until-ack (entry retained)", {
3789
+ sessionId: session.sessionId,
3790
+ promptId: rec.promptId,
3791
+ error: e instanceof Error ? e.message : String(e)
3792
+ });
3793
+ return false;
3794
+ }
3795
+ if (retryUntilAck) {
3796
+ const delay2 = Math.min(
3797
+ this.e1BlockingReRaiseBackoff.baseMs * 2 ** (attempt - 1),
3798
+ this.e1BlockingReRaiseBackoff.maxMs
3799
+ );
3800
+ await this.interruptibleBackoff(delay2, gen);
3801
+ } else {
3802
+ await new Promise((resolve3) => setTimeout(resolve3, 250 * attempt));
3803
+ }
3804
+ }
3805
+ }
3806
+ }
3807
+ /**
3808
+ * E1 (§6a-4 / F3) — retry-until-ack driver. Re-attempts reRaiseOneE1 for each
3809
+ * promptId still flagged in `e1NeedsReRaise` whose ledger entry is present on the
3810
+ * live session; reRaiseOneE1 clears the flag on ACK or re-flags it on another
3811
+ * exhaustion. A flagged promptId whose ledger entry is gone (superseded/resolved)
3812
+ * is dropped. Called at the top of every refreshE1Ttls tick so a failed
3813
+ * retirement re-point is repaired even though the entry is present (so the row-
3814
+ * exists refresh path never returns it as aged-out).
3815
+ */
3816
+ async drainPendingE1ReRaises() {
3817
+ if (this.e1NeedsReRaise.size === 0) return;
3818
+ const session = this.session;
3819
+ for (const promptId of [...this.e1NeedsReRaise]) {
3820
+ const entry = this.e1PromptRaises.get(promptId);
3821
+ if (!entry) {
3822
+ this.e1NeedsReRaise.delete(promptId);
3823
+ continue;
3824
+ }
3825
+ if (!session || entry.record.sessionId !== session.sessionId) continue;
3826
+ await this.reRaiseOneE1(session, entry);
3827
+ }
3828
+ }
3829
+ /**
3830
+ * E1 (§3.1) — one TTL-refresh tick for the current session's open prompts so a
3831
+ * long-open prompt is never TTL-deleted (under-nag). Aged-out promptIds are
3832
+ * re-materialized via reRaiseOneE1 (return-driven re-raise).
3833
+ */
3834
+ async refreshE1Ttls() {
3835
+ await this.drainPendingE1ReRaises();
3836
+ await this.drainE1RetirementPark();
3837
+ const session = this.session;
3838
+ if (!session) return;
3839
+ const sid = session.sessionId;
3840
+ const promptIds = [...this.e1PromptRaises.values()].filter((e) => e.record.sessionId === sid).map((e) => e.record.promptId);
3841
+ if (promptIds.length === 0) return;
3842
+ try {
3843
+ const agedOut = await this.appSyncClient.refreshOpenPromptTtl(promptIds);
3844
+ for (const promptId of agedOut) {
3845
+ const entry = this.e1PromptRaises.get(promptId);
3846
+ if (entry && this.session && entry.record.sessionId === this.session.sessionId) {
3847
+ await this.reRaiseOneE1(this.session, entry);
3848
+ }
3849
+ }
3850
+ } catch (e) {
3851
+ logger.warn("E1: TTL-refresh tick failed (non-fatal, retries next tick)", {
3852
+ error: e instanceof Error ? e.message : String(e)
3853
+ });
3854
+ }
3855
+ }
3856
+ /** Start the E1 TTL-refresh timer (idempotent — never stacks). */
3857
+ startE1TtlRefresh() {
3858
+ if (this.e1TtlRefreshTimer) clearInterval(this.e1TtlRefreshTimer);
3859
+ this.e1TtlRefreshTimer = setInterval(() => {
3860
+ void this.refreshE1Ttls();
3861
+ }, _McpServer.E1_TTL_REFRESH_MS);
3862
+ }
3863
+ /** Stop the E1 TTL-refresh timer. */
3864
+ stopE1TtlRefresh() {
3865
+ if (this.e1TtlRefreshTimer) {
3866
+ clearInterval(this.e1TtlRefreshTimer);
3867
+ this.e1TtlRefreshTimer = null;
3868
+ }
3869
+ }
3870
+ /**
3871
+ * E1 (§6a-4) — recover from a backend session RETIREMENT (agy had no such path;
3872
+ * built for E1). The backend retired the live session (RETIRED_SESSION_CANNOT_
3873
+ * REACTIVATE); its open prompts would orphan. Recovery: snapshot the open E1
3874
+ * prompts and PARK them durably (so a transient replacement-launch failure can
3875
+ * NOT lose them — G-1), PARTIALLY tear down the retired session (drop the
3876
+ * singleton + its subscription + heartbeat, but NOT the INACTIVE write — it's
3877
+ * already retired — and keep the daemon + observers running), then re-bootstrap a
3878
+ * FRESH replacement session via ensureLaunchSession (generateLaunchSessionId mints
3879
+ * a unique id, so this never reactivates the retired one). createLaunchSession
3880
+ * drains the park onto the replacement (same promptId+ownerToken → the backend
3881
+ * re-points the row's sessionId; no orphan) BEFORE its heartbeat. If THIS launch
3882
+ * fails, the park is retained and the NEXT successful launch / TTL tick re-homes
3883
+ * it. Single-flight; a stale callback (session already replaced) is a no-op.
3884
+ */
3885
+ recoverRetiredBackendSession(expectedSessionId) {
3886
+ if (this.retiredSessionRecoveryPromise) return this.retiredSessionRecoveryPromise;
3887
+ if (!this.started) return Promise.resolve();
3888
+ const recovery = (async () => {
3889
+ const old = this.session;
3890
+ if (!old || old.sessionId !== expectedSessionId) return;
3891
+ logger.warn("Backend retired live agy session; creating replacement", {
3892
+ sessionId: expectedSessionId
3893
+ });
3894
+ const openE1Snapshot = this.snapshotOpenE1(expectedSessionId);
3895
+ for (const snap of openE1Snapshot) {
3896
+ this.e1RetirementPark.set(snap.record.promptId, snap);
3897
+ this.e1PromptRaises.delete(snap.record.promptId);
3898
+ }
3899
+ try {
3900
+ this.appSyncClient.stopHeartbeat(old.sessionId);
3901
+ } catch {
3902
+ }
3903
+ if (this.subscription) {
3904
+ try {
3905
+ this.subscription();
3906
+ } catch {
3907
+ }
3908
+ this.subscription = null;
3909
+ }
3910
+ this.session = null;
3911
+ await this.ensureLaunchSession();
3912
+ if (this.session) await this.drainE1RetirementPark();
3913
+ if (!this.session) {
3914
+ logger.error("agy retirement recovery: replacement session not established; open prompts PARKED for the next successful launch / TTL tick", {
3915
+ oldSessionId: expectedSessionId,
3916
+ parked: this.e1RetirementPark.size
3917
+ });
3918
+ return;
3919
+ }
3920
+ })();
3921
+ this.retiredSessionRecoveryPromise = recovery.finally(() => {
3922
+ this.retiredSessionRecoveryPromise = null;
3923
+ });
3924
+ return this.retiredSessionRecoveryPromise;
3925
+ }
3447
3926
  /**
3448
3927
  * If the inbound mobile event is marked isEncrypted, decrypt content +
3449
3928
  * metadata in place. Returns a new shallow-copied Event; never mutates
@@ -3488,17 +3967,33 @@ var McpServer = class {
3488
3967
  if (!this.started) return;
3489
3968
  if (this.sessionDisabled) return;
3490
3969
  await this.ensureLaunchSession();
3491
- if (!this.session) return;
3970
+ const session = this.session;
3971
+ if (!session) {
3972
+ this.paneObserver.resetPromptHash(cand.snapshotHash);
3973
+ return;
3974
+ }
3492
3975
  const parsed = parseApprovalSnapshot(cand.snapshot);
3493
- if (!parsed) return;
3976
+ if (!parsed) {
3977
+ this.raiseSuppressedBadge(session, cand.snapshotHash);
3978
+ return;
3979
+ }
3494
3980
  const activeConvId = this.pickActiveConversationForPrompt();
3495
3981
  if (!activeConvId) {
3496
3982
  logger.warn("Prompt candidate fired but no active conversation known", {
3497
3983
  observedConvCount: this.observedMainConversationIds.size
3498
3984
  });
3985
+ this.raiseSuppressedBadge(session, cand.snapshotHash);
3499
3986
  return;
3500
3987
  }
3501
- this.approvalDetector.emitPaneOnlyPrompt(parsed, activeConvId);
3988
+ const e1rec = this.newE1Raise(session.sessionId, true);
3989
+ const emitted = this.approvalDetector.emitPaneOnlyPrompt(parsed, activeConvId, e1rec.promptId);
3990
+ if (emitted) {
3991
+ if (cand.snapshotHash) {
3992
+ this.e1ContentKeyIndex.set(`${session.sessionId}::${cand.snapshotHash}`, e1rec.promptId);
3993
+ }
3994
+ } else {
3995
+ this.e1PromptRaises.delete(e1rec.promptId);
3996
+ }
3502
3997
  }
3503
3998
  /**
3504
3999
  * Pick the agy conversation UUID that owns the current approval UI.
@@ -3528,6 +4023,9 @@ var McpServer = class {
3528
4023
  });
3529
4024
  return;
3530
4025
  }
4026
+ const e1existing = this.e1PromptRaises.get(state.promptId);
4027
+ const e1rec = e1existing ? e1existing.record : this.newE1Raise(session.sessionId, true);
4028
+ if (!e1existing) state.promptId = e1rec.promptId;
3531
4029
  const optionsArr = state.paneOptions ? state.paneOptions.map((o) => ({ number: o.number, text: o.text })) : Object.keys(state.submitMap).map((n) => ({ number: n }));
3532
4030
  const diffParsed = parseFencedDiffFromBody(
3533
4031
  state.body,
@@ -3567,6 +4065,9 @@ var McpServer = class {
3567
4065
  source: import_codevibe_core4.EventSource.DESKTOP,
3568
4066
  content,
3569
4067
  metadata: optionsForMobile,
4068
+ // E1 (§4) — raise identity top-level: promptId + ownerToken/producerKind/
4069
+ // mobileActionable (the backend's classifyRaise upserts the open-prompt row).
4070
+ ...(0, import_codevibe_core4.raiseFieldsFromRecord)(e1rec),
3570
4071
  // Per-conversation monotonic timestamp (event-timestamp-ordering fix
3571
4072
  // v5.4, 2026-05-24). orderingKey scopes the counter to this conv so
3572
4073
  // INTERACTIVE_PROMPT can't be forced to lastMs+1 by a newer event
@@ -3580,12 +4081,19 @@ var McpServer = class {
3580
4081
  const outbound = this.encryptOutbound(session, input);
3581
4082
  if (!outbound) return;
3582
4083
  await this.appSyncClient.createEvent(outbound);
4084
+ this.stashE1RaiseContent(
4085
+ e1rec.promptId,
4086
+ content,
4087
+ optionsForMobile
4088
+ );
3583
4089
  } catch (err) {
3584
4090
  logger.error("createEvent INTERACTIVE_PROMPT failed", {
3585
4091
  promptId: state.promptId,
3586
4092
  error: String(err)
3587
4093
  });
3588
4094
  this.approvalDetector.rollbackPrompt(state.promptId);
4095
+ if (state.paneHash) this.paneObserver.resetPromptHash(state.paneHash);
4096
+ this.e1PromptRaises.delete(state.promptId);
3589
4097
  }
3590
4098
  }
3591
4099
  // ─── Mobile → desktop flow ──────────────────────────────────────────────
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.10",
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": {