@estebanforge/pi-antigravity-bridge 1.5.0 → 1.5.1

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,17 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.5.1] - 2026-09-15
6
+
7
+ ### Fixed
8
+
9
+ - Delegated agy no longer sees the pi tool bridge. `AskAntigravity` spawns `agy -p`, and any agy on the machine discovers MCP servers from the global `~/.gemini/config/mcp_config.json`, which carries a live `pi-bridge-*` entry for every running pi session. A delegation could therefore connect to a host bridge and call tools (observed live: `memory_search`), which the round-trip store denies fail-closed with "no active antigravity turn" the moment no provider turn is streaming - a dead end for the delegation and an error toast in the host session. The bridge entries are now suppressed while a delegated agy starts: disabled before the spawn, re-enabled on process close or after a 5s grace, whichever lands first. Suppression is reference-counted, so overlapping delegations cannot re-enable early; session start re-enables any entries a crashed delegation left disabled. Foreign MCP servers in the file are never touched. Residual race, documented in code: another session's provider agy respawning inside the window reads the entries disabled and that process lacks bridge tools until its next recycle.
10
+ - Real bridge tool-call failures now land on disk in default mode. The daily log records only errors, and `call-tool-fail` was logged at warn tier (toast only), so a failure like the one above left no durable trace. Genuine rejections now log at error tier; routine turn-end and shutdown aborts stay silent, with the reasons shared as constants between the emit sites and the log classifier so the two cannot drift.
11
+
12
+ ### Changed
13
+
14
+ - README corrected: the bridge mechanism section described only the dir-scoped discovery and claimed the user's global agy config is never touched. Global per-pid registration shipped earlier and is now documented, along with the delegation suppression and its window.
15
+
5
16
  ## [1.5.0] - 2026-09-08
6
17
 
7
18
  ### Added
package/README.md CHANGED
@@ -2,11 +2,16 @@
2
2
 
3
3
  A Gemini model provider **and** the `AskAntigravity` delegation tool for [pi](https://github.com/earendil-works/pi-coding-agent), built on Google's official Antigravity binaries: the `agy` CLI by default, or **Google's official ACP server** (opt-in). It registers `antigravity/gemini-*` models in pi's `/model` picker (streaming), and provides the `AskAntigravity` tool for one-shot delegation - the same combined shape as `pi-claude-bridge`.
4
4
 
5
- <img width="2566" height="1723" alt="SCR-20260903-sefo" src="https://github.com/user-attachments/assets/20f0c04c-d622-4b11-962a-3478f64570c2" align="center"/>
5
+ <p>
6
+ <img width="1374" height="869" alt="image" src="https://github.com/user-attachments/assets/fdd6c1f0-5144-40ef-9371-77a94de50b76" align="center" />
7
+ </p>
8
+ <p>
9
+ <img width="1376" height="451" alt="SCR-20260908-mbox" src="https://github.com/user-attachments/assets/d105c8a7-1ace-42f7-8285-642150453761" align="center" />
10
+ </p>
11
+ <p>
12
+ <img width="1375" height="1342" alt="SCR-20260908-mbvc" src="https://github.com/user-attachments/assets/bb614c55-e0f8-4677-bb0e-6541071e91ef" align="center" />
13
+ </p>
6
14
 
7
- <br/><br/>
8
-
9
- <img width="1272" height="842" alt="SCR-20260903-sfcu" src="https://github.com/user-attachments/assets/c483613b-10ff-4d2f-9893-027d84130ac6" align="center" />
10
15
 
11
16
  If you also have [`@estebanforge/pi-ask-antigravity`](https://github.com/EstebanForge/pi-ask-antigravity) installed, this bridge takes over: pi-ask-antigravity detects the bridge and registers nothing, so the `AskAntigravity` tool is never duplicated.
12
17
 
@@ -43,13 +48,13 @@ Residual limits (with or without the bridge):
43
48
 
44
49
  While agy is the active model it normally cannot see pi's universe of extensions: agentmemory, codegraph, web search, slack/asana, the `Ask*` delegations, and any other installed pi tool. This extension optionally bridges that gap.
45
50
 
46
- The bridge starts a localhost MCP server inside pi's process. `tools/list` returns pi's registered tools (built-in file/shell tools and `AskAntigravity` are filtered out), and a `tools/call` routes into pi's own tool loop via the round-trip described below. agy discovers the server through a per-invocation config: the bridge writes `.agents/mcp_config.json` into a bridge-controlled dir (`~/.pi/agent/antigravity-bridge/agy-mcp-<pid>/`) and the provider passes that dir as an extra `--add-dir` when it spawns agy. The user's global agy config (`~/.gemini/config/mcp_config.json`) is never touched, so standalone agy outside pi is unaffected.
51
+ The bridge starts a localhost MCP server inside pi's process. `tools/list` returns pi's registered tools (built-in file/shell tools and `AskAntigravity` are filtered out), and a `tools/call` routes into pi's own tool loop via the round-trip described below. Discovery has two layers. The provider's agy gets a per-invocation config: the bridge writes `.agents/mcp_config.json` into a bridge-controlled dir (`~/.pi/agent/antigravity-bridge/agy-mcp-<pid>/`) and the driver passes that dir as an extra `--add-dir` when it spawns agy. A per-pid entry (`pi-bridge-<pid>`) is also registered in the user's global agy config (`~/.gemini/config/mcp_config.json`; foreign servers preserved, stale entries swept at start), so agy builds that read only the global config still find the bridge.
47
52
 
48
53
  **No patch required.** Bridge calls park in the provider's round-trip store; the provider ends the pi assistant message with a `toolUse` stop reason for the real pi tool, pi executes it in its own loop (native cards, permissions, hooks), and the toolResult completes the parked MCP response on the next stream call. This is the same mechanism tianzuo/pi-antigravity uses; upstream pi APIs only.
49
54
 
50
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.
51
56
 
52
- **Recursion safety.** Only the provider's agy receives the extra `--add-dir`. The `AskAntigravity` tool spawns its own agy with just the workspace, so that inner agy starts plain (no pi tools) and cannot re-enter. `AskAntigravity` is also filtered from the exposed tool list. Standalone agy is unaffected because nothing is written to its global config.
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.
53
58
 
54
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.
55
60
 
@@ -27,7 +27,7 @@ src/config.ts persisted runtime config (engine + acp block, bridgeTools,
27
27
  src/daily-log.ts daily NDJSON support log (one file per day, 14-day retention, secret redaction, AGY_DEBUG verbose gate); fed by both drivers, the bridge, round-trips, /agy, and ask-tool
28
28
  src/ask-tool.ts the AskAntigravity one-shot delegation tool (model/thinking defaults)
29
29
  src/mcp-server.ts MCP tool bridge server: ferries tools/list + tools/call; calls park in the provider round-trip. Also the approval park: POST /approval (ticket early-ack) + GET /approval/<id>, fail-closed on timeout/unwired/close
30
- src/mcp-registration.ts registers/unregisters the bridge in ~/.gemini/config/mcp_config.json for the stream-json CLI (per-pid, atomic, stale sweep)
30
+ src/mcp-registration.ts registers/unregisters the bridge in ~/.gemini/config/mcp_config.json for the stream-json CLI (per-pid, atomic, stale sweep) and flips our entries off around AskAntigravity spawns (refcounted; session start re-enables)
31
31
  src/approval-gate.ts shadow tool factory (bash/write/edit): marker calls are ticket-verified approval round-trips, non-marker calls delegate; maps agy native tools onto the shadow surface
32
32
  src/approval-detect.ts third-party pi permission-extension detection; resolves approvals.gateMode auto (off until a gate extension exists)
33
33
  src/approval-hook.ts merge-safe .agents/hooks.json staging (PreToolUse) + generated 0600 early-ack/poll hook script; staged timeout exceeds the park budget (hook timeouts soft-pass)
@@ -98,7 +98,7 @@ 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.
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`).
102
102
  - `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
103
  - `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
104
  - `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,6 +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
76
  sweepStaleBridgeServers,
76
77
  unregisterBridgeServer,
77
78
  } from "../src/mcp-registration.js";
@@ -131,6 +132,12 @@ export default async function (pi: ExtensionAPI): Promise<void> {
131
132
  const modelInput: Array<"text" | "image"> = engine === "acp" ? ["text", "image"] : ["text"];
132
133
  const models = entries.map((e) => toPiModel(e, modelInput));
133
134
 
135
+ // failAll reasons the bridge treats as routine (turn end / shutdown
136
+ // sweep), not faults. Shared by the emitters below and the mcpLog
137
+ // classifier so the substring match cannot drift from the text.
138
+ const FAIL_REASON_TURN_END = "antigravity turn ended with an unresolved pi tool call";
139
+ const FAIL_REASON_SHUTDOWN = "antigravity session shut down";
140
+
134
141
  // Daily file log: every sink below feeds ~/.pi/extensions-data/
135
142
  // estebanforge/pi-antigravity-bridge/logs/<YYYY-MM-DD>.ndjson (see
136
143
  // src/daily-log.ts). Fire-and-forget, secrets redacted, old days pruned.
@@ -140,14 +147,15 @@ export default async function (pi: ExtensionAPI): Promise<void> {
140
147
  // routine disk traffic), so a warn that never reaches the UI is lost.
141
148
  // Every warn toasts here instead; AGY_DEBUG=1 restores the full file
142
149
  // trail. Silent exceptions: deliberate aborts (pi already shows
143
- // "Operation aborted"), connection exits (the turn's own error block
144
- // carries real crashes), and call-tool-fail (same-instant duplicate of
145
- // round-trip-fail). No UI (headless): warn text falls back to stderr.
150
+ // "Operation aborted") and connection exits (the turn's own error block
151
+ // carries real crashes). call-tool-fail needs no exclusion here: its tier
152
+ // is debug or error, never warn. No UI (headless): warn text falls back
153
+ // to stderr.
146
154
  const rawFileLog = fileLog.log.bind(fileLog);
147
155
  fileLog.log = (event, data, level) => {
148
156
  rawFileLog(event, data, level);
149
157
  if (level !== "warn") return;
150
- if (event.startsWith("abort:") || event === "connection-exited" || event === "call-tool-fail") return;
158
+ if (event.startsWith("abort:") || event === "connection-exited") return;
151
159
  const d = (data ?? {}) as Record<string, unknown>;
152
160
  let text: string;
153
161
  if (event === "round-trip-fail") {
@@ -341,7 +349,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
341
349
  { status: outcome.status, error: outcome.error?.slice(0, 500), aborted: outcome.aborted },
342
350
  outcome.status === "OK" ? "info" : "warn",
343
351
  );
344
- roundTrips.failAll("antigravity turn ended with an unresolved pi tool call");
352
+ roundTrips.failAll(FAIL_REASON_TURN_END);
345
353
  };
346
354
  streamDriver.onTurnEnd = onTurnEnd;
347
355
  acpDriver.onTurnEnd = onTurnEnd;
@@ -593,18 +601,27 @@ export default async function (pi: ExtensionAPI): Promise<void> {
593
601
  // filters below only decide what reaches the user. Bridge calls
594
602
  // start/end at info (one record per tool call, the fragile-path
595
603
  // skeleton); list-tools and startup chatter stay verbose.
596
- const level = failures.has(s)
597
- ? "warn"
598
- : s === "call-tool" || s === "call-tool-ok"
604
+ // Routine abort traffic (failAll on turn end / session shutdown
605
+ // answers every parked call with an error) is not a fault: debug
606
+ // only. Any other call-tool-fail is a real rejection (e.g. "no
607
+ // active antigravity turn" from a client that should not see the
608
+ // bridge) and lands at error tier - default mode records errors
609
+ // only, so this is the sole durable trace of the incident.
610
+ const detail = (d as { msg?: string } | undefined)?.msg ?? "";
611
+ const routineAbort =
612
+ s === "call-tool-fail" &&
613
+ (detail.includes(FAIL_REASON_TURN_END) || detail.includes(FAIL_REASON_SHUTDOWN));
614
+ const level = !failures.has(s)
615
+ ? s === "call-tool" || s === "call-tool-ok"
599
616
  ? "info"
600
- : "debug";
617
+ : "debug"
618
+ : routineAbort
619
+ ? "debug"
620
+ : s === "call-tool-fail"
621
+ ? "error"
622
+ : "warn";
601
623
  fileLog.log(s, d, level);
602
- // Routine abort traffic: failAll fires on turn end / session shutdown
603
- // and the bridge answers every parked call with an error. Not a fault.
604
- if (s === "call-tool-fail") {
605
- const detail = (d as { msg?: string } | undefined)?.msg ?? "";
606
- if (detail.includes("unresolved pi tool call") || detail.includes("session shut down")) return;
607
- }
624
+ if (routineAbort) return;
608
625
  if (!failures.has(s)) return;
609
626
  const msg = `[antigravity-bridge mcp] ${s}${d !== undefined ? " " + JSON.stringify(d) : ""}`;
610
627
  if (ctx.hasUI) ctx.ui.notify(msg, "warning");
@@ -708,11 +725,11 @@ export default async function (pi: ExtensionAPI): Promise<void> {
708
725
  );
709
726
  if (r.ok && r.handle) {
710
727
  mcpHandle = r.handle;
711
- // Stream-json engine registration: the agy CLI discovers MCP servers
712
- // from ~/.gemini/config/mcp_config.json (ACP uses session/new
713
- // mcpServers instead; verified live 2026-09-07). Per-pid entry,
714
- // removed at session_shutdown; stale entries swept at start.
728
+ // Stale entries swept at start; entries a crashed delegation left
729
+ // suppressed are healed here (live bridges start every session
730
+ // enabled).
715
731
  sweepStaleBridgeServers();
732
+ setBridgeEntriesDisabled(false);
716
733
  registerBridgeServer({
717
734
  pid: process.pid,
718
735
  port: r.handle.port,
@@ -816,7 +833,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
816
833
  const h = mcpHandle;
817
834
  mcpHandle = null;
818
835
  await h?.close();
819
- roundTrips.failAll("antigravity session shut down");
836
+ roundTrips.failAll(FAIL_REASON_SHUTDOWN);
820
837
  // "recycle", NOT "shutdown": pi fires session_shutdown on /new, /resume
821
838
  // and /fork (docs/extensions.md session lifecycle), not only on process
822
839
  // exit. The drivers are process-lifetime singletons; closing them with
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estebanforge/pi-antigravity-bridge",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
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
@@ -25,6 +25,7 @@ import {
25
25
  snapshotConversations,
26
26
  } from "./discovery.js";
27
27
  import { loadConfig, type AgyMode, type ThinkingTier } from "./config.js";
28
+ import { acquireBridgeSuppression } from "./mcp-registration.js";
28
29
  import { spawnAgyModelsRaw } from "./models.js";
29
30
 
30
31
  // --- Constants -------------------------------------------------------------
@@ -46,6 +47,15 @@ const FAMILY_DEFAULT_TIER: Record<Family, ThinkingTier> = {
46
47
  pro: "high",
47
48
  other: "medium",
48
49
  };
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;
49
59
  const TIER_RANK: Record<ThinkingTier, number> = { low: 0, medium: 1, high: 2 };
50
60
 
51
61
  // Static alias overlay for non-Gemini models agy may or may not surface.
@@ -510,6 +520,31 @@ export async function registerAskAntigravityTool(
510
520
  const binary = process.env.AGY_BIN || "agy";
511
521
  let out = "";
512
522
 
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
+ };
547
+
513
548
  const statusInterval = onUpdate
514
549
  ? setInterval(() => {
515
550
  const elapsed = Math.floor((Date.now() - start) / 1000);
@@ -592,6 +627,7 @@ export async function registerAskAntigravityTool(
592
627
  if (watchdog) clearTimeout(watchdog);
593
628
  if (sigkillTimer) clearTimeout(sigkillTimer);
594
629
  if (signal) signal.removeEventListener("abort", onAbort);
630
+ restoreBridge();
595
631
  };
596
632
  const onAbort = () => killTree();
597
633
 
@@ -698,6 +734,9 @@ export async function registerAskAntigravityTool(
698
734
  return { content: [{ type: "text", text: `failed to run agy: ${msg}` }], details };
699
735
  }
700
736
  finally {
737
+ // Belt and braces: cleanup() already restores on close/error; this
738
+ // covers paths that never reached the process (sync spawn throw).
739
+ restoreBridge();
701
740
  if (contextFile) {
702
741
  try {
703
742
  fs.unlinkSync(contextFile);
@@ -93,6 +93,61 @@ export function unregisterBridgeServer(pid: number, configPath: string = mcpConf
93
93
  return { wrote: true };
94
94
  }
95
95
 
96
+ /** Flip `disabled` on every pi-bridge-* entry (foreign servers untouched).
97
+ *
98
+ * (1) Delegation isolation: the global config is read by ANY agy on the
99
+ * machine, so an `agy -p` we spawn ourselves (AskAntigravity) would discover
100
+ * live bridge entries and call tools the round-trip store cannot serve
101
+ * outside a live provider turn (fail-closed "no active antigravity turn",
102
+ * observed live 2026-09-15). agy reads the config once at startup, so a
103
+ * short suppression window around the spawn hides the bridge from it.
104
+ *
105
+ * (2) Startup healing (disabled=false): clears entries a crashed delegation
106
+ * left suppressed. */
107
+ export function setBridgeEntriesDisabled(
108
+ disabled: boolean,
109
+ configPath: string = mcpConfigPath(),
110
+ ): { wrote: boolean; changed: number; reason?: string } {
111
+ const read = readConfig(configPath);
112
+ if (!read.ok) return { wrote: false, changed: 0, reason: read.reason };
113
+ let changed = 0;
114
+ for (const [name, entry] of Object.entries(read.config.mcpServers)) {
115
+ if (!/^pi-bridge-\d+$/.test(name)) continue;
116
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
117
+ const e = entry as { disabled?: unknown };
118
+ if ((e.disabled === true) === disabled) continue;
119
+ e.disabled = disabled;
120
+ changed++;
121
+ }
122
+ if (changed === 0) return { wrote: false, changed: 0 };
123
+ writeConfig(configPath, read.config);
124
+ return { wrote: true, changed };
125
+ }
126
+
127
+ const suppressionRefs = new Map<string, number>();
128
+
129
+ /** 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);
137
+ const refs = (suppressionRefs.get(key) ?? 0) + 1;
138
+ suppressionRefs.set(key, refs);
139
+ if (refs === 1) setBridgeEntriesDisabled(true, configPath);
140
+ let released = false;
141
+ return () => {
142
+ if (released) return;
143
+ released = true;
144
+ const left = Math.max(0, (suppressionRefs.get(key) ?? 1) - 1);
145
+ if (left === 0) suppressionRefs.delete(key);
146
+ else suppressionRefs.set(key, left);
147
+ if (left === 0) setBridgeEntriesDisabled(false, configPath);
148
+ };
149
+ }
150
+
96
151
  /** Default liveness probe: can the signal be delivered? */
97
152
  function pidAlive(pid: number): boolean {
98
153
  try {