@estebanforge/pi-antigravity-bridge 1.4.8 → 1.4.10

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.
@@ -17,10 +17,15 @@
17
17
  // ~/.pi/agent/antigravity-bridge/config.json so toggles survive restarts.
18
18
 
19
19
  import os from "node:os";
20
+ import fs from "node:fs";
21
+ import path from "node:path";
20
22
  import {
21
23
  type ExtensionAPI,
22
24
  type ExtensionCommandContext,
23
25
  type ExtensionUIContext,
26
+ createBashToolDefinition,
27
+ createEditToolDefinition,
28
+ createWriteToolDefinition,
24
29
  getSettingsListTheme,
25
30
  } from "@earendil-works/pi-coding-agent";
26
31
  import {
@@ -38,6 +43,7 @@ import {
38
43
  } from "../src/models.js";
39
44
  import { SessionStore } from "../src/sessions.js";
40
45
  import {
46
+ APPROVAL_PARK_MS,
41
47
  POLL_TOOL_NAME,
42
48
  ToolRoundTrips,
43
49
  WrapperReplay,
@@ -45,7 +51,15 @@ import {
45
51
  formatEscalatedAck,
46
52
  formatPollAnswer,
47
53
  } from "../src/provider.js";
48
- import { AgyDriver } from "../src/driver.js";
54
+ import {
55
+ createShadowTool,
56
+ stripMarkerFields,
57
+ type AnyToolDefinition,
58
+ type GatePolicy,
59
+ } from "../src/approval-gate.js";
60
+ import { detectPermissionGateExtensions, resolveGateMode } from "../src/approval-detect.js";
61
+ import { hookScriptSource, removeGateHooks, stageGateHooks } from "../src/approval-hook.js";
62
+ import { StreamDriver } from "../src/driver.js";
49
63
  import { AcpDriver } from "../src/acp/driver.js";
50
64
  import { runAcpAuth } from "../src/acp/auth.js";
51
65
  import { setupAuthUrlCapture } from "../src/acp/browser-capture.js";
@@ -55,6 +69,11 @@ import { CONFIG_PATH, loadConfig, logsDir, saveConfig, type AgyMode, type Bridge
55
69
  import { createDailyLogger, type DailyLogger } from "../src/daily-log.js";
56
70
  import { registerAskAntigravityTool, toolModelsFromRaw } from "../src/ask-tool.js";
57
71
  import { startMcpServer, TOKEN_HEADER, type McpServerHandle } from "../src/mcp-server.js";
72
+ import {
73
+ registerBridgeServer,
74
+ sweepStaleBridgeServers,
75
+ unregisterBridgeServer,
76
+ } from "../src/mcp-registration.js";
58
77
  import {
59
78
  ACTIVATE_SKILL_TOOL_NAME,
60
79
  activateSkillSchema,
@@ -107,7 +126,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
107
126
  const engine: Engine = loadConfig().engine;
108
127
  // Engine switching requires a restart, so the catalog-time engine read is
109
128
  // authoritative for input advertising: image attach rides only when turns
110
- // will run on the ACP engine (the legacy CLI prompt is text-only).
129
+ // will run on the ACP engine (the stream-json CLI prompt is text-only).
111
130
  const modelInput: Array<"text" | "image"> = engine === "acp" ? ["text", "image"] : ["text"];
112
131
  const models = entries.map((e) => toPiModel(e, modelInput));
113
132
 
@@ -116,6 +135,36 @@ export default async function (pi: ExtensionAPI): Promise<void> {
116
135
  // src/daily-log.ts). Fire-and-forget, secrets redacted, old days pruned.
117
136
  // Support flow: "attach the last days' files from that dir".
118
137
  const fileLog = createDailyLogger({ dir: logsDir() });
138
+ // Warn tier = user-facing: the default file log keeps ONLY errors (zero
139
+ // routine disk traffic), so a warn that never reaches the UI is lost.
140
+ // Every warn toasts here instead; AGY_DEBUG=1 restores the full file
141
+ // trail. Silent exceptions: deliberate aborts (pi already shows
142
+ // "Operation aborted"), connection exits (the turn's own error block
143
+ // carries real crashes), and call-tool-fail (same-instant duplicate of
144
+ // round-trip-fail). No UI (headless): warn text falls back to stderr.
145
+ const rawFileLog = fileLog.log.bind(fileLog);
146
+ fileLog.log = (event, data, level) => {
147
+ rawFileLog(event, data, level);
148
+ if (level !== "warn") return;
149
+ if (event.startsWith("abort:") || event === "connection-exited" || event === "call-tool-fail") return;
150
+ const d = (data ?? {}) as Record<string, unknown>;
151
+ let text: string;
152
+ if (event === "round-trip-fail") {
153
+ text = `Bridge tool call failed: ${String(d.name ?? "tool")} (${String(d.reason ?? "unknown")})`;
154
+ } else if (event.startsWith("stall:")) {
155
+ text = "Antigravity stalled with no output; the turn was stopped";
156
+ } else if (event.startsWith("timeout:")) {
157
+ text = "Antigravity turn timed out";
158
+ } else if (event.startsWith("exit:") && event !== "exit:0") {
159
+ text = "Antigravity process exited unexpectedly";
160
+ } else if (event === "turn-error") {
161
+ text = `Antigravity turn issue: ${String(d.reason ?? "unknown")}`;
162
+ } else {
163
+ text = `Antigravity warning: ${event}`;
164
+ }
165
+ if (activeUi) activeUi.notify(text, "warning");
166
+ else console.error(`[antigravity-bridge] ${text}`);
167
+ };
119
168
  fileLog.log(
120
169
  "extension-load",
121
170
  { engine, models: models.length, fallback: usingFallback, bridge: loadConfig().bridgeTools, askTool: loadConfig().askTool },
@@ -126,6 +175,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
126
175
  // MCP bridge handle, declared early: the ACP engine reads the bridge port
127
176
  // at session/new / session/load time.
128
177
  let mcpHandle: McpServerHandle | null = null;
178
+ // Approval-gate hook script (per-pid, token embedded). Written at session
179
+ // start when the gate is active; removed at session_shutdown.
180
+ let gateScriptPath: string | null = null;
129
181
  // ACP self-heal runs once per process (session_start re-fires on /reload;
130
182
  // a ready setup is two file stats, so re-running is harmless anyway).
131
183
  let acpSelfHealRan = false;
@@ -154,11 +206,11 @@ export default async function (pi: ExtensionAPI): Promise<void> {
154
206
  "session-load-failed-creating-fresh", "connection-exited", "cancel-failed",
155
207
  "unsupported-server-request",
156
208
  ]);
157
- const legacyDriver = new AgyDriver();
158
- // Mirror the legacy driver's lifecycle ring into the daily file log
209
+ const streamDriver = new StreamDriver();
210
+ // Mirror the stream driver's lifecycle ring into the daily file log
159
211
  // (spawn/exit/abort/stall/recycle). The ACP driver reaches the same file
160
212
  // through acpLog below.
161
- legacyDriver.log = (msg, data) => {
213
+ streamDriver.log = (msg, data) => {
162
214
  // Level classification mirrors acpLog's failure set: stalls, aborts,
163
215
  // timeouts and nonzero exits are the "what broke" greps (warn);
164
216
  // turn-start is the per-turn skeleton (info); everything else is
@@ -183,12 +235,19 @@ export default async function (pi: ExtensionAPI): Promise<void> {
183
235
  : data;
184
236
  // Failures warn; turn-start + auth-url are the always-on skeleton;
185
237
  // routine per-event lifecycle (spawn, session-load, unparked, ...) is
186
- // verbose-only.
187
- const level = acpFailures.has(msg)
188
- ? "warn"
189
- : msg === "turn-start" || msg === "auth-url"
190
- ? "info"
191
- : "debug";
238
+ // verbose-only. Deliberate teardown exits (Esc abort kill, shutdown,
239
+ // idle recycle) demote to debug: routine, and the warn tier stays the
240
+ // "what broke" grep.
241
+ const expectedExit =
242
+ msg === "connection-exited" &&
243
+ (data as { expected?: boolean } | undefined)?.expected === true;
244
+ const level = expectedExit
245
+ ? "debug"
246
+ : acpFailures.has(msg)
247
+ ? "warn"
248
+ : msg === "turn-start" || msg === "auth-url"
249
+ ? "info"
250
+ : "debug";
192
251
  fileLog.log(msg, fileData, level);
193
252
  if (msg === "auth-url") {
194
253
  const { url, port } = (data ?? {}) as { url?: string; port?: number | null };
@@ -209,7 +268,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
209
268
  return;
210
269
  }
211
270
  if (!acpFailures.has(msg)) return;
212
- console.error(`[antigravity-bridge acp] ${msg}${data !== undefined ? " " + JSON.stringify(data) : ""}`);
271
+ // Console noise is gone: warns toast through the fileLog wrapper (with
272
+ // a stderr fallback when no UI exists), and raw tails stay in the file
273
+ // log behind AGY_DEBUG.
213
274
  };
214
275
  const acpDriver = new AcpDriver({
215
276
  // Resolved per connection: the setup flow can install the binary and
@@ -221,7 +282,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
221
282
  const handle = mcpHandle;
222
283
  if (!handle) return [];
223
284
  // The bridge 403s any request without the shared-secret header; the
224
- // legacy engine carries it via mcp_config.json, ACP via headers[].
285
+ // stream engine carries it via mcp_config.json, ACP via headers[].
225
286
  return [
226
287
  {
227
288
  name: "pi-bridge",
@@ -233,15 +294,23 @@ export default async function (pi: ExtensionAPI): Promise<void> {
233
294
  },
234
295
  });
235
296
  // The active engine is resolved from the latched load-time value.
236
- const activeDriver = (): TurnDriver => (engine === "acp" ? acpDriver : legacyDriver);
237
- // The provider's stream-json slot gets the LEGACY driver explicitly - never
297
+ const activeDriver = (): TurnDriver => (engine === "acp" ? acpDriver : streamDriver);
298
+ // The provider's stream-json slot gets the STREAM driver explicitly - never
238
299
  // activeDriver(), or a load-time acp engine would make deps.driver and
239
300
  // deps.acpDriver the same object and break the engine identity check.
240
- const driver = legacyDriver;
301
+ const driver = streamDriver;
241
302
  // The no-patch pi-tool round-trip store: the MCP bridge parks calls here;
242
303
  // the provider emits them as real pi toolUse turns and completes them from
243
304
  // the next call's toolResult.
244
- const roundTrips = new ToolRoundTrips(activeDriver, (s, d) => fileLog.log(s, d, s === "round-trip-fail" ? "warn" : "debug"));
305
+ const roundTrips = new ToolRoundTrips(
306
+ activeDriver,
307
+ (s, d, level) =>
308
+ fileLog.log(
309
+ s,
310
+ d,
311
+ level ?? (s === "round-trip-fail" ? "warn" : "debug"),
312
+ ),
313
+ );
245
314
  const replay = new WrapperReplay();
246
315
  // Native re-exec only emits for builtins actually active in the session;
247
316
  // anything else (or an unknown name) falls back to the wrapper card.
@@ -267,7 +336,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
267
336
  );
268
337
  roundTrips.failAll("antigravity turn ended with an unresolved pi tool call");
269
338
  };
270
- legacyDriver.onTurnEnd = onTurnEnd;
339
+ streamDriver.onTurnEnd = onTurnEnd;
271
340
  acpDriver.onTurnEnd = onTurnEnd;
272
341
  const streamSimple = createStreamSimple({
273
342
  entries,
@@ -518,9 +587,113 @@ export default async function (pi: ExtensionAPI): Promise<void> {
518
587
  isError: !skill,
519
588
  });
520
589
  };
521
- const r = await startMcpServer({ listTools, onToolCall: bridgeOnToolCall }, { log: mcpLog });
590
+ const r = await startMcpServer(
591
+ {
592
+ listTools,
593
+ onToolCall: bridgeOnToolCall,
594
+ onApproval: (ticket, payload) => roundTrips.onApproval(ticket, payload),
595
+ },
596
+ { log: mcpLog },
597
+ );
522
598
  if (r.ok && r.handle) {
523
599
  mcpHandle = r.handle;
600
+ // Stream-json engine registration: the agy CLI discovers MCP servers
601
+ // from ~/.gemini/config/mcp_config.json (ACP uses session/new
602
+ // mcpServers instead; verified live 2026-09-07). Per-pid entry,
603
+ // removed at session_shutdown; stale entries swept at start.
604
+ sweepStaleBridgeServers();
605
+ registerBridgeServer({
606
+ pid: process.pid,
607
+ port: r.handle.port,
608
+ token: r.handle.token,
609
+ tokenHeader: TOKEN_HEADER,
610
+ });
611
+ // --- Approval gate (docs/TODO.md 2.5) ------------------------------
612
+ // agy native tool calls pass through a pi-side approval: a PreToolUse
613
+ // hook parks in the bridge, the provider emits a shadow toolUse, and
614
+ // pi's permission extensions gate it like any native call. Off until
615
+ // enabled (auto = on only when a third-party gate extension exists).
616
+ {
617
+ const cfg = loadConfig();
618
+ const mode = resolveGateMode(cfg.approvals.gateMode, detectPermissionGateExtensions());
619
+ if (mode === "dedicated") {
620
+ // The explicit antigravity_approve variant is planned; until it
621
+ // ships, dedicated stages the same shadow tools. Say so, so the
622
+ // config value never lies silently.
623
+ fileLog.log("approval-dedicated-as-shadow", {}, "warn");
624
+ }
625
+ if (mode === "off") {
626
+ // Gate off: remove ONLY this session's group. Other sessions'
627
+ // groups in a shared workspace are never touched - a gate-off
628
+ // session must not strip a gate-on session's matchers.
629
+ const unstaged = removeGateHooks(process.cwd());
630
+ if (unstaged.wrote) fileLog.log("approval-unstaged", unstaged, "info");
631
+ } else {
632
+ // Script: per-pid file; 0600 because the bridge token is
633
+ // embedded (peer review 2026-09-07).
634
+ const scriptPath = path.join(logsDir(), `approval-hook-${process.pid}.js`);
635
+ fs.mkdirSync(path.dirname(scriptPath), { recursive: true, mode: 0o700 });
636
+ fs.writeFileSync(
637
+ scriptPath,
638
+ hookScriptSource({
639
+ port: r.handle.port,
640
+ token: r.handle.token,
641
+ deadlineMs: APPROVAL_PARK_MS,
642
+ }),
643
+ { mode: 0o600 },
644
+ );
645
+ gateScriptPath = scriptPath;
646
+ const staged = stageGateHooks(process.cwd(), {
647
+ port: r.handle.port,
648
+ token: r.handle.token,
649
+ scriptPath,
650
+ parkBudgetMs: APPROVAL_PARK_MS,
651
+ });
652
+ fileLog.log("approval-staged", { mode, script: scriptPath, ...staged }, staged.wrote ? "info" : "debug");
653
+
654
+ // Shadow bases: factory twins of pi's own builtins (public API).
655
+ // pi.getAllTools() is unusable here: it returns ToolInfo, which
656
+ // strips execute. Marker calls never execute; non-marker calls
657
+ // delegate to the twins, so behavior matches the standard
658
+ // builtins (session-level bash-operations overrides are not
659
+ // inherited; documented in README).
660
+ const gateCwd = process.cwd();
661
+ const bases: Record<string, AnyToolDefinition> = {
662
+ bash: createBashToolDefinition(gateCwd) as unknown as AnyToolDefinition,
663
+ write: createWriteToolDefinition(gateCwd) as unknown as AnyToolDefinition,
664
+ edit: createEditToolDefinition(gateCwd) as unknown as AnyToolDefinition,
665
+ };
666
+ const askMode = cfg.approvals.mode;
667
+ const policy: GatePolicy = async ({ tool, params, ctx }) => {
668
+ if (askMode === "allow") return { allow: true };
669
+ if (askMode === "deny") {
670
+ return { allow: false, reason: `blocked by approval gate (mode: deny): ${tool}` };
671
+ }
672
+ const extCtx = ctx as { hasUI?: boolean; ui?: Pick<ExtensionUIContext, "confirm"> } | undefined;
673
+ if (!extCtx?.hasUI || typeof extCtx.ui?.confirm !== "function") {
674
+ return { allow: false, reason: `approval gate: no UI to approve ${tool} (headless)` };
675
+ }
676
+ const what =
677
+ typeof params.command === "string"
678
+ ? params.command
679
+ : typeof params.path === "string"
680
+ ? params.path
681
+ : JSON.stringify(stripMarkerFields(params)).slice(0, 200);
682
+ const ok = await extCtx.ui.confirm(`agy ${tool}?`, what, { timeout: APPROVAL_PARK_MS });
683
+ return ok ? { allow: true } : { allow: false, reason: `declined in pi (agy ${tool})` };
684
+ };
685
+ const handle = r.handle;
686
+ for (const [name, base] of Object.entries(bases)) {
687
+ pi.registerTool(
688
+ createShadowTool(base, policy, { verifyTicket: (t) => handle.approvals.has(t) }),
689
+ );
690
+ }
691
+ // The park is wired ONLY after the shadows are registered: an
692
+ // approval toolUse must never dispatch to the REAL builtin and
693
+ // execute locally.
694
+ roundTrips.approvalPark = handle.approvals;
695
+ }
696
+ }
524
697
  } else {
525
698
  console.error(`[antigravity-bridge] MCP tool bridge disabled: ${r.reason}`);
526
699
  }
@@ -533,8 +706,29 @@ export default async function (pi: ExtensionAPI): Promise<void> {
533
706
  mcpHandle = null;
534
707
  await h?.close();
535
708
  roundTrips.failAll("antigravity session shut down");
536
- await legacyDriver.close("shutdown");
537
- await acpDriver.close("shutdown");
709
+ // "recycle", NOT "shutdown": pi fires session_shutdown on /new, /resume
710
+ // and /fork (docs/extensions.md session lifecycle), not only on process
711
+ // exit. The drivers are process-lifetime singletons; closing them with
712
+ // "shutdown" latched them permanently and every later turn failed with
713
+ // "ACP driver is shut down." (regression 2026-09-07). Recycle kills the
714
+ // connection now; the next turn respawns it. event.reason is
715
+ // deliberately ignored: recycle is correct even on real process exit
716
+ // ("quit") - the connection kill is identical and nothing runs after.
717
+ await streamDriver.close("recycle", "session shutdown");
718
+ await acpDriver.close("recycle", "session shutdown");
719
+ unregisterBridgeServer(process.pid);
720
+ // Approval gate: unstage hooks and remove the per-pid script. Pending
721
+ // approvals already failed closed via handle close (bridge shutdown deny).
722
+ const unstaged = removeGateHooks(process.cwd());
723
+ if (unstaged.wrote) fileLog.log("approval-unstaged", unstaged, "info");
724
+ if (gateScriptPath) {
725
+ try {
726
+ fs.rmSync(gateScriptPath, { force: true });
727
+ } catch {
728
+ /* best effort */
729
+ }
730
+ gateScriptPath = null;
731
+ }
538
732
  });
539
733
  }
540
734
 
@@ -793,7 +987,7 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
793
987
  ` sessions: ${ctx.store.size} bound`,
794
988
  ` models: ${ctx.entries.length} ${ctx.usingFallback ? "FALLBACK (agy models failed)" : "discovered"}`,
795
989
  ` config: ${CONFIG_PATH}`,
796
- ` logs: ${logsDir()} (attach recent days' files when reporting issues)`,
990
+ ` logs: ${logsDir()} (attach recent days' files when reporting issues; set AGY_DEBUG=1 to capture details)`,
797
991
  ];
798
992
  if (snap.engine === "acp" && snap.acp) {
799
993
  lines.push(
@@ -801,6 +995,11 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
801
995
  ` acp server: ${snap.acp.serverVersion ?? "unknown"}${snap.acp.agentTitle ? ` (${snap.acp.agentTitle})` : ""}`,
802
996
  ` acp stats: prompts=${snap.acp.prompts} created=${snap.acp.sessionsCreated} loaded=${snap.acp.sessionsLoaded} kills=${snap.acp.kills} reconnects=${snap.acp.reconnects} cancel=${snap.acp.cancelSupported === null ? "unprobed" : snap.acp.cancelSupported ? "supported" : "unsupported (kill+reload)"}`,
803
997
  );
998
+ // Gate B watch: silent while the server offers no token counts; one
999
+ // line the day it starts (then real usage mapping is worth wiring).
1000
+ if (snap.acp.usageSeen) {
1001
+ lines.push(" acp tokens: AVAILABLE in server payloads (wire real usage mapping next)");
1002
+ }
804
1003
  }
805
1004
  if (snap.lifecycle.length > 0) {
806
1005
  lines.push(" lifecycle (last 5):");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estebanforge/pi-antigravity-bridge",
3
- "version": "1.4.8",
3
+ "version": "1.4.10",
4
4
  "description": "Gemini provider for Pi on the Antigravity ACP server (official Google ACP) or the stream-json agy CLI. antigravity/* models in Pi's /model picker, no-patch MCP bridge: agy runs Pi's tools. ToS safe to use.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -24,6 +24,7 @@ import fs from "node:fs";
24
24
  import os from "node:os";
25
25
  import path from "node:path";
26
26
  import { parseAuthPort, readLastUrl } from "./browser-capture.js";
27
+ import { frameCarriesUsage } from "./events.js";
27
28
  import { JsonRpcResponseError, JsonRpcSession } from "./jsonrpc.js";
28
29
 
29
30
  export interface AcpMcpServer {
@@ -80,6 +81,7 @@ export class AcpConnection {
80
81
  #child: ChildProcess | undefined;
81
82
  #rpc: JsonRpcSession | undefined;
82
83
  #stderrTail = "";
84
+ #usageSeen = false;
83
85
  #exited = false;
84
86
  #killed = false;
85
87
  #suppressUpdates = false;
@@ -106,6 +108,13 @@ export class AcpConnection {
106
108
  return this.#stderrTail;
107
109
  }
108
110
 
111
+ /** Gate B watch: latched true once any session/update frame carried
112
+ * usage/token fields. /agy doctor surfaces it the day upstream starts
113
+ * sending token counts; silent until then. */
114
+ get usageSeen(): boolean {
115
+ return this.#usageSeen;
116
+ }
117
+
109
118
  /** While true, session/update notifications are dropped: they are the
110
119
  * full-text history replay that precedes a session/load response, never
111
120
  * live generation (run 6). */
@@ -299,6 +308,9 @@ export class AcpConnection {
299
308
 
300
309
  #onNotification(method: string, params: unknown): void {
301
310
  if (method === "session/update") {
311
+ // Latched before the suppression gate: history-replay frames are the
312
+ // server's payloads too, and they are the cheapest signal source.
313
+ if (!this.#usageSeen && frameCarriesUsage(params)) this.#usageSeen = true;
302
314
  if (this.#suppressUpdates) return; // load replay: history, not live text
303
315
  const p = typeof params === "object" && params !== null ? (params as Record<string, unknown>) : {};
304
316
  const sessionId = typeof p.sessionId === "string" ? p.sessionId : this.#updateSessionId;
package/src/acp/driver.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  // AcpDriver: the ACP turn engine. Implements the same TurnDriver surface as
2
- // the legacy stream-json driver (see src/driver-types.ts) so provider.ts and
2
+ // the stream-json driver (see src/driver-types.ts) so provider.ts and
3
3
  // the G9 round-trip store work unchanged.
4
4
  //
5
- // Engine differences vs legacy, all verified live (docs/ACP-PROTOCOL-REFERENCE.md):
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
8
  // - model/effort via session/set_config_option (configId "model", FULL slug
@@ -110,7 +110,6 @@ export class AcpDriver implements TurnDriver {
110
110
  #generation = 0;
111
111
  #active: ActiveTurn | undefined;
112
112
  #queueTail: Promise<void> = Promise.resolve();
113
- #shutdown = false;
114
113
  #lifecycle: string[] = [];
115
114
  #onTurnEnd: ((outcome: TurnOutcome) => void) | undefined;
116
115
  #stats = {
@@ -168,7 +167,7 @@ export class AcpDriver implements TurnDriver {
168
167
  }
169
168
 
170
169
  /** Turns are serialized; a parked turn stays open and the continuation
171
- * path uses reentry() (same contract as the legacy driver). */
170
+ * path uses reentry() (same contract as the stream-json driver). */
172
171
  run(request: DriverTurnRequest): Promise<TurnHandle> {
173
172
  let release!: () => void;
174
173
  const prev = this.#queueTail;
@@ -190,7 +189,10 @@ export class AcpDriver implements TurnDriver {
190
189
  }
191
190
 
192
191
  #runExclusive(request: DriverTurnRequest): Promise<TurnHandle> {
193
- if (this.#shutdown) return Promise.reject(new Error("ACP driver is shut down."));
192
+ // No shutdown latch here: pi fires session_shutdown on /new, /resume and
193
+ // /fork (not only process exit), so a closed driver must respawn on the
194
+ // next turn instead of rejecting forever. Regression 2026-09-07:
195
+ // /compact after a model switch failed with "ACP driver is shut down."
194
196
  if (request.signal?.aborted) return Promise.reject(new Error("aborted before start"));
195
197
 
196
198
  const turn = this.#createTurn(request);
@@ -218,7 +220,7 @@ export class AcpDriver implements TurnDriver {
218
220
  }
219
221
 
220
222
  // Execute asynchronously: the handle returns as soon as the prompt is
221
- // dispatched, and activities stream through next() (legacy contract).
223
+ // dispatched, and activities stream through next() (stream-json contract).
222
224
  void this.#executeTurn(turn).catch((err: unknown) => {
223
225
  this.#failTurn(turn, `ACP turn failed: ${describe(err)}`);
224
226
  });
@@ -409,7 +411,12 @@ export class AcpDriver implements TurnDriver {
409
411
  const turn = this.#active;
410
412
  this.#conn = undefined;
411
413
  this.#state = "dead";
412
- this.#log("connection-exited", { tail: info.stderrTail.slice(-200) });
414
+ // Teardown exits (abort kill, shutdown, idle recycle) are deliberate:
415
+ // their stderr tail must never reach the UI. Only a live turn dying
416
+ // mid-flight is user-reportable, and it surfaces as a normal turn
417
+ // error, not as raw console noise.
418
+ const turnActive = Boolean(turn && !turn.closed && !turn.aborted && !turn.sawResult);
419
+ this.#log("connection-exited", { expected: !turnActive, tail: info.stderrTail.slice(-200) });
413
420
  if (!turn || turn.closed) return;
414
421
  if (turn.aborted || turn.sawResult) {
415
422
  this.#settle(turn, {
@@ -421,7 +428,7 @@ export class AcpDriver implements TurnDriver {
421
428
  });
422
429
  return;
423
430
  }
424
- this.#failTurn(turn, info.stderrTail.trim() || "ACP server exited mid-turn");
431
+ this.#failTurn(turn, "Antigravity session exited unexpectedly; it restarts on the next turn");
425
432
  }
426
433
 
427
434
  #ensureConnection(request: DriverTurnRequest): Promise<AcpConnection> {
@@ -674,7 +681,6 @@ export class AcpDriver implements TurnDriver {
674
681
  // --- TurnDriver surface ----------------------------------------------------
675
682
 
676
683
  async close(reason: "recycle" | "shutdown", cause?: string): Promise<void> {
677
- if (reason === "shutdown") this.#shutdown = true;
678
684
  this.#log(`close:${reason}${cause ? `:${cause}` : ""}`);
679
685
  const turn = this.#active;
680
686
  if (turn && !turn.closed) {
@@ -682,7 +688,7 @@ export class AcpDriver implements TurnDriver {
682
688
  conversationId: turn.sessionId,
683
689
  status: "ERROR",
684
690
  response: turn.response.text,
685
- error: `ACP driver ${reason}ed mid-turn${cause ? ` (${cause})` : ""}`,
691
+ error: `ACP driver ${reason === "recycle" ? "recycled" : "shut down"} mid-turn${cause ? ` (${cause})` : ""}`,
686
692
  finished: true,
687
693
  aborted: false,
688
694
  });
@@ -721,6 +727,7 @@ export class AcpDriver implements TurnDriver {
721
727
  reconnects: Math.max(0, this.#stats.spawns - 1),
722
728
  agentName: this.#agentInfo?.name,
723
729
  agentTitle: this.#agentInfo?.title,
730
+ usageSeen: this.#conn?.usageSeen ?? false,
724
731
  },
725
732
  };
726
733
  }
package/src/acp/events.ts CHANGED
@@ -248,3 +248,20 @@ export class TextAccumulator {
248
248
  return this.#acc;
249
249
  }
250
250
  }
251
+
252
+ const USAGE_KEY = /usage|tokens?/i;
253
+
254
+ /** Gate B watch: true when a server payload carries usage/token fields.
255
+ * Key-name based so future shapes (usage blocks, _meta.tokenCount, top-level
256
+ * token totals) all trip it. String values are exempt so auth-style "token"
257
+ * args inside tool-call frames cannot false-positive the latch; depth-capped
258
+ * so the walk stays cheap on every frame until it latches. */
259
+ export function frameCarriesUsage(value: unknown, depth = 0): boolean {
260
+ if (depth > 8 || typeof value !== "object" || value === null) return false;
261
+ if (Array.isArray(value)) return value.some((v) => frameCarriesUsage(v, depth + 1));
262
+ for (const [key, v] of Object.entries(value)) {
263
+ if (USAGE_KEY.test(key) && (typeof v === "number" || typeof v === "object")) return true;
264
+ if (frameCarriesUsage(v, depth + 1)) return true;
265
+ }
266
+ return false;
267
+ }