@estebanforge/pi-antigravity-bridge 1.4.0 → 1.4.2
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 +22 -0
- package/README.md +11 -7
- package/docs/ACP-ADOPTION-PLAN.md +33 -33
- package/docs/ACP-PROTOCOL-REFERENCE.md +1 -1
- package/extensions/index.ts +207 -49
- package/package.json +5 -4
- package/src/acp/connection.ts +22 -8
- package/src/acp/driver.ts +14 -10
- package/src/acp/setup.ts +355 -0
- package/src/config.ts +8 -4
- package/src/driver.ts +20 -1
- package/src/provider.ts +8 -0
- package/src/skills.ts +109 -32
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
|
@@ -59,8 +59,10 @@ export interface AgyConfig {
|
|
|
59
59
|
* always available. */
|
|
60
60
|
patchCleanupNotified?: boolean;
|
|
61
61
|
/** Which pi tools the MCP bridge exposes to agy: "none" (bridge off),
|
|
62
|
-
* "
|
|
63
|
-
*
|
|
62
|
+
* "all" (every registered non-builtin tool incl. other Ask* delegations;
|
|
63
|
+
* default - users expect the bridge working out of the box, and the
|
|
64
|
+
* "mcp" surface serves an empty catalog on installs without
|
|
65
|
+
* pi-mcp-adapter), "mcp" (pi-mcp-adapter tools + skills bridge only). */
|
|
64
66
|
bridgeTools: BridgeTools;
|
|
65
67
|
/** Inject a delta digest of pi-side context (compaction summaries, turns
|
|
66
68
|
* handled by other providers or pi's own tools) into each agy prompt.
|
|
@@ -94,7 +96,7 @@ const DEFAULTS: AgyConfig = {
|
|
|
94
96
|
skipPermissions: true,
|
|
95
97
|
defaultModel: "flash",
|
|
96
98
|
defaultThinking: "medium",
|
|
97
|
-
bridgeTools: "
|
|
99
|
+
bridgeTools: "all",
|
|
98
100
|
digest: false,
|
|
99
101
|
systemPrompt: true,
|
|
100
102
|
acp: { bin: "", permissions: "auto" },
|
|
@@ -149,7 +151,9 @@ export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
|
|
|
149
151
|
|
|
150
152
|
const bridgeRaw = (process.env.AGY_BRIDGE_TOOLS ?? file.bridgeTools ?? DEFAULTS.bridgeTools).toLowerCase();
|
|
151
153
|
const bridgeTools: BridgeTools =
|
|
152
|
-
bridgeRaw === "none" || bridgeRaw === "all"
|
|
154
|
+
bridgeRaw === "none" || bridgeRaw === "all" || bridgeRaw === "mcp"
|
|
155
|
+
? bridgeRaw
|
|
156
|
+
: DEFAULTS.bridgeTools;
|
|
153
157
|
|
|
154
158
|
const digest = process.env.AGY_DIGEST !== undefined
|
|
155
159
|
? ["1", "true", "on"].includes(process.env.AGY_DIGEST.toLowerCase())
|
package/src/driver.ts
CHANGED
|
@@ -132,6 +132,10 @@ export class AgyDriver implements TurnDriver {
|
|
|
132
132
|
#queueTail: Promise<void> = Promise.resolve();
|
|
133
133
|
#shutdown = false;
|
|
134
134
|
#stderrTail = "";
|
|
135
|
+
// Frames can split across pipe chunks; the trailing partial line lives here
|
|
136
|
+
// until its newline arrives (same scheme as JsonRpcSession.feed). Dropping
|
|
137
|
+
// it ate large tool frames and could hide the result frame of a turn.
|
|
138
|
+
#stdoutBuf = "";
|
|
135
139
|
#lifecycle: string[] = [];
|
|
136
140
|
#onTurnEnd: ((outcome: TurnOutcome) => void) | undefined;
|
|
137
141
|
#stats = {
|
|
@@ -332,7 +336,16 @@ export class AgyDriver implements TurnDriver {
|
|
|
332
336
|
windowsHide: true,
|
|
333
337
|
});
|
|
334
338
|
this.#child = child;
|
|
339
|
+
this.#stdoutBuf = "";
|
|
335
340
|
this.#stats.spawns += 1;
|
|
341
|
+
// Pipe write failures surface asynchronously as stream 'error' events;
|
|
342
|
+
// the sync try/catch around stdin.write cannot see them. Without this
|
|
343
|
+
// listener an EPIPE (agy died mid-write) is uncaught and kills pi.
|
|
344
|
+
child.stdin!.on("error", (err) => {
|
|
345
|
+
if (generation !== this.#generation) return;
|
|
346
|
+
const turn = this.#active;
|
|
347
|
+
if (turn && !turn.closed) this.#failTurn(turn, `agy stdin write failed: ${err.message}`);
|
|
348
|
+
});
|
|
336
349
|
this.#log(`spawn:${child.pid ?? "?"}:${request.conversationId ? "resume" : "fresh"}`);
|
|
337
350
|
this.#state = "ready";
|
|
338
351
|
|
|
@@ -379,10 +392,15 @@ export class AgyDriver implements TurnDriver {
|
|
|
379
392
|
}
|
|
380
393
|
|
|
381
394
|
#onStdout(chunk: string): void {
|
|
395
|
+
// Buffer the trailing partial line: a frame split across pipe chunks is
|
|
396
|
+
// reassembled when its newline arrives (mirrors JsonRpcSession.feed).
|
|
397
|
+
this.#stdoutBuf += chunk;
|
|
398
|
+
const lines = this.#stdoutBuf.split("\n");
|
|
399
|
+
this.#stdoutBuf = lines.pop() ?? "";
|
|
382
400
|
const turn = this.#active;
|
|
383
401
|
if (!turn || turn.closed) return;
|
|
384
402
|
if (turn.idleTimer) turn.idleTimer.refresh();
|
|
385
|
-
for (const line of
|
|
403
|
+
for (const line of lines) {
|
|
386
404
|
if (!line.trim()) continue;
|
|
387
405
|
this.#applyParsed(turn, parseAgyLine(line));
|
|
388
406
|
if (turn.closed) return;
|
|
@@ -585,6 +603,7 @@ export class AgyDriver implements TurnDriver {
|
|
|
585
603
|
if (!child) return;
|
|
586
604
|
this.#child = undefined;
|
|
587
605
|
this.#generation += 1;
|
|
606
|
+
this.#stdoutBuf = "";
|
|
588
607
|
try {
|
|
589
608
|
child.stdout?.removeAllListeners();
|
|
590
609
|
child.stderr?.removeAllListeners();
|
package/src/provider.ts
CHANGED
|
@@ -846,6 +846,14 @@ export function createStreamSimple(
|
|
|
846
846
|
const config = loadConfig();
|
|
847
847
|
const engine = deps.engine ?? config.engine;
|
|
848
848
|
const selected = engine === "acp" && deps.acpDriver ? deps.acpDriver : deps.driver;
|
|
849
|
+
// RC01: ACP modes are permission modes, there is no review-only plan.
|
|
850
|
+
// A plan turn on ACP would silently run non-plan; fail visibly instead.
|
|
851
|
+
if (selected === deps.acpDriver && config.mode === "plan") {
|
|
852
|
+
const partial = newAssistant(model);
|
|
853
|
+
const blocks: BlockState = { partial, textIdx: null, thinkingIdx: null, started: false };
|
|
854
|
+
finalize(stream, blocks, "error", "ACP engine has no plan mode. /agy mode accept-edits, or /agy engine stream-json.");
|
|
855
|
+
return stream;
|
|
856
|
+
}
|
|
849
857
|
if (selected && roundTrips) {
|
|
850
858
|
void runTurnDriver(stream, model, context, options, entries, store, {
|
|
851
859
|
driver: selected,
|
package/src/skills.ts
CHANGED
|
@@ -22,7 +22,9 @@ export interface SkillLite {
|
|
|
22
22
|
dir: string;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
/** Parse `name:` / `description:` out of SKILL.md frontmatter.
|
|
25
|
+
/** Parse `name:` / `description:` out of SKILL.md frontmatter. Deliberately
|
|
26
|
+
* line-based, not YAML: multi-line block scalars (`description: >-`) keep
|
|
27
|
+
* only their first line. */
|
|
26
28
|
function parseFrontmatter(raw: string): { name?: string; description?: string } {
|
|
27
29
|
const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
28
30
|
if (!m) return {};
|
|
@@ -36,48 +38,123 @@ function parseFrontmatter(raw: string): { name?: string; description?: string }
|
|
|
36
38
|
return out;
|
|
37
39
|
}
|
|
38
40
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
// The rest of this module mirrors pi's own skill discovery (pi docs
|
|
42
|
+
// "docs/skills.md - Locations") so the bridge's activate_skill catalog
|
|
43
|
+
// matches the [Skills] list pi shows in its system prompt:
|
|
44
|
+
//
|
|
45
|
+
// - Global: ~/.pi/agent/skills (pi style) and ~/.agents/skills (agents style,
|
|
46
|
+
// the cross-harness standard).
|
|
47
|
+
// - Project: .pi/skills (pi style) and .agents/skills in cwd + ancestors up
|
|
48
|
+
// to the git repo root (agents style).
|
|
49
|
+
// - Both styles discover every directory holding a SKILL.md, recursively;
|
|
50
|
+
// grouping folders (no SKILL.md) are descended into and a skill's contents
|
|
51
|
+
// are freeform (no descent past its SKILL.md).
|
|
52
|
+
// - pi style also treats root .md files as individual skills; agents style
|
|
53
|
+
// ignores root .md but picks up frontmatter .md files inside grouping
|
|
54
|
+
// folders.
|
|
55
|
+
// - Skills without a description are not loaded (same as pi).
|
|
56
|
+
// - Name collisions keep the first skill found, pi global dirs before
|
|
57
|
+
// project dirs.
|
|
58
|
+
|
|
59
|
+
export type SkillStyle = "pi" | "agents";
|
|
60
|
+
|
|
61
|
+
export interface SkillLocation {
|
|
62
|
+
dir: string;
|
|
63
|
+
style: SkillStyle;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const MAX_DEPTH = 16; // symlink-loop insurance; real catalogs nest 1-2 levels
|
|
67
|
+
|
|
68
|
+
/** Ordered skill locations for a session: pi global dirs, then project dirs
|
|
69
|
+
* (project `.agents/skills` walks cwd ancestors up to the git root). */
|
|
70
|
+
export function skillLocations(projectDir?: string, home: string = os.homedir()): SkillLocation[] {
|
|
71
|
+
const out: SkillLocation[] = [
|
|
72
|
+
{ dir: path.join(home, ".pi", "agent", "skills"), style: "pi" },
|
|
73
|
+
{ dir: path.join(home, ".agents", "skills"), style: "agents" },
|
|
74
|
+
];
|
|
75
|
+
if (!projectDir) return out;
|
|
76
|
+
out.push({ dir: path.join(projectDir, ".pi", "skills"), style: "pi" });
|
|
77
|
+
let dir = path.resolve(projectDir);
|
|
78
|
+
for (;;) {
|
|
79
|
+
out.push({ dir: path.join(dir, ".agents", "skills"), style: "agents" });
|
|
80
|
+
if (path.dirname(dir) === dir || fs.existsSync(path.join(dir, ".git"))) break;
|
|
81
|
+
dir = path.dirname(dir);
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function readSkillFile(file: string, fallbackName: string): SkillLite | null {
|
|
87
|
+
let raw = "";
|
|
41
88
|
try {
|
|
42
|
-
|
|
89
|
+
// Strip a UTF-8 BOM so editors that add one do not break the `---` anchor.
|
|
90
|
+
raw = fs.readFileSync(file, "utf8").replace(/^\uFEFF/, "");
|
|
43
91
|
} catch {
|
|
44
|
-
return
|
|
92
|
+
return null;
|
|
45
93
|
}
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
94
|
+
const fm = parseFrontmatter(raw);
|
|
95
|
+
const description = (fm.description ?? "").trim();
|
|
96
|
+
// pi does not load skills without a description.
|
|
97
|
+
if (!description) return null;
|
|
98
|
+
return {
|
|
99
|
+
name: fm.name?.trim() || fallbackName,
|
|
100
|
+
description: description.replace(/\s+/g, " ").slice(0, 160),
|
|
101
|
+
filePath: file,
|
|
102
|
+
dir: path.dirname(file),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function scanLocation(loc: SkillLocation): SkillLite[] {
|
|
107
|
+
const walk = (dir: string, depth: number): SkillLite[] => {
|
|
108
|
+
if (depth > MAX_DEPTH) return [];
|
|
109
|
+
let entries: fs.Dirent[];
|
|
50
110
|
try {
|
|
51
|
-
|
|
111
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
52
112
|
} catch {
|
|
53
|
-
|
|
113
|
+
return [];
|
|
54
114
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
continue;
|
|
115
|
+
const found: SkillLite[] = [];
|
|
116
|
+
for (const entry of entries) {
|
|
117
|
+
// Hidden entries are harness internals (e.g. ~/.agents/skills/.system);
|
|
118
|
+
// pi's own [Skills] list never includes them.
|
|
119
|
+
if (entry.name.startsWith(".")) continue;
|
|
120
|
+
const full = path.join(dir, entry.name);
|
|
121
|
+
let isDir: boolean;
|
|
122
|
+
try {
|
|
123
|
+
isDir = entry.isDirectory() || (entry.isSymbolicLink() && fs.statSync(full).isDirectory());
|
|
124
|
+
} catch {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (isDir) {
|
|
128
|
+
if (fs.existsSync(path.join(full, "SKILL.md"))) {
|
|
129
|
+
const skill = readSkillFile(path.join(full, "SKILL.md"), entry.name);
|
|
130
|
+
if (skill) found.push(skill);
|
|
131
|
+
} else {
|
|
132
|
+
found.push(...walk(full, depth + 1));
|
|
133
|
+
}
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (!entry.name.endsWith(".md") || entry.name === "SKILL.md") continue;
|
|
137
|
+
const standalone = loc.style === "pi" ? depth === 0 : depth > 0;
|
|
138
|
+
if (!standalone) continue;
|
|
139
|
+
const skill = readSkillFile(full, entry.name.replace(/\.md$/, ""));
|
|
140
|
+
if (skill) found.push(skill);
|
|
60
141
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
description: (fm.description ?? "").replace(/\s+/g, " ").slice(0, 160),
|
|
65
|
-
filePath: skillFile,
|
|
66
|
-
dir: skillDir,
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
return found;
|
|
142
|
+
return found;
|
|
143
|
+
};
|
|
144
|
+
return walk(loc.dir, 0);
|
|
70
145
|
}
|
|
71
146
|
|
|
72
|
-
/**
|
|
73
|
-
|
|
74
|
-
|
|
147
|
+
/** pi-faithful skill catalog: every skill pi itself would load, deduped by
|
|
148
|
+
* name (first wins). `home` is injectable for tests. */
|
|
149
|
+
export function scanSkills(projectDir?: string, home: string = os.homedir()): SkillLite[] {
|
|
75
150
|
const seen = new Set<string>();
|
|
76
151
|
const out: SkillLite[] = [];
|
|
77
|
-
for (const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
152
|
+
for (const loc of skillLocations(projectDir, home)) {
|
|
153
|
+
for (const skill of scanLocation(loc)) {
|
|
154
|
+
if (seen.has(skill.name)) continue;
|
|
155
|
+
seen.add(skill.name);
|
|
156
|
+
out.push(skill);
|
|
157
|
+
}
|
|
81
158
|
}
|
|
82
159
|
return out;
|
|
83
160
|
}
|