@estebanforge/pi-antigravity-bridge 1.3.3 → 1.4.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 +57 -0
- package/README.md +22 -3
- package/docs/ACP-ADOPTION-PLAN.md +875 -0
- package/docs/ACP-PROTOCOL-REFERENCE.md +460 -0
- package/docs/ANTIGRAVITY-INTEGRATIONS.md +50 -551
- package/docs/ARCHITECTURE.md +50 -3
- package/docs/DEVELOPMENT.md +33 -0
- package/docs/PI-BRIDGE-GAPS.md +41 -140
- package/extensions/index.ts +235 -39
- package/package.json +12 -5
- package/src/acp/connection.ts +409 -0
- package/src/acp/driver.ts +723 -0
- package/src/acp/events.ts +250 -0
- package/src/acp/jsonrpc.ts +185 -0
- package/src/acp/setup.ts +355 -0
- package/src/config.ts +33 -0
- package/src/diff-render.ts +15 -0
- package/src/driver-types.ts +144 -0
- package/src/driver.ts +69 -78
- package/src/mcp-server.ts +8 -1
- package/src/models.ts +9 -7
- package/src/provider.ts +148 -23
package/src/acp/setup.ts
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
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
|
+
import fs from "node:fs";
|
|
17
|
+
import os from "node:os";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
|
|
20
|
+
export const REGISTRY_URL =
|
|
21
|
+
"https://raw.githubusercontent.com/agentclientprotocol/registry/main/antigravity-acp/agent.json";
|
|
22
|
+
|
|
23
|
+
const INSTALL_TIMEOUT_MS = 30 * 60_000;
|
|
24
|
+
|
|
25
|
+
export interface SetupOptions {
|
|
26
|
+
/** Registry agent.json URL. Overridable for tests. */
|
|
27
|
+
registryUrl?: string;
|
|
28
|
+
/** Install root. Default ~/.local/opt/agy-acp. */
|
|
29
|
+
installRoot?: string;
|
|
30
|
+
/** Server settings dir. Default ~/.gemini/antigravity-acp. */
|
|
31
|
+
geminiDir?: string;
|
|
32
|
+
/** acp.bin from config (second resolution priority after AGY_ACP_BIN). */
|
|
33
|
+
configBin?: string;
|
|
34
|
+
/** Environment. Default process.env (test injection). */
|
|
35
|
+
env?: NodeJS.ProcessEnv;
|
|
36
|
+
/** Progress messages for the user (throttled by the caller's UI). */
|
|
37
|
+
onProgress?: (msg: string) => void;
|
|
38
|
+
fetchImpl?: typeof fetch;
|
|
39
|
+
/** Archive unpacker. Default: `unzip` subprocess (tests inject a copier). */
|
|
40
|
+
unpack?: (archive: string, dest: string) => Promise<void>;
|
|
41
|
+
/** Download when the binary is missing. Default true. */
|
|
42
|
+
install?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type AcpSetupStatus =
|
|
46
|
+
| {
|
|
47
|
+
ok: true;
|
|
48
|
+
bin: string;
|
|
49
|
+
binarySource: "env" | "config" | "installed" | "existing";
|
|
50
|
+
auth: string;
|
|
51
|
+
/** True when auth was just set to oauth-personal: the server opens
|
|
52
|
+
* Google login in the browser on the first ACP message. */
|
|
53
|
+
needsLogin: boolean;
|
|
54
|
+
/** Human-readable actions taken (empty when everything was ready). */
|
|
55
|
+
actions: string[];
|
|
56
|
+
}
|
|
57
|
+
| { ok: false; stage: "install" | "auth"; error: string; manual: string };
|
|
58
|
+
|
|
59
|
+
export const MANUAL_SETUP = [
|
|
60
|
+
"For some unknown reason, Google ships its ACP server as a separate",
|
|
61
|
+
"binary from the Agy CLI. So, as with any other part of its Antigravity",
|
|
62
|
+
"suite (Agy Desktop, Agy Editor, Agy CLI), you need to log in to",
|
|
63
|
+
"this one as well. Separately ¯\\_(ツ)_/¯",
|
|
64
|
+
"ACP manual setup:",
|
|
65
|
+
"1. Download the server zip for your platform from the antigravity-acp",
|
|
66
|
+
" registry entry (github.com/agentclientprotocol/registry, folder",
|
|
67
|
+
" antigravity-acp/agent.json) and unzip it, e.g. to",
|
|
68
|
+
" ~/.local/opt/agy-acp/<build>/; chmod +x agy_acp_server.par.",
|
|
69
|
+
"2. Point AGY_ACP_BIN or /agy config acp.bin at the binary",
|
|
70
|
+
" (~/.local/opt/agy-acp/current/agy_acp_server.par works; 'current' is a",
|
|
71
|
+
" symlink to the build dir).",
|
|
72
|
+
'3. Log in: put {"auth":{"type":"oauth-personal"}} in',
|
|
73
|
+
" ~/.gemini/antigravity-acp/settings.json and complete the Google login that",
|
|
74
|
+
" opens in your browser on your first ACP message. That login IS your",
|
|
75
|
+
" Antigravity subscription (Google AI Plus/Pro/Ultra).",
|
|
76
|
+
"Details: /agy acp-auth",
|
|
77
|
+
].join("\n");
|
|
78
|
+
|
|
79
|
+
export function platformKey(): string {
|
|
80
|
+
const goos = process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "windows" : "linux";
|
|
81
|
+
const goarch = process.arch === "arm64" ? "aarch64" : "x86_64";
|
|
82
|
+
return `${goos}-${goarch}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Build id from a registry archive URL:
|
|
86
|
+
* …/agy-acp-server-<build>-<platform>-<arch>.zip. */
|
|
87
|
+
export function buildIdFromArchive(url: string): string {
|
|
88
|
+
const base = path.posix.basename(url, ".zip");
|
|
89
|
+
const m = base.match(/^agy-acp-server-(.+)-(darwin|linux|windows)-(?:arm64|x86_64)$/);
|
|
90
|
+
if (!m) throw new Error(`unrecognized registry archive name: ${base}`);
|
|
91
|
+
return m[1];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function defaultInstallRoot(): string {
|
|
95
|
+
return path.join(os.homedir(), ".local", "opt", "agy-acp");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function defaultGeminiDir(opts: SetupOptions): string {
|
|
99
|
+
return opts.geminiDir ?? path.join(os.homedir(), ".gemini", "antigravity-acp");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Expand a leading ~ (spawn does not). Mirrors resolveAcpBinary. */
|
|
103
|
+
function expandTilde(p: string): string {
|
|
104
|
+
return p === "~" || p.startsWith("~/") ? path.join(os.homedir(), p.slice(1)) : p;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function executableFile(p: string): boolean {
|
|
108
|
+
try {
|
|
109
|
+
fs.accessSync(p, fs.constants.X_OK);
|
|
110
|
+
return fs.statSync(p).isFile();
|
|
111
|
+
} catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface RegistryBinary {
|
|
117
|
+
archive: string;
|
|
118
|
+
cmd?: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function fetchRegistryBinary(opts: SetupOptions, key: string): Promise<RegistryBinary> {
|
|
122
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
123
|
+
const res = await fetchImpl(opts.registryUrl ?? REGISTRY_URL, { signal: AbortSignal.timeout(15_000) });
|
|
124
|
+
if (!res.ok) throw new Error(`registry fetch failed: HTTP ${res.status}`);
|
|
125
|
+
const json = (await res.json()) as { distribution?: { binary?: Record<string, RegistryBinary> } };
|
|
126
|
+
const entry = json.distribution?.binary?.[key];
|
|
127
|
+
if (!entry?.archive) throw new Error(`registry has no ${key} binary`);
|
|
128
|
+
return entry;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function defaultUnpack(archive: string, dest: string): Promise<void> {
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
133
|
+
const child = spawn("unzip", ["-o", "-q", archive, "-d", dest], { stdio: "ignore" });
|
|
134
|
+
child.on("error", (err) => reject(new Error(`unzip is not available: ${err.message}`)));
|
|
135
|
+
child.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`unzip exited with ${code}`))));
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function downloadTo(
|
|
140
|
+
url: string,
|
|
141
|
+
dest: string,
|
|
142
|
+
fetchImpl: typeof fetch,
|
|
143
|
+
onProgress?: (msg: string) => void,
|
|
144
|
+
): Promise<string> {
|
|
145
|
+
const res = await fetchImpl(url, { signal: AbortSignal.timeout(INSTALL_TIMEOUT_MS) });
|
|
146
|
+
if (!res.ok || !res.body) throw new Error(`download failed: HTTP ${res.status}`);
|
|
147
|
+
const hash = createHash("sha256");
|
|
148
|
+
const total = Number(res.headers.get("content-length") ?? 0);
|
|
149
|
+
const out = fs.createWriteStream(dest);
|
|
150
|
+
let done = 0;
|
|
151
|
+
let lastPct = -1;
|
|
152
|
+
for await (const chunk of res.body) {
|
|
153
|
+
const buf = Buffer.from(chunk as unknown as Uint8Array);
|
|
154
|
+
hash.update(buf);
|
|
155
|
+
done += buf.length;
|
|
156
|
+
if (total > 0) {
|
|
157
|
+
const pct = Math.floor((done / total) * 100);
|
|
158
|
+
if (pct >= lastPct + 10) {
|
|
159
|
+
lastPct = pct;
|
|
160
|
+
onProgress?.(`downloading ACP server: ${pct}%`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (!out.write(buf)) await new Promise<void>((r) => out.once("drain", r));
|
|
164
|
+
}
|
|
165
|
+
await new Promise<void>((resolve, reject) => {
|
|
166
|
+
out.on("finish", () => resolve());
|
|
167
|
+
out.on("error", reject);
|
|
168
|
+
out.end();
|
|
169
|
+
});
|
|
170
|
+
return hash.digest("hex");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface InstallResult {
|
|
174
|
+
bin: string;
|
|
175
|
+
build: string;
|
|
176
|
+
sha256: string;
|
|
177
|
+
downloaded: boolean;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Fetch the pinned registry build for this platform and install it under
|
|
181
|
+
* <root>/<build>/ with a `current` symlink (plan §12). No-op when the
|
|
182
|
+
* build is already installed. Registry publishes no checksums; the zip
|
|
183
|
+
* sha256 is recorded at install time for drift detection. */
|
|
184
|
+
export async function installAcpBinary(opts: SetupOptions = {}): Promise<InstallResult> {
|
|
185
|
+
const root = opts.installRoot ?? defaultInstallRoot();
|
|
186
|
+
const entry = await fetchRegistryBinary(opts, platformKey());
|
|
187
|
+
const build = buildIdFromArchive(entry.archive);
|
|
188
|
+
const cmd = (entry.cmd ?? "./agy_acp_server.par").replace(/^\.\//, "");
|
|
189
|
+
const dir = path.join(root, build);
|
|
190
|
+
const bin = path.join(dir, cmd);
|
|
191
|
+
if (executableFile(bin)) {
|
|
192
|
+
return { bin, build, sha256: readRecordedSha(dir), downloaded: false };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
fs.mkdirSync(root, { recursive: true });
|
|
196
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
197
|
+
const tmpZip = path.join(root, `.download-${build}.zip`);
|
|
198
|
+
opts.onProgress?.(`downloading ACP server (build ${build})…`);
|
|
199
|
+
const sha = await downloadTo(entry.archive, tmpZip, opts.fetchImpl ?? fetch, opts.onProgress);
|
|
200
|
+
opts.onProgress?.("unpacking ACP server…");
|
|
201
|
+
const unpack = opts.unpack ?? defaultUnpack;
|
|
202
|
+
await unpack(tmpZip, dir);
|
|
203
|
+
fs.rmSync(tmpZip, { force: true });
|
|
204
|
+
if (!executableFile(bin)) throw new Error(`${cmd} not found in the archive after unpacking`);
|
|
205
|
+
fs.chmodSync(bin, 0o755);
|
|
206
|
+
fs.writeFileSync(path.join(dir, "zip.sha256"), `${sha} ${path.basename(entry.archive)}\n`);
|
|
207
|
+
|
|
208
|
+
// Re-point `current` at the new build. The link is ours (managed layout).
|
|
209
|
+
const link = path.join(root, "current");
|
|
210
|
+
fs.rmSync(link, { force: true });
|
|
211
|
+
fs.symlinkSync(build, link);
|
|
212
|
+
opts.onProgress?.("ACP server installed.");
|
|
213
|
+
return { bin, build, sha256: sha, downloaded: true };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function readRecordedSha(dir: string): string {
|
|
217
|
+
try {
|
|
218
|
+
return fs.readFileSync(path.join(dir, "zip.sha256"), "utf8").split(/\s+/)[0] ?? "";
|
|
219
|
+
} catch {
|
|
220
|
+
return "";
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export interface AuthState {
|
|
225
|
+
configured: boolean;
|
|
226
|
+
/** settings.json auth.type, "token" (acp_token.json present), or undefined. */
|
|
227
|
+
type?: string;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Auth state WITHOUT reading any credential value: settings.json carries
|
|
231
|
+
* only auth.type (+ non-secret gcp placement), acp_token.json is stat()ed. */
|
|
232
|
+
export function readAuthState(dir?: string): AuthState {
|
|
233
|
+
const target = dir ?? path.join(os.homedir(), ".gemini", "antigravity-acp");
|
|
234
|
+
try {
|
|
235
|
+
const raw = JSON.parse(fs.readFileSync(path.join(target, "settings.json"), "utf8")) as {
|
|
236
|
+
auth?: { type?: unknown };
|
|
237
|
+
};
|
|
238
|
+
const type = typeof raw.auth?.type === "string" ? raw.auth.type : undefined;
|
|
239
|
+
if (type) return { configured: true, type };
|
|
240
|
+
} catch {
|
|
241
|
+
/* absent or garbage = unconfigured */
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
fs.statSync(path.join(target, "acp_token.json"));
|
|
245
|
+
return { configured: true, type: "token" };
|
|
246
|
+
} catch {
|
|
247
|
+
return { configured: false };
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Write a minimal auth block into settings.json. The server reads this file
|
|
252
|
+
* at STARTUP, so this must happen before the first server spawn. */
|
|
253
|
+
export function writeAuthType(type: "gemini-api-key" | "oauth-personal", dir?: string): void {
|
|
254
|
+
const target = dir ?? path.join(os.homedir(), ".gemini", "antigravity-acp");
|
|
255
|
+
fs.mkdirSync(target, { recursive: true, mode: 0o700 });
|
|
256
|
+
const file = path.join(target, "settings.json");
|
|
257
|
+
let settings: Record<string, unknown> = {};
|
|
258
|
+
try {
|
|
259
|
+
settings = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
|
|
260
|
+
} catch {
|
|
261
|
+
/* fresh file */
|
|
262
|
+
}
|
|
263
|
+
const prevAuth = (typeof settings.auth === "object" && settings.auth !== null ? settings.auth : {}) as Record<string, unknown>;
|
|
264
|
+
fs.writeFileSync(file, `${JSON.stringify({ ...settings, auth: { ...prevAuth, type } }, null, 2)}\n`, { mode: 0o600 });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export interface BinaryResolution {
|
|
268
|
+
bin: string | null;
|
|
269
|
+
source: "env" | "config" | "existing" | "installed" | null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Read-only binary resolution: AGY_ACP_BIN > acp.bin > installed `current`
|
|
273
|
+
* layout. Does NOT install and does NOT fall back to the bare PATH name. */
|
|
274
|
+
export function resolveInstalledBinary(opts: SetupOptions = {}): BinaryResolution {
|
|
275
|
+
const env = opts.env ?? process.env;
|
|
276
|
+
const root = opts.installRoot ?? defaultInstallRoot();
|
|
277
|
+
const candidates: Array<[string, BinaryResolution["source"]]> = [
|
|
278
|
+
[env.AGY_ACP_BIN?.trim() ?? "", "env"],
|
|
279
|
+
[opts.configBin?.trim() ?? "", "config"],
|
|
280
|
+
[path.join(root, "current", "agy_acp_server.par"), "existing"],
|
|
281
|
+
];
|
|
282
|
+
for (const [raw, source] of candidates) {
|
|
283
|
+
if (!raw) continue;
|
|
284
|
+
const bin = expandTilde(raw);
|
|
285
|
+
if (executableFile(bin)) return { bin, source };
|
|
286
|
+
}
|
|
287
|
+
return { bin: null, source: null };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Read-only setup state (for /agy doctor): no installs, no writes. */
|
|
291
|
+
export function inspectAcpSetup(opts: SetupOptions = {}): { bin: string | null; source: string | null; auth: string | null } {
|
|
292
|
+
const binary = resolveInstalledBinary(opts);
|
|
293
|
+
const auth = readAuthState(defaultGeminiDir(opts));
|
|
294
|
+
return { bin: binary.bin, source: binary.source, auth: auth.configured ? (auth.type ?? "token") : null };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Orchestrate everything the ACP engine needs, in order: binary (install
|
|
298
|
+
* from the registry when missing), then auth (oauth-personal: the user's
|
|
299
|
+
* own Antigravity subscription - the same Google account and plan as the
|
|
300
|
+
* agy CLI login; the server opens the browser itself on the first message).
|
|
301
|
+
* Every step is skipped when already satisfied; failures return `manual`
|
|
302
|
+
* text for the caller to surface. */
|
|
303
|
+
export async function ensureAcpReady(opts: SetupOptions = {}): Promise<AcpSetupStatus> {
|
|
304
|
+
const actions: string[] = [];
|
|
305
|
+
|
|
306
|
+
// 1. Binary.
|
|
307
|
+
let binary = resolveInstalledBinary(opts);
|
|
308
|
+
if (!binary.bin) {
|
|
309
|
+
if (opts.install === false) {
|
|
310
|
+
return { ok: false, stage: "install", error: "no ACP server binary found", manual: MANUAL_SETUP };
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
const installed = await installAcpBinary(opts);
|
|
314
|
+
binary = { bin: installed.bin, source: "installed" };
|
|
315
|
+
actions.push(installed.downloaded ? `installed ACP server build ${installed.build}` : `found installed ACP server build ${installed.build}`);
|
|
316
|
+
} catch (err) {
|
|
317
|
+
return {
|
|
318
|
+
ok: false,
|
|
319
|
+
stage: "install",
|
|
320
|
+
error: err instanceof Error ? err.message : String(err),
|
|
321
|
+
manual: MANUAL_SETUP,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// 2. Auth. settings.json is read at server STARTUP; setup always runs
|
|
327
|
+
// before the first spawn, so a fresh write takes effect. oauth-personal is
|
|
328
|
+
// THE default: it is the Antigravity subscription (same Google account and
|
|
329
|
+
// plan as the agy CLI login). gemini-api-key (metered paid API) stays a
|
|
330
|
+
// manual option for headless boxes (/agy acp-auth); never the default.
|
|
331
|
+
const gdir = defaultGeminiDir(opts);
|
|
332
|
+
const auth = readAuthState(gdir);
|
|
333
|
+
if (!auth.configured) {
|
|
334
|
+
try {
|
|
335
|
+
writeAuthType("oauth-personal", gdir);
|
|
336
|
+
actions.push("configured oauth-personal auth (your Antigravity subscription login)");
|
|
337
|
+
return { ok: true, bin: binary.bin!, binarySource: binary.source!, auth: "oauth-personal", needsLogin: true, actions };
|
|
338
|
+
} catch (err) {
|
|
339
|
+
return {
|
|
340
|
+
ok: false,
|
|
341
|
+
stage: "auth",
|
|
342
|
+
error: err instanceof Error ? err.message : String(err),
|
|
343
|
+
manual: MANUAL_SETUP,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return {
|
|
348
|
+
ok: true,
|
|
349
|
+
bin: binary.bin!,
|
|
350
|
+
binarySource: binary.source!,
|
|
351
|
+
auth: auth.type ?? "token",
|
|
352
|
+
needsLogin: false,
|
|
353
|
+
actions,
|
|
354
|
+
};
|
|
355
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -22,11 +22,27 @@ const CONFIG_PATH = path.join(
|
|
|
22
22
|
"config.json",
|
|
23
23
|
);
|
|
24
24
|
|
|
25
|
+
/** Which turn engine drives turns. "stream-json" is the tested default;
|
|
26
|
+
* "acp" is the official-server engine, opt-in (plan §9.5). */
|
|
27
|
+
export type Engine = "stream-json" | "acp";
|
|
25
28
|
export type AgyMode = "accept-edits" | "plan";
|
|
26
29
|
export type ThinkingTier = "low" | "medium" | "high";
|
|
27
30
|
export type BridgeTools = "none" | "mcp" | "all";
|
|
28
31
|
|
|
32
|
+
export interface AcpConfig {
|
|
33
|
+
/** Path to agy_acp_server.par. Empty = env AGY_ACP_BIN > PATH. */
|
|
34
|
+
bin: string;
|
|
35
|
+
/** Single policy today: auto-approve request_permission in-connection
|
|
36
|
+
* (parity with skipPermissions). Kept as a key so future policies do not
|
|
37
|
+
* change the config shape. */
|
|
38
|
+
permissions: "auto";
|
|
39
|
+
}
|
|
40
|
+
|
|
29
41
|
export interface AgyConfig {
|
|
42
|
+
/** Turn engine. Switching requires a pi restart (drivers wire at load). */
|
|
43
|
+
engine: Engine;
|
|
44
|
+
/** Official-server ACP engine options (used when engine = "acp"). */
|
|
45
|
+
acp: AcpConfig;
|
|
30
46
|
mode: AgyMode;
|
|
31
47
|
/** Auto-approve all agy tool permission requests (--dangerously-skip-permissions).
|
|
32
48
|
* Required for non-interactive use: without it, any `run_command` triggers an
|
|
@@ -73,6 +89,7 @@ export interface AgyConfig {
|
|
|
73
89
|
}
|
|
74
90
|
|
|
75
91
|
const DEFAULTS: AgyConfig = {
|
|
92
|
+
engine: "stream-json",
|
|
76
93
|
mode: "accept-edits",
|
|
77
94
|
skipPermissions: true,
|
|
78
95
|
defaultModel: "flash",
|
|
@@ -80,6 +97,7 @@ const DEFAULTS: AgyConfig = {
|
|
|
80
97
|
bridgeTools: "mcp",
|
|
81
98
|
digest: false,
|
|
82
99
|
systemPrompt: true,
|
|
100
|
+
acp: { bin: "", permissions: "auto" },
|
|
83
101
|
};
|
|
84
102
|
|
|
85
103
|
/** Load config merged over defaults. Env vars override the file when set. */
|
|
@@ -99,6 +117,11 @@ export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
|
|
|
99
117
|
// The naive OR `env === "plan" || file.mode === "plan"` would ignore an
|
|
100
118
|
// explicit AGY_MODE=accept-edits when the file says plan, violating the
|
|
101
119
|
// documented precedence. Check env first.
|
|
120
|
+
// Engine: narrow to the known set; anything else (incl. the pre-1.3.2
|
|
121
|
+
// "sqlite" value) falls back to the tested default.
|
|
122
|
+
const engineRaw = String(process.env.AGY_ENGINE ?? file.engine ?? DEFAULTS.engine).toLowerCase();
|
|
123
|
+
const engine: Engine = engineRaw === "acp" ? "acp" : "stream-json";
|
|
124
|
+
|
|
102
125
|
const mode: AgyMode =
|
|
103
126
|
process.env.AGY_MODE !== undefined
|
|
104
127
|
? process.env.AGY_MODE === "plan"
|
|
@@ -137,7 +160,17 @@ export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
|
|
|
137
160
|
? ["1", "true", "on"].includes(envSys.toLowerCase())
|
|
138
161
|
: file.systemPrompt ?? DEFAULTS.systemPrompt;
|
|
139
162
|
|
|
163
|
+
const fileAcp = (typeof file.acp === "object" && file.acp !== null ? file.acp : {}) as Partial<AcpConfig>;
|
|
164
|
+
const acp: AcpConfig = {
|
|
165
|
+
bin:
|
|
166
|
+
process.env.AGY_ACP_BIN ??
|
|
167
|
+
(typeof fileAcp.bin === "string" ? fileAcp.bin : DEFAULTS.acp.bin),
|
|
168
|
+
permissions: "auto",
|
|
169
|
+
};
|
|
170
|
+
|
|
140
171
|
return {
|
|
172
|
+
engine,
|
|
173
|
+
acp,
|
|
141
174
|
mode,
|
|
142
175
|
skipPermissions,
|
|
143
176
|
defaultModel,
|
package/src/diff-render.ts
CHANGED
|
@@ -188,3 +188,18 @@ function capLines(diff: string, max: number): string {
|
|
|
188
188
|
const dropped = lines.length - max;
|
|
189
189
|
return `${lines.slice(0, max).join("\n")}\n[... ${dropped} more diff lines]`;
|
|
190
190
|
}
|
|
191
|
+
|
|
192
|
+
/** Format an in-memory before/after pair as a line-numbered diff with pi's
|
|
193
|
+
* own generateDiffString. No git subprocess: the ACP engine supplies
|
|
194
|
+
* oldText/newText directly in tool_call content[] (Gate C), so the provider
|
|
195
|
+
* renders the native diff without touching the repo. Exported for the
|
|
196
|
+
* provider; TurnDiffContext keeps the git-sourced path for stream-json. */
|
|
197
|
+
export function formatInlineDiff(
|
|
198
|
+
oldContent: string,
|
|
199
|
+
newContent: string,
|
|
200
|
+
maxDiffLines: number = DEFAULT_MAX_DIFF_LINES,
|
|
201
|
+
): string {
|
|
202
|
+
if (oldContent === newContent) return "";
|
|
203
|
+
const { diff } = generateDiffString(oldContent, newContent);
|
|
204
|
+
return capLines(diff, maxDiffLines);
|
|
205
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Engine-agnostic turn-driver contract.
|
|
2
|
+
//
|
|
3
|
+
// Both turn engines (legacy stream-json driver in `driver.ts`, ACP driver in
|
|
4
|
+
// `acp/driver.ts`) implement `TurnDriver`, and everything above them — the
|
|
5
|
+
// provider's stream loop, the G9 round-trip store, the extension wiring —
|
|
6
|
+
// depends on this interface only. Types live here so the legacy module can be
|
|
7
|
+
// deleted (phase 4) without breaking imports.
|
|
8
|
+
//
|
|
9
|
+
// The ACP driver implements the same surface with protocol-native mechanics:
|
|
10
|
+
// no process recycle on profile drift, teardown-based abort (Gate D), and
|
|
11
|
+
// `session/load` resume. See docs/ACP-ADOPTION-PLAN.md section 9.
|
|
12
|
+
|
|
13
|
+
export type DriverState = "idle" | "starting" | "ready" | "running" | "dead";
|
|
14
|
+
|
|
15
|
+
export interface DriverProfile {
|
|
16
|
+
cwd: string;
|
|
17
|
+
model: string;
|
|
18
|
+
effort?: string;
|
|
19
|
+
mode: string;
|
|
20
|
+
skipPermissions: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface DriverTurnRequest extends DriverProfile {
|
|
24
|
+
/** Existing conversation/session to resume. Legacy: agy conversation id via
|
|
25
|
+
* `--conversation`. ACP: sessionId via `session/load` (falls back to
|
|
26
|
+
* `session/new` when the server no longer knows it). */
|
|
27
|
+
conversationId?: string | null;
|
|
28
|
+
prompt: string;
|
|
29
|
+
/** Image blocks riding with the prompt. ACP forwards them as typed
|
|
30
|
+
* content blocks (probe 2026-09-03: 64x64 two-tone PNG answered
|
|
31
|
+
* correctly); the legacy CLI prompt is text-only and ignores them. */
|
|
32
|
+
images?: Array<{ data: string; mimeType: string }>;
|
|
33
|
+
/** ACP only: pi-side context delivered as a native `embeddedContext`
|
|
34
|
+
* resource block instead of inline prompt text (G1 on ACP). Legacy
|
|
35
|
+
* embeds the digest in the prompt string and ignores this. */
|
|
36
|
+
contextBlock?: { uri: string; text: string };
|
|
37
|
+
signal?: AbortSignal;
|
|
38
|
+
/** Overall turn cap in minutes (default 10). Fractional values are valid
|
|
39
|
+
* (tests use sub-minute caps). */
|
|
40
|
+
timeoutMin?: number;
|
|
41
|
+
/** Stdout-inactivity cap in minutes (default 5). */
|
|
42
|
+
inactivityMin?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type AgyUsage = {
|
|
46
|
+
input_tokens?: number;
|
|
47
|
+
output_tokens?: number;
|
|
48
|
+
thinking_tokens?: number;
|
|
49
|
+
cache_read_tokens?: number;
|
|
50
|
+
total_tokens?: number;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type DriverActivity =
|
|
54
|
+
| { type: "text"; delta: string }
|
|
55
|
+
/** Legacy emits a token count only; ACP carries the actual thought text in
|
|
56
|
+
* `delta`. The provider renders whichever is present. */
|
|
57
|
+
| { type: "thought"; tokens?: number; delta?: string }
|
|
58
|
+
| { type: "tool_start"; stepId?: number; name: string; args: Record<string, unknown> }
|
|
59
|
+
| {
|
|
60
|
+
type: "tool_done";
|
|
61
|
+
stepId?: number;
|
|
62
|
+
name: string;
|
|
63
|
+
args: Record<string, unknown>;
|
|
64
|
+
output?: string;
|
|
65
|
+
durationSeconds?: number;
|
|
66
|
+
/** ACP only: the server's native edit diff from `tool_call`
|
|
67
|
+
* content[] ({type:"diff", path, oldText?, newText}). Legacy never
|
|
68
|
+
* sets it; the provider renders it without any git subprocess. */
|
|
69
|
+
diff?: { path: string; oldText?: string; newText: string };
|
|
70
|
+
}
|
|
71
|
+
| { type: "tool_error"; stepId?: number; name: string; message: string }
|
|
72
|
+
| { type: "usage"; usage: AgyUsage }
|
|
73
|
+
/** Synthetic: injected by the provider when the MCP bridge receives a call
|
|
74
|
+
* (G9). Parks the turn: output is expected to stall while pi executes the
|
|
75
|
+
* tool, so the driver suspends its idle/overall timers. */
|
|
76
|
+
| { type: "bridge_call"; callId: string; name: string; args: Record<string, unknown> };
|
|
77
|
+
|
|
78
|
+
export interface TurnOutcome {
|
|
79
|
+
conversationId?: string;
|
|
80
|
+
status: "OK" | "ERROR" | "UNKNOWN";
|
|
81
|
+
response: string;
|
|
82
|
+
error?: string;
|
|
83
|
+
usage?: AgyUsage;
|
|
84
|
+
finished: boolean;
|
|
85
|
+
aborted: boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface TurnHandle {
|
|
89
|
+
id: string;
|
|
90
|
+
/** Resolves when the turn settles (result event, exit, abort, recycle). */
|
|
91
|
+
outcome: Promise<TurnOutcome>;
|
|
92
|
+
/** Pull the next activity. Resolves null once the activity stream closes. */
|
|
93
|
+
next(): Promise<DriverActivity | null>;
|
|
94
|
+
/** Inject a synthetic activity (bridge inbox). No-op after settle. */
|
|
95
|
+
pushExternal(activity: DriverActivity): void;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface DriverSnapshot {
|
|
99
|
+
state: DriverState;
|
|
100
|
+
pid?: number;
|
|
101
|
+
conversationId?: string;
|
|
102
|
+
stats: {
|
|
103
|
+
spawns: number;
|
|
104
|
+
turns: number;
|
|
105
|
+
reused: number;
|
|
106
|
+
recycles: number;
|
|
107
|
+
lastRecycleReason?: string;
|
|
108
|
+
recycleReasons: Record<string, number>;
|
|
109
|
+
};
|
|
110
|
+
lifecycle: string[];
|
|
111
|
+
/** Present on ACP snapshots; absent on legacy. */
|
|
112
|
+
engine?: "acp";
|
|
113
|
+
acp?: {
|
|
114
|
+
sessionId?: string;
|
|
115
|
+
prompts: number;
|
|
116
|
+
sessionsCreated: number;
|
|
117
|
+
sessionsLoaded: number;
|
|
118
|
+
kills: number;
|
|
119
|
+
/** null = never probed on this server process. */
|
|
120
|
+
cancelSupported: boolean | null;
|
|
121
|
+
serverVersion?: string;
|
|
122
|
+
/** Connections beyond the first this driver process made = server
|
|
123
|
+
* restarts (Gate D kills + stale-exit replacements). */
|
|
124
|
+
reconnects: number;
|
|
125
|
+
/** From the initialize handshake agentInfo block. */
|
|
126
|
+
agentName?: string;
|
|
127
|
+
agentTitle?: string;
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The engine contract. Everything above the driver depends on this interface
|
|
132
|
+
* only; `AgyDriver` and `AcpDriver` both implement it. */
|
|
133
|
+
export interface TurnDriver {
|
|
134
|
+
readonly state: DriverState;
|
|
135
|
+
readonly activeHandle: TurnHandle | null;
|
|
136
|
+
run(request: DriverTurnRequest): Promise<TurnHandle>;
|
|
137
|
+
/** Re-attach to the active turn (pi toolUse continuation). */
|
|
138
|
+
reentry(): TurnHandle | null;
|
|
139
|
+
/** Resume turn timers after a parked G9 round-trip settles. */
|
|
140
|
+
kickIdle(): void;
|
|
141
|
+
set onTurnEnd(fn: ((outcome: TurnOutcome) => void) | undefined);
|
|
142
|
+
snapshot(): DriverSnapshot;
|
|
143
|
+
close(reason: "recycle" | "shutdown", cause?: string): Promise<void>;
|
|
144
|
+
}
|