@estebanforge/pi-antigravity-bridge 1.6.2 → 1.6.4

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,12 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.6.3] - 2026-09-22
6
+
7
+ ### Fixed
8
+
9
+ - **The approval gate no longer leaks into standalone sessions, and its verdicts parse.** Thanks @explesy for the report (https://github.com/EstebanForge/pi-antigravity-bridge/issues/5). Two defects in the generated hook script and its staging. Every terminal path printed the bare decision scalar (`"deny"` / `"allow"`) instead of a JSON object, so any session that loaded a staged gate failed hook parsing outright (`protojson: syntax error`): the direct-POST and polling branches now print the full response object guarded by a `typeof json.decision === "string"` check, fall back to a fail-closed deny object on malformed bodies, and read ticket and poll status through `json?.`. The gate group itself was staged into the shared workspace `.agents/hooks.json`, so any standalone IDE or CLI session in that workspace loaded pi's gate: tool calls were denied with "no active antigravity turn" while pi sat idle, and mid-turn they parked into pi's approval queue. A live probe of the CLI settled the discovery rule (hooks load from `--add-dir` directories in stream-json and interactive sessions; print mode reads only the global config), and the gate now stages `.agents/hooks.json` into the session-private per-pid bridge dir beside `mcp_config.json`, which only this session's agy receives as an extra `--add-dir`. Staging into a pid-private file dropped the merge/backup/symlink surface to a plain atomic replace; the driver passes the dir when the gate is staged even without an MCP config; a legacy sweep at session start strips groups that dead sessions left in workspace files (live and foreign groups are never touched). The hook also gained per-fetch aborts (`AbortSignal.timeout`, POST capped at 10s, polls bounded by the remaining park budget) so a hung bridge request can never outlive the park deadline into an ungated soft-pass. Seven stub-server hook-execution regression tests pin the object contract; the staging suite covers the private-dir semantics and the sweep. 364 tests, tsc clean.
10
+
5
11
  ## [1.6.2] - 2026-09-19
6
12
 
7
13
  ### Fixed
package/README.md CHANGED
@@ -13,7 +13,7 @@ A Gemini model provider **and** the `AskAntigravity` delegation tool for [pi](ht
13
13
  </p>
14
14
 
15
15
 
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.
16
+ If you also have [`@estebanforge/pi-ask-antigravity`](https://github.com/EstebanForge/pi-extensions/tree/main/packages/pi-ask-antigravity) installed, this bridge takes over: pi-ask-antigravity detects the bridge and registers nothing, so the `AskAntigravity` tool is never duplicated.
17
17
 
18
18
  ## What it does
19
19
 
@@ -58,7 +58,7 @@ import {
58
58
  type GatePolicy,
59
59
  } from "../src/approval-gate.js";
60
60
  import { detectPermissionGateExtensions, resolveGateMode } from "../src/approval-detect.js";
61
- import { hookScriptSource, removeGateHooks, stageGateHooks } from "../src/approval-hook.js";
61
+ import { hookScriptSource, removeGateHooks, stageGateHooks, sweepWorkspaceGateGroups } from "../src/approval-hook.js";
62
62
  import { StreamDriver } from "../src/driver.js";
63
63
  import { AcpDriver } from "../src/acp/driver.js";
64
64
  import { runAcpAuth } from "../src/acp/auth.js";
@@ -69,7 +69,7 @@ import { CONFIG_PATH, loadConfig, logsDir, MAX_TURN_CAP_MIN, parseCapMinutes, sa
69
69
  import { agyMissingMessage, isAgyInstalled, savedEngineMessage, showEnginePicker, shouldOfferEnginePicker } from "../src/engine-picker.js";
70
70
  import { createDailyLogger, type DailyLogger } from "../src/daily-log.js";
71
71
  import { registerAskAntigravityTool, toolModelsFromRaw } from "../src/ask-tool.js";
72
- import { startMcpServer, TOKEN_HEADER, type McpServerHandle } from "../src/mcp-server.js";
72
+ import { bridgeMcpConfigDir, startMcpServer, TOKEN_HEADER, type McpServerHandle } from "../src/mcp-server.js";
73
73
  import {
74
74
  registerBridgeServer,
75
75
  healBridgeSuppression,
@@ -752,11 +752,17 @@ export default async function (pi: ExtensionAPI): Promise<void> {
752
752
  // config value never lies silently.
753
753
  fileLog.log("approval-dedicated-as-shadow", {}, "warn");
754
754
  }
755
+ // Legacy cleanup (issue #5): 1.6.x staged the gate into the shared
756
+ // workspace hooks.json, where standalone IDE/CLI sessions load it.
757
+ // Staging now lives in the session-private add-dir; sweep groups left
758
+ // behind by dead sessions from the old scheme. Live sessions' and
759
+ // foreign groups are never touched.
760
+ const sweptLegacy = sweepWorkspaceGateGroups(process.cwd());
761
+ if (sweptLegacy > 0) fileLog.log("approval-workspace-swept", { swept: sweptLegacy }, "info");
755
762
  if (mode === "off") {
756
- // Gate off: remove ONLY this session's group. Other sessions'
757
- // groups in a shared workspace are never touched - a gate-off
758
- // session must not strip a gate-on session's matchers.
759
- const unstaged = removeGateHooks(process.cwd());
763
+ // Gate off: drop this session's private hooks file. Other sessions
764
+ // own their own per-pid files; nothing shared to preserve.
765
+ const unstaged = removeGateHooks();
760
766
  if (unstaged.wrote) fileLog.log("approval-unstaged", unstaged, "info");
761
767
  } else {
762
768
  // Script: per-pid file; 0600 because the bridge token is
@@ -773,7 +779,10 @@ export default async function (pi: ExtensionAPI): Promise<void> {
773
779
  { mode: 0o600 },
774
780
  );
775
781
  gateScriptPath = scriptPath;
776
- const staged = stageGateHooks(process.cwd(), {
782
+ // Private per-pid staging (issue #5 isolation): the gate group goes
783
+ // into the bridge add-dir that ONLY this session's agy receives.
784
+ // Standalone sessions in the workspace never load it.
785
+ const staged = stageGateHooks(bridgeMcpConfigDir(), {
777
786
  port: r.handle.port,
778
787
  token: r.handle.token,
779
788
  scriptPath,
@@ -856,7 +865,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
856
865
  unregisterBridgeServer(process.pid);
857
866
  // Approval gate: unstage hooks and remove the per-pid script. Pending
858
867
  // approvals already failed closed via handle close (bridge shutdown deny).
859
- const unstaged = removeGateHooks(process.cwd());
868
+ const unstaged = removeGateHooks();
860
869
  if (unstaged.wrote) fileLog.log("approval-unstaged", unstaged, "info");
861
870
  if (gateScriptPath) {
862
871
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estebanforge/pi-antigravity-bridge",
3
- "version": "1.6.2",
3
+ "version": "1.6.4",
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",
@@ -26,24 +26,19 @@
26
26
  },
27
27
  "repository": {
28
28
  "type": "git",
29
- "url": "git+https://github.com/EstebanForge/pi-antigravity-bridge.git"
29
+ "url": "git+https://github.com/EstebanForge/pi-extensions.git",
30
+ "directory": "packages/pi-antigravity-bridge"
30
31
  },
31
32
  "publishConfig": {
32
33
  "access": "public"
33
34
  },
34
35
  "type": "module",
35
- "scripts": {
36
- "build": "tsc --noEmit",
37
- "test": "vitest run",
38
- "prepublishOnly": "npm run build && npm test",
39
- "smoke:pi": "bash scripts/smoke-in-pi.sh"
40
- },
41
36
  "files": [
42
37
  "extensions",
43
38
  "src",
44
- "docs",
45
39
  "README.md",
46
- "CHANGELOG.md"
40
+ "CHANGELOG.md",
41
+ "LICENSE"
47
42
  ],
48
43
  "pi": {
49
44
  "extensions": [
@@ -58,17 +53,6 @@
58
53
  "optional": true
59
54
  }
60
55
  },
61
- "devDependencies": {
62
- "@earendil-works/pi-ai": "^0.86.0",
63
- "@earendil-works/pi-coding-agent": "^0.86.0",
64
- "@earendil-works/pi-server": "^0.86.0",
65
- "@earendil-works/pi-tui": "^0.86.0",
66
- "@types/node": "^22.0.0",
67
- "tsx": "^4.19.0",
68
- "typebox": "^1.1.38",
69
- "typescript": "^5.8.0",
70
- "vitest": "^4.1.7"
71
- },
72
56
  "dependencies": {
73
57
  "@modelcontextprotocol/sdk": "^1.29.0"
74
58
  }
@@ -1,30 +1,42 @@
1
1
  // Staging for the approval-gate hooks.json (docs/TODO.md 2.5).
2
2
  //
3
- // The ACP server and the agy CLI fire workspace `.agents/hooks.json`
4
- // PreToolUse hooks (V2: deny honored, reason reaches the model; V3: hook
5
- // TIMEOUT = soft-pass, agy proceeds ungated). Therefore:
3
+ // The agy CLI discovers `.agents/hooks.json` in its --add-dir directories
4
+ // (live-probed 2026-09-22: stream-json and interactive sessions load and
5
+ // execute PreToolUse hooks from any add-dir; print mode reads only the
6
+ // global config) and fires them before a gated tool runs (V2: deny
7
+ // honored, reason reaches the model; V3: hook TIMEOUT = soft-pass, agy
8
+ // proceeds ungated). Therefore:
6
9
  // - the staged handler timeout must exceed the whole park budget with
7
10
  // margin (never rely on timeout as a deny), and
8
11
  // - the staged command delegates to the bundled poll script, which
9
12
  // early-acks and polls the bridge for the terminal decision.
10
13
  //
11
- // Merge rules: never clobber a foreign hooks.json. Parse failures abort
12
- // staging; first-time modification of an existing file writes a
13
- // timestamped backup next to it (the 2026-09-05 incident rule: every
14
- // destructive path gets a guard).
14
+ // ISOLATION (issue #5): the gate group is staged into the session-private
15
+ // per-pid bridge dir (bridgeMcpConfigDir()), NOT the shared workspace. Only
16
+ // this session's agy gets that dir as an extra --add-dir (driver.ts), so
17
+ // standalone IDE/CLI sessions in the workspace can never load the gate -
18
+ // neither denying their tools while pi is idle nor parking their calls
19
+ // into a live pi turn. The private file is ours alone: no merge, no
20
+ // foreign-file backup dance.
21
+ //
22
+ // Legacy: 1.6.x staged into the shared workspace `.agents/hooks.json`.
23
+ // sweepWorkspaceGateGroups() removes gate groups left there by sessions
24
+ // whose pid is dead; groups of live sessions and foreign groups are never
25
+ // touched.
15
26
  //
16
27
  // Run: npm test
17
28
 
18
29
  import fs from "node:fs";
19
30
  import path from "node:path";
31
+ import { bridgeMcpConfigDir } from "./mcp-server.js";
20
32
 
21
33
  export const HOOK_GROUP = "pi-bridge-gate";
22
34
 
23
- /** Group-key namespace. Each pi session stages its OWN group keyed per-pid
24
- * (`pi-bridge-gate-<pid>`): hooks.json lives in the SHARED workspace, and
25
- * two concurrent sessions must never remove or overwrite each other's gate
26
- * (audit 2026-09-07: a single shared key let a gate-off session silently
27
- * strip a gate-on session's PreToolUse matchers). */
35
+ /** Group-key namespace. Each session's group is keyed per-pid
36
+ * (`pi-bridge-gate-<pid>`): the key doubles as the ownership proof for the
37
+ * legacy workspace sweep, which may only remove groups whose owning
38
+ * session is dead (a stale shared key must never strip a live session's
39
+ * gate - audit 2026-09-07). */
28
40
  export const GATE_GROUP_PREFIX = "pi-bridge-gate";
29
41
 
30
42
  /** This session's group key. */
@@ -85,6 +97,10 @@ export function hookScriptSource(opts: { port: number; token: string; deadlineMs
85
97
  const PORT = ${opts.port};
86
98
  const TOKEN = ${JSON.stringify(opts.token)};
87
99
  const DEADLINE = Date.now() + ${opts.deadlineMs};
100
+ // Per-fetch abort: a hung fetch must never outlive the park deadline. A hook
101
+ // killed by agy's staged timeout soft-passes (V3), so the script itself must
102
+ // always reach a printed deny.
103
+ const POST_TIMEOUT_MS = Math.min(10_000, ${opts.deadlineMs});
88
104
  let body = "";
89
105
  process.stdin.setEncoding("utf8");
90
106
  for await (const chunk of process.stdin) body += chunk;
@@ -94,14 +110,15 @@ try {
94
110
  method: "POST",
95
111
  headers: { "content-type": "application/json", "x-bridge-token": TOKEN },
96
112
  body,
113
+ signal: AbortSignal.timeout(POST_TIMEOUT_MS),
97
114
  });
98
115
  const json = await res.json();
99
116
  // Ungated payloads get a terminal decision right on the POST (no park).
100
- if (json && typeof json === "object" && "decision" in json) {
101
- console.log(JSON.stringify(json.decision));
117
+ if (json && typeof json === "object" && typeof json.decision === "string") {
118
+ console.log(JSON.stringify(json));
102
119
  process.exit(0);
103
120
  }
104
- ticket = json.ticket ?? "";
121
+ ticket = json?.ticket ?? "";
105
122
  } catch {}
106
123
  if (!ticket) {
107
124
  console.log(JSON.stringify({ decision: "deny", reason: "approval gate unreachable (bridge down?)" }));
@@ -112,10 +129,17 @@ while (Date.now() < DEADLINE) {
112
129
  try {
113
130
  const res = await fetch(\`http://127.0.0.1:\${PORT}/approval/\${encodeURIComponent(ticket)}\`, {
114
131
  headers: { "x-bridge-token": TOKEN },
132
+ // Bounded by the remaining park budget: an aborted poll falls through
133
+ // to the loop condition and lands on the deadline deny.
134
+ signal: AbortSignal.timeout(Math.max(1, DEADLINE - Date.now())),
115
135
  });
116
136
  const json = await res.json();
117
- if (json.status !== "pending") {
118
- console.log(JSON.stringify(json.decision ?? { decision: "deny", reason: "gate returned no decision" }));
137
+ if (json?.status !== "pending") {
138
+ if (json && typeof json === "object" && typeof json.decision === "string") {
139
+ console.log(JSON.stringify(json));
140
+ } else {
141
+ console.log(JSON.stringify({ decision: "deny", reason: "gate returned no decision" }));
142
+ }
119
143
  process.exit(0);
120
144
  }
121
145
  } catch {}
@@ -131,7 +155,7 @@ export function stagedTimeoutSeconds(parkBudgetMs: number): number {
131
155
  return Math.max(60, Math.ceil(parkBudgetMs / 1000) + 60);
132
156
  }
133
157
 
134
- /** Build our hooks.json group for one workspace staging. */
158
+ /** Build our hooks.json group for private-dir staging. */
135
159
  export function buildGateGroup(opts: StageOptions): Record<string, unknown> {
136
160
  const command = `node ${JSON.stringify(opts.scriptPath)}`;
137
161
  return {
@@ -153,100 +177,94 @@ export function buildGateGroup(opts: StageOptions): Record<string, unknown> {
153
177
 
154
178
  export interface StageResult {
155
179
  wrote: boolean;
156
- /** Backup file written before first modification of a foreign file. */
157
- backup?: string;
158
- /** Why nothing was written (parse failure, already current, ...). */
180
+ /** Why nothing was written (already current, ...). */
159
181
  reason?: string;
160
182
  }
161
183
 
162
- /** Stage the gate group into <workspaceDir>/.agents/hooks.json under THIS
163
- * session's per-pid key. Merge-safe:
164
- * - foreign groups are preserved;
165
- * - gate groups of DEAD sessions are swept (their bridge is gone; the hook
166
- * would fail closed forever), groups of live sessions never touched;
167
- * - a foreign file is backed up before its first modification;
168
- * - unparseable files are never touched. */
169
- export function stageGateHooks(workspaceDir: string, opts: StageOptions): StageResult {
170
- const dir = path.join(workspaceDir, ".agents");
171
- const file = path.join(dir, "hooks.json");
172
- const group = buildGateGroup(opts);
173
- const ownKey = gateGroupKey();
174
- let current: Record<string, unknown> = {};
175
- const existed = fs.existsSync(file);
176
- if (existed) {
177
- try {
178
- if (fs.lstatSync(file).isSymbolicLink()) {
179
- return { wrote: false, reason: "hooks.json is a symlink; refusing to follow it" };
180
- }
181
- } catch {
182
- return { wrote: false, reason: "hooks.json vanished while staging" };
183
- }
184
+ /** The session-private hooks.json for gate staging (beside the bridge's
185
+ * mcp_config.json in the per-pid add-dir). */
186
+ function gateHooksFile(dir: string = bridgeMcpConfigDir()): string {
187
+ return path.join(dir, ".agents", "hooks.json");
188
+ }
189
+
190
+ /** True if this session's gate hooks are currently staged in the private
191
+ * add-dir. The driver uses this to decide whether to pass the extra
192
+ * --add-dir even when the MCP config is absent. */
193
+ export function gateHooksStaged(dir: string = bridgeMcpConfigDir()): boolean {
194
+ return fs.existsSync(gateHooksFile(dir));
195
+ }
196
+
197
+ /** Stage the gate group into the session-private per-pid dir:
198
+ * <bridgeMcpConfigDir()>/.agents/hooks.json. The dir (and everything in
199
+ * it) belongs to this pid alone, so the write is a plain atomic replace -
200
+ * no merge, no foreign-file guards. */
201
+ export function stageGateHooks(dir: string, opts: StageOptions): StageResult {
202
+ const file = gateHooksFile(dir);
203
+ const next = JSON.stringify({ [gateGroupKey()]: buildGateGroup(opts) }, null, 2) + "\n";
204
+ try {
205
+ if (fs.readFileSync(file, "utf8") === next) return { wrote: false, reason: "already staged" };
206
+ } catch {
207
+ /* absent or unreadable: write */
208
+ }
209
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
210
+ const tmp = `${file}.${process.pid}.tmp`;
211
+ try {
212
+ fs.writeFileSync(tmp, next, { mode: 0o600 });
213
+ fs.renameSync(tmp, file);
214
+ } catch (err) {
184
215
  try {
185
- const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
186
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
187
- return { wrote: false, reason: "hooks.json is not an object; refusing to touch it" };
188
- }
189
- current = parsed as Record<string, unknown>;
216
+ fs.unlinkSync(tmp);
190
217
  } catch {
191
- return { wrote: false, reason: "hooks.json is not valid JSON; refusing to touch it" };
192
- }
193
- // Sweep gate groups whose owning session is gone. Never touch groups of
194
- // live sessions (concurrent pi sessions share this workspace) or groups
195
- // we cannot attribute.
196
- let swept = 0;
197
- for (const key of Object.keys(current)) {
198
- if (key === ownKey) continue;
199
- const isGateGroup = key === GATE_GROUP_PREFIX || key.startsWith(`${GATE_GROUP_PREFIX}-`);
200
- if (!isGateGroup) continue;
201
- const pid = gateGroupPid(current[key]);
202
- if (pid === null || pidAlive(pid)) continue;
203
- delete current[key];
204
- swept += 1;
205
- }
206
- if (swept > 0 && JSON.stringify(current[ownKey]) === JSON.stringify(group)) {
207
- // Own group already current; the pass only swept dead peers.
208
- fs.writeFileSync(file, JSON.stringify(current, null, 2) + "\n");
209
- return { wrote: true, reason: `swept ${swept} dead gate group(s)` };
210
- }
211
- if (JSON.stringify(current[ownKey]) === JSON.stringify(group)) {
212
- return { wrote: false, reason: "already staged" };
218
+ /* nothing */
213
219
  }
220
+ throw err;
214
221
  }
215
- const backup =
216
- existed && current[ownKey] === undefined
217
- ? `${file}.backup-${new Date().toISOString().replace(/[:.]/g, "-")}`
218
- : undefined;
219
- if (backup) fs.copyFileSync(file, backup);
220
- current[ownKey] = group;
221
- fs.mkdirSync(dir, { recursive: true });
222
- fs.writeFileSync(file, JSON.stringify(current, null, 2) + "\n");
223
- return { wrote: true, backup };
222
+ return { wrote: true };
224
223
  }
225
224
 
226
- /** Remove ONLY this session's gate group (or the given pid's). Other
227
- * sessions' groups - including live ones in a shared workspace - are never
228
- * touched: a gate-off session must not strip a gate-on session's matchers
229
- * (audit 2026-09-07). Foreign content stays; an object file is left in
230
- * place (harmless). */
231
- export function removeGateHooks(workspaceDir: string, pid: number = process.pid): StageResult {
232
- const file = path.join(workspaceDir, ".agents", "hooks.json");
225
+ /** Remove the private gate hooks file. The rest of the per-pid dir (the MCP
226
+ * config) is managed by the bridge server lifecycle and stays until close. */
227
+ export function removeGateHooks(dir: string = bridgeMcpConfigDir()): StageResult {
228
+ const file = gateHooksFile(dir);
233
229
  if (!fs.existsSync(file)) return { wrote: false, reason: "no hooks.json" };
234
230
  try {
235
- if (fs.lstatSync(file).isSymbolicLink()) {
236
- return { wrote: false, reason: "hooks.json is a symlink; refusing to follow it" };
237
- }
231
+ fs.rmSync(file, { force: true });
232
+ return { wrote: true };
233
+ } catch {
234
+ return { wrote: false, reason: "remove failed" };
235
+ }
236
+ }
237
+
238
+ /** Legacy cleanup (issue #5): remove gate groups that 1.6.x staged into the
239
+ * SHARED workspace `.agents/hooks.json` when their owning session is dead
240
+ * (its bridge is gone; the hook would fail closed forever). Live sessions'
241
+ * groups and groups we cannot attribute are never touched; foreign groups
242
+ * and foreign files are never touched. Returns the number of swept groups. */
243
+ export function sweepWorkspaceGateGroups(workspaceDir: string): number {
244
+ const file = path.join(workspaceDir, ".agents", "hooks.json");
245
+ let current: Record<string, unknown>;
246
+ try {
247
+ if (fs.lstatSync(file).isSymbolicLink()) return 0;
248
+ const parsed: unknown = JSON.parse(fs.readFileSync(file, "utf8"));
249
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return 0;
250
+ current = parsed as Record<string, unknown>;
238
251
  } catch {
239
- return { wrote: false, reason: "hooks.json vanished while removing" };
252
+ return 0;
253
+ }
254
+ let swept = 0;
255
+ for (const key of Object.keys(current)) {
256
+ const isGateGroup = key === GATE_GROUP_PREFIX || key.startsWith(`${GATE_GROUP_PREFIX}-`);
257
+ if (!isGateGroup) continue;
258
+ const pid = gateGroupPid(current[key]);
259
+ if (pid === null || pidAlive(pid)) continue;
260
+ delete current[key];
261
+ swept += 1;
240
262
  }
263
+ if (swept === 0) return 0;
241
264
  try {
242
- const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
243
- if (!parsed || typeof parsed !== "object") return { wrote: false, reason: "not an object; refusing" };
244
- const ownKey = gateGroupKey(pid);
245
- if (parsed[ownKey] === undefined) return { wrote: false, reason: "not staged" };
246
- delete parsed[ownKey];
247
- fs.writeFileSync(file, JSON.stringify(parsed, null, 2) + "\n");
248
- return { wrote: true };
265
+ fs.writeFileSync(file, JSON.stringify(current, null, 2) + "\n");
249
266
  } catch {
250
- return { wrote: false, reason: "not valid JSON; refusing" };
267
+ return 0;
251
268
  }
269
+ return swept;
252
270
  }
package/src/driver.ts CHANGED
@@ -18,6 +18,7 @@ import { spawn, type ChildProcess } from "node:child_process";
18
18
  import { randomUUID } from "node:crypto";
19
19
  import { parseAgyLine } from "./stream-events.js";
20
20
  import { bridgeMcpConfigDir, bridgeMcpConfigExists } from "./mcp-server.js";
21
+ import { gateHooksStaged } from "./approval-hook.js";
21
22
  import type {
22
23
  AgyUsage,
23
24
  DriverActivity,
@@ -335,7 +336,11 @@ export class StreamDriver implements TurnDriver {
335
336
  this.#stderrTail = "";
336
337
 
337
338
  const args: string[] = ["--add-dir", request.cwd];
338
- if (bridgeMcpConfigExists()) args.push("--add-dir", bridgeMcpConfigDir());
339
+ // The bridge dir carries .agents/mcp_config.json (tool bridge) and, when
340
+ // the approval gate is on, .agents/hooks.json (gate staging). Only this
341
+ // session's agy ever gets it: standalone IDE/CLI sessions in the
342
+ // workspace must never load our gate (issue #5 isolation).
343
+ if (bridgeMcpConfigExists() || gateHooksStaged()) args.push("--add-dir", bridgeMcpConfigDir());
339
344
  args.push("--model", request.model);
340
345
  if (request.effort) args.push("--effort", request.effort);
341
346
  args.push("--mode", request.mode);