@estebanforge/pi-antigravity-bridge 1.4.9 → 1.4.10

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.
@@ -17,10 +17,15 @@
17
17
  // ~/.pi/agent/antigravity-bridge/config.json so toggles survive restarts.
18
18
 
19
19
  import os from "node:os";
20
+ import fs from "node:fs";
21
+ import path from "node:path";
20
22
  import {
21
23
  type ExtensionAPI,
22
24
  type ExtensionCommandContext,
23
25
  type ExtensionUIContext,
26
+ createBashToolDefinition,
27
+ createEditToolDefinition,
28
+ createWriteToolDefinition,
24
29
  getSettingsListTheme,
25
30
  } from "@earendil-works/pi-coding-agent";
26
31
  import {
@@ -38,6 +43,7 @@ import {
38
43
  } from "../src/models.js";
39
44
  import { SessionStore } from "../src/sessions.js";
40
45
  import {
46
+ APPROVAL_PARK_MS,
41
47
  POLL_TOOL_NAME,
42
48
  ToolRoundTrips,
43
49
  WrapperReplay,
@@ -45,7 +51,15 @@ import {
45
51
  formatEscalatedAck,
46
52
  formatPollAnswer,
47
53
  } from "../src/provider.js";
48
- import { AgyDriver } from "../src/driver.js";
54
+ import {
55
+ createShadowTool,
56
+ stripMarkerFields,
57
+ type AnyToolDefinition,
58
+ type GatePolicy,
59
+ } from "../src/approval-gate.js";
60
+ import { detectPermissionGateExtensions, resolveGateMode } from "../src/approval-detect.js";
61
+ import { hookScriptSource, removeGateHooks, stageGateHooks } from "../src/approval-hook.js";
62
+ import { StreamDriver } from "../src/driver.js";
49
63
  import { AcpDriver } from "../src/acp/driver.js";
50
64
  import { runAcpAuth } from "../src/acp/auth.js";
51
65
  import { setupAuthUrlCapture } from "../src/acp/browser-capture.js";
@@ -55,6 +69,11 @@ import { CONFIG_PATH, loadConfig, logsDir, saveConfig, type AgyMode, type Bridge
55
69
  import { createDailyLogger, type DailyLogger } from "../src/daily-log.js";
56
70
  import { registerAskAntigravityTool, toolModelsFromRaw } from "../src/ask-tool.js";
57
71
  import { startMcpServer, TOKEN_HEADER, type McpServerHandle } from "../src/mcp-server.js";
72
+ import {
73
+ registerBridgeServer,
74
+ sweepStaleBridgeServers,
75
+ unregisterBridgeServer,
76
+ } from "../src/mcp-registration.js";
58
77
  import {
59
78
  ACTIVATE_SKILL_TOOL_NAME,
60
79
  activateSkillSchema,
@@ -107,7 +126,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
107
126
  const engine: Engine = loadConfig().engine;
108
127
  // Engine switching requires a restart, so the catalog-time engine read is
109
128
  // authoritative for input advertising: image attach rides only when turns
110
- // will run on the ACP engine (the legacy CLI prompt is text-only).
129
+ // will run on the ACP engine (the stream-json CLI prompt is text-only).
111
130
  const modelInput: Array<"text" | "image"> = engine === "acp" ? ["text", "image"] : ["text"];
112
131
  const models = entries.map((e) => toPiModel(e, modelInput));
113
132
 
@@ -156,6 +175,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
156
175
  // MCP bridge handle, declared early: the ACP engine reads the bridge port
157
176
  // at session/new / session/load time.
158
177
  let mcpHandle: McpServerHandle | null = null;
178
+ // Approval-gate hook script (per-pid, token embedded). Written at session
179
+ // start when the gate is active; removed at session_shutdown.
180
+ let gateScriptPath: string | null = null;
159
181
  // ACP self-heal runs once per process (session_start re-fires on /reload;
160
182
  // a ready setup is two file stats, so re-running is harmless anyway).
161
183
  let acpSelfHealRan = false;
@@ -184,11 +206,11 @@ export default async function (pi: ExtensionAPI): Promise<void> {
184
206
  "session-load-failed-creating-fresh", "connection-exited", "cancel-failed",
185
207
  "unsupported-server-request",
186
208
  ]);
187
- const legacyDriver = new AgyDriver();
188
- // Mirror the legacy driver's lifecycle ring into the daily file log
209
+ const streamDriver = new StreamDriver();
210
+ // Mirror the stream driver's lifecycle ring into the daily file log
189
211
  // (spawn/exit/abort/stall/recycle). The ACP driver reaches the same file
190
212
  // through acpLog below.
191
- legacyDriver.log = (msg, data) => {
213
+ streamDriver.log = (msg, data) => {
192
214
  // Level classification mirrors acpLog's failure set: stalls, aborts,
193
215
  // timeouts and nonzero exits are the "what broke" greps (warn);
194
216
  // turn-start is the per-turn skeleton (info); everything else is
@@ -260,7 +282,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
260
282
  const handle = mcpHandle;
261
283
  if (!handle) return [];
262
284
  // The bridge 403s any request without the shared-secret header; the
263
- // legacy engine carries it via mcp_config.json, ACP via headers[].
285
+ // stream engine carries it via mcp_config.json, ACP via headers[].
264
286
  return [
265
287
  {
266
288
  name: "pi-bridge",
@@ -272,15 +294,23 @@ export default async function (pi: ExtensionAPI): Promise<void> {
272
294
  },
273
295
  });
274
296
  // The active engine is resolved from the latched load-time value.
275
- const activeDriver = (): TurnDriver => (engine === "acp" ? acpDriver : legacyDriver);
276
- // The provider's stream-json slot gets the LEGACY driver explicitly - never
297
+ const activeDriver = (): TurnDriver => (engine === "acp" ? acpDriver : streamDriver);
298
+ // The provider's stream-json slot gets the STREAM driver explicitly - never
277
299
  // activeDriver(), or a load-time acp engine would make deps.driver and
278
300
  // deps.acpDriver the same object and break the engine identity check.
279
- const driver = legacyDriver;
301
+ const driver = streamDriver;
280
302
  // The no-patch pi-tool round-trip store: the MCP bridge parks calls here;
281
303
  // the provider emits them as real pi toolUse turns and completes them from
282
304
  // the next call's toolResult.
283
- const roundTrips = new ToolRoundTrips(activeDriver, (s, d) => fileLog.log(s, d, s === "round-trip-fail" ? "warn" : "debug"));
305
+ const roundTrips = new ToolRoundTrips(
306
+ activeDriver,
307
+ (s, d, level) =>
308
+ fileLog.log(
309
+ s,
310
+ d,
311
+ level ?? (s === "round-trip-fail" ? "warn" : "debug"),
312
+ ),
313
+ );
284
314
  const replay = new WrapperReplay();
285
315
  // Native re-exec only emits for builtins actually active in the session;
286
316
  // anything else (or an unknown name) falls back to the wrapper card.
@@ -306,7 +336,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
306
336
  );
307
337
  roundTrips.failAll("antigravity turn ended with an unresolved pi tool call");
308
338
  };
309
- legacyDriver.onTurnEnd = onTurnEnd;
339
+ streamDriver.onTurnEnd = onTurnEnd;
310
340
  acpDriver.onTurnEnd = onTurnEnd;
311
341
  const streamSimple = createStreamSimple({
312
342
  entries,
@@ -557,9 +587,113 @@ export default async function (pi: ExtensionAPI): Promise<void> {
557
587
  isError: !skill,
558
588
  });
559
589
  };
560
- const r = await startMcpServer({ listTools, onToolCall: bridgeOnToolCall }, { log: mcpLog });
590
+ const r = await startMcpServer(
591
+ {
592
+ listTools,
593
+ onToolCall: bridgeOnToolCall,
594
+ onApproval: (ticket, payload) => roundTrips.onApproval(ticket, payload),
595
+ },
596
+ { log: mcpLog },
597
+ );
561
598
  if (r.ok && r.handle) {
562
599
  mcpHandle = r.handle;
600
+ // Stream-json engine registration: the agy CLI discovers MCP servers
601
+ // from ~/.gemini/config/mcp_config.json (ACP uses session/new
602
+ // mcpServers instead; verified live 2026-09-07). Per-pid entry,
603
+ // removed at session_shutdown; stale entries swept at start.
604
+ sweepStaleBridgeServers();
605
+ registerBridgeServer({
606
+ pid: process.pid,
607
+ port: r.handle.port,
608
+ token: r.handle.token,
609
+ tokenHeader: TOKEN_HEADER,
610
+ });
611
+ // --- Approval gate (docs/TODO.md 2.5) ------------------------------
612
+ // agy native tool calls pass through a pi-side approval: a PreToolUse
613
+ // hook parks in the bridge, the provider emits a shadow toolUse, and
614
+ // pi's permission extensions gate it like any native call. Off until
615
+ // enabled (auto = on only when a third-party gate extension exists).
616
+ {
617
+ const cfg = loadConfig();
618
+ const mode = resolveGateMode(cfg.approvals.gateMode, detectPermissionGateExtensions());
619
+ if (mode === "dedicated") {
620
+ // The explicit antigravity_approve variant is planned; until it
621
+ // ships, dedicated stages the same shadow tools. Say so, so the
622
+ // config value never lies silently.
623
+ fileLog.log("approval-dedicated-as-shadow", {}, "warn");
624
+ }
625
+ if (mode === "off") {
626
+ // Gate off: remove ONLY this session's group. Other sessions'
627
+ // groups in a shared workspace are never touched - a gate-off
628
+ // session must not strip a gate-on session's matchers.
629
+ const unstaged = removeGateHooks(process.cwd());
630
+ if (unstaged.wrote) fileLog.log("approval-unstaged", unstaged, "info");
631
+ } else {
632
+ // Script: per-pid file; 0600 because the bridge token is
633
+ // embedded (peer review 2026-09-07).
634
+ const scriptPath = path.join(logsDir(), `approval-hook-${process.pid}.js`);
635
+ fs.mkdirSync(path.dirname(scriptPath), { recursive: true, mode: 0o700 });
636
+ fs.writeFileSync(
637
+ scriptPath,
638
+ hookScriptSource({
639
+ port: r.handle.port,
640
+ token: r.handle.token,
641
+ deadlineMs: APPROVAL_PARK_MS,
642
+ }),
643
+ { mode: 0o600 },
644
+ );
645
+ gateScriptPath = scriptPath;
646
+ const staged = stageGateHooks(process.cwd(), {
647
+ port: r.handle.port,
648
+ token: r.handle.token,
649
+ scriptPath,
650
+ parkBudgetMs: APPROVAL_PARK_MS,
651
+ });
652
+ fileLog.log("approval-staged", { mode, script: scriptPath, ...staged }, staged.wrote ? "info" : "debug");
653
+
654
+ // Shadow bases: factory twins of pi's own builtins (public API).
655
+ // pi.getAllTools() is unusable here: it returns ToolInfo, which
656
+ // strips execute. Marker calls never execute; non-marker calls
657
+ // delegate to the twins, so behavior matches the standard
658
+ // builtins (session-level bash-operations overrides are not
659
+ // inherited; documented in README).
660
+ const gateCwd = process.cwd();
661
+ const bases: Record<string, AnyToolDefinition> = {
662
+ bash: createBashToolDefinition(gateCwd) as unknown as AnyToolDefinition,
663
+ write: createWriteToolDefinition(gateCwd) as unknown as AnyToolDefinition,
664
+ edit: createEditToolDefinition(gateCwd) as unknown as AnyToolDefinition,
665
+ };
666
+ const askMode = cfg.approvals.mode;
667
+ const policy: GatePolicy = async ({ tool, params, ctx }) => {
668
+ if (askMode === "allow") return { allow: true };
669
+ if (askMode === "deny") {
670
+ return { allow: false, reason: `blocked by approval gate (mode: deny): ${tool}` };
671
+ }
672
+ const extCtx = ctx as { hasUI?: boolean; ui?: Pick<ExtensionUIContext, "confirm"> } | undefined;
673
+ if (!extCtx?.hasUI || typeof extCtx.ui?.confirm !== "function") {
674
+ return { allow: false, reason: `approval gate: no UI to approve ${tool} (headless)` };
675
+ }
676
+ const what =
677
+ typeof params.command === "string"
678
+ ? params.command
679
+ : typeof params.path === "string"
680
+ ? params.path
681
+ : JSON.stringify(stripMarkerFields(params)).slice(0, 200);
682
+ const ok = await extCtx.ui.confirm(`agy ${tool}?`, what, { timeout: APPROVAL_PARK_MS });
683
+ return ok ? { allow: true } : { allow: false, reason: `declined in pi (agy ${tool})` };
684
+ };
685
+ const handle = r.handle;
686
+ for (const [name, base] of Object.entries(bases)) {
687
+ pi.registerTool(
688
+ createShadowTool(base, policy, { verifyTicket: (t) => handle.approvals.has(t) }),
689
+ );
690
+ }
691
+ // The park is wired ONLY after the shadows are registered: an
692
+ // approval toolUse must never dispatch to the REAL builtin and
693
+ // execute locally.
694
+ roundTrips.approvalPark = handle.approvals;
695
+ }
696
+ }
563
697
  } else {
564
698
  console.error(`[antigravity-bridge] MCP tool bridge disabled: ${r.reason}`);
565
699
  }
@@ -572,8 +706,29 @@ export default async function (pi: ExtensionAPI): Promise<void> {
572
706
  mcpHandle = null;
573
707
  await h?.close();
574
708
  roundTrips.failAll("antigravity session shut down");
575
- await legacyDriver.close("shutdown");
576
- await acpDriver.close("shutdown");
709
+ // "recycle", NOT "shutdown": pi fires session_shutdown on /new, /resume
710
+ // and /fork (docs/extensions.md session lifecycle), not only on process
711
+ // exit. The drivers are process-lifetime singletons; closing them with
712
+ // "shutdown" latched them permanently and every later turn failed with
713
+ // "ACP driver is shut down." (regression 2026-09-07). Recycle kills the
714
+ // connection now; the next turn respawns it. event.reason is
715
+ // deliberately ignored: recycle is correct even on real process exit
716
+ // ("quit") - the connection kill is identical and nothing runs after.
717
+ await streamDriver.close("recycle", "session shutdown");
718
+ await acpDriver.close("recycle", "session shutdown");
719
+ unregisterBridgeServer(process.pid);
720
+ // Approval gate: unstage hooks and remove the per-pid script. Pending
721
+ // approvals already failed closed via handle close (bridge shutdown deny).
722
+ const unstaged = removeGateHooks(process.cwd());
723
+ if (unstaged.wrote) fileLog.log("approval-unstaged", unstaged, "info");
724
+ if (gateScriptPath) {
725
+ try {
726
+ fs.rmSync(gateScriptPath, { force: true });
727
+ } catch {
728
+ /* best effort */
729
+ }
730
+ gateScriptPath = null;
731
+ }
577
732
  });
578
733
  }
579
734
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estebanforge/pi-antigravity-bridge",
3
- "version": "1.4.9",
3
+ "version": "1.4.10",
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/acp/driver.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  // AcpDriver: the ACP turn engine. Implements the same TurnDriver surface as
2
- // the legacy stream-json driver (see src/driver-types.ts) so provider.ts and
2
+ // the stream-json driver (see src/driver-types.ts) so provider.ts and
3
3
  // the G9 round-trip store work unchanged.
4
4
  //
5
- // Engine differences vs legacy, all verified live (docs/ACP-PROTOCOL-REFERENCE.md):
5
+ // Engine differences vs stream-json, all verified live (docs/ACP-PROTOCOL-REFERENCE.md):
6
6
  // - no process recycle on profile drift: one server process, sessions
7
7
  // selected per turn via session/new / session/load
8
8
  // - model/effort via session/set_config_option (configId "model", FULL slug
@@ -110,7 +110,6 @@ export class AcpDriver implements TurnDriver {
110
110
  #generation = 0;
111
111
  #active: ActiveTurn | undefined;
112
112
  #queueTail: Promise<void> = Promise.resolve();
113
- #shutdown = false;
114
113
  #lifecycle: string[] = [];
115
114
  #onTurnEnd: ((outcome: TurnOutcome) => void) | undefined;
116
115
  #stats = {
@@ -168,7 +167,7 @@ export class AcpDriver implements TurnDriver {
168
167
  }
169
168
 
170
169
  /** Turns are serialized; a parked turn stays open and the continuation
171
- * path uses reentry() (same contract as the legacy driver). */
170
+ * path uses reentry() (same contract as the stream-json driver). */
172
171
  run(request: DriverTurnRequest): Promise<TurnHandle> {
173
172
  let release!: () => void;
174
173
  const prev = this.#queueTail;
@@ -190,7 +189,10 @@ export class AcpDriver implements TurnDriver {
190
189
  }
191
190
 
192
191
  #runExclusive(request: DriverTurnRequest): Promise<TurnHandle> {
193
- if (this.#shutdown) return Promise.reject(new Error("ACP driver is shut down."));
192
+ // No shutdown latch here: pi fires session_shutdown on /new, /resume and
193
+ // /fork (not only process exit), so a closed driver must respawn on the
194
+ // next turn instead of rejecting forever. Regression 2026-09-07:
195
+ // /compact after a model switch failed with "ACP driver is shut down."
194
196
  if (request.signal?.aborted) return Promise.reject(new Error("aborted before start"));
195
197
 
196
198
  const turn = this.#createTurn(request);
@@ -218,7 +220,7 @@ export class AcpDriver implements TurnDriver {
218
220
  }
219
221
 
220
222
  // Execute asynchronously: the handle returns as soon as the prompt is
221
- // dispatched, and activities stream through next() (legacy contract).
223
+ // dispatched, and activities stream through next() (stream-json contract).
222
224
  void this.#executeTurn(turn).catch((err: unknown) => {
223
225
  this.#failTurn(turn, `ACP turn failed: ${describe(err)}`);
224
226
  });
@@ -679,7 +681,6 @@ export class AcpDriver implements TurnDriver {
679
681
  // --- TurnDriver surface ----------------------------------------------------
680
682
 
681
683
  async close(reason: "recycle" | "shutdown", cause?: string): Promise<void> {
682
- if (reason === "shutdown") this.#shutdown = true;
683
684
  this.#log(`close:${reason}${cause ? `:${cause}` : ""}`);
684
685
  const turn = this.#active;
685
686
  if (turn && !turn.closed) {
@@ -687,7 +688,7 @@ export class AcpDriver implements TurnDriver {
687
688
  conversationId: turn.sessionId,
688
689
  status: "ERROR",
689
690
  response: turn.response.text,
690
- error: `ACP driver ${reason}ed mid-turn${cause ? ` (${cause})` : ""}`,
691
+ error: `ACP driver ${reason === "recycle" ? "recycled" : "shut down"} mid-turn${cause ? ` (${cause})` : ""}`,
691
692
  finished: true,
692
693
  aborted: false,
693
694
  });
@@ -0,0 +1,146 @@
1
+ // Third-party pi permission-extension detection for the approval gate
2
+ // (docs/TODO.md 2.5). The gate defaults to "auto": OFF until one of these
3
+ // extensions is present. Detection reads pi's settings (the packages array
4
+ // is the primary, name-exact signal) plus known on-disk config markers for
5
+ // the audited permission packages (sources: ~/tmp/pi-perm-research/, audit
6
+ // 2026-09-07). Best effort by design: a miss only means the user enables
7
+ // the gate manually; a false positive stages hooks.json, which is inert
8
+ // unless the ACP/CLI server loads it, and observation-only hooks are safe.
9
+ //
10
+ // Run: npm test
11
+
12
+ import fs from "node:fs";
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+
16
+ /** npm names of the audited pi permission packages (docs/TODO.md 2.4). */
17
+ export const KNOWN_GATE_PACKAGES = [
18
+ "@gotgenes/pi-permission-system",
19
+ "@zhushanwen/pi-permission",
20
+ "pi-permission-system",
21
+ "@xzzpig/pi-permission-system",
22
+ "@diegopetrucci/pi-permission-gate",
23
+ "pi-permission-modes",
24
+ "@inobit/pi-permission",
25
+ "@thurstonsand/pi-permissions",
26
+ "@monroewilliams/pi-permission-system",
27
+ "@rhedbull/pi-permissions",
28
+ ] as const;
29
+
30
+ export interface GateExtensionHit {
31
+ /** The known package name matched. */
32
+ name: string;
33
+ /** How it was detected: "settings:<path>" or "config:<path>". */
34
+ evidence: string;
35
+ }
36
+
37
+ function readJsonIfPresent(file: string): { packages?: unknown } | undefined {
38
+ try {
39
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
40
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
41
+ } catch {
42
+ return undefined;
43
+ }
44
+ }
45
+
46
+ function packagesFrom(settingsFile: string): string[] {
47
+ const parsed = readJsonIfPresent(settingsFile);
48
+ const raw = parsed?.packages;
49
+ if (!Array.isArray(raw)) return [];
50
+ return raw.filter((entry): entry is string => typeof entry === "string");
51
+ }
52
+
53
+ function nameMatchesPackage(entry: string): string | undefined {
54
+ // entries look like "npm:@scope/name@1.2.3", "git:...", or a bare path.
55
+ // Boundary-aware match: the known name must start after :/@ (or the
56
+ // string start) and must not be a prefix of a longer package name
57
+ // ("pi-permission-system-clone" must NOT match). Version suffix @x.y is
58
+ // fine. Peer review 2026-09-07 finding 4.
59
+ const lower = entry.toLowerCase();
60
+ let best: string | undefined;
61
+ for (const name of KNOWN_GATE_PACKAGES) {
62
+ if (containsName(lower, name) && (best === undefined || name.length > best.length)) best = name;
63
+ }
64
+ // Longest match wins: "@xzzpig/pi-permission-system" must resolve to the
65
+ // scoped name, not to the shorter unscoped fork "pi-permission-system".
66
+ return best;
67
+ }
68
+
69
+ function containsName(lower: string, name: string): boolean {
70
+ let idx = lower.indexOf(name);
71
+ while (idx >= 0) {
72
+ const before = idx === 0 ? "" : lower[idx - 1];
73
+ const after = lower[idx + name.length] ?? "";
74
+ const okBefore = before === "" || ":/@".includes(before);
75
+ const okAfter = after === "" || after === "@" || !/[a-z0-9_-]/.test(after);
76
+ if (okBefore && okAfter) return true;
77
+ idx = lower.indexOf(name, idx + 1);
78
+ }
79
+ return false;
80
+ }
81
+
82
+ /**
83
+ * Detect installed third-party permission extensions.
84
+ *
85
+ * @param opts.home HOME override for tests.
86
+ * @param opts.cwd project dir override for tests (project settings).
87
+ * @param opts.settingsFiles extra settings files to scan (tests).
88
+ */
89
+ export function detectPermissionGateExtensions(
90
+ opts: { home?: string; cwd?: string; settingsFiles?: string[] } = {},
91
+ ): GateExtensionHit[] {
92
+ const home = opts.home ?? os.homedir();
93
+ const cwd = opts.cwd ?? process.cwd();
94
+ const hits: GateExtensionHit[] = [];
95
+ const seen = new Set<string>();
96
+
97
+ const settingsCandidates = [
98
+ ...(opts.settingsFiles ?? []),
99
+ path.join(home, ".pi", "agent", "settings.json"),
100
+ path.join(cwd, ".pi", "settings.json"),
101
+ ];
102
+ for (const file of settingsCandidates) {
103
+ for (const entry of packagesFrom(file)) {
104
+ const name = nameMatchesPackage(entry);
105
+ if (name && !seen.has(name)) {
106
+ seen.add(name);
107
+ hits.push({ name, evidence: `settings:${file}` });
108
+ }
109
+ }
110
+ }
111
+
112
+ // Known config-file markers from the audit. Presence of the config does
113
+ // not prove the extension is installed, but every audited package writes
114
+ // its config only after install + first run, which is evidence enough for
115
+ // an opt-in default.
116
+ const markers: Array<{ file: string; name: string }> = [
117
+ { file: path.join(home, ".pi", "agent", "extensions", "pi-permission-system"), name: "@gotgenes/pi-permission-system" },
118
+ { file: path.join(home, ".agent", "pi-permissions.jsonc"), name: "@monroewilliams/pi-permission-system" },
119
+ { file: path.join(home, ".pi", "agent", "extensions", "permissions.json"), name: "@rhedbull/pi-permissions" },
120
+ { file: path.join(cwd, ".pi", "agent", "pi-permissions.jsonc"), name: "@gotgenes/pi-permission-system" },
121
+ ];
122
+ for (const marker of markers) {
123
+ if (!seen.has(marker.name)) {
124
+ try {
125
+ fs.statSync(marker.file);
126
+ seen.add(marker.name);
127
+ hits.push({ name: marker.name, evidence: `config:${marker.file}` });
128
+ } catch {
129
+ /* absent - fine */
130
+ }
131
+ }
132
+ }
133
+
134
+ return hits;
135
+ }
136
+
137
+ /** Resolve the effective gate shape from config + detection. "auto" defers
138
+ * to detection (shadow when any gate extension is present, else off). */
139
+ export function resolveGateMode(
140
+ gateMode: "auto" | "shadow" | "dedicated" | "off",
141
+ hits: GateExtensionHit[],
142
+ ): "shadow" | "dedicated" | "off" {
143
+ if (gateMode === "off") return "off";
144
+ if (gateMode === "shadow" || gateMode === "dedicated") return gateMode;
145
+ return hits.length > 0 ? "shadow" : "off";
146
+ }