@estebanforge/pi-antigravity-bridge 1.5.0 → 1.5.2

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,28 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.5.2] - 2026-09-15
6
+
7
+ ### Fixed
8
+
9
+ - 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).
10
+ - 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.
11
+
12
+ ### Changed
13
+
14
+ - 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.
15
+
16
+ ## [1.5.1] - 2026-09-15
17
+
18
+ ### Fixed
19
+
20
+ - 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.
21
+ - 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.
22
+
23
+ ### Changed
24
+
25
+ - 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.
26
+
5
27
  ## [1.5.0] - 2026-09-08
6
28
 
7
29
  ### 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 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.
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,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.
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,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
+ healBridgeSuppression,
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,12 @@ 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 - but only when no live delegation is in
730
+ // flight anywhere (marker-aware heal). A blind re-enable used to
731
+ // un-hide the bridge during another session's active delegation.
715
732
  sweepStaleBridgeServers();
733
+ healBridgeSuppression();
716
734
  registerBridgeServer({
717
735
  pid: process.pid,
718
736
  port: r.handle.port,
@@ -816,7 +834,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
816
834
  const h = mcpHandle;
817
835
  mcpHandle = null;
818
836
  await h?.close();
819
- roundTrips.failAll("antigravity session shut down");
837
+ roundTrips.failAll(FAIL_REASON_SHUTDOWN);
820
838
  // "recycle", NOT "shutdown": pi fires session_shutdown on /new, /resume
821
839
  // and /fork (docs/extensions.md session lifecycle), not only on process
822
840
  // 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.2",
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,7 @@ const FAMILY_DEFAULT_TIER: Record<Family, ThinkingTier> = {
46
47
  pro: "high",
47
48
  other: "medium",
48
49
  };
50
+
49
51
  const TIER_RANK: Record<ThinkingTier, number> = { low: 0, medium: 1, high: 2 };
50
52
 
51
53
  // Static alias overlay for non-Gemini models agy may or may not surface.
@@ -510,6 +512,22 @@ export async function registerAskAntigravityTool(
510
512
  const binary = process.env.AGY_BIN || "agy";
511
513
  let out = "";
512
514
 
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();
530
+
513
531
  const statusInterval = onUpdate
514
532
  ? setInterval(() => {
515
533
  const elapsed = Math.floor((Date.now() - start) / 1000);
@@ -592,6 +610,7 @@ export async function registerAskAntigravityTool(
592
610
  if (watchdog) clearTimeout(watchdog);
593
611
  if (sigkillTimer) clearTimeout(sigkillTimer);
594
612
  if (signal) signal.removeEventListener("abort", onAbort);
613
+ restoreBridge();
595
614
  };
596
615
  const onAbort = () => killTree();
597
616
 
@@ -698,6 +717,9 @@ export async function registerAskAntigravityTool(
698
717
  return { content: [{ type: "text", text: `failed to run agy: ${msg}` }], details };
699
718
  }
700
719
  finally {
720
+ // Belt and braces: cleanup() already restores on close/error; this
721
+ // covers paths that never reached the process (sync spawn throw).
722
+ restoreBridge();
701
723
  if (contextFile) {
702
724
  try {
703
725
  fs.unlinkSync(contextFile);
@@ -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). */
@@ -93,6 +105,215 @@ export function unregisterBridgeServer(pid: number, configPath: string = mcpConf
93
105
  return { wrote: true };
94
106
  }
95
107
 
108
+ /** Flip `disabled` on every pi-bridge-* entry (foreign servers untouched).
109
+ *
110
+ * (1) Delegation isolation: the global config is read by ANY agy on the
111
+ * machine, so an `agy -p` we spawn ourselves (AskAntigravity) would discover
112
+ * live bridge entries and call tools the round-trip store cannot serve
113
+ * outside a live provider turn (fail-closed "no active antigravity turn",
114
+ * observed live 2026-09-15). agy reads the config once at startup, so a
115
+ * short suppression window around the spawn hides the bridge from it.
116
+ *
117
+ * (2) Startup healing (disabled=false): clears entries a crashed delegation
118
+ * left suppressed. */
119
+ export function setBridgeEntriesDisabled(
120
+ disabled: boolean,
121
+ configPath: string = mcpConfigPath(),
122
+ ): { wrote: boolean; changed: number; reason?: string } {
123
+ const read = readConfig(configPath);
124
+ if (!read.ok) return { wrote: false, changed: 0, reason: read.reason };
125
+ let changed = 0;
126
+ for (const [name, entry] of Object.entries(read.config.mcpServers)) {
127
+ if (!/^pi-bridge-\d+$/.test(name)) continue;
128
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
129
+ const e = entry as { disabled?: unknown };
130
+ if ((e.disabled === true) === disabled) continue;
131
+ e.disabled = disabled;
132
+ changed++;
133
+ }
134
+ if (changed === 0) return { wrote: false, changed: 0 };
135
+ writeConfig(configPath, read.config);
136
+ return { wrote: true, changed };
137
+ }
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
+
224
+ const suppressionRefs = new Map<string, number>();
225
+
226
+ /** Reference-counted suppression for a self-spawned agy process (AskAntigravity
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}`;
244
+ const refs = (suppressionRefs.get(key) ?? 0) + 1;
245
+ suppressionRefs.set(key, refs);
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
+ }
256
+ let released = false;
257
+ return () => {
258
+ if (released) return;
259
+ released = true;
260
+ const left = Math.max(0, (suppressionRefs.get(key) ?? 1) - 1);
261
+ if (left === 0) suppressionRefs.delete(key);
262
+ else suppressionRefs.set(key, left);
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
+ }
276
+ };
277
+ }
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
+
96
317
  /** Default liveness probe: can the signal be delivered? */
97
318
  function pidAlive(pid: number): boolean {
98
319
  try {