@estebanforge/pi-antigravity-bridge 1.9.3 → 1.10.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/src/agents.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  //
9
9
  // A picked name rides the turn request as `--agent` (same flag our web
10
10
  // tools already pass for their throwaway agents). ACP has no agent slot in
11
- // the protocol (RC01), so selection is refused at the command level there.
11
+ // per-turn agent selection, so the bridge refuses it at the command level.
12
12
 
13
13
  import { spawnAgyRaw } from "./models.js";
14
14
 
@@ -0,0 +1,85 @@
1
+ import { spawn } from "node:child_process";
2
+ import { isAgyInstalled } from "./engine-picker.js";
3
+
4
+ export interface AgyInstallCommand {
5
+ command: string;
6
+ args: string[];
7
+ }
8
+
9
+ export function agyInstallCommand(platform: NodeJS.Platform = process.platform): AgyInstallCommand {
10
+ if (platform === "win32") {
11
+ return {
12
+ command: "powershell.exe",
13
+ args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", "irm https://antigravity.google/cli/install.ps1 | iex"],
14
+ };
15
+ }
16
+ return {
17
+ command: "bash",
18
+ args: ["-c", "set -o pipefail; curl -fsSL https://antigravity.google/cli/install.sh | bash"],
19
+ };
20
+ }
21
+
22
+ export function agyAuthenticationMessage(): string {
23
+ return "Run `agy` in your terminal and complete the Google sign-in. Restart pi afterwards.";
24
+ }
25
+
26
+ type InstallRunner = (command: AgyInstallCommand, timeoutMs: number) => Promise<{ code: number | null; output: string }>;
27
+
28
+ const defaultRunner: InstallRunner = ({ command, args }, timeoutMs) =>
29
+ new Promise((resolve, reject) => {
30
+ const child = spawn(command, args, {
31
+ stdio: ["ignore", "pipe", "pipe"],
32
+ detached: process.platform !== "win32",
33
+ windowsHide: true,
34
+ });
35
+ let output = "";
36
+ let settled = false;
37
+ const append = (chunk: Buffer) => {
38
+ output = (output + chunk.toString("utf8")).slice(-64_000);
39
+ };
40
+ const fail = (err: Error) => {
41
+ if (settled) return;
42
+ settled = true;
43
+ clearTimeout(watchdog);
44
+ reject(err);
45
+ };
46
+ child.stdout?.on("data", append);
47
+ child.stderr?.on("data", append);
48
+ child.on("error", fail);
49
+ child.on("close", (code) => {
50
+ if (settled) return;
51
+ settled = true;
52
+ clearTimeout(watchdog);
53
+ resolve({ code, output: output.trim() });
54
+ });
55
+ const watchdog = setTimeout(() => {
56
+ try {
57
+ if (child.pid && process.platform === "win32") {
58
+ const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
59
+ killer.on("error", () => {});
60
+ killer.unref();
61
+ } else if (child.pid) process.kill(-child.pid, "SIGKILL");
62
+ else child.kill("SIGKILL");
63
+ } catch {
64
+ // The process may have exited between the deadline and kill.
65
+ }
66
+ fail(new Error("Antigravity CLI installer exceeded 10 minutes"));
67
+ }, timeoutMs);
68
+ });
69
+
70
+ export async function installAgyCli(opts: {
71
+ platform?: NodeJS.Platform;
72
+ run?: InstallRunner;
73
+ verify?: () => boolean | Promise<boolean>;
74
+ timeoutMs?: number;
75
+ } = {}): Promise<{ ok: true; output: string } | { ok: false; error: string }> {
76
+ try {
77
+ const result = await (opts.run ?? defaultRunner)(agyInstallCommand(opts.platform), opts.timeoutMs ?? 10 * 60_000);
78
+ if (result.code !== 0) return { ok: false, error: result.output || `installer exited with code ${result.code ?? "unknown"}` };
79
+ const verify = opts.verify ?? (() => isAgyInstalled(process.env.AGY_BIN || "agy"));
80
+ if (!(await verify())) return { ok: false, error: "installer finished, but agy is not available in this pi process; open a new terminal, run `agy --version`, then restart pi" };
81
+ return { ok: true, output: result.output };
82
+ } catch (err) {
83
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
84
+ }
85
+ }
package/src/agy-paths.ts CHANGED
@@ -1,9 +1,7 @@
1
1
  // agy per-engine state dirs ("brain" trees).
2
2
  //
3
- // Both engines keep per-conversation state on disk; the roots differ
4
- // (live-probed 2026-09-24, this machine):
5
- // stream-json CLI: ~/.gemini/antigravity-cli/brain/<conversationId>/
6
- // ACP server: ~/.gemini/antigravity-acp/brain/<conversationId>/
3
+ // Both engines keep per-conversation state in the native CLI tree:
4
+ // ~/.gemini/antigravity-cli/brain/<conversationId>/
7
5
  // The ACP tree mirrors the CLI layout (.system_generated/tasks/,
8
6
  // .system_generated/steps/), so downstream scans (tasks, artifacts) work
9
7
  // unchanged once the right root is picked. A third tree exists —
@@ -18,8 +16,7 @@ import type { Engine } from "./config.js";
18
16
 
19
17
  /** Brain root for one engine. `home` injectable for tests. */
20
18
  export function agyBrainRoot(engine: Engine, home: string = os.homedir()): string {
21
- const scope = engine === "acp" ? "antigravity-acp" : "antigravity-cli";
22
- return path.join(home, ".gemini", scope, "brain");
19
+ return path.join(home, ".gemini", "antigravity-cli", "brain");
23
20
  }
24
21
 
25
22
  /** Per-conversation state dir. `conversationId` is the raw id agy reports
@@ -0,0 +1,72 @@
1
+ import { agyAuthenticationMessage, agyInstallCommand, installAgyCli } from "./agy-install.js";
2
+
3
+ export interface AgySetupUi {
4
+ confirm: (title: string, body: string) => Promise<boolean>;
5
+ notify: (message: string, level: "info" | "warning") => void;
6
+ setStatus: (id: string, text?: string) => void;
7
+ }
8
+
9
+ export function createAgySetupOffer(opts: {
10
+ ui: AgySetupUi;
11
+ binaryOverride?: string;
12
+ install?: typeof installAgyCli;
13
+ missingMessage: () => string;
14
+ log: (event: string, data: unknown, level: "warn" | "info") => void;
15
+ }): () => Promise<void> {
16
+ let offered = false;
17
+ let inFlight: Promise<void> | null = null;
18
+ const install = opts.install ?? installAgyCli;
19
+ const notify = (message: string, level: "info" | "warning") => {
20
+ try {
21
+ opts.ui.notify(message, level);
22
+ } catch (err) {
23
+ opts.log("agy-install", { error: String(err) }, "warn");
24
+ }
25
+ };
26
+
27
+ return () => {
28
+ if (inFlight) return inFlight;
29
+ if (offered) return Promise.resolve();
30
+ offered = true;
31
+ inFlight = (async () => {
32
+ try {
33
+ if (opts.binaryOverride) {
34
+ notify(
35
+ `AGY_BIN points to ${opts.binaryOverride}, which is missing or unsupported. Fix or unset AGY_BIN. The official installer cannot change this override.`,
36
+ "warning",
37
+ );
38
+ return;
39
+ }
40
+ const spec = agyInstallCommand();
41
+ const command = [spec.command, ...spec.args].join(" ");
42
+ const approved = await opts.ui.confirm(
43
+ "Antigravity CLI required",
44
+ `Both engines require the agy CLI. Run Google's official installer now?\n\n${command}`,
45
+ );
46
+ if (!approved) {
47
+ notify(opts.missingMessage(), "warning");
48
+ return;
49
+ }
50
+ opts.ui.setStatus("agy-install", "Installing Antigravity CLI…");
51
+ const result = await install();
52
+ if (!result.ok) {
53
+ opts.log("agy-install", { error: result.error }, "warn");
54
+ notify(`Antigravity CLI installation failed: ${result.error}\n${opts.missingMessage()}`, "warning");
55
+ return;
56
+ }
57
+ notify(`Antigravity CLI installed. ${agyAuthenticationMessage()}`, "info");
58
+ } catch (err) {
59
+ opts.log("agy-install", { error: String(err) }, "warn");
60
+ notify(`Antigravity CLI setup failed: ${String(err)}\n${opts.missingMessage()}`, "warning");
61
+ } finally {
62
+ try {
63
+ opts.ui.setStatus("agy-install", undefined);
64
+ } catch {
65
+ // UI teardown must not reject a shared setup promise.
66
+ }
67
+ inFlight = null;
68
+ }
69
+ })();
70
+ return inFlight;
71
+ };
72
+ }
@@ -7,16 +7,15 @@
7
7
  // `agy --version`, memoized per binary because discovery already spawns agy
8
8
  // once per load and the version will not change mid-process.
9
9
  //
10
- // The ACP server reports its own version at initialize (agentInfo.version,
11
- // e.g. "agy_acp_server_1.1.1") and /agy doctor already prints it. ACP build
12
- // numbering is a different lineage from the CLI, so no floor is enforced
13
- // there: the one known-good build must not be locked out by a CLI number.
10
+ // The ACP server reports the CLI version at initialize. agy acp has its own
11
+ // floor because older CLI releases do not ship the subcommand.
14
12
 
15
13
  import { spawn } from "node:child_process";
16
14
 
17
15
  /** Floor for the stream-json CLI. Matches the protocol grammar our driver
18
16
  * and the event mapping rely on; older agy predates verified fields. */
19
17
  export const MIN_AGY_VERSION = "1.1.22";
18
+ export const MIN_AGY_ACP_VERSION = "1.5.3";
20
19
 
21
20
  const VERSION_TIMEOUT_MS = 5_000;
22
21
 
package/src/ask-tool.ts CHANGED
@@ -747,7 +747,7 @@ export async function registerAskAntigravityTool(
747
747
  if (args.includeContext) tags.push("context=full");
748
748
  if (args.background) tags.push("bg");
749
749
 
750
- let text = theme.fg("mdLink", theme.bold("AskAntigravity "));
750
+ let text = theme.fg("success", theme.bold("AskAntigravity "));
751
751
  text += `${theme.fg("accent", `[${tags.join(", ")}]`)} `;
752
752
 
753
753
  const prompt = String(args.prompt ?? "");
@@ -763,7 +763,7 @@ export async function registerAskAntigravityTool(
763
763
  const d = result.details as AgyDetails | undefined;
764
764
  if (isPartial) {
765
765
  const status = result.content[0]?.type === "text" ? result.content[0].text : "working...";
766
- return new Text(theme.fg("mdLink", "◉ AskAntigravity ") + theme.fg("muted", status), 0, 0);
766
+ return new Text(theme.fg("success", "◉ AskAntigravity ") + theme.fg("muted", status), 0, 0);
767
767
  }
768
768
 
769
769
  const body = result.content[0]?.type === "text" ? result.content[0].text : "";
@@ -772,7 +772,7 @@ export async function registerAskAntigravityTool(
772
772
 
773
773
  let text = errored
774
774
  ? theme.fg("error", "✗ AskAntigravity error")
775
- : theme.fg("mdLink", "✓ AskAntigravity");
775
+ : theme.fg("success", "✓ AskAntigravity");
776
776
 
777
777
  const rTags: string[] = [];
778
778
  if (d?.resolvedModel || d?.model) rTags.push(`model=${d?.resolvedModel ?? d?.model}`);
package/src/config.ts CHANGED
@@ -38,15 +38,13 @@ export function logsDir(): string {
38
38
  );
39
39
  }
40
40
 
41
- /** Which turn engine drives turns. "acp" (the official server) is the
42
- * default for new installs; "stream-json" (the `agy` CLI) remains a fully
43
- * supported fallback. */
41
+ /** Native `agy acp` is the default for new installs; stream-json remains
42
+ * a fully supported fallback. */
44
43
  export type Engine = "stream-json" | "acp";
45
44
 
46
45
  /** The engine a fresh install runs when nothing is configured (no file key,
47
46
  * no AGY_ENGINE): the official ACP server. Unknown/stale stored values also
48
- * fall back here; the first startup's acp self-heal installs the binary and
49
- * chains the sign-in. */
47
+ * fall back here. Startup checks that the required agy CLI is available. */
50
48
  export const DEFAULT_ENGINE: Engine = "acp";
51
49
  export type AgyMode = "accept-edits" | "plan";
52
50
  export type ThinkingTier = "low" | "medium" | "high";
@@ -82,8 +80,6 @@ export interface GateConfig {
82
80
  }
83
81
 
84
82
  export interface AcpConfig {
85
- /** Path to agy_acp_server.par. Empty = env AGY_ACP_BIN > PATH. */
86
- bin: string;
87
83
  /** Gate B stopgap: client-side token estimates for ACP turns so pi's
88
84
  * usage surfaces show nonzero numbers. Estimates are labeled as such in
89
85
  * /agy doctor and auto-disable when the server sends real usage. */
@@ -100,7 +96,7 @@ export interface AgyConfig {
100
96
  * is refused visibly: the agy CLI has no review-only enforcement on any
101
97
  * path (probed 2026-10-07, upstream #1181: plan mode stages a plan and
102
98
  * executes the write in the same turn on -p and stream-json alike; the
103
- * ACP engine has no plan mode at all). Built for callers like
99
+ * provider plan turns are refused on ACP too). Built for callers like
104
100
  * `pi -p --no-tools` health checks that must never mutate a workspace
105
101
  * and prefer a visible refusal over a write-capable turn. Wins over
106
102
  * `mode`; the AskAntigravity delegation tool is refused while this is on
@@ -215,7 +211,7 @@ const DEFAULTS: AgyConfig = {
215
211
  turnTimeoutMin: 0, // placeholder: the real default is resolved per loadConfig call (defaultTurnTimeoutMin)
216
212
  inactivityTimeoutMin: 5,
217
213
  approvals: { gateMode: "auto", mode: "ask" },
218
- acp: { bin: "", usageEstimate: "estimate" },
214
+ acp: { usageEstimate: "estimate" },
219
215
  };
220
216
 
221
217
  /** Hard ceiling for a free-typed turn cap: one day, in minutes. */
@@ -255,6 +251,21 @@ function readConfigFile(configPath: string): Partial<AgyConfig> {
255
251
  return {};
256
252
  }
257
253
 
254
+ /** Strip the retired acp.bin key from the raw config file; returns true when
255
+ * removed. Raw passthrough on purpose (no defaults merge): users keep their
256
+ * minimal hand-edited shape instead of gaining the full default set. */
257
+ export function removeLegacyAcpBin(configPath: string = CONFIG_PATH): boolean {
258
+ const file = readConfigFile(configPath) as Record<string, unknown>;
259
+ if (!file.acp || typeof file.acp !== "object" || Array.isArray(file.acp)) return false;
260
+ const acp = file.acp as Record<string, unknown>;
261
+ if (!("bin" in acp)) return false;
262
+ delete acp.bin;
263
+ // Rename would replace a dotfiles-managed symlink with a regular file;
264
+ // write through the resolved target so the link survives.
265
+ writeConfigJson(fs.realpathSync(configPath), file);
266
+ return true;
267
+ }
268
+
258
269
  /** Load config merged over defaults. Env vars override the file when set. */
259
270
  export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
260
271
  let file: Partial<AgyConfig> = readConfigFile(configPath);
@@ -268,8 +279,7 @@ export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
268
279
  // One absence exception: a config file WITHOUT an engine key belongs to an
269
280
  // existing install (saveConfig persists the resolved legacy engine), and
270
281
  // the acp default is for new installs only - an existing install keeps
271
- // the legacy stream-json engine instead of silently starting the ~1.5 GB
272
- // acp download. existsSync is false on EACCES too, which reads as fresh
282
+ // the legacy stream-json engine. existsSync is false on EACCES too, which reads as fresh
273
283
  // and picks acp; an unreadable config dir is a broken install either way.
274
284
  const freshInstall = !fs.existsSync(configPath);
275
285
  const engineFallback = freshInstall || file.engine !== undefined ? DEFAULT_ENGINE : "stream-json";
@@ -379,12 +389,7 @@ export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
379
389
  ).toLowerCase();
380
390
  const usageEstimate: UsageEstimate =
381
391
  usageRaw === "direct" || usageRaw === "off" ? usageRaw : "estimate";
382
- const acp: AcpConfig = {
383
- bin:
384
- process.env.AGY_ACP_BIN ??
385
- (typeof fileAcp.bin === "string" ? fileAcp.bin : DEFAULTS.acp.bin),
386
- usageEstimate,
387
- };
392
+ const acp: AcpConfig = { usageEstimate };
388
393
 
389
394
  // Custom agent for stream-json turns (--agent). Env wins over the file
390
395
  // (same pattern as mode). Normalized (trim/cap/control-strip); an empty
@@ -421,6 +426,24 @@ function narrowEngine(raw: string): Engine {
421
426
  return raw === "acp" || raw === "stream-json" ? raw : DEFAULT_ENGINE;
422
427
  }
423
428
 
429
+ /** Atomic JSON write (temp + rename, 0600) shared by every config writer. */
430
+ function writeConfigJson(configPath: string, value: unknown): void {
431
+ const dir = path.dirname(configPath);
432
+ fs.mkdirSync(dir, { recursive: true });
433
+ const tmp = `${configPath}.${process.pid}.tmp`;
434
+ try {
435
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 });
436
+ fs.renameSync(tmp, configPath);
437
+ } catch (err) {
438
+ try {
439
+ fs.unlinkSync(tmp);
440
+ } catch {
441
+ /* nothing to clean */
442
+ }
443
+ throw err;
444
+ }
445
+ }
446
+
424
447
  /** Atomically persist a config patch (temp + rename). Merges onto the RAW
425
448
  * FILE values, never loadConfig(): env overrides must not be baked into the
426
449
  * file (AGY_READONLY=1 in the env would otherwise persist readOnly: true to
@@ -438,14 +461,14 @@ export function saveConfig(patch: Partial<AgyConfig>, configPath: string = CONFI
438
461
  // must never be baked into the file). A legacy file without an engine key
439
462
  // persists "stream-json": the bare DEFAULTS spread below would otherwise
440
463
  // write the acp default into an existing install's file on any unrelated
441
- // save, arming the ~1.5 GB download on the next start.
464
+ // save, switching an existing install to ACP on the next start.
442
465
  const persistedEngine = patch.engine ?? file.engine
443
466
  ?? (fs.existsSync(configPath) ? "stream-json" : DEFAULT_ENGINE);
444
467
  const next: AgyConfig = {
445
468
  ...DEFAULTS,
446
469
  ...file,
447
470
  ...(file.acp !== undefined
448
- ? { acp: { bin: fileAcp.bin ?? DEFAULTS.acp.bin, usageEstimate: fileAcp.usageEstimate ?? DEFAULTS.acp.usageEstimate } }
471
+ ? { acp: { usageEstimate: fileAcp.usageEstimate ?? DEFAULTS.acp.usageEstimate } }
449
472
  : {}),
450
473
  ...(file.approvals !== undefined
451
474
  ? { approvals: { gateMode: fileApprovals.gateMode ?? DEFAULTS.approvals.gateMode, mode: fileApprovals.mode ?? DEFAULTS.approvals.mode } }
@@ -453,20 +476,7 @@ export function saveConfig(patch: Partial<AgyConfig>, configPath: string = CONFI
453
476
  ...patch,
454
477
  engine: narrowEngine(String(persistedEngine).toLowerCase()),
455
478
  };
456
- const dir = path.dirname(configPath);
457
- fs.mkdirSync(dir, { recursive: true });
458
- const tmp = `${configPath}.${process.pid}.tmp`;
459
- try {
460
- fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
461
- fs.renameSync(tmp, configPath);
462
- } catch (err) {
463
- try {
464
- fs.unlinkSync(tmp);
465
- } catch {
466
- /* nothing to clean */
467
- }
468
- throw err;
469
- }
479
+ writeConfigJson(configPath, next);
470
480
  return next;
471
481
  }
472
482
 
@@ -7,8 +7,8 @@
7
7
  // deleted (phase 4) without breaking imports.
8
8
  //
9
9
  // The ACP driver implements the same surface with protocol-native mechanics:
10
- // no process recycle on profile drift, teardown-based abort (Gate D), and
11
- // `session/load` resume. See docs/ACP-ADOPTION-PLAN.md section 9.
10
+ // no process recycle on profile drift, notification cancel with teardown
11
+ // fallback, and `session/load` resume. See docs/ACP-ADOPTION-PLAN.md section 9.
12
12
 
13
13
  export type DriverState = "idle" | "starting" | "ready" | "running" | "dead";
14
14
 
@@ -19,7 +19,7 @@ export interface DriverProfile {
19
19
  mode: string;
20
20
  skipPermissions: boolean;
21
21
  /** Custom agy agent (stream-json `--agent`). ACP has no agent slot in the
22
- * protocol (RC01) and ignores it; the /agy agent command refuses to set
22
+ * per-turn agent selection and ignores it; the /agy agent command refuses to set
23
23
  * one while the ACP engine is active. */
24
24
  agent?: string;
25
25
  }
@@ -138,8 +138,6 @@ export interface DriverSnapshot {
138
138
  sessionsCreated: number;
139
139
  sessionsLoaded: number;
140
140
  kills: number;
141
- /** null = never probed on this server process. */
142
- cancelSupported: boolean | null;
143
141
  serverVersion?: string;
144
142
  /** Connections beyond the first this driver process made = server
145
143
  * restarts (Gate D kills + stale-exit replacements). */
@@ -1,11 +1,9 @@
1
1
  // First-run onboarding: the engine picker plus the agy-presence warning.
2
2
  //
3
3
  // On the first interactive start (no config file yet, no AGY_ENGINE env) pi
4
- // asks which turn engine to use: the stream-json `agy` CLI or Google's
5
- // official ACP server. The choice persists via saveConfig({ engine }) and,
4
+ // asks which native agy engine to use: stream-json or `agy acp`. The choice persists via saveConfig({ engine }) and,
6
5
  // like /agy engine, takes effect on the next pi start (drivers wire at load).
7
- // Every start with the stream-json engine active also re-checks that the
8
- // `agy` binary exists and warns until it does (re-auth is out of scope).
6
+ // Startup checks the CLI once per process and offers setup when needed.
9
7
  //
10
8
  // UI: the shared framed choice modal from @estebanforge/pi-tui-dialogs
11
9
  // (dialogChoice): one decision statement, a closed option list, esc cancels.
@@ -21,8 +19,8 @@ import { DEFAULT_ENGINE, type Engine } from "./config.js";
21
19
  export const ENGINE_PICKER_ITEMS: ChoiceOption[] = [
22
20
  {
23
21
  value: "acp",
24
- label: "ACP server (official Google)",
25
- description: "default · official Google server",
22
+ label: "ACP (agy acp)",
23
+ description: "default · ACP server in the agy CLI",
26
24
  },
27
25
  {
28
26
  value: "stream-json",
@@ -36,7 +34,7 @@ export const ENGINE_PICKER_ITEMS: ChoiceOption[] = [
36
34
  export const ENGINE_PICKER_INTRO = [
37
35
  "Pick the engine that runs your Antigravity turns. Switch anytime with /agy engine (restart applies it).",
38
36
  "",
39
- "ACP (default): Google's official server (agy_acp_server.par). Needs a second Google sign-in and a ~1.5 GB server binary downloaded from Google (automatic, one-time, unavoidable: the server is not part of the agy CLI).",
37
+ "ACP (default): `agy acp`, the ACP server built into the agy CLI. Uses the same installation and Google sign-in as the CLI.",
40
38
  "stream-json (fallback): the `agy` CLI you already installed and authenticated. Persistent process, streamed output.",
41
39
  ].join("\n");
42
40
 
@@ -63,12 +61,10 @@ export function toEngine(value: string): Engine {
63
61
  return value === "acp" || value === "stream-json" ? value : DEFAULT_ENGINE;
64
62
  }
65
63
 
66
- /** Toast copy shown after the choice is saved. ACP names the promise that
67
- * matters: the binary download starts NOW (not on restart), sign-in follows
68
- * when it lands, restart applies the engine. */
64
+ /** Toast copy shown after the choice is saved. */
69
65
  export function savedEngineMessage(engine: Engine): string {
70
66
  return engine === "acp"
71
- ? "Engine saved: acp. The ~1.5 GB server binary downloads now; the Google sign-in opens when it lands. Restart applies the engine."
67
+ ? "Engine saved: acp. Turns will use `agy acp` and your existing CLI authentication. Restart applies the engine."
72
68
  : "Engine saved: stream-json. Restart pi to apply.";
73
69
  }
74
70
 
@@ -84,33 +80,56 @@ export function engineSettingsRow(engine: Engine): DialogRow {
84
80
  id: ENGINE_SETTINGS_ROW_ID,
85
81
  label: "Switch engine",
86
82
  description:
87
- "Open the engine picker (stream-json CLI vs the official ACP server). Enter opens it and closes settings; esc in the picker changes nothing. Restart applies a switch.",
83
+ "Open the engine picker (stream-json CLI vs agy acp). Enter opens it and closes settings; esc in the picker changes nothing. Restart applies a switch.",
88
84
  currentValue: engine,
89
85
  };
90
86
  }
91
87
 
92
- /** True when the `agy` CLI binary can be found. A binRef with a path
93
- * separator (AGY_BIN=/opt/agy/agy) must exist as a file; a bare name is
94
- * searched on PATH. statSync cannot throw through the guards, but a race
95
- * (file removed between listing and stat) fails closed to false. */
96
- export function isAgyInstalled(binRef: string, env: NodeJS.ProcessEnv = process.env): boolean {
97
- if (binRef.includes("/")) {
88
+ /** Bare-name suffix candidates per platform. Windows pins the probe to
89
+ * agy.exe: that is the only artifact Google ships there, and .bat/.cmd
90
+ * cannot spawn anyway (shell:false + EINVAL since CVE-2024-27980). */
91
+ function executableSuffixes(platform: NodeJS.Platform): string[] {
92
+ return platform === "win32" ? [".EXE"] : [""];
93
+ }
94
+
95
+ /** True when the `agy` CLI binary can be found. A binRef carrying a path
96
+ * separator must exist as a file - backslashes count as separators on
97
+ * win32, where path.join would mangle a bare "C:\..." AGY_BIN into a PATH
98
+ * segment. Otherwise the bare name is searched on PATH, appending the
99
+ * Windows candidate (agy.exe only: Google ships nothing else there, and
100
+ * an extensionless or .cmd file cannot be spawned anyway). A bare
101
+ * AGY_BIN that already ends in .exe probes the raw name too. Only a
102
+ * regular file counts; races fail closed. */
103
+ export function isAgyInstalled(
104
+ binRef: string,
105
+ env: NodeJS.ProcessEnv = process.env,
106
+ platform: NodeJS.Platform = process.platform,
107
+ ): boolean {
108
+ if (binRef.includes("/") || (platform === "win32" && binRef.includes("\\"))) {
98
109
  try {
99
110
  return fs.statSync(binRef).isFile();
100
111
  } catch {
101
112
  return false;
102
113
  }
103
114
  }
115
+ let suffixes = executableSuffixes(platform);
116
+ // A bare AGY_BIN=agy.exe already carries the extension; name+suffix
117
+ // would stat agy.exe.EXE and miss the real file (issue #11 class).
118
+ if (platform === "win32" && path.extname(binRef).toLowerCase() === ".exe") {
119
+ suffixes = ["", ...suffixes];
120
+ }
104
121
  return (env.PATH ?? "")
105
122
  .split(path.delimiter)
106
123
  .filter(Boolean)
107
- .some((dir) => {
108
- try {
109
- return fs.statSync(path.join(dir, binRef)).isFile();
110
- } catch {
111
- return false;
112
- }
113
- });
124
+ .some((dir) =>
125
+ suffixes.some((suffix) => {
126
+ try {
127
+ return fs.statSync(path.join(dir, binRef) + suffix).isFile();
128
+ } catch {
129
+ return false;
130
+ }
131
+ }),
132
+ );
114
133
  }
115
134
 
116
135
  /** Toast copy for the missing-CLI warning. Fires every pi start while the
@@ -8,7 +8,7 @@
8
8
  // { "mcpServers": { "<name>": { "disabled": false,
9
9
  // "headers": { "x-bridge-token": "..." }, "serverUrl": "http://..." } } }
10
10
  //
11
- // The ACP engine does not use this file (mcpServers ride session/new).
11
+ // Native ACP gets a sanitized private copy; stream-json reads this file.
12
12
  //
13
13
  // Merge rules: foreign servers are preserved; corrupt JSON is refused (the
14
14
  // file is shared user config - never clobber); writes are atomic.
@@ -36,19 +36,29 @@ export interface BridgeServerEntry {
36
36
 
37
37
  type McpConfig = { mcpServers: Record<string, unknown> };
38
38
 
39
+ /** Tolerant read of the global mcp_config.json: strips a UTF-8 BOM and
40
+ * treats the CLI's fresh-install 0-byte file as no config (issue #11).
41
+ * Content that is not a JSON object still throws; ENOENT propagates.
42
+ * A malformed mcpServers value is NOT normalized here - staging wants
43
+ * strictness; registration normalizes at its own call site. */
44
+ export function readGlobalMcpConfig(file: string): McpConfig {
45
+ const raw = fs.readFileSync(file, "utf8").replace(/^\uFEFF/, "");
46
+ const parsed = raw.trim() === "" ? { mcpServers: {} } : JSON.parse(raw);
47
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
48
+ throw new Error("mcp_config.json is not an object");
49
+ }
50
+ return parsed as McpConfig;
51
+ }
52
+
39
53
  function readConfig(file: string): { ok: true; config: McpConfig } | { ok: false; reason: string } {
40
- let parsed: unknown;
54
+ let config: McpConfig;
41
55
  try {
42
- parsed = JSON.parse(fs.readFileSync(file, "utf8"));
56
+ config = readGlobalMcpConfig(file);
43
57
  } catch (err) {
44
58
  const code = (err as NodeJS.ErrnoException).code;
45
59
  if (code === "ENOENT") return { ok: true, config: { mcpServers: {} } };
46
60
  return { ok: false, reason: `mcp_config.json is not valid JSON; refusing to touch it (${String(err)})` };
47
61
  }
48
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
49
- return { ok: false, reason: "mcp_config.json is not an object; refusing to touch it" };
50
- }
51
- const config = parsed as McpConfig;
52
62
  if (!config.mcpServers || typeof config.mcpServers !== "object" || Array.isArray(config.mcpServers)) {
53
63
  config.mcpServers = {};
54
64
  }
package/src/mcp-server.ts CHANGED
@@ -155,7 +155,7 @@ export function bridgeMcpConfigExists(): boolean {
155
155
  return fs.existsSync(bridgeMcpConfigPath());
156
156
  }
157
157
 
158
- function isPidAlive(pid: number): boolean {
158
+ export function isPidAlive(pid: number): boolean {
159
159
  try {
160
160
  process.kill(pid, 0);
161
161
  return true;
@@ -166,10 +166,10 @@ function isPidAlive(pid: number): boolean {
166
166
  }
167
167
 
168
168
  /** Best-effort cleanup of stale per-pid dirs left by crashed sessions. */
169
- function sweepStaleBridgeDirs(): void {
169
+ export function sweepStaleBridgeDirs(baseDir: string = BRIDGE_BASE, alive: (pid: number) => boolean = isPidAlive): void {
170
170
  let entries: string[];
171
171
  try {
172
- entries = fs.readdirSync(BRIDGE_BASE);
172
+ entries = fs.readdirSync(baseDir);
173
173
  } catch {
174
174
  return;
175
175
  }
@@ -177,9 +177,9 @@ function sweepStaleBridgeDirs(): void {
177
177
  if (!name.startsWith("agy-mcp-")) continue;
178
178
  const pid = Number(name.slice("agy-mcp-".length));
179
179
  if (!Number.isInteger(pid) || pid === process.pid) continue;
180
- if (isPidAlive(pid)) continue;
180
+ if (alive(pid)) continue;
181
181
  try {
182
- fs.rmSync(path.join(BRIDGE_BASE, name), { recursive: true, force: true });
182
+ fs.rmSync(path.join(baseDir, name), { recursive: true, force: true });
183
183
  } catch {
184
184
  /* best effort */
185
185
  }
package/src/provider.ts CHANGED
@@ -1213,7 +1213,7 @@ function acpEditFileArg(args: Record<string, unknown>): string | undefined {
1213
1213
  // Edit display has TWO paths on ACP:
1214
1214
  // a) native diff in tool_call content[] (future builds / the
1215
1215
  // permission-request flow carries it; phase-2 probe shape);
1216
- // b) RC01 with the auto policy sends NO content: the file simply
1216
+ // b) auto-approved edits can send NO content: the file simply
1217
1217
  // lands on disk. Read it and diff against git HEAD - the same
1218
1218
  // output the stream-json engine produces, one readFileSync.
1219
1219
  if (feats.engine === "acp") {
@@ -1802,8 +1802,8 @@ export function createStreamSimple(
1802
1802
  const config = loadConfig();
1803
1803
  const engine = deps.engine ?? config.engine;
1804
1804
  const selected = engine === "acp" && deps.acpDriver ? deps.acpDriver : deps.driver;
1805
- // Plan-shaped provider turns are fail-closed on BOTH engines. ACP has
1806
- // no plan mode at all (RC01). Stream-json is worse and probed
1805
+ // Plan-shaped provider turns are fail-closed on BOTH engines because
1806
+ // agy does not enforce review-only writes. Stream-json, probed
1807
1807
  // 2026-10-07: the engine WARNS that --mode plan is a no-op while
1808
1808
  // --disable-slash-commands is set, executes file writes even with
1809
1809
  // slash expansion left on (it stages a plan.md and still writes in the
@@ -1828,7 +1828,7 @@ export function createStreamSimple(
1828
1828
  stream,
1829
1829
  blocks,
1830
1830
  "error",
1831
- "Plan mode cannot be enforced on provider turns: the agy CLI stages a plan and executes writes in the same turn (probed 2026-10-07, google-antigravity/antigravity-cli#1181), and the ACP engine has no plan mode (RC01). /agy mode accept-edits, and enforce review-only in the host.",
1831
+ "Plan mode cannot be enforced on provider turns: the agy CLI stages a plan and executes writes in the same turn (probed 2026-10-07, google-antigravity/antigravity-cli#1181), and provider plan turns are disabled on ACP too. /agy mode accept-edits, and enforce review-only in the host.",
1832
1832
  );
1833
1833
  return stream;
1834
1834
  }