@estebanforge/pi-antigravity-bridge 1.9.2 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +12 -14
- package/extensions/index.ts +115 -369
- package/package.json +1 -1
- package/src/acp/connection.ts +56 -109
- package/src/acp/driver.ts +38 -62
- package/src/acp/events.ts +17 -11
- package/src/acp/home.ts +95 -0
- package/src/acp/jsonrpc.ts +6 -1
- package/src/acp/setup.ts +71 -390
- 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 +17 -19
- package/src/driver-types.ts +3 -5
- package/src/engine-picker.ts +8 -12
- package/src/mcp-registration.ts +1 -1
- 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 -119
- package/src/acp/browser-capture.ts +0 -104
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@estebanforge/pi-antigravity-bridge",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"description": "Gemini provider for Pi on the Antigravity ACP server (official Google ACP) or the stream-json agy CLI. Use antigravity/* models in Pi. Full /model picker support. Models runs Pi's tools. ToS safe to use.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
package/src/acp/connection.ts
CHANGED
|
@@ -1,16 +1,10 @@
|
|
|
1
|
-
// ACP connection: owns the
|
|
2
|
-
//
|
|
3
|
-
// one session at a time (the driver's model), but the transport supports more.
|
|
1
|
+
// ACP connection: owns the agy acp process, the JSON-RPC session over its
|
|
2
|
+
// stdio, and the protocol methods the driver needs.
|
|
4
3
|
//
|
|
5
|
-
//
|
|
6
|
-
// agy_acp_server_20260818_01_RC01 (docs/ACP-PROTOCOL-REFERENCE.md):
|
|
4
|
+
// Verified live against agy 1.5.3:
|
|
7
5
|
// - initialize: {protocolVersion:1, clientCapabilities{fs:false,terminal:false}}
|
|
8
|
-
// -
|
|
9
|
-
//
|
|
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
|
|
6
|
+
// - model and thought_level are separate session config options
|
|
7
|
+
// - session/cancel is a notification; the prompt settles with stopReason cancelled
|
|
14
8
|
// - session/request_permission: skipPermissions turns answer the first
|
|
15
9
|
// allow option synchronously; otherwise a wired human handler PARKS the
|
|
16
10
|
// request (the JSON-RPC reply waits for the decision), and everything
|
|
@@ -21,14 +15,11 @@
|
|
|
21
15
|
// LIST; a dead URL is accepted at session time (lazy connect)
|
|
22
16
|
|
|
23
17
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
24
|
-
import fs from "node:fs";
|
|
25
|
-
import os from "node:os";
|
|
26
|
-
import path from "node:path";
|
|
27
|
-
import { parseAuthPort, readLastUrl } from "./browser-capture.js";
|
|
28
18
|
import { frameCarriesUsage } from "./events.js";
|
|
29
19
|
import type { AgyUsage } from "../driver-types.js";
|
|
30
20
|
import { redactText } from "../redact.js";
|
|
31
21
|
import { JsonRpcResponseError, JsonRpcSession } from "./jsonrpc.js";
|
|
22
|
+
import { removeStagedAcpHome, stageAcpHome } from "./home.js";
|
|
32
23
|
|
|
33
24
|
export interface AcpMcpServer {
|
|
34
25
|
name: string;
|
|
@@ -50,16 +41,6 @@ export class AcpAuthError extends Error {
|
|
|
50
41
|
}
|
|
51
42
|
}
|
|
52
43
|
|
|
53
|
-
/** Fired exactly when the SERVER reports an auth failure (-32000 family).
|
|
54
|
-
* The extension entry wires this to clearAcpAuthVerified(): a verified
|
|
55
|
-
* marker must never outlive the server disagreeing with it. Kept as a hook
|
|
56
|
-
* so the low-level connection stays decoupled from bridge file paths. */
|
|
57
|
-
let authInvalidatedHook: (() => void) | null = null;
|
|
58
|
-
|
|
59
|
-
export function setAuthInvalidatedHook(fn: (() => void) | null): void {
|
|
60
|
-
authInvalidatedHook = fn;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
44
|
export interface AcpConnectionOptions {
|
|
64
45
|
/** Process working directory for the spawned server. */
|
|
65
46
|
cwd: string;
|
|
@@ -67,6 +48,8 @@ export interface AcpConnectionOptions {
|
|
|
67
48
|
* fake-server script for tests). */
|
|
68
49
|
bin: string;
|
|
69
50
|
binArgs?: string[];
|
|
51
|
+
/** Native agy only: filter per-pid bridges out of inherited global MCP. */
|
|
52
|
+
isolateHome?: boolean | { geminiHome?: string; parentDir?: string };
|
|
70
53
|
extraEnv?: Record<string, string>;
|
|
71
54
|
log: (msg: string, data?: unknown) => void;
|
|
72
55
|
/** Verified `session/update` payloads (post-suppression). */
|
|
@@ -87,11 +70,6 @@ export interface AcpConnectionOptions {
|
|
|
87
70
|
onPermissionRequest?: AcpPermissionHandler;
|
|
88
71
|
/** Park budget for one permission dialog. Tests shrink it. */
|
|
89
72
|
permissionParkMs?: number;
|
|
90
|
-
/** Record file for BROWSER-captured OAuth URLs (src/acp/browser-capture.ts).
|
|
91
|
-
* The server hands the login URL only to the browser-open call, so the
|
|
92
|
-
* wrapper records it there; the connection watches the file and logs
|
|
93
|
-
* "auth-url" {url, port} for every new URL. */
|
|
94
|
-
authUrlFile?: string;
|
|
95
73
|
}
|
|
96
74
|
|
|
97
75
|
const INIT_TIMEOUT_MS = 30_000;
|
|
@@ -126,6 +104,7 @@ export class AcpConnection {
|
|
|
126
104
|
#child: ChildProcess | undefined;
|
|
127
105
|
#rpc: JsonRpcSession | undefined;
|
|
128
106
|
#stderrTail = "";
|
|
107
|
+
#stagedHome: string | undefined;
|
|
129
108
|
#usageSeen = false;
|
|
130
109
|
/** Set ONLY by a parsed PromptResponse.usage; this gates estimate
|
|
131
110
|
* suppression. #usageSeen stays diagnostic: any usage-shaped frame trips
|
|
@@ -138,11 +117,8 @@ export class AcpConnection {
|
|
|
138
117
|
agentInfo: Record<string, unknown> | undefined;
|
|
139
118
|
/** Last known mode/model config echo from set_config_option results. */
|
|
140
119
|
lastConfigOptions: unknown = undefined;
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
/** Parked permission dialogs: each entry settles its own request with a
|
|
144
|
-
* deny. #finish drains the set so a dying connection never leaves the
|
|
145
|
-
* server's request hanging. */
|
|
120
|
+
/** Parked permission dialogs: each entry settles its own request as
|
|
121
|
+
* cancelled. Cancellation and connection death drain the set. */
|
|
146
122
|
#permissionPending = new Set<() => void>();
|
|
147
123
|
/** allow_always / reject_always answers, keyed by toolCall kind|title for
|
|
148
124
|
* the life of this connection: identical requests skip the dialog. */
|
|
@@ -190,15 +166,24 @@ export class AcpConnection {
|
|
|
190
166
|
async start(): Promise<void> {
|
|
191
167
|
if (this.#child) return;
|
|
192
168
|
const args = [...(this.#opts.binArgs ?? [])];
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
169
|
+
if (this.#opts.isolateHome) {
|
|
170
|
+
this.#stagedHome = stageAcpHome(typeof this.#opts.isolateHome === "object" ? this.#opts.isolateHome : {});
|
|
171
|
+
args.push("--gemini-dir", this.#stagedHome);
|
|
172
|
+
}
|
|
173
|
+
let child: ChildProcess;
|
|
174
|
+
try {
|
|
175
|
+
child = spawn(this.#opts.bin, args, {
|
|
176
|
+
cwd: this.#opts.cwd,
|
|
177
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
178
|
+
detached: process.platform !== "win32",
|
|
179
|
+
windowsHide: true,
|
|
180
|
+
...(this.#opts.extraEnv ? { env: { ...process.env, ...this.#opts.extraEnv } } : {}),
|
|
181
|
+
});
|
|
182
|
+
} catch (err) {
|
|
183
|
+
this.#releaseHome();
|
|
184
|
+
throw err;
|
|
185
|
+
}
|
|
200
186
|
this.#child = child;
|
|
201
|
-
if (this.#opts.authUrlFile) this.#watchAuthUrl();
|
|
202
187
|
child.stdout?.setEncoding("utf8");
|
|
203
188
|
child.stderr?.setEncoding("utf8");
|
|
204
189
|
// Pipe failures arrive asynchronously as stream 'error' events; the sync
|
|
@@ -246,8 +231,13 @@ export class AcpConnection {
|
|
|
246
231
|
child.on("exit", (code, signal) => {
|
|
247
232
|
this.#opts.log("exit", { code: code ?? signal ?? "?" });
|
|
248
233
|
// The reason rides into pending-request rejections, so it must be
|
|
249
|
-
// redacted here (server stderr can carry auth material).
|
|
250
|
-
|
|
234
|
+
// redacted here (server stderr can carry auth material). When the
|
|
235
|
+
// server dies with no output the exit code/signal is the only clue,
|
|
236
|
+
// so it goes into the reason ahead of the stderr tail.
|
|
237
|
+
const where = signal ? `signal ${signal}` : code !== null ? `code ${code}` : "";
|
|
238
|
+
const tail = redactText(this.#stderrTail.trim());
|
|
239
|
+
const detail = [where, tail || "process gone"].filter(Boolean).join(": ");
|
|
240
|
+
this.#finish(detail, code, signal);
|
|
251
241
|
});
|
|
252
242
|
|
|
253
243
|
const result = (await this.request(
|
|
@@ -268,10 +258,6 @@ export class AcpConnection {
|
|
|
268
258
|
return typeof v === "string" ? v : undefined;
|
|
269
259
|
}
|
|
270
260
|
|
|
271
|
-
/** True once the server answered -32601 for session/cancel on THIS
|
|
272
|
-
* process (probed lazily by the driver; never assumed). */
|
|
273
|
-
cancelSupported: boolean | null = null;
|
|
274
|
-
|
|
275
261
|
async newSession(cwd: string): Promise<AcpSessionInfo> {
|
|
276
262
|
const result = (await this.guarded(
|
|
277
263
|
"session/new",
|
|
@@ -347,9 +333,8 @@ export class AcpConnection {
|
|
|
347
333
|
if (typeof stopReason !== "string") {
|
|
348
334
|
throw new Error("ACP session/prompt returned no stopReason");
|
|
349
335
|
}
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
// infer exact counts from a context-window usage_update ({used,size}).
|
|
336
|
+
// Exact PromptResponse.usage is distinct from context-window occupancy
|
|
337
|
+
// updates; never infer token counts from {used,size}.
|
|
353
338
|
const usage = parseAcpTurnUsage(result?.usage);
|
|
354
339
|
if (usage) {
|
|
355
340
|
this.#usageSeen = true;
|
|
@@ -358,17 +343,10 @@ export class AcpConnection {
|
|
|
358
343
|
return { stopReason, ...(usage ? { usage } : {}) };
|
|
359
344
|
}
|
|
360
345
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
try {
|
|
366
|
-
await this.request("session/cancel", { sessionId }, SESSION_OP_TIMEOUT_MS);
|
|
367
|
-
return { supported: true };
|
|
368
|
-
} catch (err) {
|
|
369
|
-
if (err instanceof JsonRpcResponseError && err.code === -32601) return { supported: false };
|
|
370
|
-
throw translateError("session/cancel", err);
|
|
371
|
-
}
|
|
346
|
+
cancel(sessionId: string): void {
|
|
347
|
+
if (!this.#rpc || !this.alive) throw new Error("ACP connection is not running");
|
|
348
|
+
this.#rpc.notify("session/cancel", { sessionId });
|
|
349
|
+
for (const cancel of [...this.#permissionPending]) cancel();
|
|
372
350
|
}
|
|
373
351
|
|
|
374
352
|
async closeSession(sessionId: string): Promise<void> {
|
|
@@ -431,7 +409,7 @@ export class AcpConnection {
|
|
|
431
409
|
if (settled) return;
|
|
432
410
|
settled = true;
|
|
433
411
|
if (timer) clearTimeout(timer);
|
|
434
|
-
this.#permissionPending.delete(
|
|
412
|
+
this.#permissionPending.delete(settleCancel);
|
|
435
413
|
this.#opts.log("permission", { optionId, policy: why });
|
|
436
414
|
// undefined optionId (no reject option on the table) cancels
|
|
437
415
|
// instead of selecting nothing.
|
|
@@ -442,11 +420,12 @@ export class AcpConnection {
|
|
|
442
420
|
);
|
|
443
421
|
};
|
|
444
422
|
const settleDeny = (): void => settle(denyId, "dialog-deny");
|
|
423
|
+
const settleCancel = (): void => settle(undefined, "cancel");
|
|
445
424
|
timer = setTimeout(() => {
|
|
446
425
|
this.#opts.log("permission-timeout", { parkMs });
|
|
447
426
|
settleDeny();
|
|
448
427
|
}, parkMs);
|
|
449
|
-
this.#permissionPending.add(
|
|
428
|
+
this.#permissionPending.add(settleCancel);
|
|
450
429
|
Promise.resolve()
|
|
451
430
|
.then(() => handler(params))
|
|
452
431
|
.then((picked) => {
|
|
@@ -557,43 +536,22 @@ export class AcpConnection {
|
|
|
557
536
|
}
|
|
558
537
|
}
|
|
559
538
|
|
|
560
|
-
|
|
561
|
-
* URL in the record file is logged once as "auth-url" {url, port}. Best
|
|
562
|
-
* effort only - a broken watch never affects turns. */
|
|
563
|
-
#watchAuthUrl(): void {
|
|
564
|
-
const file = this.#opts.authUrlFile!;
|
|
565
|
-
try {
|
|
566
|
-
const watcher = fs.watch(file, () => this.#emitAuthUrl());
|
|
567
|
-
// Without a listener, a watch 'error' event escapes as uncaught.
|
|
568
|
-
watcher.on("error", () => {
|
|
569
|
-
/* record file gone: login URL surfacing is off until respawn */
|
|
570
|
-
});
|
|
571
|
-
this.#authUrlWatcher = watcher;
|
|
572
|
-
} catch {
|
|
573
|
-
/* no watch; the one-shot check below still covers earlier writes */
|
|
574
|
-
}
|
|
575
|
-
this.#emitAuthUrl(); // URLs recorded before the watch started
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
#emitAuthUrl(): void {
|
|
579
|
-
const url = readLastUrl(this.#opts.authUrlFile!);
|
|
580
|
-
if (!url || url === this.#lastAuthUrl) return;
|
|
581
|
-
this.#lastAuthUrl = url;
|
|
582
|
-
this.#opts.log("auth-url", { url, port: parseAuthPort(url) });
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
#finish(reason: string): void {
|
|
539
|
+
#finish(reason: string, code: number | null = null, signal: NodeJS.Signals | null = null): void {
|
|
586
540
|
if (this.#exited) return;
|
|
587
541
|
this.#exited = true;
|
|
588
|
-
|
|
589
|
-
this.#authUrlWatcher = undefined;
|
|
590
|
-
// Pending permission dialogs must not outlive the connection: deny them
|
|
542
|
+
// Pending permission dialogs must not outlive the connection: cancel them
|
|
591
543
|
// so their replies race the teardown instead of hanging the server.
|
|
592
544
|
const pendings = [...this.#permissionPending];
|
|
593
545
|
this.#permissionPending.clear();
|
|
594
546
|
for (const deny of pendings) deny();
|
|
595
547
|
this.abortAll(`connection exited: ${reason || "process gone"}`);
|
|
596
|
-
this.#
|
|
548
|
+
this.#releaseHome();
|
|
549
|
+
this.#opts.onExit({ code, signal, stderrTail: redactText(this.#stderrTail) });
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
#releaseHome(): void {
|
|
553
|
+
if (this.#stagedHome) removeStagedAcpHome(this.#stagedHome);
|
|
554
|
+
this.#stagedHome = undefined;
|
|
597
555
|
}
|
|
598
556
|
}
|
|
599
557
|
|
|
@@ -623,26 +581,15 @@ export function translateError(method: string, err: unknown): Error {
|
|
|
623
581
|
if (err instanceof AcpAuthError) return err;
|
|
624
582
|
if (err instanceof JsonRpcResponseError) {
|
|
625
583
|
if (err.code === -32000) {
|
|
626
|
-
authInvalidatedHook?.();
|
|
627
584
|
const detail = typeof err.data === "object" && err.data !== null ? (err.data as { message?: unknown }).message : undefined;
|
|
628
585
|
const detailText = typeof detail === "string" ? `: ${detail}` : `: ${err.message}`;
|
|
629
|
-
return new AcpAuthError(`ACP authentication required for ${method}${detailText}. Run
|
|
586
|
+
return new AcpAuthError(`ACP authentication required for ${method}${detailText}. Run agy in your terminal and complete Google sign-in, then restart pi.`);
|
|
630
587
|
}
|
|
631
|
-
// Typed protocol errors survive: callers
|
|
632
|
-
//
|
|
588
|
+
// Typed protocol errors survive: callers inspect .code for parameter
|
|
589
|
+
// and capability failures. The method prefix is lost — describe()
|
|
633
590
|
// callers that need it log the method at the call site.
|
|
634
591
|
return err;
|
|
635
592
|
}
|
|
636
593
|
if (err instanceof Error) return err;
|
|
637
594
|
return new Error(`ACP ${method} failed: ${String(err)}`);
|
|
638
595
|
}
|
|
639
|
-
|
|
640
|
-
/** Resolve the server binary: env > config > PATH. Resolution failure is a
|
|
641
|
-
* visible turn error naming what was tried (plan §9.5). */
|
|
642
|
-
/** Resolve the server binary: env > config > PATH. `~` expands to the home
|
|
643
|
-
* directory (spawn does not expand it). Resolution failure is a visible turn
|
|
644
|
-
* error naming what was tried (plan §9.5). */
|
|
645
|
-
export function resolveAcpBinary(configBin: string): string {
|
|
646
|
-
const raw = process.env.AGY_ACP_BIN || (configBin.length > 0 ? configBin : "agy_acp_server.par");
|
|
647
|
-
return raw === "~" || raw.startsWith("~/") ? path.join(os.homedir(), raw.slice(1)) : raw;
|
|
648
|
-
}
|
package/src/acp/driver.ts
CHANGED
|
@@ -5,14 +5,11 @@
|
|
|
5
5
|
// Engine differences vs stream-json, all verified live (docs/ACP-PROTOCOL-REFERENCE.md):
|
|
6
6
|
// - no process recycle on profile drift: one server process, sessions
|
|
7
7
|
// selected per turn via session/new / session/load
|
|
8
|
-
// - model
|
|
9
|
-
// with the effort tier baked in); mode via configId "mode"
|
|
8
|
+
// - model and thought_level are separate session/set_config_option calls
|
|
10
9
|
// - config does NOT persist across server restarts: re-applied every turn
|
|
11
10
|
// - session/load replays history as full-text notification pairs BEFORE its
|
|
12
11
|
// response; the connection suppresses updates while loading
|
|
13
|
-
// - session/cancel is
|
|
14
|
-
// teardown + kill, then session/load on the next turn. The method is
|
|
15
|
-
// probed once per connection; when upstream ships it, abort goes graceful
|
|
12
|
+
// - session/cancel is a notification; teardown remains the safety net
|
|
16
13
|
// - overall-timer pause uses remaining-budget semantics on G9 parks (never a
|
|
17
14
|
// fresh cap); every park carries its own timeout (BRIDGE_TIMEOUT_MS)
|
|
18
15
|
// - request_permission: skipPermissions turns answer the first allow option
|
|
@@ -23,7 +20,7 @@
|
|
|
23
20
|
|
|
24
21
|
import { randomUUID } from "node:crypto";
|
|
25
22
|
import type { UsageEstimate } from "../config.js";
|
|
26
|
-
import { AcpConnection,
|
|
23
|
+
import { AcpConnection, type AcpMcpServer, type AcpPermissionRequest } from "./connection.js";
|
|
27
24
|
import type { AcpPermissionHandler } from "./connection.js";
|
|
28
25
|
import { mapStopReason, mapUpdate, TextAccumulator, type AcpEditDiff } from "./events.js";
|
|
29
26
|
import { estimateTokens, synthesizeUsage } from "./usage-estimate.js";
|
|
@@ -41,16 +38,13 @@ import type {
|
|
|
41
38
|
const LIFECYCLE_LIMIT = 24;
|
|
42
39
|
|
|
43
40
|
export interface AcpDriverOptions {
|
|
44
|
-
/**
|
|
45
|
-
* is resolved per connection: setup can install the binary and update
|
|
46
|
-
* config mid-session, and the next turn picks it up without a restart. */
|
|
41
|
+
/** agy CLI binary. Tests may substitute node with a fake-server script. */
|
|
47
42
|
bin: string | (() => string);
|
|
48
|
-
/** Extra argv
|
|
49
|
-
binArgs?: string[];
|
|
43
|
+
/** Extra argv (production: ["acp"]; tests: fake-server script). */
|
|
44
|
+
binArgs?: string[] | (() => string[]);
|
|
45
|
+
/** Native agy only: isolate inherited global MCP from other pi sessions. */
|
|
46
|
+
isolateHome?: boolean;
|
|
50
47
|
extraEnv?: Record<string, string>;
|
|
51
|
-
/** Record file for BROWSER-captured OAuth URLs; passed to the connection,
|
|
52
|
-
* which logs "auth-url" when the server tries to open a login. */
|
|
53
|
-
authUrlFile?: string;
|
|
54
48
|
/** Bridge registration for session/new AND session/load. */
|
|
55
49
|
mcpServers?: () => AcpMcpServer[];
|
|
56
50
|
/** Gate B stopgap: how ACP turns synthesize usage while the server sends
|
|
@@ -108,24 +102,13 @@ interface ActiveTurn {
|
|
|
108
102
|
toolCalls: Map<string, { name: string; args: Record<string, unknown>; diff?: AcpEditDiff; mcpServer?: string }>;
|
|
109
103
|
}
|
|
110
104
|
|
|
111
|
-
/** Recombine the provider's (base slug, effort) into the FULL ACP model slug.
|
|
112
|
-
* Fixed families (no effort) pass through unchanged. Verified against the
|
|
113
|
-
* run-5 catalog: session/set_config_option wants "gemini-3.8-flash-low"-style
|
|
114
|
-
* full slugs. */
|
|
115
|
-
export function acpModelSlug(model: string, effort?: string): string {
|
|
116
|
-
if (!effort) return model;
|
|
117
|
-
if (/(?:^|-)(?:high|medium|low)$/.test(model)) return model;
|
|
118
|
-
return `${model}-${effort}`;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
105
|
/** Map our config knobs onto ACP session modes. skipPermissions=false also
|
|
122
106
|
* fail-closes the in-connection permission handler (reject options), so the
|
|
123
|
-
* modes keep their server-side meaning.
|
|
124
|
-
*
|
|
125
|
-
* command level and fails the turn visibly. */
|
|
107
|
+
* modes keep their server-side meaning. Provider plan turns are refused
|
|
108
|
+
* because agy does not enforce review-only writes. */
|
|
126
109
|
export function acpMode(mode: string, skipPermissions: boolean): string {
|
|
127
|
-
if (skipPermissions) return "
|
|
128
|
-
return mode === "plan" ? "
|
|
110
|
+
if (skipPermissions) return "accept-edits";
|
|
111
|
+
return mode === "plan" ? "plan" : mode === "accept-edits" ? "accept-edits" : "default";
|
|
129
112
|
}
|
|
130
113
|
|
|
131
114
|
export class AcpDriver implements TurnDriver {
|
|
@@ -146,12 +129,11 @@ export class AcpDriver implements TurnDriver {
|
|
|
146
129
|
};
|
|
147
130
|
#serverVersion: string | undefined;
|
|
148
131
|
#lastSessionId: string | undefined;
|
|
149
|
-
#lastCancelSupported: boolean | null = null;
|
|
150
132
|
#agentInfo: { name?: string; title?: string } | undefined;
|
|
151
133
|
|
|
152
134
|
constructor(opts: AcpDriverOptions) {
|
|
153
135
|
this.#opts = opts;
|
|
154
|
-
this.#log("driver-created", { bin: typeof opts.bin === "function" ? "(resolved per turn)" :
|
|
136
|
+
this.#log("driver-created", { bin: typeof opts.bin === "function" ? "(resolved per turn)" : opts.bin });
|
|
155
137
|
}
|
|
156
138
|
|
|
157
139
|
get state(): DriverState {
|
|
@@ -332,7 +314,8 @@ export class AcpDriver implements TurnDriver {
|
|
|
332
314
|
// are a parity break); mode failure is best-effort (auto policy makes
|
|
333
315
|
// the modes converge anyway).
|
|
334
316
|
try {
|
|
335
|
-
await conn.setConfigOption(turn.sessionId, "model",
|
|
317
|
+
await conn.setConfigOption(turn.sessionId, "model", request.model);
|
|
318
|
+
if (request.effort) await conn.setConfigOption(turn.sessionId, "thought_level", request.effort);
|
|
336
319
|
} catch (err) {
|
|
337
320
|
this.#failTurn(turn, `ACP model selection failed: ${describe(err)}`);
|
|
338
321
|
return;
|
|
@@ -347,9 +330,8 @@ export class AcpDriver implements TurnDriver {
|
|
|
347
330
|
// Timers: overall (turn deadline, pause-aware) + idle (inactivity).
|
|
348
331
|
this.#armOverall(turn);
|
|
349
332
|
this.#armIdle(turn);
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
// estimates advance with each text/thought delta below.
|
|
333
|
+
// Estimates cover servers without exact PromptResponse.usage. Native
|
|
334
|
+
// agy replaces them with exact counts when the prompt completes.
|
|
353
335
|
this.#emitLiveEstimate(turn);
|
|
354
336
|
|
|
355
337
|
// Prompt. Updates stream through the connection's onUpdate callback.
|
|
@@ -472,11 +454,10 @@ export class AcpDriver implements TurnDriver {
|
|
|
472
454
|
}
|
|
473
455
|
}
|
|
474
456
|
|
|
475
|
-
#onConnectionExit(conn: AcpConnection, info: { stderrTail: string }): void {
|
|
457
|
+
#onConnectionExit(conn: AcpConnection, info: { code: number | null; signal: string | null; stderrTail: string }): void {
|
|
476
458
|
if (this.#conn !== conn) {
|
|
477
|
-
// A replaced connection
|
|
478
|
-
//
|
|
479
|
-
// seconds): its turn is long gone and the new connection owns the
|
|
459
|
+
// A replaced connection can report its death after the replacement
|
|
460
|
+
// spawns: its turn is long gone and the new connection owns the
|
|
480
461
|
// driver state. Clobbering #conn here would orphan the live one.
|
|
481
462
|
this.#log("stale-connection-exited", { tail: info.stderrTail.slice(-200) });
|
|
482
463
|
return;
|
|
@@ -489,7 +470,12 @@ export class AcpDriver implements TurnDriver {
|
|
|
489
470
|
// mid-flight is user-reportable, and it surfaces as a normal turn
|
|
490
471
|
// error, not as raw console noise.
|
|
491
472
|
const turnActive = Boolean(turn && !turn.closed && !turn.aborted && !turn.sawResult);
|
|
492
|
-
this.#log("connection-exited", {
|
|
473
|
+
this.#log("connection-exited", {
|
|
474
|
+
expected: !turnActive,
|
|
475
|
+
code: info.code,
|
|
476
|
+
signal: info.signal,
|
|
477
|
+
tail: info.stderrTail.slice(-200),
|
|
478
|
+
});
|
|
493
479
|
if (!turn || turn.closed) return;
|
|
494
480
|
if (turn.aborted || turn.sawResult) {
|
|
495
481
|
this.#settle(turn, {
|
|
@@ -501,7 +487,10 @@ export class AcpDriver implements TurnDriver {
|
|
|
501
487
|
});
|
|
502
488
|
return;
|
|
503
489
|
}
|
|
504
|
-
|
|
490
|
+
// A silent death (no stderr) leaves only the exit code/signal as a
|
|
491
|
+
// clue; surface it so the failure is not a bare "exited unexpectedly".
|
|
492
|
+
const where = info.signal ? ` (signal ${info.signal})` : info.code !== null ? ` (code ${info.code})` : "";
|
|
493
|
+
this.#failTurn(turn, `Antigravity session exited unexpectedly${where}; it restarts on the next turn`);
|
|
505
494
|
}
|
|
506
495
|
|
|
507
496
|
#ensureConnection(request: DriverTurnRequest): Promise<AcpConnection> {
|
|
@@ -510,10 +499,10 @@ export class AcpDriver implements TurnDriver {
|
|
|
510
499
|
this.#state = "starting";
|
|
511
500
|
this.#stats.spawns += 1;
|
|
512
501
|
const conn = new AcpConnection({
|
|
513
|
-
bin:
|
|
514
|
-
binArgs: this.#opts.binArgs,
|
|
502
|
+
bin: typeof this.#opts.bin === "function" ? this.#opts.bin() : this.#opts.bin,
|
|
503
|
+
binArgs: typeof this.#opts.binArgs === "function" ? this.#opts.binArgs() : this.#opts.binArgs,
|
|
504
|
+
isolateHome: this.#opts.isolateHome,
|
|
515
505
|
extraEnv: this.#opts.extraEnv,
|
|
516
|
-
authUrlFile: this.#opts.authUrlFile,
|
|
517
506
|
cwd: request.cwd,
|
|
518
507
|
mcpServers: this.#opts.mcpServers,
|
|
519
508
|
log: (msg, data) => this.#log(msg, data),
|
|
@@ -532,7 +521,7 @@ export class AcpDriver implements TurnDriver {
|
|
|
532
521
|
onExit: (info) => this.#onConnectionExit(conn, info),
|
|
533
522
|
});
|
|
534
523
|
this.#conn = conn;
|
|
535
|
-
this.#log("spawn", { bin:
|
|
524
|
+
this.#log("spawn", { bin: typeof this.#opts.bin === "function" ? this.#opts.bin() : this.#opts.bin });
|
|
536
525
|
return conn
|
|
537
526
|
.start()
|
|
538
527
|
.then(() => {
|
|
@@ -556,8 +545,7 @@ export class AcpDriver implements TurnDriver {
|
|
|
556
545
|
});
|
|
557
546
|
}
|
|
558
547
|
|
|
559
|
-
/**
|
|
560
|
-
* then either wait for the cancelled result or tear down. */
|
|
548
|
+
/** Send native cancellation, then wait for the prompt or tear down. */
|
|
561
549
|
async #abortTurn(turn: ActiveTurn): Promise<void> {
|
|
562
550
|
if (turn.closed) return;
|
|
563
551
|
turn.aborted = true;
|
|
@@ -565,19 +553,12 @@ export class AcpDriver implements TurnDriver {
|
|
|
565
553
|
const conn = this.#conn;
|
|
566
554
|
// No session yet (killed during setup), no live connection, or a server
|
|
567
555
|
// already known not to implement cancel: teardown directly.
|
|
568
|
-
if (!conn?.alive || turn.sessionId === "" || !turn.promptStarted
|
|
556
|
+
if (!conn?.alive || turn.sessionId === "" || !turn.promptStarted) {
|
|
569
557
|
this.#teardownAbort(turn);
|
|
570
558
|
return;
|
|
571
559
|
}
|
|
572
560
|
try {
|
|
573
|
-
|
|
574
|
-
conn.cancelSupported = probe.supported;
|
|
575
|
-
this.#lastCancelSupported = probe.supported;
|
|
576
|
-
if (!probe.supported) {
|
|
577
|
-
this.#log("cancel-unsupported", { build: this.#serverVersion });
|
|
578
|
-
this.#teardownAbort(turn);
|
|
579
|
-
return;
|
|
580
|
-
}
|
|
561
|
+
conn.cancel(turn.sessionId);
|
|
581
562
|
// Cancel accepted: the prompt result (stopReason cancelled) settles
|
|
582
563
|
// the turn through the normal path. Safety net below in case the
|
|
583
564
|
// server never answers.
|
|
@@ -598,10 +579,6 @@ export class AcpDriver implements TurnDriver {
|
|
|
598
579
|
}
|
|
599
580
|
}
|
|
600
581
|
|
|
601
|
-
#cancelUnsupported(): boolean {
|
|
602
|
-
return this.#conn?.cancelSupported === false;
|
|
603
|
-
}
|
|
604
|
-
|
|
605
582
|
/** Gate D teardown: reject everything pending, kill the process. The turn
|
|
606
583
|
* settles through the connection-exit path as aborted. */
|
|
607
584
|
#teardownAbort(turn: ActiveTurn): void {
|
|
@@ -800,7 +777,7 @@ export class AcpDriver implements TurnDriver {
|
|
|
800
777
|
// Superseded only by EXACT PromptResponse.usage. Diagnostic usage-shaped
|
|
801
778
|
// frames (context windows, occupancy) must not silence the estimates:
|
|
802
779
|
// the connection outlives turns and no exact data ever replaces them
|
|
803
|
-
// on
|
|
780
|
+
// on servers without PromptResponse.usage.
|
|
804
781
|
if (this.#conn?.exactUsageSeen) return undefined;
|
|
805
782
|
return synthesizeUsage({
|
|
806
783
|
mode,
|
|
@@ -863,7 +840,6 @@ export class AcpDriver implements TurnDriver {
|
|
|
863
840
|
sessionsCreated: this.#stats.sessionsCreated,
|
|
864
841
|
sessionsLoaded: this.#stats.sessionsLoaded,
|
|
865
842
|
kills: this.#stats.kills,
|
|
866
|
-
cancelSupported: this.#conn?.cancelSupported ?? this.#lastCancelSupported,
|
|
867
843
|
serverVersion: this.#serverVersion,
|
|
868
844
|
// Connections beyond the first are server restarts (Gate D kills,
|
|
869
845
|
// stale-exit replacements) = reconnects.
|
package/src/acp/events.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Pure functions, no I/O: unit-tested against the captured probe transcripts
|
|
4
4
|
// (probe-logs/acp-traffic-run5.jsonl, run6-restart-load-tools.jsonl).
|
|
5
5
|
//
|
|
6
|
-
// Shapes verified
|
|
6
|
+
// Shapes verified against native agy ACP and captured legacy frames:
|
|
7
7
|
// session/update params = { sessionId, update: { sessionUpdate: <discriminator>, ... } }
|
|
8
8
|
// - agent_message_chunk { content: { type: "text", text } } (pure deltas)
|
|
9
9
|
// - agent_thought_chunk { content: { type: "text", text } } (thought TEXT)
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// - tool_call_update { toolCallId, status: completed|failed, rawOutput? }
|
|
13
13
|
// - plan { entries: [...] }
|
|
14
14
|
// - available_commands_update { availableCommands: [...] }
|
|
15
|
-
//
|
|
15
|
+
// Exact turn usage is read from PromptResponse by the connection.
|
|
16
16
|
|
|
17
17
|
import { StreamSanitizer, sanitizeSystemMessage } from "../stream-sanitizer.js";
|
|
18
18
|
|
|
@@ -55,12 +55,13 @@ export function mapUpdate(update: unknown): MappedUpdate {
|
|
|
55
55
|
case "tool_call": {
|
|
56
56
|
const id = stringField(u.toolCallId);
|
|
57
57
|
if (!id) return null;
|
|
58
|
+
const nativeMcp = nativeMcpTool(u);
|
|
58
59
|
return {
|
|
59
60
|
kind: "tool_start",
|
|
60
61
|
toolCallId: id,
|
|
61
|
-
name: metaToolName(u._meta) ?? toolName(u.title, u.kind),
|
|
62
|
+
name: metaToolName(u._meta) ?? nativeMcp?.name ?? stringField(u.name) ?? toolName(u.title, u.kind),
|
|
62
63
|
args: rawArguments(u.rawInput),
|
|
63
|
-
mcpServer: stringField(recordField(recordField(u._meta).mcp).server),
|
|
64
|
+
mcpServer: stringField(recordField(recordField(u._meta).mcp).server) ?? nativeMcp?.server,
|
|
64
65
|
// Edits carry their native diff HERE, on the pending tool_call
|
|
65
66
|
// frame (run 6:10) — the completed update carries only display
|
|
66
67
|
// text. contentDiff on the update stays as a future-build
|
|
@@ -72,14 +73,14 @@ export function mapUpdate(update: unknown): MappedUpdate {
|
|
|
72
73
|
const id = stringField(u.toolCallId);
|
|
73
74
|
if (!id) return null;
|
|
74
75
|
if (u.status === "failed") {
|
|
75
|
-
//
|
|
76
|
+
// Legacy approved-echo: after an allow, the APPROVED call
|
|
76
77
|
// id reports failed with this sentinel while the real work runs
|
|
77
78
|
// under a different id that reports its own lifecycle. Track
|
|
78
79
|
// effects, not ids: drop the echo instead of rendering a bogus
|
|
79
80
|
// failure next to a successful edit.
|
|
80
81
|
const raw = stringField(u.rawOutput);
|
|
81
82
|
if (raw?.includes("approved but never executed")) return null;
|
|
82
|
-
const mcpServer = stringField(recordField(recordField(u._meta).mcp).server);
|
|
83
|
+
const mcpServer = stringField(recordField(recordField(u._meta).mcp).server) ?? nativeMcpTool(u)?.server;
|
|
83
84
|
return { kind: "tool_error", toolCallId: id, message: raw ?? "tool failed", ...(mcpServer ? { mcpServer } : {}) };
|
|
84
85
|
}
|
|
85
86
|
if (u.status === "completed") {
|
|
@@ -87,7 +88,7 @@ export function mapUpdate(update: unknown): MappedUpdate {
|
|
|
87
88
|
// often just the server's display title, not the result (probe
|
|
88
89
|
// 2026-09-03: completed MCP call, rawOutput "Call bridge_echo").
|
|
89
90
|
const diff = contentDiff(u.content);
|
|
90
|
-
const mcpServer = stringField(recordField(recordField(u._meta).mcp).server);
|
|
91
|
+
const mcpServer = stringField(recordField(recordField(u._meta).mcp).server) ?? nativeMcpTool(u)?.server;
|
|
91
92
|
return {
|
|
92
93
|
kind: "tool_done",
|
|
93
94
|
toolCallId: id,
|
|
@@ -116,6 +117,12 @@ function rawArguments(rawInput: unknown): Record<string, unknown> {
|
|
|
116
117
|
: rec;
|
|
117
118
|
}
|
|
118
119
|
|
|
120
|
+
/** Only our known server prefix is unambiguous without _meta. */
|
|
121
|
+
function nativeMcpTool(update: Record<string, unknown>): { name: string; server: string } | undefined {
|
|
122
|
+
const match = /^mcp_(pi-bridge(?:-\d+)?)_(.+)$/.exec(stringField(update.name) ?? "");
|
|
123
|
+
return match ? { server: match[1]!, name: match[2]! } : undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
119
126
|
/** Clean tool name for MCP tools: the title is "<server>_<tool>" (e.g.
|
|
120
127
|
* "pi-bridge_bridge_echo") and the real name hides in _meta.mcp.tool. */
|
|
121
128
|
function metaToolName(meta: unknown): string | undefined {
|
|
@@ -185,8 +192,7 @@ export function toolName(title: unknown, kind: unknown): string {
|
|
|
185
192
|
|
|
186
193
|
/** Map a `session/prompt` result stopReason to a TurnOutcome shape.
|
|
187
194
|
* Verified live: `end_turn`. Schema enumerates cancelled / max_tokens /
|
|
188
|
-
* max_turn_requests / refusal.
|
|
189
|
-
* cancel method) but mapped for the day upstream ships it. */
|
|
195
|
+
* max_turn_requests / refusal. Native cancellation returns `cancelled`. */
|
|
190
196
|
export function mapStopReason(stopReason: unknown): {
|
|
191
197
|
status: "OK" | "ERROR";
|
|
192
198
|
aborted: boolean;
|
|
@@ -208,8 +214,8 @@ export function mapStopReason(stopReason: unknown): {
|
|
|
208
214
|
}
|
|
209
215
|
}
|
|
210
216
|
|
|
211
|
-
/** Defensive accumulation with the cumulative-resend port.
|
|
212
|
-
* deltas
|
|
217
|
+
/** Defensive accumulation with the cumulative-resend port. Native agy streams
|
|
218
|
+
* deltas, so on
|
|
213
219
|
* a compliant server the resend branch stays inert; it exists because the
|
|
214
220
|
* same backend's private protocol exhibited exactly this failure mode.
|
|
215
221
|
*
|