@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/CHANGELOG.md +27 -0
- package/README.md +12 -14
- package/extensions/index.ts +137 -375
- package/package.json +1 -1
- package/src/acp/connection.ts +47 -105
- package/src/acp/driver.ts +25 -59
- package/src/acp/events.ts +17 -11
- package/src/acp/home.ts +98 -0
- package/src/acp/jsonrpc.ts +6 -1
- package/src/acp/setup.ts +71 -437
- package/src/agents.ts +1 -1
- package/src/agy-install.ts +85 -0
- package/src/agy-paths.ts +3 -6
- package/src/agy-setup.ts +72 -0
- package/src/agy-version.ts +3 -4
- package/src/ask-tool.ts +3 -3
- package/src/config.ts +43 -33
- package/src/driver-types.ts +3 -5
- package/src/engine-picker.ts +44 -25
- package/src/mcp-registration.ts +17 -7
- package/src/mcp-server.ts +5 -5
- package/src/provider.ts +4 -4
- package/src/queued-messages.ts +7 -26
- package/src/acp/auth.ts +0 -174
- package/src/acp/browser-capture.ts +0 -104
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
|
-
//
|
|
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
|
|
4
|
-
//
|
|
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
|
-
|
|
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
|
package/src/agy-setup.ts
ADDED
|
@@ -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
|
+
}
|
package/src/agy-version.ts
CHANGED
|
@@ -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
|
|
11
|
-
//
|
|
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("
|
|
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("
|
|
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("
|
|
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
|
-
/**
|
|
42
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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: {
|
|
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
|
|
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,
|
|
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: {
|
|
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
|
-
|
|
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
|
|
package/src/driver-types.ts
CHANGED
|
@@ -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,
|
|
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
|
-
*
|
|
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). */
|
package/src/engine-picker.ts
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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
|
|
25
|
-
description: "default ·
|
|
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):
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
/**
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
package/src/mcp-registration.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// { "mcpServers": { "<name>": { "disabled": false,
|
|
9
9
|
// "headers": { "x-bridge-token": "..." }, "serverUrl": "http://..." } } }
|
|
10
10
|
//
|
|
11
|
-
//
|
|
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
|
|
54
|
+
let config: McpConfig;
|
|
41
55
|
try {
|
|
42
|
-
|
|
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(
|
|
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 (
|
|
180
|
+
if (alive(pid)) continue;
|
|
181
181
|
try {
|
|
182
|
-
fs.rmSync(path.join(
|
|
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)
|
|
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
|
|
1806
|
-
//
|
|
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
|
|
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
|
}
|