@estebanforge/pi-antigravity-bridge 1.5.1 → 1.5.3

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/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.5.3] - 2026-09-10
6
+
7
+ ### Fixed
8
+
9
+ - **Parallel agy tool approvals no longer clobber each other.** The shadow-tool `GatePolicy` asked through pi's `ui.confirm`, but pi's TUI shows ONE extension dialog at a time and an overlapping call replaces the live dialog without settling it: with several agy bash/write/edit calls in flight, approvals were silently lost (the park timeout eventually resolved them as declines). The confirm now holds the same shared cross-extension dialog lock as the pi-*-me gates (`withDialogLock`, `Symbol.for("pi-me.dialog-lock")`), so approvals queue and render in turn; the park timeout still bounds each dialog and is held inside the lock. New `src/dialog-lock.ts` module plus contract tests (FIFO order, throw-release, shared key).
10
+
11
+ ## [1.5.2] - 2026-09-09
12
+
13
+ ### Fixed
14
+
15
+ - Delegation suppression now spans the whole delegated run. The 1.5.1 suppression window ended after 5 seconds, but agy watches `mcp_config.json` and hot-reloads MCP servers on every file change (`ReloadMcpConfig` in the binary): the window's rewrite poked the live delegation into reconnecting, and a delegated peer review hit the fail-closed "no active antigravity turn" deny ~50 seconds in (observed live, reproduced on demand through `AskAntigravity`). The bridge entries are now hidden until the delegated process actually closes; the release fires on process close or error only, and a regression test pins "exactly one release, at close, never on a timer" with a fake `AGY_BIN` that outlives the old grace (plus the spawn-error route).
16
+ - Suppression is coordinated across pi processes through a shared marker file (`suppression.json` in the bridge's extensions-data dir, `{pid: since}` per live delegation, 0600/0700, atomic rename). Release and the session-start heal re-enable the entries only when no live delegator remains, so two sessions delegating concurrently - or a second pi window opening mid-delegation, which the old blind heal did unconditionally - no longer un-hide each other's entries, and a session starting mid-delegation registers its own entry disabled. Dead delegators are pruned by pid liveness with a 24h age bound against pid reuse; re-enable decisions read the marker fresh at the flip, so a racing acquire keeps its suppression. The marker is coordination only (the disabled flags in `mcp_config.json` stay the gate), lives outside agy's watched config dir, and its writes are best-effort: a lost entry in the syscall-scale read-modify-write window degrades to the status-quo fail-closed deny, and any residual interleave is bounded and self-heals at the next release or heal.
17
+
18
+ ### Changed
19
+
20
+ - README's recursion-safety paragraph, the DEVELOPMENT regression list, and the AGENTS.md module lines describe the shipped mechanism: whole-run suppression, cross-process marker, marker-aware heal and registration guard.
21
+
5
22
  ## [1.5.1] - 2026-09-15
6
23
 
7
24
  ### Fixed
package/README.md CHANGED
@@ -54,7 +54,7 @@ The bridge starts a localhost MCP server inside pi's process. `tools/list` retur
54
54
 
55
55
  **Long calls don't die.** agy's MCP client abandons a `tools/call` request at a flat ~180s, which used to kill any pi tool that ran longer (a long peer review, a build, a commit preview waiting for you). A call still running after ~20 seconds now settles its HTTP request with a `STILL RUNNING` answer carrying a `callId` while pi keeps executing; agy fetches the result through the bridge-local `bridge_poll_result` tool and polls until it lands. Escalated calls get their own 30-minute budget, so human-gated tools can take as long as the human takes. Fast calls stay fully synchronous and never see any of this. If a park does fail (abort, timeout, recycle), the late result is re-routed to agy as a follow-up prompt in the same conversation instead of being lost.
56
56
 
57
- **Recursion safety.** Only the provider's agy receives the extra `--add-dir`. `AskAntigravity` is also filtered from the exposed tool list. Its delegated `agy -p` spawns with just the workspace, and while it starts the bridge suppresses its global per-pid entries: disabled before the spawn, re-enabled on process close or after a 5s grace (whichever lands first), refcounted across overlapping delegations, and re-enabled at every session start. The inner agy therefore sees no bridge and cannot re-enter pi. A hand-run agy outside pi that starts inside the seconds-wide window briefly misses the bridge; foreign servers in the global config are never touched.
57
+ **Recursion safety.** Only the provider's agy receives the extra `--add-dir`. `AskAntigravity` is also filtered from the exposed tool list. Its delegated `agy -p` spawns with just the workspace, and for the whole delegated run the bridge hides its global per-pid entries: disabled before the spawn, released only on process close or error - agy watches the config and hot-reloads MCP servers on file changes, so a mid-run re-enable would poke the live delegation back into pi. A cross-process marker (`suppression.json` in the bridge's extensions-data dir) coordinates concurrent delegations: entries come back only when no live delegation remains, a session starting mid-delegation registers its own entry disabled, and the session-start heal re-enables only once the last delegator is gone (dead entries pruned by pid liveness with a 24h age bound). The inner agy therefore sees no bridge and cannot re-enter pi. Foreign servers in the global config are never touched.
58
58
 
59
59
  **Cost / fan-out.** Every registered pi tool except builtins (and `AskAntigravity`) is exposed, including other delegation tools like `AskClaude`/`AskCodex`. agy can therefore chain into other models via the bridge, which is a new cost/time fan-out vector that did not exist before this feature.
60
60
 
@@ -98,7 +98,8 @@ Most "stuck" reports trace to one of:
98
98
  - `tests/acp-driver.test.ts` - the ACP driver over the fake server (`tests/helpers/fake-acp-server.mjs`, scenario-selected): happy flow, load-replay, permission auto-answer, Gate D abort (cancel probe, teardown, `cancelSupported` memory), the stale-exit race (a killed connection's late exit must not fail its replacement - `ACP_FAKE_SLOW_DEATH_MS`), auth errors, park/kickIdle timer pause with remaining budget.
99
99
  - `tests/acp-config.test.ts` - engine selection narrowing (`AGY_ENGINE`/`config.engine`), acp block parsing.
100
100
  - `tests/daily-log.test.ts` - the support log: day rotation, retention cutoff boundary, secret redaction (incl. header blocks), the 4 KB record cap, never-throw on a broken dir, and the two-tier gate (debug records dropped unless `AGY_DEBUG`).
101
- - `tests/mcp-registration.test.ts` - the `~/.gemini/config/mcp_config.json` registration for the stream-json CLI: exact agy entry shape, foreign servers preserved, corrupt config refused, atomic writes, stale-entry sweep, delegation suppression (`setBridgeEntriesDisabled` flip/restore/idempotence, refcounted `acquireBridgeSuppression`).
101
+ - `tests/mcp-registration.test.ts` - the `~/.gemini/config/mcp_config.json` registration for the stream-json CLI: exact agy entry shape, foreign servers preserved, corrupt config refused, atomic writes, stale-entry sweep, delegation suppression (`setBridgeEntriesDisabled` flip/restore/idempotence, refcounted `acquireBridgeSuppression`) and the cross-process marker (concurrent two-session simulation, dead and stale delegator pruning, marker-aware `healBridgeSuppression`, the registration guard).
102
+ - `tests/ask-tool-suppression.test.ts` - the AskAntigravity delegation path end-to-end over a fake `AGY_BIN` that outlives the old grace: exactly one suppression release, at process close, never on a timer.
102
103
  - `tests/engine-picker.test.ts` - first-run onboarding: picker gate (no config file + no AGY_ENGINE, fail-closed fs), option order (stream-json first), the ACP download/sign-in disclosure pins (intro + saved toast), agy binary detection (PATH scan, explicit path, fail-closed), missing-CLI toast copy.
103
104
  - `tests/provider-escalation.test.ts` - the early-ack + poll pipeline (escalation registry, poll views, late-delivery tombstones) and tool-result image forwarding on both engines.
104
105
  - `tests/approval-gate.test.ts` - the shadow tool factory: marker calls never execute, ticket verification denies forged/stale markers before the policy, denials throw, native-to-shadow mapping.
@@ -72,7 +72,7 @@ import { registerAskAntigravityTool, toolModelsFromRaw } from "../src/ask-tool.j
72
72
  import { startMcpServer, TOKEN_HEADER, type McpServerHandle } from "../src/mcp-server.js";
73
73
  import {
74
74
  registerBridgeServer,
75
- setBridgeEntriesDisabled,
75
+ healBridgeSuppression,
76
76
  sweepStaleBridgeServers,
77
77
  unregisterBridgeServer,
78
78
  } from "../src/mcp-registration.js";
@@ -88,6 +88,7 @@ import {
88
88
  import { mapAgyToolToNative } from "../src/native-tools.js";
89
89
  import { Type } from "typebox";
90
90
  import { patchStatus, restorePatch } from "../src/patch-cleanup.js";
91
+ import { withDialogLock } from "../src/dialog-lock.js";
91
92
 
92
93
  // Last UI seen (session_start / /agy commands). The ACP login URL arrives
93
94
  // via the driver log sink, which has no command context; the stash lets that
@@ -726,10 +727,11 @@ export default async function (pi: ExtensionAPI): Promise<void> {
726
727
  if (r.ok && r.handle) {
727
728
  mcpHandle = r.handle;
728
729
  // Stale entries swept at start; entries a crashed delegation left
729
- // suppressed are healed here (live bridges start every session
730
- // enabled).
730
+ // suppressed are healed here - but only when no live delegation is in
731
+ // flight anywhere (marker-aware heal). A blind re-enable used to
732
+ // un-hide the bridge during another session's active delegation.
731
733
  sweepStaleBridgeServers();
732
- setBridgeEntriesDisabled(false);
734
+ healBridgeSuppression();
733
735
  registerBridgeServer({
734
736
  pid: process.pid,
735
737
  port: r.handle.port,
@@ -807,7 +809,14 @@ export default async function (pi: ExtensionAPI): Promise<void> {
807
809
  : typeof params.path === "string"
808
810
  ? params.path
809
811
  : JSON.stringify(stripMarkerFields(params)).slice(0, 200);
810
- const ok = await extCtx.ui.confirm(`agy ${tool}?`, what, { timeout: APPROVAL_PARK_MS });
812
+ // Dialog lock: parallel agy tool approvals queue up instead of
813
+ // clobbering the live dialog (which silently loses the approval).
814
+ // Capture the narrowed method: TS drops the guard's narrowing
815
+ // inside the deferred closure.
816
+ const uiConfirm = extCtx.ui.confirm.bind(extCtx.ui);
817
+ const ok = await withDialogLock(() =>
818
+ uiConfirm(`agy ${tool}?`, what, { timeout: APPROVAL_PARK_MS }),
819
+ );
811
820
  return ok ? { allow: true } : { allow: false, reason: `declined in pi (agy ${tool})` };
812
821
  };
813
822
  const handle = r.handle;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estebanforge/pi-antigravity-bridge",
3
- "version": "1.5.1",
3
+ "version": "1.5.3",
4
4
  "description": "Gemini provider for Pi on the Antigravity ACP server (official Google ACP) or the stream-json agy CLI. antigravity/* models in Pi's /model picker, no-patch MCP bridge: agy runs Pi's tools. ToS safe to use.",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/ask-tool.ts CHANGED
@@ -48,14 +48,6 @@ const FAMILY_DEFAULT_TIER: Record<Family, ThinkingTier> = {
48
48
  other: "medium",
49
49
  };
50
50
 
51
- /** Bridge-suppression grace for a delegated `agy -p`. The shared config stays
52
- * disabled for min(process close, this): agy reads it once at startup, so
53
- * this bound covers that read without staying disabled for the whole run.
54
- * Residual race: a provider agy respawn in ANOTHER session landing inside
55
- * the window reads the entries disabled and that process lacks bridge tools
56
- * until its next recycle (session start heals the file). Fail-open past the
57
- * window = status-quo behavior. */
58
- const BRIDGE_SUPPRESS_MS = 5000;
59
51
  const TIER_RANK: Record<ThinkingTier, number> = { low: 0, medium: 1, high: 2 };
60
52
 
61
53
  // Static alias overlay for non-Gemini models agy may or may not surface.
@@ -520,30 +512,21 @@ export async function registerAskAntigravityTool(
520
512
  const binary = process.env.AGY_BIN || "agy";
521
513
  let out = "";
522
514
 
523
- // Delegation isolation: any agy on this machine reads the global
524
- // mcp_config.json, so this spawned `agy -p` would discover live
525
- // pi-bridge-* entries and call tools the host bridge cannot serve
526
- // outside a live provider turn ("no active antigravity turn").
527
- // Delegation isolation: any agy on this machine reads the global
528
- // mcp_config.json, so this spawned `agy -p` would discover live
529
- // pi-bridge-* entries and call tools the host bridge cannot serve
530
- // outside a live provider turn ("no active antigravity turn").
531
- // Window = min(close, grace): the release fires on process close or
532
- // after BRIDGE_SUPPRESS_MS, whichever lands first. Refcounted, so an
533
- // overlapping delegation cannot re-enable early. A refused config
534
- // fail-opens to the status quo.
535
- const releaseBridge = acquireBridgeSuppression();
536
- let suppressTimer: ReturnType<typeof setTimeout> | undefined = setTimeout(
537
- releaseBridge,
538
- BRIDGE_SUPPRESS_MS,
539
- );
540
- const restoreBridge = (): void => {
541
- if (suppressTimer) {
542
- clearTimeout(suppressTimer);
543
- suppressTimer = undefined;
544
- }
545
- releaseBridge();
546
- };
515
+ // Delegation isolation: any agy on this machine discovers MCP servers
516
+ // from the global mcp_config.json, so this spawned `agy -p` would find
517
+ // live pi-bridge-* entries and call tools the host bridge cannot serve
518
+ // outside a live provider turn ("no active antigravity turn"). agy also
519
+ // WATCHES that file (ReloadMcpConfig): re-enabling mid-run pokes the live
520
+ // delegation to reconnect. The old 5s grace timer did exactly that -
521
+ // observed live 2026-09-09 as a call-tool-fail ~50s into a delegation -
522
+ // so the entries now stay hidden for the WHOLE delegated run. The release
523
+ // fires only on process close/error (cleanup + finally below); if pi
524
+ // itself dies first, session start heals the file
525
+ // (healBridgeSuppression in extensions/index.ts, marker-aware). Cross-
526
+ // process coordination rides the suppression marker in mcp-registration.
527
+ // Refcounted, so overlapping delegations in this process cannot release
528
+ // each other's window early. A refused config fail-opens to the status quo.
529
+ const restoreBridge = acquireBridgeSuppression();
547
530
 
548
531
  const statusInterval = onUpdate
549
532
  ? setInterval(() => {
@@ -0,0 +1,48 @@
1
+ // Process-wide FIFO lock for human-facing dialogs (confirm/editor/select).
2
+ //
3
+ // pi's interactive UI shows ONE extension dialog at a time; an overlapping
4
+ // ctx.ui.* dialog call REPLACES the live dialog and the replaced promise
5
+ // never settles, so parallel gated tool calls hang forever (or silently lose
6
+ // their prompt). Holding this lock around each dialog serializes them: the
7
+ // first renders, the rest appear in turn as each is answered.
8
+ //
9
+ // The lock is keyed via Symbol.for because ALL pi-* extensions run in the
10
+ // SAME pi process and must share one queue: a per-module lock would still let
11
+ // a dialog from one extension clobber a dialog from another in the same
12
+ // parallel tool-call batch.
13
+ const DIALOG_LOCK: unique symbol = Symbol.for("pi-me.dialog-lock");
14
+
15
+ interface DialogQueue {
16
+ tail: Promise<void>;
17
+ }
18
+
19
+ function dialogQueue(): DialogQueue {
20
+ const host = globalThis as typeof globalThis & Record<symbol, unknown>;
21
+ const existing = host[DIALOG_LOCK] as DialogQueue | undefined;
22
+ if (existing) return existing;
23
+ const created: DialogQueue = { tail: Promise.resolve() };
24
+ host[DIALOG_LOCK] = created;
25
+ return created;
26
+ }
27
+
28
+ /**
29
+ * Run `run` while holding the cross-extension dialog lock. FIFO: each caller
30
+ * chains onto the queue tail synchronously (before its first await), so call
31
+ * order is the order the dialogs appear. Released in a finally block, so one
32
+ * throwing dialog can never wedge the queue for the callers behind it.
33
+ * NOT reentrant: calling it inside a held `run` self-deadlocks.
34
+ */
35
+ export async function withDialogLock<T>(run: () => Promise<T>): Promise<T> {
36
+ const queue = dialogQueue();
37
+ const prev = queue.tail;
38
+ let release!: () => void;
39
+ queue.tail = new Promise<void>((resolve) => {
40
+ release = resolve;
41
+ });
42
+ await prev;
43
+ try {
44
+ return await run();
45
+ } finally {
46
+ release();
47
+ }
48
+ }
@@ -66,20 +66,32 @@ function writeConfig(file: string, config: McpConfig): void {
66
66
  }
67
67
 
68
68
  /** Register (or refresh) the bridge's per-pid server entry. Foreign servers
69
- * in the file are preserved. */
69
+ * in the file are preserved.
70
+ *
71
+ * One shared guard for delegation isolation: while ANY live delegation is
72
+ * in flight anywhere on the machine (per the suppression marker), a fresh
73
+ * registration lands disabled - the delegated agy must not discover a new
74
+ * bridge mid-run. The last release re-enables every entry again. */
70
75
  export function registerBridgeServer(
71
76
  entry: { pid: number; port: number; token: string; tokenHeader: string },
72
77
  configPath: string = mcpConfigPath(),
73
- ): { wrote: boolean; reason?: string } {
78
+ opts: {
79
+ markerPath?: string;
80
+ isAlive?: (pid: number) => boolean;
81
+ now?: () => number;
82
+ } = {},
83
+ ): { wrote: boolean; disabled: boolean; reason?: string } {
84
+ const marker = readSuppressionMarker(opts.markerPath ?? suppressionMarkerPath());
85
+ const disabled = hasLiveDelegator(marker, opts.isAlive ?? pidAlive, (opts.now ?? Date.now)());
74
86
  const read = readConfig(configPath);
75
- if (!read.ok) return { wrote: false, reason: read.reason };
87
+ if (!read.ok) return { wrote: false, disabled, reason: read.reason };
76
88
  read.config.mcpServers[bridgeServerName(entry.pid)] = {
77
- disabled: false,
89
+ disabled,
78
90
  headers: { [entry.tokenHeader]: entry.token },
79
91
  serverUrl: `http://127.0.0.1:${entry.port}/mcp`,
80
92
  } satisfies BridgeServerEntry;
81
93
  writeConfig(configPath, read.config);
82
- return { wrote: true };
94
+ return { wrote: true, disabled };
83
95
  }
84
96
 
85
97
  /** Remove the bridge's per-pid server entry (close path). */
@@ -124,19 +136,123 @@ export function setBridgeEntriesDisabled(
124
136
  return { wrote: true, changed };
125
137
  }
126
138
 
139
+ // --- Cross-process suppression marker --------------------------------------
140
+
141
+ /** Shape of the shared delegator marker (suppression.json). Keyed by pi pid;
142
+ * `since` is the acquire timestamp in ms since epoch. Coordination ONLY:
143
+ * the disabled flags in mcp_config.json stay the actual gate. */
144
+ export interface SuppressionMarker {
145
+ delegators: Record<string, { since: number }>;
146
+ }
147
+
148
+ /** Marker path, mirroring the extensions-data convention (src/config.ts
149
+ * logsDir). Lives OUTSIDE ~/.gemini so agy's watched config dir stays
150
+ * untouched. */
151
+ export function suppressionMarkerPath(home: string = os.homedir()): string {
152
+ return path.join(
153
+ home,
154
+ ".pi",
155
+ "extensions-data",
156
+ "estebanforge",
157
+ "pi-antigravity-bridge",
158
+ "suppression.json",
159
+ );
160
+ }
161
+
162
+ /** A SIGKILLed pi can leave its marker entry behind, and pid reuse could
163
+ * keep kill(pid,0) answering forever. The age bound caps that wedge: a
164
+ * delegation never legitimately runs this long, so an entry this old is
165
+ * dead regardless of what the pid probe says. */
166
+ const DELEGATOR_MAX_AGE_MS = 24 * 60 * 60 * 1000;
167
+
168
+ function readSuppressionMarker(file: string): SuppressionMarker {
169
+ try {
170
+ const parsed: unknown = JSON.parse(fs.readFileSync(file, "utf8"));
171
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { delegators: {} };
172
+ const delegators = (parsed as SuppressionMarker).delegators;
173
+ if (!delegators || typeof delegators !== "object" || Array.isArray(delegators)) {
174
+ return { delegators: {} };
175
+ }
176
+ return { delegators };
177
+ } catch {
178
+ // Missing or corrupt marker: fail-open to empty. Never worse than the
179
+ // pre-marker blind re-enable; the next write replaces the file.
180
+ return { delegators: {} };
181
+ }
182
+ }
183
+
184
+ function writeSuppressionMarker(file: string, marker: SuppressionMarker): void {
185
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
186
+ const tmp = `${file}.${process.pid}.tmp`;
187
+ fs.writeFileSync(tmp, JSON.stringify(marker, null, 2) + "\n", { mode: 0o600 });
188
+ fs.renameSync(tmp, file);
189
+ }
190
+
191
+ function delegatorLive(pid: string, entry: { since?: number } | undefined, isAlive: (pid: number) => boolean, now: number): boolean {
192
+ const n = Number(pid);
193
+ const since = typeof entry?.since === "number" ? entry.since : 0;
194
+ return Number.isFinite(n) && n > 0 && now - since < DELEGATOR_MAX_AGE_MS && isAlive(n);
195
+ }
196
+
197
+ function pruneDelegators(
198
+ marker: SuppressionMarker,
199
+ isAlive: (pid: number) => boolean,
200
+ now: number,
201
+ ): { kept: SuppressionMarker; pruned: string[] } {
202
+ const kept: SuppressionMarker = { delegators: {} };
203
+ const pruned: string[] = [];
204
+ for (const [pid, entry] of Object.entries(marker.delegators)) {
205
+ if (delegatorLive(pid, entry, isAlive, now)) kept.delegators[pid] = { since: entry.since };
206
+ else pruned.push(pid);
207
+ }
208
+ return { kept, pruned };
209
+ }
210
+
211
+ function hasLiveDelegator(marker: SuppressionMarker, isAlive: (pid: number) => boolean, now: number): boolean {
212
+ return Object.entries(marker.delegators).some(([pid, entry]) => delegatorLive(pid, entry, isAlive, now));
213
+ }
214
+
215
+ function bestEffortWriteMarker(file: string, marker: SuppressionMarker): void {
216
+ try {
217
+ writeSuppressionMarker(file, marker);
218
+ } catch {
219
+ // Coordination hint only; a failed write must never break a release or
220
+ // a heal. The disabled flags in mcp_config.json remain the gate.
221
+ }
222
+ }
223
+
127
224
  const suppressionRefs = new Map<string, number>();
128
225
 
129
226
  /** Reference-counted suppression for a self-spawned agy process (AskAntigravity
130
- * delegation). First acquire disables every pi-bridge-* entry, last release
131
- * re-enables; nested acquires are free, so overlapping delegations in one
132
- * process cannot clobber each other's window. Same-process only: delegations
133
- * from two pi sessions still race on the shared file - accepted, fail-open
134
- * to the status-quo error. */
135
- export function acquireBridgeSuppression(configPath: string = mcpConfigPath()): () => void {
136
- const key = path.resolve(configPath);
227
+ * delegation). First acquire disables every pi-bridge-* entry AND records
228
+ * this process's pid in the shared suppression marker; last release removes
229
+ * it and re-enables only when no LIVE delegator remains - so two pi sessions
230
+ * delegating concurrently no longer re-enable each other's entries
231
+ * (previously a same-process-only refcount raced on the shared file).
232
+ * Nested acquires in one process are free. Residual race: concurrent
233
+ * read-modify-write of the marker across processes is last-writer-wins
234
+ * (atomic rename), and a syscall-scale interleave can still briefly re-open
235
+ * the bridge during a live delegation; both are bounded, self-heal at the
236
+ * next release/heal, and degrade to the status-quo fail-closed deny. */
237
+ export function acquireBridgeSuppression(opts: SuppressionOptions = {}): () => void {
238
+ const configPath = opts.configPath ?? mcpConfigPath();
239
+ const markerPath = opts.markerPath ?? suppressionMarkerPath();
240
+ const pid = opts.pid ?? process.pid;
241
+ const isAlive = opts.isAlive ?? pidAlive;
242
+ const now = opts.now ?? Date.now;
243
+ const key = `${path.resolve(markerPath)}\u0000${pid}`;
137
244
  const refs = (suppressionRefs.get(key) ?? 0) + 1;
138
245
  suppressionRefs.set(key, refs);
139
- if (refs === 1) setBridgeEntriesDisabled(true, configPath);
246
+ if (refs === 1) {
247
+ // Marker BEFORE the config flip: a session-start heal racing between
248
+ // the two writes reads an empty marker and would re-enable entries for
249
+ // a delegation that is about to go live. Recording first shrinks that
250
+ // window to the config flip itself.
251
+ const marker = readSuppressionMarker(markerPath);
252
+ marker.delegators[String(pid)] = { since: now() };
253
+ bestEffortWriteMarker(markerPath, marker);
254
+ setBridgeEntriesDisabled(true, configPath);
255
+ }
140
256
  let released = false;
141
257
  return () => {
142
258
  if (released) return;
@@ -144,10 +260,60 @@ export function acquireBridgeSuppression(configPath: string = mcpConfigPath()):
144
260
  const left = Math.max(0, (suppressionRefs.get(key) ?? 1) - 1);
145
261
  if (left === 0) suppressionRefs.delete(key);
146
262
  else suppressionRefs.set(key, left);
147
- if (left === 0) setBridgeEntriesDisabled(false, configPath);
263
+ if (left === 0) {
264
+ const marker = readSuppressionMarker(markerPath);
265
+ delete marker.delegators[String(pid)];
266
+ const { kept } = pruneDelegators(marker, isAlive, now());
267
+ bestEffortWriteMarker(markerPath, kept);
268
+ // Re-read right before the flip: an acquire that raced us between
269
+ // the marker write and this read is already live and must keep its
270
+ // suppression (a stale snapshot here would re-enable over it).
271
+ const fresh = readSuppressionMarker(markerPath);
272
+ if (Object.keys(fresh.delegators).length === 0) {
273
+ setBridgeEntriesDisabled(false, configPath);
274
+ }
275
+ }
148
276
  };
149
277
  }
150
278
 
279
+ export interface SuppressionOptions {
280
+ /** Global mcp_config.json path (default: the real user config). */
281
+ configPath?: string;
282
+ /** Cross-process delegator marker path (default: suppression.json in the
283
+ * bridge's extensions-data dir). */
284
+ markerPath?: string;
285
+ /** Delegator identity; defaults to this process's pid. Injectable so tests
286
+ * can simulate two sessions in one process. */
287
+ pid?: number;
288
+ isAlive?: (pid: number) => boolean;
289
+ now?: () => number;
290
+ }
291
+
292
+ /** Session-start heal, marker-aware. Prunes dead or stale delegators, then
293
+ * re-enables the entries ONLY when no live delegator remains. A blind
294
+ * re-enable here used to un-hide the bridge during another session's active
295
+ * delegation - a plain session start in a second window could reproduce the
296
+ * fail-closed deny this file exists to prevent. */
297
+ export function healBridgeSuppression(opts: SuppressionOptions = {}): {
298
+ pruned: string[];
299
+ reEnabled: boolean;
300
+ reason?: string;
301
+ } {
302
+ const configPath = opts.configPath ?? mcpConfigPath();
303
+ const markerPath = opts.markerPath ?? suppressionMarkerPath();
304
+ const isAlive = opts.isAlive ?? pidAlive;
305
+ const now = opts.now ?? Date.now;
306
+ const marker = readSuppressionMarker(markerPath);
307
+ const { kept, pruned } = pruneDelegators(marker, isAlive, now());
308
+ if (pruned.length > 0) bestEffortWriteMarker(markerPath, kept);
309
+ // Decide on a FRESH read, not the pruned snapshot: an acquire that raced
310
+ // us after our write is already live and must keep its suppression.
311
+ const fresh = readSuppressionMarker(markerPath);
312
+ if (Object.keys(fresh.delegators).length > 0) return { pruned, reEnabled: false };
313
+ const flip = setBridgeEntriesDisabled(false, configPath);
314
+ return { pruned, reEnabled: flip.changed > 0, reason: flip.reason };
315
+ }
316
+
151
317
  /** Default liveness probe: can the signal be delivered? */
152
318
  function pidAlive(pid: number): boolean {
153
319
  try {