@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estebanforge/pi-antigravity-bridge",
3
- "version": "1.9.3",
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
@@ -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
- // Optional, unstable ACP PromptResponse.usage: absent on 1.1.1 but
356
- // parsed for the day the server forwards its SDK usage_metadata. Never
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
- /** Probe-and-cancel. Returns supported:false when the server does not
367
- * implement session/cancel (-32601 on RC01); other errors propagate as
368
- * translated Errors. Protocol probing stays inside the connection layer. */
369
- async cancel(sessionId: string): Promise<{ supported: boolean }> {
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(settleDeny);
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(settleDeny);
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
- this.#authUrlWatcher?.close();
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 /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.`);
635
587
  }
636
- // Typed protocol errors survive: callers probe .code (-32601 cancel-
637
- // 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()
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/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,18 +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). A
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. Known gap: the CLI's `--mode plan`
126
- * has no ACP equivalent (review 4, finding 4) — plan + acp is refused at the
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 "yolo";
130
- return mode === "plan" ? "default" : "auto_edit";
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)" : resolveAcpBinary(opts.bin) });
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", 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);
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
- // Live usage is explicitly an estimate: ACP 1.1.1 does not forward the
353
- // SDK's exact usage_metadata onto the ACP wire. Emit input first; output
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 reporting its death late (RC01's signal
480
- // handler intercepts SIGTERM and can outlive its replacement by
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: resolveAcpBinary(typeof this.#opts.bin === "function" ? this.#opts.bin() : this.#opts.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: 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 });
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
- /** Gate D abort. RC01 has no session/cancel: probe it once per connection,
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 || this.#cancelUnsupported()) {
556
+ if (!conn?.alive || turn.sessionId === "" || !turn.promptStarted) {
579
557
  this.#teardownAbort(turn);
580
558
  return;
581
559
  }
582
560
  try {
583
- const probe = await conn.cancel(turn.sessionId);
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 ACP 1.1.1 (peer review 2026-09-23).
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 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
  *