@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estebanforge/pi-antigravity-bridge",
3
- "version": "1.9.2",
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",
@@ -1,16 +1,10 @@
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.
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
- // Protocol facts encoded here were verified live against
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
- // - 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
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
- #authUrlWatcher: fs.FSWatcher | undefined;
142
- #lastAuthUrl: string | null = null;
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
- const child = spawn(this.#opts.bin, args, {
194
- cwd: this.#opts.cwd,
195
- stdio: ["pipe", "pipe", "pipe"],
196
- detached: process.platform !== "win32",
197
- windowsHide: true,
198
- ...(this.#opts.extraEnv ? { env: { ...process.env, ...this.#opts.extraEnv } } : {}),
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
- this.#finish(redactText(this.#stderrTail.trim()));
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
- // Optional, unstable ACP PromptResponse.usage: absent on 1.1.1 but
351
- // parsed for the day the server forwards its SDK usage_metadata. Never
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
- /** Probe-and-cancel. Returns supported:false when the server does not
362
- * implement session/cancel (-32601 on RC01); other errors propagate as
363
- * translated Errors. Protocol probing stays inside the connection layer. */
364
- async cancel(sessionId: string): Promise<{ supported: boolean }> {
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(settleDeny);
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(settleDeny);
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
- /** Surface OAuth login URLs captured by the BROWSER wrapper: every new
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
- this.#authUrlWatcher?.close();
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.#opts.onExit({ code: null, signal: null, stderrTail: redactText(this.#stderrTail) });
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 /agy auth to sign in; /agy auth-manual has manual steps.`);
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 probe .code (-32601 cancel-
632
- // unsupported, -32602 param shape). The method prefix is lost — describe()
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/effort via session/set_config_option (configId "model", FULL slug
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 unimplemented on RC01 (-32601): abort = abortAll()
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, resolveAcpBinary, type AcpMcpServer, type AcpPermissionRequest } from "./connection.js";
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
- /** Config acp.bin value (may be empty). Env AGY_ACP_BIN wins. A function
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 for the binary (tests: node + fake-server script). */
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. Known gap: the CLI's `--mode plan`
124
- * has no ACP equivalent (review 4, finding 4) — plan + acp is refused at the
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 "yolo";
128
- return mode === "plan" ? "default" : "auto_edit";
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)" : resolveAcpBinary(opts.bin) });
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", acpModelSlug(request.model, request.effort));
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
- // Live usage is explicitly an estimate: ACP 1.1.1 does not forward the
351
- // SDK's exact usage_metadata onto the ACP wire. Emit input first; output
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 reporting its death late (RC01's signal
478
- // handler intercepts SIGTERM and can outlive its replacement by
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", { expected: !turnActive, tail: info.stderrTail.slice(-200) });
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
- this.#failTurn(turn, "Antigravity session exited unexpectedly; it restarts on the next turn");
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: resolveAcpBinary(typeof this.#opts.bin === "function" ? this.#opts.bin() : this.#opts.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: resolveAcpBinary(typeof this.#opts.bin === "function" ? this.#opts.bin() : this.#opts.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
- /** Gate D abort. RC01 has no session/cancel: probe it once per connection,
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 || this.#cancelUnsupported()) {
556
+ if (!conn?.alive || turn.sessionId === "" || !turn.promptStarted) {
569
557
  this.#teardownAbort(turn);
570
558
  return;
571
559
  }
572
560
  try {
573
- const probe = await conn.cancel(turn.sessionId);
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 ACP 1.1.1 (peer review 2026-09-23).
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 live against agy_acp_server 20260818_01_RC01:
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
- // Usage: ABSENT on RC01 (Gate B) — mapped defensively should a future build add it.
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
- // RC01 echo (run 6, finding 7): after an allow, the APPROVED call
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. `cancelled` is unreachable on RC01 (no
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. RC01 streams pure
212
- * deltas (verified: mid-token splits across the run-5 stress streams), so on
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
  *