@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.
@@ -0,0 +1,409 @@
1
+ // ACP connection: owns the agy_acp_server process, the JSON-RPC session over
2
+ // its stdio, and the protocol methods the driver needs. One connection hosts
3
+ // one session at a time (the driver's model), but the transport supports more.
4
+ //
5
+ // Protocol facts encoded here were verified live against
6
+ // agy_acp_server_20260818_01_RC01 (docs/ACP-PROTOCOL-REFERENCE.md):
7
+ // - initialize: {protocolVersion:1, clientCapabilities{fs:false,terminal:false}}
8
+ // - authenticate takes `methodId`; auth errors are -32000 with remediation in
9
+ // data.message; the token persists in ~/.gemini/antigravity-acp/acp_token.json
10
+ // - session/set_config_option takes `configId` (model value = FULL slug with
11
+ // the effort tier baked in, e.g. gemini-3.8-flash-low)
12
+ // - session/cancel returns -32601 on RC01 (not implemented) — the driver
13
+ // treats that as "cancel unsupported" and falls back to teardown+kill
14
+ // - session/request_permission is answered in-connection: policy per turn
15
+ // (skipPermissions on -> first allow option; off -> first reject option,
16
+ // fail-closed)
17
+ // - session/load replays history as notifications BEFORE its response; the
18
+ // driver suppresses updates while the load is in flight
19
+ // - mcpServers entries: {name, type:"http", url, headers:[]} — headers is a
20
+ // LIST; a dead URL is accepted at session time (lazy connect)
21
+
22
+ import { spawn, type ChildProcess } from "node:child_process";
23
+ import os from "node:os";
24
+ import path from "node:path";
25
+ import { JsonRpcResponseError, JsonRpcSession } from "./jsonrpc.js";
26
+
27
+ export interface AcpMcpServer {
28
+ name: string;
29
+ type: "http" | "sse";
30
+ url: string;
31
+ headers: Array<{ name: string; value: string }>;
32
+ }
33
+
34
+ export interface AcpSessionInfo {
35
+ sessionId: string;
36
+ }
37
+
38
+ /** Auth failed (-32000 family). Message carries the remediation the server
39
+ * provided plus the /agy acp-auth pointer. */
40
+ export class AcpAuthError extends Error {
41
+ constructor(message: string) {
42
+ super(message);
43
+ this.name = "AcpAuthError";
44
+ }
45
+ }
46
+
47
+ export interface AcpConnectionOptions {
48
+ /** Process working directory for the spawned server. */
49
+ cwd: string;
50
+ /** Resolved server binary (or node executable when binArgs carries the
51
+ * fake-server script for tests). */
52
+ bin: string;
53
+ binArgs?: string[];
54
+ extraEnv?: Record<string, string>;
55
+ log: (msg: string, data?: unknown) => void;
56
+ /** Verified `session/update` payloads (post-suppression). */
57
+ onUpdate: (sessionId: string | null, update: unknown) => void; /** Process exit. Fires exactly once. */
58
+ onExit: (info: { code: number | null; signal: string | null; stderrTail: string }) => void;
59
+ /** mcpServers entries for session/new AND session/load (run 6: load takes
60
+ * the same param). Evaluated lazily per call. */
61
+ mcpServers?: () => AcpMcpServer[];
62
+ /** Permission policy for session/request_permission, evaluated per request:
63
+ * "auto" selects the first allow option; "deny" fail-closes to the first
64
+ * reject option. Absent = deny (fail closed). */
65
+ permissions?: () => "auto" | "deny";
66
+ }
67
+
68
+ const INIT_TIMEOUT_MS = 30_000;
69
+ const SESSION_OP_TIMEOUT_MS = 20_000;
70
+
71
+ export class AcpConnection {
72
+ #opts: AcpConnectionOptions;
73
+ #child: ChildProcess | undefined;
74
+ #rpc: JsonRpcSession | undefined;
75
+ #stderrTail = "";
76
+ #exited = false;
77
+ #killed = false;
78
+ #suppressUpdates = false;
79
+ #updateSessionId: string | null = null;
80
+ agentInfo: Record<string, unknown> | undefined;
81
+ /** Last known mode/model config echo from set_config_option results. */
82
+ lastConfigOptions: unknown = undefined;
83
+
84
+ constructor(opts: AcpConnectionOptions) {
85
+ this.#opts = opts;
86
+ }
87
+
88
+ get alive(): boolean {
89
+ return this.#child !== undefined && !this.#exited && !this.#killed;
90
+ }
91
+
92
+ get pid(): number | undefined {
93
+ return this.#child?.pid;
94
+ }
95
+
96
+ get stderrTail(): string {
97
+ return this.#stderrTail;
98
+ }
99
+
100
+ /** While true, session/update notifications are dropped: they are the
101
+ * full-text history replay that precedes a session/load response, never
102
+ * live generation (run 6). */
103
+ set updateSuppression(flag: boolean) {
104
+ this.#suppressUpdates = flag;
105
+ }
106
+
107
+ /** Spawn the server and perform the initialize handshake. Idempotent per
108
+ * instance: call once. */
109
+ async start(): Promise<void> {
110
+ if (this.#child) return;
111
+ const args = [...(this.#opts.binArgs ?? [])];
112
+ const child = spawn(this.#opts.bin, args, {
113
+ cwd: this.#opts.cwd,
114
+ stdio: ["pipe", "pipe", "pipe"],
115
+ detached: process.platform !== "win32",
116
+ windowsHide: true,
117
+ ...(this.#opts.extraEnv ? { env: { ...process.env, ...this.#opts.extraEnv } } : {}),
118
+ });
119
+ this.#child = child;
120
+ child.stdout?.setEncoding("utf8");
121
+ child.stderr?.setEncoding("utf8");
122
+ // Pipe failures arrive asynchronously as stream 'error' events; the sync
123
+ // try/catch in #write cannot see them. Without this listener an EPIPE
124
+ // (server died mid-handshake) is uncaught and kills pi.
125
+ child.stdin?.on("error", (err) => {
126
+ this.#opts.log("stdin-error", { message: err.message });
127
+ if (!this.#exited && !this.#killed) this.#finish(err.message);
128
+ });
129
+
130
+ const rpc = new JsonRpcSession({
131
+ send: (frame) => this.#write(frame),
132
+ onRequest: (method, params) => this.#onServerRequest(method, params),
133
+ onNotification: (method, params) => this.#onNotification(method, params),
134
+ onParseError: (line) => this.#opts.log("parse-error", { line }),
135
+ });
136
+ this.#rpc = rpc;
137
+
138
+ child.stdout?.on("data", (chunk: string) => rpc.feed(chunk));
139
+ child.stderr?.on("data", (chunk: string) => {
140
+ this.#stderrTail = (this.#stderrTail + chunk).slice(-8192);
141
+ });
142
+ child.on("error", (err) => {
143
+ this.#opts.log("spawn-error", { message: err.message });
144
+ this.#finish(err.message);
145
+ });
146
+ child.on("exit", (code, signal) => {
147
+ this.#opts.log("exit", { code: code ?? signal ?? "?" });
148
+ this.#finish(this.#stderrTail.trim());
149
+ });
150
+
151
+ const result = (await this.request(
152
+ "initialize",
153
+ {
154
+ protocolVersion: 1,
155
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
156
+ },
157
+ INIT_TIMEOUT_MS,
158
+ )) as Record<string, unknown> | undefined;
159
+ const info = result?.agentInfo;
160
+ if (typeof info === "object" && info !== null) this.agentInfo = info as Record<string, unknown>;
161
+ this.#opts.log("initialized", { version: this.serverVersion() });
162
+ }
163
+
164
+ serverVersion(): string | undefined {
165
+ const v = this.agentInfo?.version;
166
+ return typeof v === "string" ? v : undefined;
167
+ }
168
+
169
+ /** True once the server answered -32601 for session/cancel on THIS
170
+ * process (probed lazily by the driver; never assumed). */
171
+ cancelSupported: boolean | null = null;
172
+
173
+ async newSession(cwd: string): Promise<AcpSessionInfo> {
174
+ const result = (await this.guarded(
175
+ "session/new",
176
+ { cwd, mcpServers: this.#mcpServers() },
177
+ SESSION_OP_TIMEOUT_MS,
178
+ )) as Record<string, unknown>;
179
+ const sessionId = result.sessionId;
180
+ if (typeof sessionId !== "string" || sessionId.length === 0) {
181
+ throw new Error("ACP session/new returned no sessionId");
182
+ }
183
+ this.#updateSessionId = sessionId;
184
+ this.lastConfigOptions = result.configOptions;
185
+ return { sessionId };
186
+ }
187
+
188
+ async loadSession(sessionId: string, cwd: string): Promise<void> {
189
+ this.#updateSessionId = sessionId;
190
+ this.updateSuppression = true;
191
+ try {
192
+ // History replay arrives as notifications BEFORE this resolves; the
193
+ // suppression flag drops it (never live text).
194
+ const result = (await this.request(
195
+ "session/load",
196
+ { sessionId, cwd, mcpServers: this.#mcpServers() },
197
+ SESSION_OP_TIMEOUT_MS,
198
+ )) as Record<string, unknown> | undefined;
199
+ this.lastConfigOptions = result?.configOptions;
200
+ } finally {
201
+ this.updateSuppression = false;
202
+ }
203
+ }
204
+
205
+ async setConfigOption(sessionId: string, configId: string, value: string): Promise<void> {
206
+ const result = (await this.guarded(
207
+ "session/set_config_option",
208
+ { sessionId, configId, value },
209
+ SESSION_OP_TIMEOUT_MS,
210
+ )) as Record<string, unknown> | undefined;
211
+ this.lastConfigOptions = result?.configOptions;
212
+ }
213
+
214
+ /** No request timeout on purpose: the driver's overall/idle timers govern
215
+ * the turn and fire abortAll on breach. */
216
+ async prompt(
217
+ sessionId: string,
218
+ text: string,
219
+ images: Array<{ data: string; mimeType: string }> = [],
220
+ contextBlock?: { uri: string; text: string },
221
+ ): Promise<{ stopReason: string }> {
222
+ // Block order: images, then the embedded-context resource (pi-side
223
+ // digest), then the text question last (it refers to everything before
224
+ // it). Shapes per the ACP v1 content-block schema; embeddedContext is
225
+ // advertised in promptCapabilities (verified run 5).
226
+ const blocks: Array<Record<string, unknown>> = [
227
+ ...images.map((i) => ({ type: "image", data: i.data, mimeType: i.mimeType })),
228
+ ];
229
+ if (contextBlock) {
230
+ blocks.push({
231
+ type: "resource",
232
+ resource: {
233
+ uri: contextBlock.uri,
234
+ mimeType: "text/markdown",
235
+ text: contextBlock.text,
236
+ },
237
+ });
238
+ }
239
+ blocks.push({ type: "text", text });
240
+ const result = (await this.request("session/prompt", {
241
+ sessionId,
242
+ prompt: blocks,
243
+ })) as Record<string, unknown> | undefined;
244
+ const stopReason = result?.stopReason;
245
+ if (typeof stopReason !== "string") {
246
+ throw new Error("ACP session/prompt returned no stopReason");
247
+ }
248
+ return { stopReason };
249
+ }
250
+
251
+ /** Probe-and-cancel. Returns supported:false when the server does not
252
+ * implement session/cancel (-32601 on RC01); other errors propagate as
253
+ * translated Errors. Protocol probing stays inside the connection layer. */
254
+ async cancel(sessionId: string): Promise<{ supported: boolean }> {
255
+ try {
256
+ await this.request("session/cancel", { sessionId }, SESSION_OP_TIMEOUT_MS);
257
+ return { supported: true };
258
+ } catch (err) {
259
+ if (err instanceof JsonRpcResponseError && err.code === -32601) return { supported: false };
260
+ throw translateError("session/cancel", err);
261
+ }
262
+ }
263
+
264
+ async closeSession(sessionId: string): Promise<void> {
265
+ await this.guarded("session/close", { sessionId }, SESSION_OP_TIMEOUT_MS);
266
+ }
267
+
268
+ /** Protocol-level permission answering: policy from the driver ("auto"
269
+ * selects the first allow option; "deny" fail-closes to the first reject
270
+ * option). Never hangs: options are data from the server and the answer
271
+ * is computed synchronously. */
272
+ #onServerRequest(method: string, params: unknown): Promise<unknown> {
273
+ if (method === "session/request_permission") {
274
+ const options = (
275
+ typeof params === "object" && params !== null ? (params as Record<string, unknown>).options : undefined
276
+ ) as Array<{ optionId?: string; kind?: string }> | undefined;
277
+ const allow = options?.find((o) => typeof o.kind === "string" && o.kind.startsWith("allow"));
278
+ const deny = options?.find((o) => typeof o.kind === "string" && o.kind.startsWith("reject"));
279
+ const policy = this.#opts.permissions?.() ?? "deny";
280
+ const chosen = policy === "auto" ? (allow ?? deny ?? options?.[0]) : (deny ?? options?.[0]);
281
+ this.#opts.log("permission", { optionId: chosen?.optionId, policy });
282
+ return Promise.resolve({ outcome: { outcome: "selected", optionId: chosen?.optionId } });
283
+ }
284
+ // fs/* and terminal/* are declined: our client capabilities are off and
285
+ // agy keeps executing its own tools (plan §8 capability posture).
286
+ this.#opts.log("unsupported-server-request", { method });
287
+ return Promise.reject(new Error(`client capability not enabled: ${method}`));
288
+ }
289
+
290
+ #onNotification(method: string, params: unknown): void {
291
+ if (method === "session/update") {
292
+ if (this.#suppressUpdates) return; // load replay: history, not live text
293
+ const p = typeof params === "object" && params !== null ? (params as Record<string, unknown>) : {};
294
+ const sessionId = typeof p.sessionId === "string" ? p.sessionId : this.#updateSessionId;
295
+ this.#opts.onUpdate(sessionId, p.update);
296
+ return;
297
+ }
298
+ if (method === "auth_required") {
299
+ this.#opts.log("auth-required", params);
300
+ return;
301
+ }
302
+ this.#opts.log("notification", { method });
303
+ }
304
+
305
+ /** Wrap a request: -32000 family becomes AcpAuthError with the remediation
306
+ * the server provided. */
307
+ private async guarded(method: string, params: unknown, timeoutMs: number): Promise<unknown> {
308
+ try {
309
+ return await this.request(method, params, timeoutMs);
310
+ } catch (err) {
311
+ throw translateError(method, err);
312
+ }
313
+ }
314
+
315
+ async request(method: string, params?: unknown, timeoutMs?: number): Promise<unknown> {
316
+ if (!this.#rpc || !this.alive) throw new Error("ACP connection is not running");
317
+ try {
318
+ return await this.#rpc.request(method, params, timeoutMs);
319
+ } catch (err) {
320
+ throw translateError(method, err);
321
+ }
322
+ }
323
+
324
+ #mcpServers(): AcpMcpServer[] {
325
+ try {
326
+ return this.#opts.mcpServers?.() ?? [];
327
+ } catch {
328
+ return [];
329
+ }
330
+ }
331
+
332
+ #write(frame: string): void {
333
+ const stdin = this.#child?.stdin;
334
+ if (!stdin || !stdin.writable || this.#exited) {
335
+ this.abortAll("connection closed");
336
+ return;
337
+ }
338
+ try {
339
+ stdin.write(frame + "\n");
340
+ } catch (err) {
341
+ this.#opts.log("write-failed", { message: err instanceof Error ? err.message : String(err) });
342
+ this.abortAll("connection closed");
343
+ }
344
+ }
345
+
346
+ /** Gate D teardown: reject every pending request, then kill the process.
347
+ * Nothing is ever written to the dying transport; no promise survives. */
348
+ abortAll(reason: string): void {
349
+ this.#rpc?.abortAll(reason);
350
+ }
351
+ kill(): void {
352
+ if (this.#killed) return;
353
+ this.#killed = true;
354
+ this.abortAll("connection killed");
355
+ const child = this.#child;
356
+ if (!child || this.#exited) return;
357
+ this.#opts.log("kill", { pid: child.pid ?? "?" });
358
+ try {
359
+ if (child.pid && process.platform !== "win32") {
360
+ process.kill(-child.pid, "SIGTERM");
361
+ setTimeout(() => {
362
+ try {
363
+ if (!this.#exited && child.pid) process.kill(-child.pid, "SIGKILL");
364
+ } catch {
365
+ /* already gone */
366
+ }
367
+ }, 750);
368
+ } else {
369
+ child.kill("SIGTERM");
370
+ }
371
+ } catch {
372
+ /* already gone */
373
+ }
374
+ }
375
+
376
+ #finish(reason: string): void {
377
+ if (this.#exited) return;
378
+ this.#exited = true;
379
+ this.abortAll(`connection exited: ${reason || "process gone"}`);
380
+ this.#opts.onExit({ code: null, signal: null, stderrTail: this.#stderrTail });
381
+ }
382
+ }
383
+
384
+ export function translateError(method: string, err: unknown): Error {
385
+ if (err instanceof AcpAuthError) return err;
386
+ if (err instanceof JsonRpcResponseError) {
387
+ if (err.code === -32000) {
388
+ const detail = typeof err.data === "object" && err.data !== null ? (err.data as { message?: unknown }).message : undefined;
389
+ const detailText = typeof detail === "string" ? `: ${detail}` : `: ${err.message}`;
390
+ return new AcpAuthError(`ACP authentication required for ${method}${detailText}. Run /agy acp-auth for setup instructions.`);
391
+ }
392
+ // Typed protocol errors survive: callers probe .code (-32601 cancel-
393
+ // unsupported, -32602 param shape). The method prefix is lost — describe()
394
+ // callers that need it log the method at the call site.
395
+ return err;
396
+ }
397
+ if (err instanceof Error) return err;
398
+ return new Error(`ACP ${method} failed: ${String(err)}`);
399
+ }
400
+
401
+ /** Resolve the server binary: env > config > PATH. Resolution failure is a
402
+ * visible turn error naming what was tried (plan §9.5). */
403
+ /** Resolve the server binary: env > config > PATH. `~` expands to the home
404
+ * directory (spawn does not expand it). Resolution failure is a visible turn
405
+ * error naming what was tried (plan §9.5). */
406
+ export function resolveAcpBinary(configBin: string): string {
407
+ const raw = process.env.AGY_ACP_BIN || (configBin.length > 0 ? configBin : "agy_acp_server.par");
408
+ return raw === "~" || raw.startsWith("~/") ? path.join(os.homedir(), raw.slice(1)) : raw;
409
+ }