@estebanforge/pi-antigravity-bridge 1.9.3 → 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,473 +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
-
50
- export type AcpSetupStatus =
51
- | {
52
- ok: true;
53
- bin: string;
54
- binarySource: "env" | "config" | "installed" | "existing";
55
- /** Registry argv for the managed binary; empty for env/config overrides. */
56
- args: string[];
57
- auth: string;
58
- /** True when auth was just set to oauth-personal: /agy auth opens
59
- * the Google login in the browser. */
60
- needsLogin: boolean;
61
- /** Human-readable actions taken (empty when everything was ready). */
62
- actions: string[];
63
- }
64
- | { ok: false; stage: "install" | "auth"; error: string; manual: string };
65
4
 
66
- export const MANUAL_SETUP = [
67
- "For some unknown reason, Google ships its ACP server as a separate",
68
- "binary from the Agy CLI. So, as with any other part of its Antigravity",
69
- "suite (Agy Desktop, Agy Editor, Agy CLI), you need to log in to",
70
- "this one as well. Separately ¯\\_(ツ)_/¯",
71
- "ACP manual setup:",
72
- "1. Download the server zip for your platform from the antigravity-acp",
73
- " registry entry (github.com/agentclientprotocol/registry, folder",
74
- " antigravity-acp/agent.json) and unzip it, e.g. to",
75
- " ~/.local/opt/agy-acp/<build>/; chmod +x agy_acp_server.par.",
76
- "2. Point AGY_ACP_BIN or /agy config acp.bin at the binary",
77
- " (~/.local/opt/agy-acp/current/agy_acp_server.par works; 'current' is a",
78
- " symlink to the build dir).",
79
- '3. Log in: put {"auth":{"type":"oauth-personal"}} in',
80
- " ~/.gemini/antigravity-acp/settings.json and run /agy auth, then complete",
81
- " the Google login that opens in your browser. That login IS your",
82
- " Antigravity subscription (Google AI Plus/Pro/Ultra).",
83
- "Details: /agy auth-manual",
84
- ].join("\n");
85
-
86
- export function platformKey(): string {
87
- const goos = process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "windows" : "linux";
88
- const goarch = process.arch === "arm64" ? "aarch64" : "x86_64";
89
- return `${goos}-${goarch}`;
90
- }
91
-
92
- /** Build id from a registry archive URL:
93
- * …/agy-acp-server-<build>-<platform>-<arch>.zip. */
94
- export function buildIdFromArchive(url: string): string {
95
- const base = path.posix.basename(url, ".zip");
96
- const m = base.match(/^agy-acp-server-(.+)-(darwin|linux|windows)-(?:arm64|x86_64)$/);
97
- if (!m) throw new Error(`unrecognized registry archive name: ${base}`);
98
- return m[1];
5
+ export interface AcpCleanupResult {
6
+ removedBuilds: string[];
7
+ rootRemoved: boolean;
99
8
  }
100
9
 
101
- function defaultInstallRoot(): string {
10
+ export function defaultManagedAcpRoot(): string {
102
11
  return path.join(os.homedir(), ".local", "opt", "agy-acp");
103
12
  }
104
13
 
105
- function defaultGeminiDir(opts: SetupOptions): string {
106
- return opts.geminiDir ?? path.join(os.homedir(), ".gemini", "antigravity-acp");
107
- }
108
-
109
- /** Expand a leading ~ (spawn does not). Mirrors resolveAcpBinary. */
110
- function expandTilde(p: string): string {
111
- return p === "~" || p.startsWith("~/") ? path.join(os.homedir(), p.slice(1)) : p;
112
- }
113
-
114
- export function executableFile(p: string): boolean {
14
+ function regularFile(file: string): boolean {
115
15
  try {
116
- fs.accessSync(p, fs.constants.X_OK);
117
- return fs.statSync(p).isFile();
16
+ return fs.lstatSync(file).isFile();
118
17
  } catch {
119
18
  return false;
120
19
  }
121
20
  }
122
21
 
123
- interface RegistryBinary {
124
- archive: string;
125
- cmd?: string;
126
- /** Platform-specific argv the server needs to boot (e.g. linux `--uid=`).
127
- * Untrusted registry data: validated as a string array before use. */
128
- args?: unknown;
129
- }
130
-
131
- /** Registry args are untrusted; accept only a flat string array. */
132
- function normalizeRegistryArgs(raw: unknown): string[] {
133
- if (!Array.isArray(raw)) return [];
134
- return raw.filter((a): a is string => typeof a === "string");
135
- }
136
-
137
- async function fetchRegistryBinary(opts: SetupOptions, key: string): Promise<RegistryBinary> {
138
- const fetchImpl = opts.fetchImpl ?? fetch;
139
- const res = await fetchImpl(opts.registryUrl ?? REGISTRY_URL, { signal: AbortSignal.timeout(15_000) });
140
- if (!res.ok) throw new Error(`registry fetch failed: HTTP ${res.status}`);
141
- const json = (await res.json()) as { distribution?: { binary?: Record<string, RegistryBinary> } };
142
- const entry = json.distribution?.binary?.[key];
143
- if (!entry?.archive) throw new Error(`registry has no ${key} binary`);
144
- return entry;
145
- }
146
-
147
- function defaultUnpack(archive: string, dest: string): Promise<void> {
148
- return new Promise((resolve, reject) => {
149
- const child = spawn("unzip", ["-o", "-q", archive, "-d", dest], { stdio: "ignore" });
150
- child.on("error", (err) => reject(new Error(`unzip is not available: ${err.message}`)));
151
- child.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`unzip exited with ${code}`))));
152
- });
153
- }
154
-
155
- async function downloadTo(
156
- url: string,
157
- dest: string,
158
- fetchImpl: typeof fetch,
159
- onProgress?: (msg: string) => void,
160
- ): Promise<string> {
161
- const res = await fetchImpl(url, { signal: AbortSignal.timeout(INSTALL_TIMEOUT_MS) });
162
- if (!res.ok || !res.body) throw new Error(`download failed: HTTP ${res.status}`);
163
- const hash = createHash("sha256");
164
- const total = Number(res.headers.get("content-length") ?? 0);
165
- const out = fs.createWriteStream(dest);
166
- let done = 0;
167
- let lastPct = -1;
168
- for await (const chunk of res.body) {
169
- const buf = Buffer.from(chunk as unknown as Uint8Array);
170
- hash.update(buf);
171
- done += buf.length;
172
- if (total > 0) {
173
- const pct = Math.floor((done / total) * 100);
174
- if (pct >= lastPct + 10) {
175
- lastPct = pct;
176
- onProgress?.(`downloading ACP server: ${pct}%`);
177
- }
178
- }
179
- if (!out.write(buf)) await new Promise<void>((r) => out.once("drain", r));
180
- }
181
- await new Promise<void>((resolve, reject) => {
182
- out.on("finish", () => resolve());
183
- out.on("error", reject);
184
- out.end();
185
- });
186
- return hash.digest("hex");
187
- }
188
-
189
- export interface InstallResult {
190
- bin: string;
191
- build: string;
192
- sha256: string;
193
- downloaded: boolean;
194
- /** Registry-declared argv for the managed binary (may be empty). */
195
- args: string[];
196
- }
197
-
198
- /** Fetch the pinned registry build for this platform and install it under
199
- * <root>/<build>/ with a `current` symlink (plan §12). No-op when the
200
- * build is already installed. Registry publishes no checksums; the zip
201
- * sha256 is recorded at install time for drift detection. */
202
- export async function installAcpBinary(opts: SetupOptions = {}): Promise<InstallResult> {
203
- const root = opts.installRoot ?? defaultInstallRoot();
204
- const entry = await fetchRegistryBinary(opts, platformKey());
205
- const build = buildIdFromArchive(entry.archive);
206
- const cmd = (entry.cmd ?? "./agy_acp_server.par").replace(/^\.\//, "");
207
- const dir = path.join(root, build);
208
- const bin = path.join(dir, cmd);
209
- const args = normalizeRegistryArgs(entry.args);
210
- if (executableFile(bin)) {
211
- // Already installed: recover any recorded args so the early-return
212
- // path does not silently drop them.
213
- return { bin, build, sha256: readRecordedSha(dir), downloaded: false, args: readRecordedArgs(dir) };
214
- }
215
-
216
- fs.mkdirSync(root, { recursive: true });
217
- fs.mkdirSync(dir, { recursive: true });
218
- const tmpZip = path.join(root, `.download-${build}.zip`);
219
- opts.onProgress?.(`downloading ACP server (build ${build})…`);
220
- const sha = await downloadTo(entry.archive, tmpZip, opts.fetchImpl ?? fetch, opts.onProgress);
221
- opts.onProgress?.("unpacking ACP server…");
222
- const unpack = opts.unpack ?? defaultUnpack;
223
- await unpack(tmpZip, dir);
224
- fs.rmSync(tmpZip, { force: true });
225
- if (!executableFile(bin)) throw new Error(`${cmd} not found in the archive after unpacking`);
226
- fs.chmodSync(bin, 0o755);
227
- fs.writeFileSync(path.join(dir, "zip.sha256"), `${sha} ${path.basename(entry.archive)}\n`);
228
- if (args.length > 0) fs.writeFileSync(path.join(dir, "args.json"), JSON.stringify(args));
229
-
230
- // Re-point `current` at the new build. The link is ours (managed layout).
231
- const link = path.join(root, "current");
232
- fs.rmSync(link, { force: true });
233
- fs.symlinkSync(build, link);
234
- opts.onProgress?.("ACP server installed.");
235
- return { bin, build, sha256: sha, downloaded: true, args };
236
- }
237
-
238
- function readRecordedSha(dir: string): string {
22
+ function realDirectory(file: string): boolean {
239
23
  try {
240
- return fs.readFileSync(path.join(dir, "zip.sha256"), "utf8").split(/\s+/)[0] ?? "";
241
- } catch {
242
- return "";
243
- }
244
- }
245
-
246
- function readRecordedArgs(dir: string): string[] {
247
- try {
248
- return normalizeRegistryArgs(JSON.parse(fs.readFileSync(path.join(dir, "args.json"), "utf8")));
249
- } catch {
250
- return [];
251
- }
252
- }
253
-
254
- export interface AuthState {
255
- configured: boolean;
256
- /** settings.json auth.type, "token" (acp_token.json present), or undefined. */
257
- type?: string;
258
- }
259
-
260
- /** Token presence WITHOUT reading any credential value: acp_token.json is
261
- * stat()ed, never opened. The token only exists after the browser login
262
- * round-trip, so this is the real proof of a completed login. */
263
- function tokenPresent(target: string): boolean {
264
- try {
265
- fs.statSync(path.join(target, "acp_token.json"));
266
- return true;
24
+ const stat = fs.lstatSync(file);
25
+ return stat.isDirectory() && !stat.isSymbolicLink();
267
26
  } catch {
268
27
  return false;
269
28
  }
270
29
  }
271
30
 
272
- /** Whether a login token exists (default dir, or an explicit one). */
273
- export function hasAcpToken(dir?: string): boolean {
274
- return tokenPresent(dir ?? path.join(os.homedir(), ".gemini", "antigravity-acp"));
31
+ function enoent(err: unknown): boolean {
32
+ return (err as NodeJS.ErrnoException).code === "ENOENT";
275
33
  }
276
34
 
277
- /** Bridge-side record that the ACP server proved its own credentials in a
278
- * verified sign-in (runAcpAuth). Needed because servers may authenticate
279
- * from artifacts this module cannot see: 1.1.1 honors legacy ~/.gemini
280
- * credential files (e.g. jetski-standalone-oauth-token) and never writes
281
- * acp_token.json, so the file check alone would nag login-pending forever
282
- * on a working setup. Cleared the moment the server reports an auth error
283
- * (-32000), so a stale marker never masks a real logout. Never holds a
284
- * credential value: a boolean and a timestamp. */
285
- export function defaultAcpAuthVerifiedPath(): string {
286
- return path.join(os.homedir(), ".pi", "agent", "antigravity-bridge", "acp-auth-verified.json");
287
- }
288
-
289
- export function readAcpAuthVerified(file: string = defaultAcpAuthVerifiedPath()): boolean {
290
- try {
291
- const raw = JSON.parse(fs.readFileSync(file, "utf8")) as { verified?: unknown };
292
- return raw.verified === true;
293
- } catch {
294
- return false;
295
- }
296
- }
297
-
298
- export function writeAcpAuthVerified(file: string = defaultAcpAuthVerifiedPath()): void {
299
- fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
300
- fs.writeFileSync(file, `${JSON.stringify({ verified: true, at: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 });
301
- }
302
-
303
- export function clearAcpAuthVerified(file: string = defaultAcpAuthVerifiedPath()): void {
35
+ /** Remove only artifacts carrying the old installer's layout markers. */
36
+ export function cleanupManagedAcpInstall(root: string = defaultManagedAcpRoot()): AcpCleanupResult {
37
+ let rootStat: fs.Stats;
304
38
  try {
305
- fs.rmSync(file, { force: true });
306
- } catch {
307
- /* best effort: a leftover marker only over-reports readiness, and the
308
- next auth error clears it again. */
39
+ rootStat = fs.lstatSync(root);
40
+ } catch (err) {
41
+ if (enoent(err)) return { removedBuilds: [], rootRemoved: false };
42
+ throw err;
309
43
  }
310
- }
311
-
312
- /** Auth state WITHOUT reading any credential value: settings.json carries
313
- * only auth.type (+ non-secret gcp placement), acp_token.json is stat()ed. */
314
- export function readAuthState(dir?: string): AuthState {
315
- const target = dir ?? path.join(os.homedir(), ".gemini", "antigravity-acp");
316
- try {
317
- const raw = JSON.parse(fs.readFileSync(path.join(target, "settings.json"), "utf8")) as {
318
- auth?: { type?: unknown };
319
- };
320
- const type = typeof raw.auth?.type === "string" ? raw.auth.type : undefined;
321
- if (type) return { configured: true, type };
322
- } catch {
323
- /* absent or garbage = unconfigured */
324
- }
325
- return tokenPresent(target) ? { configured: true, type: "token" } : { configured: false };
326
- }
327
-
328
- /** Write a minimal auth block into settings.json. The server reads this file
329
- * at STARTUP, so this must happen before the first server spawn. */
330
- export function writeAuthType(type: "gemini-api-key" | "oauth-personal", dir?: string): void {
331
- const target = dir ?? path.join(os.homedir(), ".gemini", "antigravity-acp");
332
- fs.mkdirSync(target, { recursive: true, mode: 0o700 });
333
- const file = path.join(target, "settings.json");
334
- let settings: Record<string, unknown> = {};
335
- try {
336
- settings = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
337
- } catch {
338
- /* fresh file */
339
- }
340
- const prevAuth = (typeof settings.auth === "object" && settings.auth !== null ? settings.auth : {}) as Record<string, unknown>;
341
- const nextSettings: Record<string, unknown> = { ...settings, auth: { ...prevAuth, type } };
342
- if (typeof nextSettings.queuedMessages !== "string" || !nextSettings.queuedMessages.trim()) {
343
- nextSettings.queuedMessages = QUEUED_MESSAGES_SEND_IMMEDIATELY;
344
- }
345
- fs.writeFileSync(file, `${JSON.stringify(nextSettings, null, 2)}\n`, { mode: 0o600 });
346
- }
347
-
348
- export interface BinaryResolution {
349
- bin: string | null;
350
- source: "env" | "config" | "existing" | "installed" | null;
351
- }
352
-
353
- /** Read-only binary resolution: AGY_ACP_BIN > acp.bin > installed `current`
354
- * layout. Does NOT install and does NOT fall back to the bare PATH name. */
355
- export function resolveInstalledBinary(opts: SetupOptions = {}): BinaryResolution {
356
- const env = opts.env ?? process.env;
357
- const root = opts.installRoot ?? defaultInstallRoot();
358
- const candidates: Array<[string, BinaryResolution["source"]]> = [
359
- [env.AGY_ACP_BIN?.trim() ?? "", "env"],
360
- [opts.configBin?.trim() ?? "", "config"],
361
- [path.join(root, "current", "agy_acp_server.par"), "existing"],
362
- ];
363
- for (const [raw, source] of candidates) {
364
- if (!raw) continue;
365
- const bin = expandTilde(raw);
366
- if (executableFile(bin)) return { bin, source };
367
- }
368
- return { bin: null, source: null };
369
- }
370
-
371
- /** Registry argv for the managed install only. Env/config overrides (a user
372
- * shim like `agy acp`, or AGY_ACP_BIN) own their argv, so they get none.
373
- * Identity is by PATH, not by which candidate won: setup persists the managed
374
- * path into acp.bin, so after install the config candidate resolves to the
375
- * managed binary too. Compare realpaths against `<root>/current/...` (the
376
- * symlink target carries the args.json sidecar). Empty when not managed. */
377
- export function resolveInstalledArgs(opts: SetupOptions = {}): string[] {
378
- const binary = resolveInstalledBinary(opts);
379
- if (!binary.bin) return [];
380
- const root = opts.installRoot ?? defaultInstallRoot();
381
- const managed = path.join(root, "current", "agy_acp_server.par");
382
- try {
383
- if (fs.realpathSync(binary.bin) !== fs.realpathSync(managed)) return [];
384
- } catch {
385
- return [];
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
+ }
386
57
  }
387
- return readRecordedArgs(path.dirname(fs.realpathSync(managed)));
388
- }
389
58
 
390
- /** Read-only setup state (for /agy doctor): no installs, no writes. */
391
- export function inspectAcpSetup(opts: SetupOptions = {}): { bin: string | null; source: string | null; auth: string | null } {
392
- const binary = resolveInstalledBinary(opts);
393
- const gdir = defaultGeminiDir(opts);
394
- const auth = readAuthState(gdir);
395
- if (!auth.configured) return { bin: binary.bin, source: binary.source, auth: null };
396
- const type = auth.type ?? "token";
397
- // Surface the verified-marker state so a setup that authenticates without
398
- // a token file (1.1.1 legacy artifacts) reads as healthy in doctor.
399
- const verified = type !== "gemini-api-key" && !tokenPresent(gdir) && readAcpAuthVerified(opts.authVerifiedFile);
400
- return { bin: binary.bin, source: binary.source, auth: verified ? `${type} (verified, no token file)` : type };
401
- }
402
-
403
- /** Orchestrate everything the ACP engine needs, in order: binary (install
404
- * from the registry when missing), then auth (oauth-personal: the user's
405
- * own Antigravity subscription - the same Google account and plan as the
406
- * agy CLI login; the server opens the browser itself on the first message).
407
- * Every step is skipped when already satisfied; failures return `manual`
408
- * text for the caller to surface. */
409
- export async function ensureAcpReady(opts: SetupOptions = {}): Promise<AcpSetupStatus> {
410
- const actions: string[] = [];
411
-
412
- // 1. Binary.
413
- let binary = resolveInstalledBinary(opts);
414
- let args = resolveInstalledArgs(opts);
415
- if (!binary.bin) {
416
- if (opts.install === false) {
417
- 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;
418
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;
419
78
  try {
420
- const installed = await installAcpBinary(opts);
421
- binary = { bin: installed.bin, source: "installed" };
422
- args = installed.args;
423
- 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);
424
81
  } catch (err) {
425
- return {
426
- ok: false,
427
- stage: "install",
428
- error: err instanceof Error ? err.message : String(err),
429
- manual: MANUAL_SETUP,
430
- };
82
+ if (!enoent(err)) throw err;
431
83
  }
432
84
  }
433
85
 
434
- // 2. Auth. settings.json is read at server STARTUP; setup always runs
435
- // before the first spawn, so a fresh write takes effect. oauth-personal is
436
- // THE default: it is the Antigravity subscription (same Google account and
437
- // plan as the agy CLI login). gemini-api-key (metered paid API) stays a
438
- // manual option for headless boxes (/agy auth-manual); never the default.
439
- const gdir = defaultGeminiDir(opts);
440
- ensureQueuedMessagesInFile(path.join(gdir, "settings.json"));
441
- const auth = readAuthState(gdir);
442
- if (!auth.configured) {
443
- try {
444
- writeAuthType("oauth-personal", gdir);
445
- actions.push("configured oauth-personal auth (your Antigravity subscription login)");
446
- return { ok: true, bin: binary.bin!, binarySource: binary.source!, args, auth: "oauth-personal", needsLogin: true, actions };
447
- } catch (err) {
448
- return {
449
- ok: false,
450
- stage: "auth",
451
- error: err instanceof Error ? err.message : String(err),
452
- manual: MANUAL_SETUP,
453
- };
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;
454
101
  }
102
+ } catch (err) {
103
+ if (enoent(err)) rootRemoved = true;
104
+ else throw err;
455
105
  }
456
- // settings.json alone proves nothing: setup writes it before the login,
457
- // the token file only appears after the browser round-trip. oauth-personal
458
- // without a token is login-pending UNLESS a verified sign-in recorded that
459
- // this server authenticates from artifacts the bridge cannot see (1.1.1
460
- // legacy ~/.gemini credentials; marker written by runAcpAuth, cleared on
461
- // any -32000). gemini-api-key has no browser login; a present token file
462
- // means done.
463
- const needsLogin = auth.type !== "gemini-api-key" && !tokenPresent(gdir) && !readAcpAuthVerified(opts.authVerifiedFile);
464
- return {
465
- ok: true,
466
- bin: binary.bin!,
467
- binarySource: binary.source!,
468
- args,
469
- auth: auth.type ?? "token",
470
- needsLogin,
471
- actions,
472
- };
106
+ return { removedBuilds, rootRemoved };
473
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