@estebanforge/pi-antigravity-bridge 1.9.2 → 1.10.0

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.
@@ -0,0 +1,95 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { isPidAlive } from "../mcp-server.js";
5
+
6
+ function record(value: unknown, label: string): Record<string, unknown> {
7
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must contain a JSON object`);
8
+ return value as Record<string, unknown>;
9
+ }
10
+
11
+ function linkDirectory(source: string, target: string): void {
12
+ fs.symlinkSync(source, target, "junction");
13
+ }
14
+
15
+ export function acpHomeStagingDir(): string {
16
+ return path.join(os.homedir(), ".pi", "agent", "antigravity-bridge", "acp-homes");
17
+ }
18
+
19
+ /** Isolate native ACP from per-pid bridge entries in the global MCP config. */
20
+ export function stageAcpHome(opts: { geminiHome?: string; parentDir?: string } = {}): string {
21
+ const geminiHome = opts.geminiHome ?? path.join(os.homedir(), ".gemini");
22
+ const globalConfigDir = path.join(geminiHome, "config");
23
+ const globalMcpPath = path.join(globalConfigDir, "mcp_config.json");
24
+ let config: Record<string, unknown> = { mcpServers: {} };
25
+ try {
26
+ config = record(JSON.parse(fs.readFileSync(globalMcpPath, "utf8")), globalMcpPath);
27
+ } catch (err) {
28
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
29
+ throw new Error(`Cannot isolate inherited MCP config ${globalMcpPath}: ${err instanceof Error ? err.message : String(err)}`);
30
+ }
31
+ }
32
+ const servers = record(config.mcpServers ?? {}, `${globalMcpPath} mcpServers`);
33
+ const filtered = Object.fromEntries(Object.entries(servers).filter(([name]) => !/^pi-bridge(?:-\d+)?$/.test(name)));
34
+
35
+ const parentDir = opts.parentDir ?? acpHomeStagingDir();
36
+ fs.mkdirSync(parentDir, { recursive: true, mode: 0o700 });
37
+ const staged = fs.mkdtempSync(path.join(parentDir, `acp-home-${process.pid}-`));
38
+ try {
39
+ fs.mkdirSync(path.join(staged, "config"), { mode: 0o700 });
40
+ fs.writeFileSync(path.join(staged, "config", "mcp_config.json"), JSON.stringify({ ...config, mcpServers: filtered }), { mode: 0o600 });
41
+
42
+ const appData = path.join(geminiHome, "antigravity-cli");
43
+ if (fs.existsSync(appData)) linkDirectory(appData, path.join(staged, "antigravity-cli"));
44
+ const instructions = path.join(geminiHome, "GEMINI.md");
45
+ if (fs.existsSync(instructions)) fs.copyFileSync(instructions, path.join(staged, "GEMINI.md"));
46
+ if (fs.existsSync(globalConfigDir)) {
47
+ for (const entry of fs.readdirSync(globalConfigDir, { withFileTypes: true })) {
48
+ if (entry.name === "mcp_config.json") continue;
49
+ const source = path.join(globalConfigDir, entry.name);
50
+ const target = path.join(staged, "config", entry.name);
51
+ let stat: fs.Stats | fs.Dirent = entry;
52
+ if (entry.isSymbolicLink()) {
53
+ try {
54
+ stat = fs.statSync(source);
55
+ } catch (err) {
56
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") continue;
57
+ throw err;
58
+ }
59
+ }
60
+ if (stat.isDirectory()) linkDirectory(source, target);
61
+ else if (stat.isFile()) fs.copyFileSync(source, target);
62
+ }
63
+ }
64
+ return staged;
65
+ } catch (err) {
66
+ fs.rmSync(staged, { recursive: true, force: true });
67
+ throw err;
68
+ }
69
+ }
70
+
71
+ export function sweepStaleAcpHomes(baseDir: string = acpHomeStagingDir(), alive: (pid: number) => boolean = isPidAlive): void {
72
+ let entries: string[];
73
+ try {
74
+ entries = fs.readdirSync(baseDir);
75
+ } catch {
76
+ return;
77
+ }
78
+ for (const name of entries) {
79
+ const match = /^acp-home-(\d+)-/.exec(name);
80
+ if (!match) continue;
81
+ const pid = Number(match[1]);
82
+ if (pid === process.pid || alive(pid)) continue;
83
+ removeStagedAcpHome(path.join(baseDir, name));
84
+ }
85
+ }
86
+
87
+ export function removeStagedAcpHome(staged: string): void {
88
+ if (!path.basename(staged).startsWith("acp-home-")) return;
89
+ try {
90
+ if (!fs.lstatSync(staged).isDirectory()) return;
91
+ fs.rmSync(staged, { recursive: true, force: true });
92
+ } catch {
93
+ // Connection teardown is best-effort; startup sweeps crash leftovers.
94
+ }
95
+ }
@@ -4,7 +4,7 @@
4
4
  // serialized frame + newline to the child's stdin) and feeds incoming bytes to
5
5
  // `feed()`. Everything ACP-specific lives in connection.ts.
6
6
  //
7
- // Semantics verified live against agy_acp_server (20260818_01_RC01):
7
+ // Semantics verified live against agy 1.5.3 ACP:
8
8
  // - requests are correlated by numeric id; responses reject the pending
9
9
  // promise on JSON-RPC `error` results;
10
10
  // - server-to-client REQUESTS (session/request_permission, fs/*, terminal/*)
@@ -197,6 +197,11 @@ export class JsonRpcSession {
197
197
  });
198
198
  }
199
199
 
200
+ notify(method: string, params?: unknown): void {
201
+ if (this.#aborted) return;
202
+ this.#send(JSON.stringify({ jsonrpc: "2.0", method, params: params ?? {} }));
203
+ }
204
+
200
205
  /** Reject every pending request (Gate D teardown). The transport is going
201
206
  * away; nothing will ever be written again. */
202
207
  abortAll(reason: string): void {
package/src/acp/setup.ts CHANGED
@@ -1,426 +1,107 @@
1
- // ACP engine self-service setup: binary install from the official registry
2
- // and auth bootstrap. Everything is automatic when possible; the caller
3
- // surfaces `manual` only when a step fails. Instructions-as-first-resort is
4
- // gone: /agy engine acp and session_start self-heal silently when ready.
5
- //
6
- // Sources: docs/ACP-ADOPTION-PLAN.md §2.1 (registry entry), §12 (pinned
7
- // install layout: ~/.local/opt/agy-acp/<build>/ + current symlink, zip
8
- // sha256 recorded at install time), docs/ACP-PROTOCOL-REFERENCE.md
9
- // (settings.json auth shapes; the server opens the oauth browser itself).
10
- // Credential rule: this module never reads or writes credential VALUES —
11
- // acp_token.json is only stat()ed, gemini-api-key is read by the server
12
- // from the environment.
13
-
14
- import { spawn } from "node:child_process";
15
- import { createHash } from "node:crypto";
16
1
  import fs from "node:fs";
17
2
  import os from "node:os";
18
3
  import path from "node:path";
19
- import { ensureQueuedMessagesInFile, QUEUED_MESSAGES_SEND_IMMEDIATELY } from "../queued-messages.js";
20
-
21
- export const REGISTRY_URL =
22
- "https://raw.githubusercontent.com/agentclientprotocol/registry/main/antigravity-acp/agent.json";
23
-
24
- const INSTALL_TIMEOUT_MS = 30 * 60_000;
25
-
26
- export interface SetupOptions {
27
- /** Registry agent.json URL. Overridable for tests. */
28
- registryUrl?: string;
29
- /** Install root. Default ~/.local/opt/agy-acp. */
30
- installRoot?: string;
31
- /** Server settings dir. Default ~/.gemini/antigravity-acp. */
32
- geminiDir?: string;
33
- /** Bridge-side verified-sign-in marker file. Default
34
- * ~/.pi/agent/antigravity-bridge/acp-auth-verified.json (tests point this
35
- * at a fixture to stay hermetic). */
36
- authVerifiedFile?: string;
37
- /** acp.bin from config (second resolution priority after AGY_ACP_BIN). */
38
- configBin?: string;
39
- /** Environment. Default process.env (test injection). */
40
- env?: NodeJS.ProcessEnv;
41
- /** Progress messages for the user (throttled by the caller's UI). */
42
- onProgress?: (msg: string) => void;
43
- fetchImpl?: typeof fetch;
44
- /** Archive unpacker. Default: `unzip` subprocess (tests inject a copier). */
45
- unpack?: (archive: string, dest: string) => Promise<void>;
46
- /** Download when the binary is missing. Default true. */
47
- install?: boolean;
48
- }
49
4
 
50
- export type AcpSetupStatus =
51
- | {
52
- ok: true;
53
- bin: string;
54
- binarySource: "env" | "config" | "installed" | "existing";
55
- auth: string;
56
- /** True when auth was just set to oauth-personal: /agy auth opens
57
- * the Google login in the browser. */
58
- needsLogin: boolean;
59
- /** Human-readable actions taken (empty when everything was ready). */
60
- actions: string[];
61
- }
62
- | { ok: false; stage: "install" | "auth"; error: string; manual: string };
63
-
64
- export const MANUAL_SETUP = [
65
- "For some unknown reason, Google ships its ACP server as a separate",
66
- "binary from the Agy CLI. So, as with any other part of its Antigravity",
67
- "suite (Agy Desktop, Agy Editor, Agy CLI), you need to log in to",
68
- "this one as well. Separately ¯\\_(ツ)_/¯",
69
- "ACP manual setup:",
70
- "1. Download the server zip for your platform from the antigravity-acp",
71
- " registry entry (github.com/agentclientprotocol/registry, folder",
72
- " antigravity-acp/agent.json) and unzip it, e.g. to",
73
- " ~/.local/opt/agy-acp/<build>/; chmod +x agy_acp_server.par.",
74
- "2. Point AGY_ACP_BIN or /agy config acp.bin at the binary",
75
- " (~/.local/opt/agy-acp/current/agy_acp_server.par works; 'current' is a",
76
- " symlink to the build dir).",
77
- '3. Log in: put {"auth":{"type":"oauth-personal"}} in',
78
- " ~/.gemini/antigravity-acp/settings.json and run /agy auth, then complete",
79
- " the Google login that opens in your browser. That login IS your",
80
- " Antigravity subscription (Google AI Plus/Pro/Ultra).",
81
- "Details: /agy auth-manual",
82
- ].join("\n");
83
-
84
- export function platformKey(): string {
85
- const goos = process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "windows" : "linux";
86
- const goarch = process.arch === "arm64" ? "aarch64" : "x86_64";
87
- return `${goos}-${goarch}`;
5
+ export interface AcpCleanupResult {
6
+ removedBuilds: string[];
7
+ rootRemoved: boolean;
88
8
  }
89
9
 
90
- /** Build id from a registry archive URL:
91
- * …/agy-acp-server-<build>-<platform>-<arch>.zip. */
92
- export function buildIdFromArchive(url: string): string {
93
- const base = path.posix.basename(url, ".zip");
94
- const m = base.match(/^agy-acp-server-(.+)-(darwin|linux|windows)-(?:arm64|x86_64)$/);
95
- if (!m) throw new Error(`unrecognized registry archive name: ${base}`);
96
- return m[1];
97
- }
98
-
99
- function defaultInstallRoot(): string {
10
+ export function defaultManagedAcpRoot(): string {
100
11
  return path.join(os.homedir(), ".local", "opt", "agy-acp");
101
12
  }
102
13
 
103
- function defaultGeminiDir(opts: SetupOptions): string {
104
- return opts.geminiDir ?? path.join(os.homedir(), ".gemini", "antigravity-acp");
105
- }
106
-
107
- /** Expand a leading ~ (spawn does not). Mirrors resolveAcpBinary. */
108
- function expandTilde(p: string): string {
109
- return p === "~" || p.startsWith("~/") ? path.join(os.homedir(), p.slice(1)) : p;
110
- }
111
-
112
- export function executableFile(p: string): boolean {
14
+ function regularFile(file: string): boolean {
113
15
  try {
114
- fs.accessSync(p, fs.constants.X_OK);
115
- return fs.statSync(p).isFile();
16
+ return fs.lstatSync(file).isFile();
116
17
  } catch {
117
18
  return false;
118
19
  }
119
20
  }
120
21
 
121
- interface RegistryBinary {
122
- archive: string;
123
- cmd?: string;
124
- }
125
-
126
- async function fetchRegistryBinary(opts: SetupOptions, key: string): Promise<RegistryBinary> {
127
- const fetchImpl = opts.fetchImpl ?? fetch;
128
- const res = await fetchImpl(opts.registryUrl ?? REGISTRY_URL, { signal: AbortSignal.timeout(15_000) });
129
- if (!res.ok) throw new Error(`registry fetch failed: HTTP ${res.status}`);
130
- const json = (await res.json()) as { distribution?: { binary?: Record<string, RegistryBinary> } };
131
- const entry = json.distribution?.binary?.[key];
132
- if (!entry?.archive) throw new Error(`registry has no ${key} binary`);
133
- return entry;
134
- }
135
-
136
- function defaultUnpack(archive: string, dest: string): Promise<void> {
137
- return new Promise((resolve, reject) => {
138
- const child = spawn("unzip", ["-o", "-q", archive, "-d", dest], { stdio: "ignore" });
139
- child.on("error", (err) => reject(new Error(`unzip is not available: ${err.message}`)));
140
- child.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`unzip exited with ${code}`))));
141
- });
142
- }
143
-
144
- async function downloadTo(
145
- url: string,
146
- dest: string,
147
- fetchImpl: typeof fetch,
148
- onProgress?: (msg: string) => void,
149
- ): Promise<string> {
150
- const res = await fetchImpl(url, { signal: AbortSignal.timeout(INSTALL_TIMEOUT_MS) });
151
- if (!res.ok || !res.body) throw new Error(`download failed: HTTP ${res.status}`);
152
- const hash = createHash("sha256");
153
- const total = Number(res.headers.get("content-length") ?? 0);
154
- const out = fs.createWriteStream(dest);
155
- let done = 0;
156
- let lastPct = -1;
157
- for await (const chunk of res.body) {
158
- const buf = Buffer.from(chunk as unknown as Uint8Array);
159
- hash.update(buf);
160
- done += buf.length;
161
- if (total > 0) {
162
- const pct = Math.floor((done / total) * 100);
163
- if (pct >= lastPct + 10) {
164
- lastPct = pct;
165
- onProgress?.(`downloading ACP server: ${pct}%`);
166
- }
167
- }
168
- if (!out.write(buf)) await new Promise<void>((r) => out.once("drain", r));
169
- }
170
- await new Promise<void>((resolve, reject) => {
171
- out.on("finish", () => resolve());
172
- out.on("error", reject);
173
- out.end();
174
- });
175
- return hash.digest("hex");
176
- }
177
-
178
- export interface InstallResult {
179
- bin: string;
180
- build: string;
181
- sha256: string;
182
- downloaded: boolean;
183
- }
184
-
185
- /** Fetch the pinned registry build for this platform and install it under
186
- * <root>/<build>/ with a `current` symlink (plan §12). No-op when the
187
- * build is already installed. Registry publishes no checksums; the zip
188
- * sha256 is recorded at install time for drift detection. */
189
- export async function installAcpBinary(opts: SetupOptions = {}): Promise<InstallResult> {
190
- const root = opts.installRoot ?? defaultInstallRoot();
191
- const entry = await fetchRegistryBinary(opts, platformKey());
192
- const build = buildIdFromArchive(entry.archive);
193
- const cmd = (entry.cmd ?? "./agy_acp_server.par").replace(/^\.\//, "");
194
- const dir = path.join(root, build);
195
- const bin = path.join(dir, cmd);
196
- if (executableFile(bin)) {
197
- return { bin, build, sha256: readRecordedSha(dir), downloaded: false };
198
- }
199
-
200
- fs.mkdirSync(root, { recursive: true });
201
- fs.mkdirSync(dir, { recursive: true });
202
- const tmpZip = path.join(root, `.download-${build}.zip`);
203
- opts.onProgress?.(`downloading ACP server (build ${build})…`);
204
- const sha = await downloadTo(entry.archive, tmpZip, opts.fetchImpl ?? fetch, opts.onProgress);
205
- opts.onProgress?.("unpacking ACP server…");
206
- const unpack = opts.unpack ?? defaultUnpack;
207
- await unpack(tmpZip, dir);
208
- fs.rmSync(tmpZip, { force: true });
209
- if (!executableFile(bin)) throw new Error(`${cmd} not found in the archive after unpacking`);
210
- fs.chmodSync(bin, 0o755);
211
- fs.writeFileSync(path.join(dir, "zip.sha256"), `${sha} ${path.basename(entry.archive)}\n`);
212
-
213
- // Re-point `current` at the new build. The link is ours (managed layout).
214
- const link = path.join(root, "current");
215
- fs.rmSync(link, { force: true });
216
- fs.symlinkSync(build, link);
217
- opts.onProgress?.("ACP server installed.");
218
- return { bin, build, sha256: sha, downloaded: true };
219
- }
220
-
221
- function readRecordedSha(dir: string): string {
222
- try {
223
- return fs.readFileSync(path.join(dir, "zip.sha256"), "utf8").split(/\s+/)[0] ?? "";
224
- } catch {
225
- return "";
226
- }
227
- }
228
-
229
- export interface AuthState {
230
- configured: boolean;
231
- /** settings.json auth.type, "token" (acp_token.json present), or undefined. */
232
- type?: string;
233
- }
234
-
235
- /** Token presence WITHOUT reading any credential value: acp_token.json is
236
- * stat()ed, never opened. The token only exists after the browser login
237
- * round-trip, so this is the real proof of a completed login. */
238
- function tokenPresent(target: string): boolean {
22
+ function realDirectory(file: string): boolean {
239
23
  try {
240
- fs.statSync(path.join(target, "acp_token.json"));
241
- return true;
24
+ const stat = fs.lstatSync(file);
25
+ return stat.isDirectory() && !stat.isSymbolicLink();
242
26
  } catch {
243
27
  return false;
244
28
  }
245
29
  }
246
30
 
247
- /** Whether a login token exists (default dir, or an explicit one). */
248
- export function hasAcpToken(dir?: string): boolean {
249
- return tokenPresent(dir ?? path.join(os.homedir(), ".gemini", "antigravity-acp"));
250
- }
251
-
252
- /** Bridge-side record that the ACP server proved its own credentials in a
253
- * verified sign-in (runAcpAuth). Needed because servers may authenticate
254
- * from artifacts this module cannot see: 1.1.1 honors legacy ~/.gemini
255
- * credential files (e.g. jetski-standalone-oauth-token) and never writes
256
- * acp_token.json, so the file check alone would nag login-pending forever
257
- * on a working setup. Cleared the moment the server reports an auth error
258
- * (-32000), so a stale marker never masks a real logout. Never holds a
259
- * credential value: a boolean and a timestamp. */
260
- export function defaultAcpAuthVerifiedPath(): string {
261
- return path.join(os.homedir(), ".pi", "agent", "antigravity-bridge", "acp-auth-verified.json");
31
+ function enoent(err: unknown): boolean {
32
+ return (err as NodeJS.ErrnoException).code === "ENOENT";
262
33
  }
263
34
 
264
- export function readAcpAuthVerified(file: string = defaultAcpAuthVerifiedPath()): boolean {
35
+ /** Remove only artifacts carrying the old installer's layout markers. */
36
+ export function cleanupManagedAcpInstall(root: string = defaultManagedAcpRoot()): AcpCleanupResult {
37
+ let rootStat: fs.Stats;
265
38
  try {
266
- const raw = JSON.parse(fs.readFileSync(file, "utf8")) as { verified?: unknown };
267
- return raw.verified === true;
268
- } catch {
269
- return false;
270
- }
271
- }
272
-
273
- export function writeAcpAuthVerified(file: string = defaultAcpAuthVerifiedPath()): void {
274
- fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
275
- fs.writeFileSync(file, `${JSON.stringify({ verified: true, at: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 });
276
- }
277
-
278
- export function clearAcpAuthVerified(file: string = defaultAcpAuthVerifiedPath()): void {
279
- try {
280
- fs.rmSync(file, { force: true });
281
- } catch {
282
- /* best effort: a leftover marker only over-reports readiness, and the
283
- next auth error clears it again. */
284
- }
285
- }
286
-
287
- /** Auth state WITHOUT reading any credential value: settings.json carries
288
- * only auth.type (+ non-secret gcp placement), acp_token.json is stat()ed. */
289
- export function readAuthState(dir?: string): AuthState {
290
- const target = dir ?? path.join(os.homedir(), ".gemini", "antigravity-acp");
291
- try {
292
- const raw = JSON.parse(fs.readFileSync(path.join(target, "settings.json"), "utf8")) as {
293
- auth?: { type?: unknown };
294
- };
295
- const type = typeof raw.auth?.type === "string" ? raw.auth.type : undefined;
296
- if (type) return { configured: true, type };
297
- } catch {
298
- /* absent or garbage = unconfigured */
299
- }
300
- return tokenPresent(target) ? { configured: true, type: "token" } : { configured: false };
301
- }
302
-
303
- /** Write a minimal auth block into settings.json. The server reads this file
304
- * at STARTUP, so this must happen before the first server spawn. */
305
- export function writeAuthType(type: "gemini-api-key" | "oauth-personal", dir?: string): void {
306
- const target = dir ?? path.join(os.homedir(), ".gemini", "antigravity-acp");
307
- fs.mkdirSync(target, { recursive: true, mode: 0o700 });
308
- const file = path.join(target, "settings.json");
309
- let settings: Record<string, unknown> = {};
310
- try {
311
- settings = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
312
- } catch {
313
- /* fresh file */
314
- }
315
- const prevAuth = (typeof settings.auth === "object" && settings.auth !== null ? settings.auth : {}) as Record<string, unknown>;
316
- const nextSettings: Record<string, unknown> = { ...settings, auth: { ...prevAuth, type } };
317
- if (typeof nextSettings.queuedMessages !== "string" || !nextSettings.queuedMessages.trim()) {
318
- nextSettings.queuedMessages = QUEUED_MESSAGES_SEND_IMMEDIATELY;
39
+ rootStat = fs.lstatSync(root);
40
+ } catch (err) {
41
+ if (enoent(err)) return { removedBuilds: [], rootRemoved: false };
42
+ throw err;
319
43
  }
320
- fs.writeFileSync(file, `${JSON.stringify(nextSettings, null, 2)}\n`, { mode: 0o600 });
321
- }
322
-
323
- export interface BinaryResolution {
324
- bin: string | null;
325
- source: "env" | "config" | "existing" | "installed" | null;
326
- }
327
-
328
- /** Read-only binary resolution: AGY_ACP_BIN > acp.bin > installed `current`
329
- * layout. Does NOT install and does NOT fall back to the bare PATH name. */
330
- export function resolveInstalledBinary(opts: SetupOptions = {}): BinaryResolution {
331
- const env = opts.env ?? process.env;
332
- const root = opts.installRoot ?? defaultInstallRoot();
333
- const candidates: Array<[string, BinaryResolution["source"]]> = [
334
- [env.AGY_ACP_BIN?.trim() ?? "", "env"],
335
- [opts.configBin?.trim() ?? "", "config"],
336
- [path.join(root, "current", "agy_acp_server.par"), "existing"],
337
- ];
338
- for (const [raw, source] of candidates) {
339
- if (!raw) continue;
340
- const bin = expandTilde(raw);
341
- if (executableFile(bin)) return { bin, source };
44
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) return { removedBuilds: [], rootRemoved: false };
45
+
46
+ const entries = fs.readdirSync(root, { withFileTypes: true });
47
+ const interrupted = new Set<string>();
48
+ for (const entry of entries) {
49
+ const match = /^\.download-(.+)\.zip$/.exec(entry.name);
50
+ if (!match || !regularFile(path.join(root, entry.name))) continue;
51
+ interrupted.add(match[1]!);
52
+ try {
53
+ fs.unlinkSync(path.join(root, entry.name));
54
+ } catch (err) {
55
+ if (!enoent(err)) throw err;
56
+ }
342
57
  }
343
- return { bin: null, source: null };
344
- }
345
-
346
- /** Read-only setup state (for /agy doctor): no installs, no writes. */
347
- export function inspectAcpSetup(opts: SetupOptions = {}): { bin: string | null; source: string | null; auth: string | null } {
348
- const binary = resolveInstalledBinary(opts);
349
- const gdir = defaultGeminiDir(opts);
350
- const auth = readAuthState(gdir);
351
- if (!auth.configured) return { bin: binary.bin, source: binary.source, auth: null };
352
- const type = auth.type ?? "token";
353
- // Surface the verified-marker state so a setup that authenticates without
354
- // a token file (1.1.1 legacy artifacts) reads as healthy in doctor.
355
- const verified = type !== "gemini-api-key" && !tokenPresent(gdir) && readAcpAuthVerified(opts.authVerifiedFile);
356
- return { bin: binary.bin, source: binary.source, auth: verified ? `${type} (verified, no token file)` : type };
357
- }
358
58
 
359
- /** Orchestrate everything the ACP engine needs, in order: binary (install
360
- * from the registry when missing), then auth (oauth-personal: the user's
361
- * own Antigravity subscription - the same Google account and plan as the
362
- * agy CLI login; the server opens the browser itself on the first message).
363
- * Every step is skipped when already satisfied; failures return `manual`
364
- * text for the caller to surface. */
365
- export async function ensureAcpReady(opts: SetupOptions = {}): Promise<AcpSetupStatus> {
366
- const actions: string[] = [];
367
-
368
- // 1. Binary.
369
- let binary = resolveInstalledBinary(opts);
370
- if (!binary.bin) {
371
- if (opts.install === false) {
372
- return { ok: false, stage: "install", error: "no ACP server binary found", manual: MANUAL_SETUP };
59
+ const removedBuilds: string[] = [];
60
+ for (const entry of entries) {
61
+ if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
62
+ const dir = path.join(root, entry.name);
63
+ if (!regularFile(path.join(dir, "agy_acp_server.par"))) {
64
+ if (interrupted.has(entry.name)) {
65
+ try {
66
+ if (fs.readdirSync(dir).length === 0) fs.rmdirSync(dir);
67
+ } catch (err) {
68
+ if (!enoent(err)) throw err;
69
+ }
70
+ }
71
+ continue;
373
72
  }
73
+ const marked =
74
+ regularFile(path.join(dir, "zip.sha256")) ||
75
+ realDirectory(path.join(dir, "localharness_external")) ||
76
+ interrupted.has(entry.name);
77
+ if (!marked) continue;
374
78
  try {
375
- const installed = await installAcpBinary(opts);
376
- binary = { bin: installed.bin, source: "installed" };
377
- actions.push(installed.downloaded ? `installed ACP server build ${installed.build}` : `found installed ACP server build ${installed.build}`);
79
+ fs.rmSync(dir, { recursive: true, force: true });
80
+ removedBuilds.push(entry.name);
378
81
  } catch (err) {
379
- return {
380
- ok: false,
381
- stage: "install",
382
- error: err instanceof Error ? err.message : String(err),
383
- manual: MANUAL_SETUP,
384
- };
82
+ if (!enoent(err)) throw err;
385
83
  }
386
84
  }
387
85
 
388
- // 2. Auth. settings.json is read at server STARTUP; setup always runs
389
- // before the first spawn, so a fresh write takes effect. oauth-personal is
390
- // THE default: it is the Antigravity subscription (same Google account and
391
- // plan as the agy CLI login). gemini-api-key (metered paid API) stays a
392
- // manual option for headless boxes (/agy auth-manual); never the default.
393
- const gdir = defaultGeminiDir(opts);
394
- ensureQueuedMessagesInFile(path.join(gdir, "settings.json"));
395
- const auth = readAuthState(gdir);
396
- if (!auth.configured) {
397
- try {
398
- writeAuthType("oauth-personal", gdir);
399
- actions.push("configured oauth-personal auth (your Antigravity subscription login)");
400
- return { ok: true, bin: binary.bin!, binarySource: binary.source!, auth: "oauth-personal", needsLogin: true, actions };
401
- } catch (err) {
402
- return {
403
- ok: false,
404
- stage: "auth",
405
- error: err instanceof Error ? err.message : String(err),
406
- manual: MANUAL_SETUP,
407
- };
86
+ const current = path.join(root, "current");
87
+ try {
88
+ if (fs.lstatSync(current).isSymbolicLink()) {
89
+ const target = path.resolve(root, fs.readlinkSync(current));
90
+ if (path.dirname(target) === root && removedBuilds.includes(path.basename(target))) fs.unlinkSync(current);
91
+ }
92
+ } catch (err) {
93
+ if (!enoent(err)) throw err;
94
+ }
95
+
96
+ let rootRemoved = false;
97
+ try {
98
+ if (fs.readdirSync(root).length === 0) {
99
+ fs.rmdirSync(root);
100
+ rootRemoved = true;
408
101
  }
102
+ } catch (err) {
103
+ if (enoent(err)) rootRemoved = true;
104
+ else throw err;
409
105
  }
410
- // settings.json alone proves nothing: setup writes it before the login,
411
- // the token file only appears after the browser round-trip. oauth-personal
412
- // without a token is login-pending UNLESS a verified sign-in recorded that
413
- // this server authenticates from artifacts the bridge cannot see (1.1.1
414
- // legacy ~/.gemini credentials; marker written by runAcpAuth, cleared on
415
- // any -32000). gemini-api-key has no browser login; a present token file
416
- // means done.
417
- const needsLogin = auth.type !== "gemini-api-key" && !tokenPresent(gdir) && !readAcpAuthVerified(opts.authVerifiedFile);
418
- return {
419
- ok: true,
420
- bin: binary.bin!,
421
- binarySource: binary.source!,
422
- auth: auth.type ?? "token",
423
- needsLogin,
424
- actions,
425
- };
106
+ return { removedBuilds, rootRemoved };
426
107
  }
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