@quantiya/codevibe-antigravity-plugin 2.0.8 → 2.0.9
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 +8 -516
- package/package.json +2 -2
package/dist/server.js
CHANGED
|
@@ -1251,20 +1251,6 @@ 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
|
-
}
|
|
1268
1254
|
/** Cancel any in-flight debounce timer + drop the pending candidate.
|
|
1269
1255
|
* Called from stop() so a wrapper exit / restart doesn't fire a
|
|
1270
1256
|
* prompt for a torn-down observer. Also bumps debounceCycleId so
|
|
@@ -1752,14 +1738,14 @@ var ApprovalDetector = class extends import_events3.EventEmitter {
|
|
|
1752
1738
|
* Returns the emitted state, or null if dedupe (already emitted for
|
|
1753
1739
|
* this paneHash) or disabled.
|
|
1754
1740
|
*/
|
|
1755
|
-
emitPaneOnlyPrompt(candidate, conversationId
|
|
1741
|
+
emitPaneOnlyPrompt(candidate, conversationId) {
|
|
1756
1742
|
if (this.paneOnlyEmittedHashes.has(candidate.paneHash)) {
|
|
1757
1743
|
logger.debug("Skipping pane-only re-emit for duplicate paneHash", {
|
|
1758
1744
|
paneHash: candidate.paneHash.substring(0, 16)
|
|
1759
1745
|
});
|
|
1760
1746
|
return null;
|
|
1761
1747
|
}
|
|
1762
|
-
const promptId =
|
|
1748
|
+
const promptId = (0, import_uuid.v4)();
|
|
1763
1749
|
const syntheticCall = {
|
|
1764
1750
|
conversationId,
|
|
1765
1751
|
intentStepIndex: -1,
|
|
@@ -1787,11 +1773,7 @@ var ApprovalDetector = class extends import_events3.EventEmitter {
|
|
|
1787
1773
|
body: candidate.body,
|
|
1788
1774
|
emittedAt: Date.now(),
|
|
1789
1775
|
ttlMs: this.promptTtlMs,
|
|
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
|
|
1776
|
+
paneOptions: candidate.options
|
|
1795
1777
|
};
|
|
1796
1778
|
this.pendingPrompts.set(promptId, state);
|
|
1797
1779
|
this.paneOnlyEmittedHashes.add(candidate.paneHash);
|
|
@@ -2834,7 +2816,7 @@ function truncate(s, maxBytes) {
|
|
|
2834
2816
|
// src/server.ts
|
|
2835
2817
|
var MOBILE_PROMPT_FLOOR_RECENCY_MS = 12e4;
|
|
2836
2818
|
var LAUNCH_SETTLE_TIMEOUT_MS = 3e3;
|
|
2837
|
-
var McpServer = class
|
|
2819
|
+
var McpServer = class {
|
|
2838
2820
|
constructor(options) {
|
|
2839
2821
|
/** Per-session causal floor for transcript-event timestamps emitted right
|
|
2840
2822
|
* after a MOBILE prompt. The mobile USER_PROMPT is created by the iOS app
|
|
@@ -2851,59 +2833,6 @@ var McpServer = class _McpServer {
|
|
|
2851
2833
|
* under this single sessionId. /resume does NOT create a new
|
|
2852
2834
|
* session — same row. */
|
|
2853
2835
|
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();
|
|
2907
2836
|
/** Single subscription for the launch session (v9 — was per-conv Map). */
|
|
2908
2837
|
this.subscription = null;
|
|
2909
2838
|
/** Wrapper-level disable flag (free-tier limit / collision). v9
|
|
@@ -3006,9 +2935,6 @@ var McpServer = class _McpServer {
|
|
|
3006
2935
|
getActiveSession: () => this.getAnySession()
|
|
3007
2936
|
});
|
|
3008
2937
|
}
|
|
3009
|
-
static {
|
|
3010
|
-
this.E1_TTL_REFRESH_MS = 5 * 60 * 1e3;
|
|
3011
|
-
}
|
|
3012
2938
|
// ─── Lifecycle ──────────────────────────────────────────────────────────
|
|
3013
2939
|
async start() {
|
|
3014
2940
|
if (this.started) throw new Error("McpServer.start() called twice");
|
|
@@ -3200,8 +3126,6 @@ var McpServer = class _McpServer {
|
|
|
3200
3126
|
logger.warn("appSyncClient.cleanupSubscriptions failed", { error: String(err) });
|
|
3201
3127
|
}
|
|
3202
3128
|
this.session = null;
|
|
3203
|
-
this.stopE1TtlRefresh();
|
|
3204
|
-
this.e1RetirementPark.clear();
|
|
3205
3129
|
this.sessionDisabled = false;
|
|
3206
3130
|
this.launchKeyUnavailable = false;
|
|
3207
3131
|
this.ensureLaunchInFlight = null;
|
|
@@ -3261,16 +3185,6 @@ var McpServer = class _McpServer {
|
|
|
3261
3185
|
* events under this single sessionId. /resume does NOT create a new
|
|
3262
3186
|
* row. (Codex Stage 2 R1 v5/v6/v7 switch machinery is now dead code,
|
|
3263
3187
|
* 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).
|
|
3274
3188
|
*/
|
|
3275
3189
|
async createLaunchSession() {
|
|
3276
3190
|
const gen = this.lifecycleGen;
|
|
@@ -3354,9 +3268,7 @@ var McpServer = class _McpServer {
|
|
|
3354
3268
|
logger.error("handleMobileEvent failed", { error: String(err) });
|
|
3355
3269
|
});
|
|
3356
3270
|
},
|
|
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) }
|
|
3271
|
+
(err) => logger.warn("AppSync subscription error", { error: String(err) })
|
|
3360
3272
|
);
|
|
3361
3273
|
if (gen !== this.lifecycleGen || !this.started) {
|
|
3362
3274
|
try {
|
|
@@ -3370,8 +3282,6 @@ var McpServer = class _McpServer {
|
|
|
3370
3282
|
} catch (err) {
|
|
3371
3283
|
logger.error("subscribeToEvents failed (non-fatal)", { sessionId, error: String(err) });
|
|
3372
3284
|
}
|
|
3373
|
-
await this.drainE1RetirementPark({ retryUntilAck: true, gen });
|
|
3374
|
-
if (gen !== this.lifecycleGen || !this.started) return;
|
|
3375
3285
|
try {
|
|
3376
3286
|
this.appSyncClient.startHeartbeat(sessionId);
|
|
3377
3287
|
} catch (err) {
|
|
@@ -3534,395 +3444,6 @@ var McpServer = class _McpServer {
|
|
|
3534
3444
|
isEncrypted: true
|
|
3535
3445
|
};
|
|
3536
3446
|
}
|
|
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
|
-
}
|
|
3926
3447
|
/**
|
|
3927
3448
|
* If the inbound mobile event is marked isEncrypted, decrypt content +
|
|
3928
3449
|
* metadata in place. Returns a new shallow-copied Event; never mutates
|
|
@@ -3967,33 +3488,17 @@ var McpServer = class _McpServer {
|
|
|
3967
3488
|
if (!this.started) return;
|
|
3968
3489
|
if (this.sessionDisabled) return;
|
|
3969
3490
|
await this.ensureLaunchSession();
|
|
3970
|
-
|
|
3971
|
-
if (!session) {
|
|
3972
|
-
this.paneObserver.resetPromptHash(cand.snapshotHash);
|
|
3973
|
-
return;
|
|
3974
|
-
}
|
|
3491
|
+
if (!this.session) return;
|
|
3975
3492
|
const parsed = parseApprovalSnapshot(cand.snapshot);
|
|
3976
|
-
if (!parsed)
|
|
3977
|
-
this.raiseSuppressedBadge(session, cand.snapshotHash);
|
|
3978
|
-
return;
|
|
3979
|
-
}
|
|
3493
|
+
if (!parsed) return;
|
|
3980
3494
|
const activeConvId = this.pickActiveConversationForPrompt();
|
|
3981
3495
|
if (!activeConvId) {
|
|
3982
3496
|
logger.warn("Prompt candidate fired but no active conversation known", {
|
|
3983
3497
|
observedConvCount: this.observedMainConversationIds.size
|
|
3984
3498
|
});
|
|
3985
|
-
this.raiseSuppressedBadge(session, cand.snapshotHash);
|
|
3986
3499
|
return;
|
|
3987
3500
|
}
|
|
3988
|
-
|
|
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
|
-
}
|
|
3501
|
+
this.approvalDetector.emitPaneOnlyPrompt(parsed, activeConvId);
|
|
3997
3502
|
}
|
|
3998
3503
|
/**
|
|
3999
3504
|
* Pick the agy conversation UUID that owns the current approval UI.
|
|
@@ -4023,9 +3528,6 @@ var McpServer = class _McpServer {
|
|
|
4023
3528
|
});
|
|
4024
3529
|
return;
|
|
4025
3530
|
}
|
|
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;
|
|
4029
3531
|
const optionsArr = state.paneOptions ? state.paneOptions.map((o) => ({ number: o.number, text: o.text })) : Object.keys(state.submitMap).map((n) => ({ number: n }));
|
|
4030
3532
|
const diffParsed = parseFencedDiffFromBody(
|
|
4031
3533
|
state.body,
|
|
@@ -4065,9 +3567,6 @@ var McpServer = class _McpServer {
|
|
|
4065
3567
|
source: import_codevibe_core4.EventSource.DESKTOP,
|
|
4066
3568
|
content,
|
|
4067
3569
|
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),
|
|
4071
3570
|
// Per-conversation monotonic timestamp (event-timestamp-ordering fix
|
|
4072
3571
|
// v5.4, 2026-05-24). orderingKey scopes the counter to this conv so
|
|
4073
3572
|
// INTERACTIVE_PROMPT can't be forced to lastMs+1 by a newer event
|
|
@@ -4081,19 +3580,12 @@ var McpServer = class _McpServer {
|
|
|
4081
3580
|
const outbound = this.encryptOutbound(session, input);
|
|
4082
3581
|
if (!outbound) return;
|
|
4083
3582
|
await this.appSyncClient.createEvent(outbound);
|
|
4084
|
-
this.stashE1RaiseContent(
|
|
4085
|
-
e1rec.promptId,
|
|
4086
|
-
content,
|
|
4087
|
-
optionsForMobile
|
|
4088
|
-
);
|
|
4089
3583
|
} catch (err) {
|
|
4090
3584
|
logger.error("createEvent INTERACTIVE_PROMPT failed", {
|
|
4091
3585
|
promptId: state.promptId,
|
|
4092
3586
|
error: String(err)
|
|
4093
3587
|
});
|
|
4094
3588
|
this.approvalDetector.rollbackPrompt(state.promptId);
|
|
4095
|
-
if (state.paneHash) this.paneObserver.resetPromptHash(state.paneHash);
|
|
4096
|
-
this.e1PromptRaises.delete(state.promptId);
|
|
4097
3589
|
}
|
|
4098
3590
|
}
|
|
4099
3591
|
// ─── Mobile → desktop flow ──────────────────────────────────────────────
|
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.9",
|
|
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.9",
|
|
52
52
|
"chokidar": "^5.0.0",
|
|
53
53
|
"dotenv": "^16.6.1",
|
|
54
54
|
"express": "^5.1.0",
|