@quantiya/codevibe-antigravity-plugin 2.0.6 → 2.0.8
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/.env.example +7 -6
- package/README.md +18 -22
- package/dist/server.js +700 -48
- package/{bin/codevibe-agy → libexec/companion-launcher} +51 -56
- package/package.json +5 -5
package/dist/server.js
CHANGED
|
@@ -378,7 +378,7 @@ var TranscriptTailer = class extends import_events.EventEmitter {
|
|
|
378
378
|
this.watcher.on("unlink", (filePath) => this.onFileUnlinked(filePath));
|
|
379
379
|
this.watcher.on("error", (error) => {
|
|
380
380
|
logger.error("Chokidar error", { error });
|
|
381
|
-
this.
|
|
381
|
+
this.emitError(error);
|
|
382
382
|
});
|
|
383
383
|
this.watcher.on("ready", () => {
|
|
384
384
|
logger.info("Transcript tailer ready (chokidar initial scan complete)");
|
|
@@ -388,6 +388,26 @@ var TranscriptTailer = class extends import_events.EventEmitter {
|
|
|
388
388
|
if (this.flushTimer.unref) this.flushTimer.unref();
|
|
389
389
|
this.isWatching = true;
|
|
390
390
|
}
|
|
391
|
+
/**
|
|
392
|
+
* (#638 G4) SAFE 'error' emission. `EventEmitter.emit('error', …)` with NO
|
|
393
|
+
* registered listener THROWS synchronously — inside a chokidar callback that
|
|
394
|
+
* becomes an uncaughtException, and inside the async read chain it becomes a
|
|
395
|
+
* rejection; either way the F6 symmetric process guard can classify it fatal
|
|
396
|
+
* and kill the daemon over a non-fatal tailing fault (over-death). The server
|
|
397
|
+
* attaches an 'error' listener before start() (primary path); this guard
|
|
398
|
+
* covers the residual windows — the doStop teardown detaches listeners
|
|
399
|
+
* BEFORE the tailer stops, and any embedder that never attached one.
|
|
400
|
+
*/
|
|
401
|
+
emitError(error) {
|
|
402
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
403
|
+
if (this.listenerCount("error") > 0) {
|
|
404
|
+
this.emit("error", err);
|
|
405
|
+
} else {
|
|
406
|
+
logger.error("Transcript tailer error (no listener attached \u2014 logged, non-fatal)", {
|
|
407
|
+
error: String(err)
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
}
|
|
391
411
|
/**
|
|
392
412
|
* Stop watching. Flushes offsets one last time before closing chokidar.
|
|
393
413
|
* Idempotent.
|
|
@@ -594,7 +614,13 @@ var TranscriptTailer = class extends import_events.EventEmitter {
|
|
|
594
614
|
return inflight;
|
|
595
615
|
}
|
|
596
616
|
const generation = this.readGeneration;
|
|
597
|
-
const read = this.drainReadQueue(filePath, conversationId, generation)
|
|
617
|
+
const read = this.drainReadQueue(filePath, conversationId, generation).catch((error) => {
|
|
618
|
+
logger.error("Transcript read chain failed (non-fatal) \u2014 next change event retries", {
|
|
619
|
+
filePath,
|
|
620
|
+
conversationId,
|
|
621
|
+
error: String(error)
|
|
622
|
+
});
|
|
623
|
+
});
|
|
598
624
|
this.activeReads.set(filePath, read);
|
|
599
625
|
void read.finally(() => {
|
|
600
626
|
if (this.activeReads.get(filePath) === read) {
|
|
@@ -651,7 +677,7 @@ var TranscriptTailer = class extends import_events.EventEmitter {
|
|
|
651
677
|
});
|
|
652
678
|
} catch (error) {
|
|
653
679
|
logger.error("createReadStream failed", { filePath, error });
|
|
654
|
-
this.
|
|
680
|
+
this.emitError(error);
|
|
655
681
|
return;
|
|
656
682
|
}
|
|
657
683
|
let buffered = entry.carryover;
|
|
@@ -684,7 +710,7 @@ var TranscriptTailer = class extends import_events.EventEmitter {
|
|
|
684
710
|
}
|
|
685
711
|
} catch (error) {
|
|
686
712
|
logger.error("Error reading transcript stream", { filePath, error });
|
|
687
|
-
this.
|
|
713
|
+
this.emitError(error);
|
|
688
714
|
return;
|
|
689
715
|
}
|
|
690
716
|
if (generation !== this.readGeneration) return;
|
|
@@ -1225,6 +1251,20 @@ var TmuxPaneObserver = class _TmuxPaneObserver extends import_events2.EventEmitt
|
|
|
1225
1251
|
this.currentApprovalHeader = extractHeader(candidate.snapshot);
|
|
1226
1252
|
this.emit("prompt-candidate", candidate);
|
|
1227
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
|
+
}
|
|
1228
1268
|
/** Cancel any in-flight debounce timer + drop the pending candidate.
|
|
1229
1269
|
* Called from stop() so a wrapper exit / restart doesn't fire a
|
|
1230
1270
|
* prompt for a torn-down observer. Also bumps debounceCycleId so
|
|
@@ -1712,14 +1752,14 @@ var ApprovalDetector = class extends import_events3.EventEmitter {
|
|
|
1712
1752
|
* Returns the emitted state, or null if dedupe (already emitted for
|
|
1713
1753
|
* this paneHash) or disabled.
|
|
1714
1754
|
*/
|
|
1715
|
-
emitPaneOnlyPrompt(candidate, conversationId) {
|
|
1755
|
+
emitPaneOnlyPrompt(candidate, conversationId, forcedPromptId) {
|
|
1716
1756
|
if (this.paneOnlyEmittedHashes.has(candidate.paneHash)) {
|
|
1717
1757
|
logger.debug("Skipping pane-only re-emit for duplicate paneHash", {
|
|
1718
1758
|
paneHash: candidate.paneHash.substring(0, 16)
|
|
1719
1759
|
});
|
|
1720
1760
|
return null;
|
|
1721
1761
|
}
|
|
1722
|
-
const promptId = (0, import_uuid.v4)();
|
|
1762
|
+
const promptId = forcedPromptId ?? (0, import_uuid.v4)();
|
|
1723
1763
|
const syntheticCall = {
|
|
1724
1764
|
conversationId,
|
|
1725
1765
|
intentStepIndex: -1,
|
|
@@ -1747,7 +1787,11 @@ var ApprovalDetector = class extends import_events3.EventEmitter {
|
|
|
1747
1787
|
body: candidate.body,
|
|
1748
1788
|
emittedAt: Date.now(),
|
|
1749
1789
|
ttlMs: this.promptTtlMs,
|
|
1750
|
-
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
|
|
1751
1795
|
};
|
|
1752
1796
|
this.pendingPrompts.set(promptId, state);
|
|
1753
1797
|
this.paneOnlyEmittedHashes.add(candidate.paneHash);
|
|
@@ -2790,7 +2834,7 @@ function truncate(s, maxBytes) {
|
|
|
2790
2834
|
// src/server.ts
|
|
2791
2835
|
var MOBILE_PROMPT_FLOOR_RECENCY_MS = 12e4;
|
|
2792
2836
|
var LAUNCH_SETTLE_TIMEOUT_MS = 3e3;
|
|
2793
|
-
var McpServer = class {
|
|
2837
|
+
var McpServer = class _McpServer {
|
|
2794
2838
|
constructor(options) {
|
|
2795
2839
|
/** Per-session causal floor for transcript-event timestamps emitted right
|
|
2796
2840
|
* after a MOBILE prompt. The mobile USER_PROMPT is created by the iOS app
|
|
@@ -2807,12 +2851,75 @@ var McpServer = class {
|
|
|
2807
2851
|
* under this single sessionId. /resume does NOT create a new
|
|
2808
2852
|
* session — same row. */
|
|
2809
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();
|
|
2810
2907
|
/** Single subscription for the launch session (v9 — was per-conv Map). */
|
|
2811
2908
|
this.subscription = null;
|
|
2812
2909
|
/** Wrapper-level disable flag (free-tier limit / collision). v9
|
|
2813
2910
|
* collapses v8's per-conv disabledConversations Set because there's
|
|
2814
2911
|
* only ONE backend session per wrapper. */
|
|
2815
2912
|
this.sessionDisabled = false;
|
|
2913
|
+
/** (#638 G2) Terminal fail-closed-by-absence marker: createLaunchSession hit
|
|
2914
|
+
* ENCRYPTED_SESSION_NO_KEY for this identity. Retrying cannot derive a key
|
|
2915
|
+
* this device does not have, so ensureLaunchSession must NOT re-bootstrap —
|
|
2916
|
+
* the no-session/no-plaintext outcome is the correct terminal state. Logged
|
|
2917
|
+
* once at the catch; reset on doStop() alongside sessionDisabled. */
|
|
2918
|
+
this.launchKeyUnavailable = false;
|
|
2919
|
+
/** (#638 G2) Single-flight guard for ensureLaunchSession(): concurrent
|
|
2920
|
+
* activity-triggered re-bootstraps share ONE createLaunchSession attempt
|
|
2921
|
+
* instead of double-creating backend rows. Null when no attempt in flight. */
|
|
2922
|
+
this.ensureLaunchInFlight = null;
|
|
2816
2923
|
/** Registry of agy conv UUIDs the wrapper has observed this lifecycle.
|
|
2817
2924
|
* Populated from handleConversationDiscovered + handleTranscriptEmit
|
|
2818
2925
|
* (after isMainConversation passes). Used by:
|
|
@@ -2850,6 +2957,15 @@ var McpServer = class {
|
|
|
2850
2957
|
* out mid-cleanup — separating the bounded "wait for create" from the
|
|
2851
2958
|
* always-awaited "mark INACTIVE". (#142 Stage 2 R1 iter-2 HIGH.) */
|
|
2852
2959
|
this.pendingLaunchSessionId = null;
|
|
2960
|
+
/** (#638 H4) Set by doStop() when a stop runs while a createLaunchSession may
|
|
2961
|
+
* still be in flight (the bounded Phase-0 settle can expire mid-create). The
|
|
2962
|
+
* create's post-await tail includes this in its bail check, so a create that
|
|
2963
|
+
* resolves AFTER doStop's settle bound still deactivates its just-created row
|
|
2964
|
+
* itself — the settlement obligation survives until the create CONCLUSIVELY
|
|
2965
|
+
* finishes, instead of doStop "completing" it with a missing-row INACTIVE
|
|
2966
|
+
* write the backend rejected. Reset on the next start() (a stale prior-
|
|
2967
|
+
* lifecycle create is additionally caught by the gen mismatch). */
|
|
2968
|
+
this.stopRequestedDuringCreate = false;
|
|
2853
2969
|
/** Listener references kept so doStop() can detach them. Without this,
|
|
2854
2970
|
* a server restart (test mode or future hot-reload) accumulates
|
|
2855
2971
|
* listeners on the shared module instances. (Stage 2 HIGH finding
|
|
@@ -2890,6 +3006,9 @@ var McpServer = class {
|
|
|
2890
3006
|
getActiveSession: () => this.getAnySession()
|
|
2891
3007
|
});
|
|
2892
3008
|
}
|
|
3009
|
+
static {
|
|
3010
|
+
this.E1_TTL_REFRESH_MS = 5 * 60 * 1e3;
|
|
3011
|
+
}
|
|
2893
3012
|
// ─── Lifecycle ──────────────────────────────────────────────────────────
|
|
2894
3013
|
async start() {
|
|
2895
3014
|
if (this.started) throw new Error("McpServer.start() called twice");
|
|
@@ -2907,11 +3026,12 @@ var McpServer = class {
|
|
|
2907
3026
|
error_message: "no stored OAuth tokens"
|
|
2908
3027
|
});
|
|
2909
3028
|
throw new Error(
|
|
2910
|
-
"codevibe-antigravity-plugin: no OAuth tokens \u2014 run `codevibe
|
|
3029
|
+
"codevibe-antigravity-plugin: no OAuth tokens \u2014 run `codevibe login` first"
|
|
2911
3030
|
);
|
|
2912
3031
|
}
|
|
2913
3032
|
this.started = true;
|
|
2914
3033
|
this.lifecycleGen++;
|
|
3034
|
+
this.stopRequestedDuringCreate = false;
|
|
2915
3035
|
this.registerSignalHandlers();
|
|
2916
3036
|
await (0, import_codevibe_core4.registerDeviceEncryptionKey)(this.appSyncClient, logger);
|
|
2917
3037
|
if (!this.started) return { httpPort: 0 };
|
|
@@ -2946,6 +3066,9 @@ var McpServer = class {
|
|
|
2946
3066
|
if (!this.started) {
|
|
2947
3067
|
return { httpPort: 0 };
|
|
2948
3068
|
}
|
|
3069
|
+
this.addListener(this.transcriptTailer, "error", (err) => {
|
|
3070
|
+
logger.error("transcript tailer error (non-fatal)", { error: String(err) });
|
|
3071
|
+
});
|
|
2949
3072
|
this.addListener(this.transcriptTailer, "event", (emit) => {
|
|
2950
3073
|
void this.handleTranscriptEmit(emit).catch((err) => {
|
|
2951
3074
|
logger.error("handleTranscriptEmit failed", { error: String(err) });
|
|
@@ -3008,8 +3131,16 @@ var McpServer = class {
|
|
|
3008
3131
|
}
|
|
3009
3132
|
if (this.pendingLaunchSessionId) {
|
|
3010
3133
|
const pendingId = this.pendingLaunchSessionId;
|
|
3011
|
-
this.
|
|
3012
|
-
await this.deactivateLaunchRow(pendingId);
|
|
3134
|
+
this.stopRequestedDuringCreate = true;
|
|
3135
|
+
const deactivated = await this.deactivateLaunchRow(pendingId);
|
|
3136
|
+
if (deactivated) {
|
|
3137
|
+
if (this.pendingLaunchSessionId === pendingId) this.pendingLaunchSessionId = null;
|
|
3138
|
+
} else {
|
|
3139
|
+
logger.error(
|
|
3140
|
+
"doStop: INACTIVE write for the pending launch row FAILED (row may not exist yet \u2014 create still in flight). Keeping the settlement obligation: if the create resolves in-process its tail deactivates the row; if the process exits first, an ACTIVE row may leak (SIGKILL-class residual) (#638 H4)",
|
|
3141
|
+
{ sessionId: pendingId }
|
|
3142
|
+
);
|
|
3143
|
+
}
|
|
3013
3144
|
}
|
|
3014
3145
|
if (this.subscription) {
|
|
3015
3146
|
try {
|
|
@@ -3069,7 +3200,11 @@ var McpServer = class {
|
|
|
3069
3200
|
logger.warn("appSyncClient.cleanupSubscriptions failed", { error: String(err) });
|
|
3070
3201
|
}
|
|
3071
3202
|
this.session = null;
|
|
3203
|
+
this.stopE1TtlRefresh();
|
|
3204
|
+
this.e1RetirementPark.clear();
|
|
3072
3205
|
this.sessionDisabled = false;
|
|
3206
|
+
this.launchKeyUnavailable = false;
|
|
3207
|
+
this.ensureLaunchInFlight = null;
|
|
3073
3208
|
this.observedMainConversationIds.clear();
|
|
3074
3209
|
await fireDaemonBeacon("daemon_init_step", {
|
|
3075
3210
|
step: "shutdown",
|
|
@@ -3079,6 +3214,7 @@ var McpServer = class {
|
|
|
3079
3214
|
async handleConversationDiscovered(conversationId) {
|
|
3080
3215
|
if (!this.started) return;
|
|
3081
3216
|
if (this.sessionDisabled) return;
|
|
3217
|
+
await this.ensureLaunchSession();
|
|
3082
3218
|
if (!this.session) return;
|
|
3083
3219
|
if (!this.isMainConversation(conversationId)) {
|
|
3084
3220
|
logger.debug("Ignoring discovery for non-main conversation (subagent)", { conversationId });
|
|
@@ -3098,17 +3234,25 @@ var McpServer = class {
|
|
|
3098
3234
|
* can't clobber or resurrect ACTIVE. (Separate from the ACTIVE-vs-INACTIVE
|
|
3099
3235
|
* per-session ordering that core 1.0.29's status-write chain handles.)
|
|
3100
3236
|
* (#142 Stage 2 R1 iter-2.)
|
|
3237
|
+
*
|
|
3238
|
+
* (#638 H4) Returns whether the INACTIVE write actually SUCCEEDED. A swallowed
|
|
3239
|
+
* failure here is NOT a settled obligation — the row may not exist yet (a
|
|
3240
|
+
* create still in flight) or the network may be down — so callers must keep
|
|
3241
|
+
* their settlement marker/obligation alive on `false` instead of treating the
|
|
3242
|
+
* attempt as completion. The log stays inside so no failure is ever silent.
|
|
3101
3243
|
*/
|
|
3102
3244
|
async deactivateLaunchRow(sessionId) {
|
|
3103
3245
|
try {
|
|
3104
3246
|
this.appSyncClient.stopHeartbeat(sessionId);
|
|
3105
3247
|
await this.appSyncClient.updateSession({ sessionId, status: "INACTIVE" });
|
|
3106
3248
|
logger.info("Marked superseded/interrupted launch session INACTIVE", { sessionId });
|
|
3249
|
+
return true;
|
|
3107
3250
|
} catch (err) {
|
|
3108
3251
|
logger.warn("Failed to mark superseded/interrupted launch session INACTIVE", {
|
|
3109
3252
|
sessionId,
|
|
3110
3253
|
error: String(err)
|
|
3111
3254
|
});
|
|
3255
|
+
return false;
|
|
3112
3256
|
}
|
|
3113
3257
|
}
|
|
3114
3258
|
/**
|
|
@@ -3117,6 +3261,16 @@ var McpServer = class {
|
|
|
3117
3261
|
* events under this single sessionId. /resume does NOT create a new
|
|
3118
3262
|
* row. (Codex Stage 2 R1 v5/v6/v7 switch machinery is now dead code,
|
|
3119
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).
|
|
3120
3274
|
*/
|
|
3121
3275
|
async createLaunchSession() {
|
|
3122
3276
|
const gen = this.lifecycleGen;
|
|
@@ -3143,7 +3297,7 @@ var McpServer = class {
|
|
|
3143
3297
|
);
|
|
3144
3298
|
sessionKey = result.sessionKey ?? null;
|
|
3145
3299
|
} catch (err) {
|
|
3146
|
-
this.pendingLaunchSessionId = null;
|
|
3300
|
+
if (this.pendingLaunchSessionId === sessionId) this.pendingLaunchSessionId = null;
|
|
3147
3301
|
const msg = String(err);
|
|
3148
3302
|
if (msg.includes("session-limit-exceeded")) {
|
|
3149
3303
|
logger.warn("Free-tier session limit reached \u2014 mobile sync disabled for this wrapper", { sessionId });
|
|
@@ -3151,12 +3305,25 @@ var McpServer = class {
|
|
|
3151
3305
|
this.sessionDisabled = true;
|
|
3152
3306
|
return;
|
|
3153
3307
|
}
|
|
3154
|
-
|
|
3308
|
+
if (err?.code === "ENCRYPTED_SESSION_NO_KEY") {
|
|
3309
|
+
logger.error("createLaunchSession: ENCRYPTED_SESSION_NO_KEY \u2014 no key for the encrypted session; no session created, mobile sync fails closed (no plaintext) (#638)", { sessionId });
|
|
3310
|
+
if (gen !== this.lifecycleGen || !this.started) return;
|
|
3311
|
+
this.launchKeyUnavailable = true;
|
|
3312
|
+
return;
|
|
3313
|
+
}
|
|
3314
|
+
logger.error("createLaunchSession failed (non-fatal; next activity re-attempts via ensureLaunchSession \u2014 #638 G2)", { sessionId, error: msg });
|
|
3155
3315
|
return;
|
|
3156
3316
|
}
|
|
3157
|
-
if (gen !== this.lifecycleGen || !this.started) {
|
|
3158
|
-
await this.deactivateLaunchRow(sessionId);
|
|
3159
|
-
if (
|
|
3317
|
+
if (gen !== this.lifecycleGen || !this.started || this.stopRequestedDuringCreate) {
|
|
3318
|
+
const deactivated = await this.deactivateLaunchRow(sessionId);
|
|
3319
|
+
if (deactivated) {
|
|
3320
|
+
if (this.pendingLaunchSessionId === sessionId) this.pendingLaunchSessionId = null;
|
|
3321
|
+
} else {
|
|
3322
|
+
logger.error(
|
|
3323
|
+
"createLaunchSession bail: INACTIVE write for the just-created row FAILED \u2014 settlement obligation remains open (marker kept); the row may leak ACTIVE if nothing retries before exit (#638 H4)",
|
|
3324
|
+
{ sessionId }
|
|
3325
|
+
);
|
|
3326
|
+
}
|
|
3160
3327
|
return;
|
|
3161
3328
|
}
|
|
3162
3329
|
this.session = {
|
|
@@ -3169,7 +3336,14 @@ var McpServer = class {
|
|
|
3169
3336
|
createdAt: /* @__PURE__ */ new Date(),
|
|
3170
3337
|
subscriptionActive: false,
|
|
3171
3338
|
metadata: { wrapperPid: this.wrapperPid ?? void 0, launch: true },
|
|
3172
|
-
sessionKey
|
|
3339
|
+
sessionKey,
|
|
3340
|
+
// (#638) A null key here means a genuinely unencrypted/legacy session
|
|
3341
|
+
// (resumeOrCreateSession THROWS ENCRYPTED_SESSION_NO_KEY for an encrypted
|
|
3342
|
+
// session this device can't key, handled in the catch above — no session
|
|
3343
|
+
// is created there). So `!!sessionKey` correctly classifies this row; if a
|
|
3344
|
+
// later path ever nulls the key on an encrypted session, encryptOutbound
|
|
3345
|
+
// fails closed instead of leaking plaintext.
|
|
3346
|
+
sessionIsEncrypted: !!sessionKey
|
|
3173
3347
|
};
|
|
3174
3348
|
this.pendingLaunchSessionId = null;
|
|
3175
3349
|
try {
|
|
@@ -3180,7 +3354,9 @@ var McpServer = class {
|
|
|
3180
3354
|
logger.error("handleMobileEvent failed", { error: String(err) });
|
|
3181
3355
|
});
|
|
3182
3356
|
},
|
|
3183
|
-
(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) }
|
|
3184
3360
|
);
|
|
3185
3361
|
if (gen !== this.lifecycleGen || !this.started) {
|
|
3186
3362
|
try {
|
|
@@ -3194,6 +3370,8 @@ var McpServer = class {
|
|
|
3194
3370
|
} catch (err) {
|
|
3195
3371
|
logger.error("subscribeToEvents failed (non-fatal)", { sessionId, error: String(err) });
|
|
3196
3372
|
}
|
|
3373
|
+
await this.drainE1RetirementPark({ retryUntilAck: true, gen });
|
|
3374
|
+
if (gen !== this.lifecycleGen || !this.started) return;
|
|
3197
3375
|
try {
|
|
3198
3376
|
this.appSyncClient.startHeartbeat(sessionId);
|
|
3199
3377
|
} catch (err) {
|
|
@@ -3206,6 +3384,43 @@ var McpServer = class {
|
|
|
3206
3384
|
});
|
|
3207
3385
|
logger.info("Launch session created", { sessionId, userId });
|
|
3208
3386
|
}
|
|
3387
|
+
/**
|
|
3388
|
+
* (#638 G2) Single-flight re-bootstrap of the launch session on first
|
|
3389
|
+
* subsequent activity. createLaunchSession() runs once from start(); before
|
|
3390
|
+
* this fix a transient failure there (slow/absent network at daemon start —
|
|
3391
|
+
* the incident) left `this.session = null` forever: every emit site guards
|
|
3392
|
+
* `if (!this.session) return` with no re-bootstrap trigger, so the daemon
|
|
3393
|
+
* lived but never synced (permanently headless).
|
|
3394
|
+
*
|
|
3395
|
+
* Called from the emit-relevant entry points (transcript event handling /
|
|
3396
|
+
* prompt handling / mobile-event handling) BEFORE their `!this.session`
|
|
3397
|
+
* guards. Semantics preserved:
|
|
3398
|
+
* - session present → returns immediately (fast path, no work);
|
|
3399
|
+
* - session-limit-exceeded → sessionDisabled stays TERMINAL (no retry);
|
|
3400
|
+
* - ENCRYPTED_SESSION_NO_KEY → launchKeyUnavailable stays TERMINAL for this
|
|
3401
|
+
* identity (fail-closed by absence, logged once);
|
|
3402
|
+
* - ONLY generic/transient errors are retried, single-flight per activity
|
|
3403
|
+
* arrival (inherently rate-bounded — one attempt per incoming event, and
|
|
3404
|
+
* concurrent arrivals share one attempt);
|
|
3405
|
+
* - lifecycleGen/started guards are honored by createLaunchSession itself
|
|
3406
|
+
* (gen captured at entry, rechecked after every await).
|
|
3407
|
+
*/
|
|
3408
|
+
async ensureLaunchSession() {
|
|
3409
|
+
if (this.session) return;
|
|
3410
|
+
if (!this.started || this.sessionDisabled || this.launchKeyUnavailable) return;
|
|
3411
|
+
if (this.ensureLaunchInFlight) {
|
|
3412
|
+
await this.ensureLaunchInFlight;
|
|
3413
|
+
return;
|
|
3414
|
+
}
|
|
3415
|
+
const attempt = this.createLaunchSession();
|
|
3416
|
+
this.ensureLaunchInFlight = attempt;
|
|
3417
|
+
this.launchSessionPromise = attempt;
|
|
3418
|
+
try {
|
|
3419
|
+
await attempt;
|
|
3420
|
+
} finally {
|
|
3421
|
+
if (this.ensureLaunchInFlight === attempt) this.ensureLaunchInFlight = null;
|
|
3422
|
+
}
|
|
3423
|
+
}
|
|
3209
3424
|
isMainConversation(conversationId) {
|
|
3210
3425
|
if (process.env.JEST_WORKER_ID && !this.cliLogPath) {
|
|
3211
3426
|
return true;
|
|
@@ -3224,6 +3439,7 @@ var McpServer = class {
|
|
|
3224
3439
|
if (this.sessionDisabled) {
|
|
3225
3440
|
return;
|
|
3226
3441
|
}
|
|
3442
|
+
await this.ensureLaunchSession();
|
|
3227
3443
|
if (!this.session) {
|
|
3228
3444
|
return;
|
|
3229
3445
|
}
|
|
@@ -3271,7 +3487,9 @@ var McpServer = class {
|
|
|
3271
3487
|
}
|
|
3272
3488
|
}
|
|
3273
3489
|
try {
|
|
3274
|
-
|
|
3490
|
+
const outbound = this.encryptOutbound(session, input);
|
|
3491
|
+
if (!outbound) continue;
|
|
3492
|
+
await this.appSyncClient.createEvent(outbound);
|
|
3275
3493
|
} catch (err) {
|
|
3276
3494
|
logger.error("createEvent failed", {
|
|
3277
3495
|
sessionId: session.sessionId,
|
|
@@ -3282,38 +3500,429 @@ var McpServer = class {
|
|
|
3282
3500
|
}
|
|
3283
3501
|
}
|
|
3284
3502
|
/**
|
|
3285
|
-
*
|
|
3286
|
-
*
|
|
3287
|
-
*
|
|
3288
|
-
* (
|
|
3503
|
+
* (#638) THE single encrypt-or-fail-closed decision for EVERY mobile-visible
|
|
3504
|
+
* emit, delegated to @quantiya/codevibe-core's encryptForEmit so all three
|
|
3505
|
+
* companion agents share ONE chokepoint. Returns a new CreateEventInput
|
|
3506
|
+
* (never mutates the input), or NULL when the session IS encrypted but has no
|
|
3507
|
+
* derivable key — the caller MUST DROP the event (fail-closed). Previously
|
|
3508
|
+
* this method returned `input` unchanged for ANY keyless session, leaking
|
|
3509
|
+
* cleartext for an encrypted-but-keyless session; the `sessionIsEncrypted`
|
|
3510
|
+
* flag now distinguishes a genuinely unencrypted session (plaintext OK) from
|
|
3511
|
+
* an encrypted keyless one (drop).
|
|
3289
3512
|
*/
|
|
3290
3513
|
encryptOutbound(session, input) {
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
}
|
|
3302
|
-
return
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3514
|
+
const enc = (0, import_codevibe_core4.encryptForEmit)(
|
|
3515
|
+
session.sessionKey ?? null,
|
|
3516
|
+
session.sessionIsEncrypted ?? false,
|
|
3517
|
+
input.content,
|
|
3518
|
+
input.metadata
|
|
3519
|
+
);
|
|
3520
|
+
if (enc === null) {
|
|
3521
|
+
logger.error("No session key for ENCRYPTED session \u2014 DROPPING event to avoid plaintext leak (#638)", {
|
|
3522
|
+
sessionId: session.sessionId,
|
|
3523
|
+
type: input.type
|
|
3524
|
+
});
|
|
3525
|
+
return null;
|
|
3526
|
+
}
|
|
3527
|
+
if (!enc.isEncrypted) {
|
|
3528
|
+
return input;
|
|
3529
|
+
}
|
|
3530
|
+
return {
|
|
3531
|
+
...input,
|
|
3532
|
+
content: enc.content,
|
|
3533
|
+
metadata: enc.metadata,
|
|
3534
|
+
isEncrypted: true
|
|
3535
|
+
};
|
|
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 }
|
|
3307
3656
|
};
|
|
3308
|
-
|
|
3309
|
-
|
|
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", {
|
|
3310
3690
|
sessionId: session.sessionId,
|
|
3311
|
-
|
|
3312
|
-
error: String(err)
|
|
3691
|
+
retained: unacked.length
|
|
3313
3692
|
});
|
|
3314
|
-
throw err;
|
|
3315
3693
|
}
|
|
3316
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
|
+
}
|
|
3317
3926
|
/**
|
|
3318
3927
|
* If the inbound mobile event is marked isEncrypted, decrypt content +
|
|
3319
3928
|
* metadata in place. Returns a new shallow-copied Event; never mutates
|
|
@@ -3357,17 +3966,34 @@ var McpServer = class {
|
|
|
3357
3966
|
async handlePromptCandidate(cand) {
|
|
3358
3967
|
if (!this.started) return;
|
|
3359
3968
|
if (this.sessionDisabled) return;
|
|
3360
|
-
|
|
3969
|
+
await this.ensureLaunchSession();
|
|
3970
|
+
const session = this.session;
|
|
3971
|
+
if (!session) {
|
|
3972
|
+
this.paneObserver.resetPromptHash(cand.snapshotHash);
|
|
3973
|
+
return;
|
|
3974
|
+
}
|
|
3361
3975
|
const parsed = parseApprovalSnapshot(cand.snapshot);
|
|
3362
|
-
if (!parsed)
|
|
3976
|
+
if (!parsed) {
|
|
3977
|
+
this.raiseSuppressedBadge(session, cand.snapshotHash);
|
|
3978
|
+
return;
|
|
3979
|
+
}
|
|
3363
3980
|
const activeConvId = this.pickActiveConversationForPrompt();
|
|
3364
3981
|
if (!activeConvId) {
|
|
3365
3982
|
logger.warn("Prompt candidate fired but no active conversation known", {
|
|
3366
3983
|
observedConvCount: this.observedMainConversationIds.size
|
|
3367
3984
|
});
|
|
3985
|
+
this.raiseSuppressedBadge(session, cand.snapshotHash);
|
|
3368
3986
|
return;
|
|
3369
3987
|
}
|
|
3370
|
-
this.
|
|
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
|
+
}
|
|
3371
3997
|
}
|
|
3372
3998
|
/**
|
|
3373
3999
|
* Pick the agy conversation UUID that owns the current approval UI.
|
|
@@ -3397,6 +4023,9 @@ var McpServer = class {
|
|
|
3397
4023
|
});
|
|
3398
4024
|
return;
|
|
3399
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;
|
|
3400
4029
|
const optionsArr = state.paneOptions ? state.paneOptions.map((o) => ({ number: o.number, text: o.text })) : Object.keys(state.submitMap).map((n) => ({ number: n }));
|
|
3401
4030
|
const diffParsed = parseFencedDiffFromBody(
|
|
3402
4031
|
state.body,
|
|
@@ -3436,6 +4065,9 @@ var McpServer = class {
|
|
|
3436
4065
|
source: import_codevibe_core4.EventSource.DESKTOP,
|
|
3437
4066
|
content,
|
|
3438
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),
|
|
3439
4071
|
// Per-conversation monotonic timestamp (event-timestamp-ordering fix
|
|
3440
4072
|
// v5.4, 2026-05-24). orderingKey scopes the counter to this conv so
|
|
3441
4073
|
// INTERACTIVE_PROMPT can't be forced to lastMs+1 by a newer event
|
|
@@ -3446,13 +4078,22 @@ var McpServer = class {
|
|
|
3446
4078
|
})
|
|
3447
4079
|
};
|
|
3448
4080
|
try {
|
|
3449
|
-
|
|
4081
|
+
const outbound = this.encryptOutbound(session, input);
|
|
4082
|
+
if (!outbound) return;
|
|
4083
|
+
await this.appSyncClient.createEvent(outbound);
|
|
4084
|
+
this.stashE1RaiseContent(
|
|
4085
|
+
e1rec.promptId,
|
|
4086
|
+
content,
|
|
4087
|
+
optionsForMobile
|
|
4088
|
+
);
|
|
3450
4089
|
} catch (err) {
|
|
3451
4090
|
logger.error("createEvent INTERACTIVE_PROMPT failed", {
|
|
3452
4091
|
promptId: state.promptId,
|
|
3453
4092
|
error: String(err)
|
|
3454
4093
|
});
|
|
3455
4094
|
this.approvalDetector.rollbackPrompt(state.promptId);
|
|
4095
|
+
if (state.paneHash) this.paneObserver.resetPromptHash(state.paneHash);
|
|
4096
|
+
this.e1PromptRaises.delete(state.promptId);
|
|
3456
4097
|
}
|
|
3457
4098
|
}
|
|
3458
4099
|
// ─── Mobile → desktop flow ──────────────────────────────────────────────
|
|
@@ -3542,6 +4183,7 @@ var McpServer = class {
|
|
|
3542
4183
|
async handleMobileEvent(evt) {
|
|
3543
4184
|
if (!this.started) return;
|
|
3544
4185
|
if (this.sessionDisabled) return;
|
|
4186
|
+
await this.ensureLaunchSession();
|
|
3545
4187
|
if (!this.session) return;
|
|
3546
4188
|
const session = this.session;
|
|
3547
4189
|
if (evt.source !== import_codevibe_core4.EventSource.MOBILE) return;
|
|
@@ -3705,6 +4347,11 @@ var McpServer = class {
|
|
|
3705
4347
|
timestamp: (0, import_codevibe_core4.prepareEventTimestamp)({ orderingKey: payload.sessionId })
|
|
3706
4348
|
};
|
|
3707
4349
|
const finalInput = this.encryptOutbound(session, input);
|
|
4350
|
+
if (!finalInput) {
|
|
4351
|
+
throw new Error(
|
|
4352
|
+
`fail-closed: encrypted session ${payload.sessionId} has no session key \u2014 event dropped (#638)`
|
|
4353
|
+
);
|
|
4354
|
+
}
|
|
3708
4355
|
const created = await this.appSyncClient.createEvent(finalInput);
|
|
3709
4356
|
return { eventId: created.eventId };
|
|
3710
4357
|
}
|
|
@@ -3846,6 +4493,11 @@ async function main() {
|
|
|
3846
4493
|
tmuxTarget: process.env.CODEVIBE_AGY_TMUX_TARGET ?? void 0,
|
|
3847
4494
|
wrapperPid: process.env.CODEVIBE_AGY_WRAPPER_PID ? parseInt(process.env.CODEVIBE_AGY_WRAPPER_PID, 10) : void 0
|
|
3848
4495
|
});
|
|
4496
|
+
(0, import_codevibe_core4.installDaemonProcessGuards)(logger, {
|
|
4497
|
+
onFatal: () => {
|
|
4498
|
+
void server.stop().finally(() => process.exit(1));
|
|
4499
|
+
}
|
|
4500
|
+
});
|
|
3849
4501
|
try {
|
|
3850
4502
|
const { httpPort } = await server.start();
|
|
3851
4503
|
logger.info("codevibe-antigravity-plugin ready", { httpPort });
|