@estebanforge/pi-antigravity-bridge 1.9.3 → 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 +16 -0
- package/README.md +12 -14
- package/extensions/index.ts +115 -375
- package/package.json +1 -1
- package/src/acp/connection.ts +47 -105
- package/src/acp/driver.ts +25 -59
- package/src/acp/events.ts +17 -11
- package/src/acp/home.ts +95 -0
- package/src/acp/jsonrpc.ts +6 -1
- package/src/acp/setup.ts +71 -437
- package/src/agents.ts +1 -1
- package/src/agy-install.ts +85 -0
- package/src/agy-paths.ts +3 -6
- package/src/agy-setup.ts +72 -0
- package/src/agy-version.ts +3 -4
- package/src/ask-tool.ts +3 -3
- package/src/config.ts +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 -174
- 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
|
|
@@ -273,10 +258,6 @@ export class AcpConnection {
|
|
|
273
258
|
return typeof v === "string" ? v : undefined;
|
|
274
259
|
}
|
|
275
260
|
|
|
276
|
-
/** True once the server answered -32601 for session/cancel on THIS
|
|
277
|
-
* process (probed lazily by the driver; never assumed). */
|
|
278
|
-
cancelSupported: boolean | null = null;
|
|
279
|
-
|
|
280
261
|
async newSession(cwd: string): Promise<AcpSessionInfo> {
|
|
281
262
|
const result = (await this.guarded(
|
|
282
263
|
"session/new",
|
|
@@ -352,9 +333,8 @@ export class AcpConnection {
|
|
|
352
333
|
if (typeof stopReason !== "string") {
|
|
353
334
|
throw new Error("ACP session/prompt returned no stopReason");
|
|
354
335
|
}
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
// 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}.
|
|
358
338
|
const usage = parseAcpTurnUsage(result?.usage);
|
|
359
339
|
if (usage) {
|
|
360
340
|
this.#usageSeen = true;
|
|
@@ -363,17 +343,10 @@ export class AcpConnection {
|
|
|
363
343
|
return { stopReason, ...(usage ? { usage } : {}) };
|
|
364
344
|
}
|
|
365
345
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
try {
|
|
371
|
-
await this.request("session/cancel", { sessionId }, SESSION_OP_TIMEOUT_MS);
|
|
372
|
-
return { supported: true };
|
|
373
|
-
} catch (err) {
|
|
374
|
-
if (err instanceof JsonRpcResponseError && err.code === -32601) return { supported: false };
|
|
375
|
-
throw translateError("session/cancel", err);
|
|
376
|
-
}
|
|
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();
|
|
377
350
|
}
|
|
378
351
|
|
|
379
352
|
async closeSession(sessionId: string): Promise<void> {
|
|
@@ -436,7 +409,7 @@ export class AcpConnection {
|
|
|
436
409
|
if (settled) return;
|
|
437
410
|
settled = true;
|
|
438
411
|
if (timer) clearTimeout(timer);
|
|
439
|
-
this.#permissionPending.delete(
|
|
412
|
+
this.#permissionPending.delete(settleCancel);
|
|
440
413
|
this.#opts.log("permission", { optionId, policy: why });
|
|
441
414
|
// undefined optionId (no reject option on the table) cancels
|
|
442
415
|
// instead of selecting nothing.
|
|
@@ -447,11 +420,12 @@ export class AcpConnection {
|
|
|
447
420
|
);
|
|
448
421
|
};
|
|
449
422
|
const settleDeny = (): void => settle(denyId, "dialog-deny");
|
|
423
|
+
const settleCancel = (): void => settle(undefined, "cancel");
|
|
450
424
|
timer = setTimeout(() => {
|
|
451
425
|
this.#opts.log("permission-timeout", { parkMs });
|
|
452
426
|
settleDeny();
|
|
453
427
|
}, parkMs);
|
|
454
|
-
this.#permissionPending.add(
|
|
428
|
+
this.#permissionPending.add(settleCancel);
|
|
455
429
|
Promise.resolve()
|
|
456
430
|
.then(() => handler(params))
|
|
457
431
|
.then((picked) => {
|
|
@@ -562,44 +536,23 @@ export class AcpConnection {
|
|
|
562
536
|
}
|
|
563
537
|
}
|
|
564
538
|
|
|
565
|
-
/** Surface OAuth login URLs captured by the BROWSER wrapper: every new
|
|
566
|
-
* URL in the record file is logged once as "auth-url" {url, port}. Best
|
|
567
|
-
* effort only - a broken watch never affects turns. */
|
|
568
|
-
#watchAuthUrl(): void {
|
|
569
|
-
const file = this.#opts.authUrlFile!;
|
|
570
|
-
try {
|
|
571
|
-
const watcher = fs.watch(file, () => this.#emitAuthUrl());
|
|
572
|
-
// Without a listener, a watch 'error' event escapes as uncaught.
|
|
573
|
-
watcher.on("error", () => {
|
|
574
|
-
/* record file gone: login URL surfacing is off until respawn */
|
|
575
|
-
});
|
|
576
|
-
this.#authUrlWatcher = watcher;
|
|
577
|
-
} catch {
|
|
578
|
-
/* no watch; the one-shot check below still covers earlier writes */
|
|
579
|
-
}
|
|
580
|
-
this.#emitAuthUrl(); // URLs recorded before the watch started
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
#emitAuthUrl(): void {
|
|
584
|
-
const url = readLastUrl(this.#opts.authUrlFile!);
|
|
585
|
-
if (!url || url === this.#lastAuthUrl) return;
|
|
586
|
-
this.#lastAuthUrl = url;
|
|
587
|
-
this.#opts.log("auth-url", { url, port: parseAuthPort(url) });
|
|
588
|
-
}
|
|
589
|
-
|
|
590
539
|
#finish(reason: string, code: number | null = null, signal: NodeJS.Signals | null = null): void {
|
|
591
540
|
if (this.#exited) return;
|
|
592
541
|
this.#exited = true;
|
|
593
|
-
|
|
594
|
-
this.#authUrlWatcher = undefined;
|
|
595
|
-
// Pending permission dialogs must not outlive the connection: deny them
|
|
542
|
+
// Pending permission dialogs must not outlive the connection: cancel them
|
|
596
543
|
// so their replies race the teardown instead of hanging the server.
|
|
597
544
|
const pendings = [...this.#permissionPending];
|
|
598
545
|
this.#permissionPending.clear();
|
|
599
546
|
for (const deny of pendings) deny();
|
|
600
547
|
this.abortAll(`connection exited: ${reason || "process gone"}`);
|
|
548
|
+
this.#releaseHome();
|
|
601
549
|
this.#opts.onExit({ code, signal, stderrTail: redactText(this.#stderrTail) });
|
|
602
550
|
}
|
|
551
|
+
|
|
552
|
+
#releaseHome(): void {
|
|
553
|
+
if (this.#stagedHome) removeStagedAcpHome(this.#stagedHome);
|
|
554
|
+
this.#stagedHome = undefined;
|
|
555
|
+
}
|
|
603
556
|
}
|
|
604
557
|
|
|
605
558
|
/** ACP's optional (unstable) per-turn PromptResponse.usage, not the
|
|
@@ -628,26 +581,15 @@ export function translateError(method: string, err: unknown): Error {
|
|
|
628
581
|
if (err instanceof AcpAuthError) return err;
|
|
629
582
|
if (err instanceof JsonRpcResponseError) {
|
|
630
583
|
if (err.code === -32000) {
|
|
631
|
-
authInvalidatedHook?.();
|
|
632
584
|
const detail = typeof err.data === "object" && err.data !== null ? (err.data as { message?: unknown }).message : undefined;
|
|
633
585
|
const detailText = typeof detail === "string" ? `: ${detail}` : `: ${err.message}`;
|
|
634
|
-
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.`);
|
|
635
587
|
}
|
|
636
|
-
// Typed protocol errors survive: callers
|
|
637
|
-
//
|
|
588
|
+
// Typed protocol errors survive: callers inspect .code for parameter
|
|
589
|
+
// and capability failures. The method prefix is lost — describe()
|
|
638
590
|
// callers that need it log the method at the call site.
|
|
639
591
|
return err;
|
|
640
592
|
}
|
|
641
593
|
if (err instanceof Error) return err;
|
|
642
594
|
return new Error(`ACP ${method} failed: ${String(err)}`);
|
|
643
595
|
}
|
|
644
|
-
|
|
645
|
-
/** Resolve the server binary: env > config > PATH. Resolution failure is a
|
|
646
|
-
* visible turn error naming what was tried (plan §9.5). */
|
|
647
|
-
/** Resolve the server binary: env > config > PATH. `~` expands to the home
|
|
648
|
-
* directory (spawn does not expand it). Resolution failure is a visible turn
|
|
649
|
-
* error naming what was tried (plan §9.5). */
|
|
650
|
-
export function resolveAcpBinary(configBin: string): string {
|
|
651
|
-
const raw = process.env.AGY_ACP_BIN || (configBin.length > 0 ? configBin : "agy_acp_server.par");
|
|
652
|
-
return raw === "~" || raw.startsWith("~/") ? path.join(os.homedir(), raw.slice(1)) : raw;
|
|
653
|
-
}
|
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,18 +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
|
-
* function is resolved per connection, like bin, so registry args from a
|
|
50
|
-
* mid-session managed install apply without a restart. */
|
|
43
|
+
/** Extra argv (production: ["acp"]; tests: fake-server script). */
|
|
51
44
|
binArgs?: string[] | (() => string[]);
|
|
45
|
+
/** Native agy only: isolate inherited global MCP from other pi sessions. */
|
|
46
|
+
isolateHome?: boolean;
|
|
52
47
|
extraEnv?: Record<string, string>;
|
|
53
|
-
/** Record file for BROWSER-captured OAuth URLs; passed to the connection,
|
|
54
|
-
* which logs "auth-url" when the server tries to open a login. */
|
|
55
|
-
authUrlFile?: string;
|
|
56
48
|
/** Bridge registration for session/new AND session/load. */
|
|
57
49
|
mcpServers?: () => AcpMcpServer[];
|
|
58
50
|
/** Gate B stopgap: how ACP turns synthesize usage while the server sends
|
|
@@ -110,24 +102,13 @@ interface ActiveTurn {
|
|
|
110
102
|
toolCalls: Map<string, { name: string; args: Record<string, unknown>; diff?: AcpEditDiff; mcpServer?: string }>;
|
|
111
103
|
}
|
|
112
104
|
|
|
113
|
-
/** Recombine the provider's (base slug, effort) into the FULL ACP model slug.
|
|
114
|
-
* Fixed families (no effort) pass through unchanged. Verified against the
|
|
115
|
-
* run-5 catalog: session/set_config_option wants "gemini-3.8-flash-low"-style
|
|
116
|
-
* full slugs. */
|
|
117
|
-
export function acpModelSlug(model: string, effort?: string): string {
|
|
118
|
-
if (!effort) return model;
|
|
119
|
-
if (/(?:^|-)(?:high|medium|low)$/.test(model)) return model;
|
|
120
|
-
return `${model}-${effort}`;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
105
|
/** Map our config knobs onto ACP session modes. skipPermissions=false also
|
|
124
106
|
* fail-closes the in-connection permission handler (reject options), so the
|
|
125
|
-
* modes keep their server-side meaning.
|
|
126
|
-
*
|
|
127
|
-
* 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. */
|
|
128
109
|
export function acpMode(mode: string, skipPermissions: boolean): string {
|
|
129
|
-
if (skipPermissions) return "
|
|
130
|
-
return mode === "plan" ? "
|
|
110
|
+
if (skipPermissions) return "accept-edits";
|
|
111
|
+
return mode === "plan" ? "plan" : mode === "accept-edits" ? "accept-edits" : "default";
|
|
131
112
|
}
|
|
132
113
|
|
|
133
114
|
export class AcpDriver implements TurnDriver {
|
|
@@ -148,12 +129,11 @@ export class AcpDriver implements TurnDriver {
|
|
|
148
129
|
};
|
|
149
130
|
#serverVersion: string | undefined;
|
|
150
131
|
#lastSessionId: string | undefined;
|
|
151
|
-
#lastCancelSupported: boolean | null = null;
|
|
152
132
|
#agentInfo: { name?: string; title?: string } | undefined;
|
|
153
133
|
|
|
154
134
|
constructor(opts: AcpDriverOptions) {
|
|
155
135
|
this.#opts = opts;
|
|
156
|
-
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 });
|
|
157
137
|
}
|
|
158
138
|
|
|
159
139
|
get state(): DriverState {
|
|
@@ -334,7 +314,8 @@ export class AcpDriver implements TurnDriver {
|
|
|
334
314
|
// are a parity break); mode failure is best-effort (auto policy makes
|
|
335
315
|
// the modes converge anyway).
|
|
336
316
|
try {
|
|
337
|
-
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);
|
|
338
319
|
} catch (err) {
|
|
339
320
|
this.#failTurn(turn, `ACP model selection failed: ${describe(err)}`);
|
|
340
321
|
return;
|
|
@@ -349,9 +330,8 @@ export class AcpDriver implements TurnDriver {
|
|
|
349
330
|
// Timers: overall (turn deadline, pause-aware) + idle (inactivity).
|
|
350
331
|
this.#armOverall(turn);
|
|
351
332
|
this.#armIdle(turn);
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
// 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.
|
|
355
335
|
this.#emitLiveEstimate(turn);
|
|
356
336
|
|
|
357
337
|
// Prompt. Updates stream through the connection's onUpdate callback.
|
|
@@ -476,9 +456,8 @@ export class AcpDriver implements TurnDriver {
|
|
|
476
456
|
|
|
477
457
|
#onConnectionExit(conn: AcpConnection, info: { code: number | null; signal: string | null; stderrTail: string }): void {
|
|
478
458
|
if (this.#conn !== conn) {
|
|
479
|
-
// A replaced connection
|
|
480
|
-
//
|
|
481
|
-
// 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
|
|
482
461
|
// driver state. Clobbering #conn here would orphan the live one.
|
|
483
462
|
this.#log("stale-connection-exited", { tail: info.stderrTail.slice(-200) });
|
|
484
463
|
return;
|
|
@@ -520,10 +499,10 @@ export class AcpDriver implements TurnDriver {
|
|
|
520
499
|
this.#state = "starting";
|
|
521
500
|
this.#stats.spawns += 1;
|
|
522
501
|
const conn = new AcpConnection({
|
|
523
|
-
bin:
|
|
502
|
+
bin: typeof this.#opts.bin === "function" ? this.#opts.bin() : this.#opts.bin,
|
|
524
503
|
binArgs: typeof this.#opts.binArgs === "function" ? this.#opts.binArgs() : this.#opts.binArgs,
|
|
504
|
+
isolateHome: this.#opts.isolateHome,
|
|
525
505
|
extraEnv: this.#opts.extraEnv,
|
|
526
|
-
authUrlFile: this.#opts.authUrlFile,
|
|
527
506
|
cwd: request.cwd,
|
|
528
507
|
mcpServers: this.#opts.mcpServers,
|
|
529
508
|
log: (msg, data) => this.#log(msg, data),
|
|
@@ -542,7 +521,7 @@ export class AcpDriver implements TurnDriver {
|
|
|
542
521
|
onExit: (info) => this.#onConnectionExit(conn, info),
|
|
543
522
|
});
|
|
544
523
|
this.#conn = conn;
|
|
545
|
-
this.#log("spawn", { bin:
|
|
524
|
+
this.#log("spawn", { bin: typeof this.#opts.bin === "function" ? this.#opts.bin() : this.#opts.bin });
|
|
546
525
|
return conn
|
|
547
526
|
.start()
|
|
548
527
|
.then(() => {
|
|
@@ -566,8 +545,7 @@ export class AcpDriver implements TurnDriver {
|
|
|
566
545
|
});
|
|
567
546
|
}
|
|
568
547
|
|
|
569
|
-
/**
|
|
570
|
-
* then either wait for the cancelled result or tear down. */
|
|
548
|
+
/** Send native cancellation, then wait for the prompt or tear down. */
|
|
571
549
|
async #abortTurn(turn: ActiveTurn): Promise<void> {
|
|
572
550
|
if (turn.closed) return;
|
|
573
551
|
turn.aborted = true;
|
|
@@ -575,19 +553,12 @@ export class AcpDriver implements TurnDriver {
|
|
|
575
553
|
const conn = this.#conn;
|
|
576
554
|
// No session yet (killed during setup), no live connection, or a server
|
|
577
555
|
// already known not to implement cancel: teardown directly.
|
|
578
|
-
if (!conn?.alive || turn.sessionId === "" || !turn.promptStarted
|
|
556
|
+
if (!conn?.alive || turn.sessionId === "" || !turn.promptStarted) {
|
|
579
557
|
this.#teardownAbort(turn);
|
|
580
558
|
return;
|
|
581
559
|
}
|
|
582
560
|
try {
|
|
583
|
-
|
|
584
|
-
conn.cancelSupported = probe.supported;
|
|
585
|
-
this.#lastCancelSupported = probe.supported;
|
|
586
|
-
if (!probe.supported) {
|
|
587
|
-
this.#log("cancel-unsupported", { build: this.#serverVersion });
|
|
588
|
-
this.#teardownAbort(turn);
|
|
589
|
-
return;
|
|
590
|
-
}
|
|
561
|
+
conn.cancel(turn.sessionId);
|
|
591
562
|
// Cancel accepted: the prompt result (stopReason cancelled) settles
|
|
592
563
|
// the turn through the normal path. Safety net below in case the
|
|
593
564
|
// server never answers.
|
|
@@ -608,10 +579,6 @@ export class AcpDriver implements TurnDriver {
|
|
|
608
579
|
}
|
|
609
580
|
}
|
|
610
581
|
|
|
611
|
-
#cancelUnsupported(): boolean {
|
|
612
|
-
return this.#conn?.cancelSupported === false;
|
|
613
|
-
}
|
|
614
|
-
|
|
615
582
|
/** Gate D teardown: reject everything pending, kill the process. The turn
|
|
616
583
|
* settles through the connection-exit path as aborted. */
|
|
617
584
|
#teardownAbort(turn: ActiveTurn): void {
|
|
@@ -810,7 +777,7 @@ export class AcpDriver implements TurnDriver {
|
|
|
810
777
|
// Superseded only by EXACT PromptResponse.usage. Diagnostic usage-shaped
|
|
811
778
|
// frames (context windows, occupancy) must not silence the estimates:
|
|
812
779
|
// the connection outlives turns and no exact data ever replaces them
|
|
813
|
-
// on
|
|
780
|
+
// on servers without PromptResponse.usage.
|
|
814
781
|
if (this.#conn?.exactUsageSeen) return undefined;
|
|
815
782
|
return synthesizeUsage({
|
|
816
783
|
mode,
|
|
@@ -873,7 +840,6 @@ export class AcpDriver implements TurnDriver {
|
|
|
873
840
|
sessionsCreated: this.#stats.sessionsCreated,
|
|
874
841
|
sessionsLoaded: this.#stats.sessionsLoaded,
|
|
875
842
|
kills: this.#stats.kills,
|
|
876
|
-
cancelSupported: this.#conn?.cancelSupported ?? this.#lastCancelSupported,
|
|
877
843
|
serverVersion: this.#serverVersion,
|
|
878
844
|
// Connections beyond the first are server restarts (Gate D kills,
|
|
879
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
|
*
|