@estebanforge/pi-antigravity-bridge 1.4.7 → 1.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,42 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.4.9] - 2026-09-05
6
+
7
+ ### Added
8
+
9
+ - Warn toasts in the pi UI: bridge tool failures, stalls, timeouts, process exits, and other warnings surface as native warning notifications the moment they happen, so users see them and can report them. Over SSH or without a UI the same text falls back to stderr. Deliberate aborts, connection exits (the turn's own error block carries real crashes), and `call-tool-fail` (same-instant duplicate of `round-trip-fail`) stay silent.
10
+ - Gate B watch in `/agy doctor`: the ACP connection latches the first `session/update` payload that carries usage/token fields, and doctor prints one "acp tokens: AVAILABLE" line when that happens. Silent until then; the day the line appears, wiring real token counts becomes a small mapping job. Auth-style string `"token"` keys inside tool frames cannot trip the latch.
11
+
12
+ ### Fixed
13
+
14
+ - Esc-abort on the ACP engine no longer dumps the dying server's raw stderr tail (a google3 stack trace) into the transcript. Teardown exits (abort kill, shutdown, idle recycle) are marked expected and never render; a genuine mid-turn crash surfaces as the turn's own clean error block instead. Raw tails still land in the file log for post-mortems.
15
+
16
+ ### Changed
17
+
18
+ - The ACP engine is now beta. Parity is verified live against RC01 (text streaming, multi-turn resume, bridge tools, effort switching, serialization, abort recovery - see `scripts/parity-live.mjs`), so it graduates from opt-in-experimental to a supported alternative engine. It stays behind `config.engine` for now; the two known RC01 gaps (no usage fields, kill+reload abort) are documented in the README.
19
+ - Daily log volume: the default tier now writes ONLY errors, so a regular session costs the disk nothing. Warns live in the UI (see above); `AGY_DEBUG=1` restores the full debug/info/warn/error trail for reproducing a problem. Docs: README debug-logs section, `/agy doctor` hint.
20
+ - The tool-priority note now steers agy's native `view_file` (artifact-sandboxed on RC01) to the Pi Bridge file tools (`read`, `ls`, `grep`, `find`, `edit`, `execute`) for any real filesystem path. Observed live: repeated `invalid_args` rejections on `operator/pkg/tmux/client.go` before the model fell back to `edit`.
21
+
22
+ ## [1.4.8] - 2026-09-05
23
+
24
+ ### Added
25
+
26
+ - Early-ack + poll for long bridge calls. agy's MCP client abandons a `tools/call` request at a flat ~180s (observed twice at exactly 180.000s), so any pi tool that ran longer died with "agy disconnected before the tool result arrived": the 225.7s `AskClaude` peer review that exposed it never reached agy, and agy salvaged its turn without the result. Now a call still running after ~20 seconds settles the HTTP request with a `STILL RUNNING` answer carrying a `callId`, and the new bridge-local `bridge_poll_result` tool returns the result when it lands (or "still running" on the way). pi keeps executing the whole time; fast calls never see any of this. An escalated park re-arms its own timeout to 30 minutes, so human-gated tools (commit previews, permission dialogs) can take as long as the human takes.
27
+ - Late tool-result delivery as a backstop: a park that does fail (abort, timeout, recycle) leaves a bounded tombstone, and when the toolResult arrives anyway the provider re-routes it to agy as a new prompt in the same conversation ("Late tool delivery: ...") instead of erroring the turn. A late result that lands while another park still anchors the pass is deferred to the next pass (`late-result-deferred`), never dropped.
28
+ - A `progress-token` probe in the bridge server: if agy's requests ever carry `_meta.progressToken`, MCP progress notifications become a testable zero-UX fix for the deadline. The exact-180s signature says it likely never fires; one log line settles it.
29
+
30
+ ### Fixed
31
+
32
+ - A toolResult whose park already died no longer misclassifies the turn as "No user message to send to agy." That was the second half of the incident, and it turned a recoverable late delivery into a hard error.
33
+ - `failAll` (fired on every turn end, OK turns included) no longer marks escalated calls failed: an escalated call outlives its agy turn by design, and the poll handle must not lie about a still-running tool (peer-review blocker).
34
+ - `EscalationRegistry` eviction can no longer strand a running call: only settled entries evict, so a saturated cap grows instead of losing an in-flight result.
35
+
36
+ ### Changed
37
+
38
+ - The tool-priority note now also teaches the poll pattern: long bridge calls answer `STILL RUNNING` + `bridge_poll_result`, and work that is known-long should use `exec_command`'s session output or background agents.
39
+ - New daily-log events: `call-tool-escalated`, `poll-tool`, `late-result` (with a `freshConversation` flag), `late-result-deferred`, `progress-token`. Docs: README bridge section, ACP-PROTOCOL-REFERENCE timing table.
40
+
5
41
  ## [1.4.7] - 2026-09-05
6
42
 
7
43
  ### Added
package/README.md CHANGED
@@ -21,11 +21,11 @@ Multi-turn works. The provider binds a pi session to an agy conversation id (per
21
21
  Turns run through one of two engines behind the same provider surface (`config.engine`, default `stream-json`):
22
22
 
23
23
  - **stream-json** (default): the persistent `agy` CLI process. The tested default; live token usage; conversation resume via `--conversation`.
24
- - **acp**: Google's official ACP server (`agy_acp_server.par`), JSON-RPC 2.0 over stdio. Opt-in while it matures: the current build (RC01) ships no usage fields (token display shows zero) and no cancel (abort tears the server down and reloads it next turn). Everything else is parity-verified live - text streaming, multi-turn resume via `session/load`, bridge tools, effort switching, serialization, abort recovery - see `scripts/parity-live.mjs`.
24
+ - **acp** (beta): Google's official ACP server (`agy_acp_server.par`), JSON-RPC 2.0 over stdio. Beta: parity-verified live against the current build (RC01) - text streaming, multi-turn resume via `session/load`, bridge tools, effort switching, serialization, abort recovery (see `scripts/parity-live.mjs`). Two known RC01 gaps remain: no usage fields (token display shows zero) and no cancel (abort tears the server down and reloads it next turn).
25
25
 
26
26
  Engine-dependent features: pi image attachments ride natively only on the ACP engine (the picker offers image attach automatically when `config.engine` is `acp`; the stream-json CLI prompt is text-only). With the optional G1 digest enabled, its delivery also differs: ACP ships it as a native `embeddedContext` resource block, stream-json prepends it to the prompt text. The `AskAntigravity` delegation tool is unaffected by `config.engine` and runs the `stream-json` CLI (`agy -p`) across both configurations.
27
27
 
28
- | Capability | `stream-json` (default) | `acp` |
28
+ | Capability | `stream-json` (default) | `acp` (beta) |
29
29
  | --- | --- | --- |
30
30
  | Show thinking text | No (token count only, floor 64, no text body) | Yes (streams thought text via `agent_thought_chunk`; sparse on RC01 where reasoning often arrives in message text) |
31
31
  | Live token usage | Yes (live metrics from CLI step events) | No (absent in RC01, displays zero tokens) |
@@ -68,6 +68,8 @@ The bridge starts a localhost MCP server inside pi's process. `tools/list` retur
68
68
 
69
69
  **No patch required.** Bridge calls park in the provider's round-trip store; the provider ends the pi assistant message with a `toolUse` stop reason for the real pi tool, pi executes it in its own loop (native cards, permissions, hooks), and the toolResult completes the parked MCP response on the next stream call. This is the same mechanism tianzuo/pi-antigravity uses; upstream pi APIs only.
70
70
 
71
+ **Long calls don't die.** agy's MCP client abandons a `tools/call` request at a flat ~180s, which used to kill any pi tool that ran longer (a long peer review, a build, a commit preview waiting for you). A call still running after ~20 seconds now settles its HTTP request with a `STILL RUNNING` answer carrying a `callId` while pi keeps executing; agy fetches the result through the bridge-local `bridge_poll_result` tool and polls until it lands. Escalated calls get their own 30-minute budget, so human-gated tools can take as long as the human takes. Fast calls stay fully synchronous and never see any of this. If a park does fail (abort, timeout, recycle), the late result is re-routed to agy as a follow-up prompt in the same conversation instead of being lost.
72
+
71
73
  **Recursion safety.** Only the provider's agy receives the extra `--add-dir`. The `AskAntigravity` tool spawns its own agy with just the workspace, so that inner agy starts plain (no pi tools) and cannot re-enter. `AskAntigravity` is also filtered from the exposed tool list. Standalone agy is unaffected because nothing is written to its global config.
72
74
 
73
75
  **Cost / fan-out.** Every registered pi tool except builtins (and `AskAntigravity`) is exposed, including other delegation tools like `AskClaude`/`AskCodex`. agy can therefore chain into other models via the bridge, which is a new cost/time fan-out vector that did not exist before this feature.
@@ -155,7 +157,7 @@ The `activate_skill` catalog mirrors pi's directory-based skill discovery: the t
155
157
  /agy system-prompt on|off send pi's system prompt + AGENTS.md + the Pi Bridge tool-priority note to new agy conversations (default on)
156
158
  /agy bridge all|mcp|none which pi tools the MCP bridge exposes to agy (default all; none = bridge off)
157
159
  /agy acp-bin <path|auto> point the ACP engine at a specific server binary (auto = setup installs, or AGY_ACP_BIN; applies on the next ACP turn)
158
- /agy engine acp|stream-json switch the turn engine (restart to apply; default stream-json; acp runs self-service setup: binary install + auth bootstrap)
160
+ /agy engine acp|stream-json switch the turn engine (restart to apply; default stream-json; acp is beta and runs self-service setup: binary install + auth bootstrap)
159
161
  /agy auth-manual manual ACP credential setup (fallback; auto-setup normally covers this; default login = your Antigravity subscription, same account as the agy CLI)
160
162
  /agy patch-cleanup restore the original pi files if an older version patched them
161
163
  /agy clear drop all session bindings (force fresh conversations)
@@ -186,7 +188,7 @@ For isolation when running any agent that executes commands without a confirmati
186
188
  | `AGY_SKIP_PERMISSIONS` | `1`/`true` (default) to pass `--dangerously-skip-permissions` so commands don't hang on an unanswerable prompt in `-p` mode. `0`/`false` to prompt (hangs any `run_command` non-interactively). Wins over the config file. |
187
189
  | `AGY_DEFAULT_MODEL` | Default model alias for the `AskAntigravity` tool (`flash`/`pro`/`gemini`, or a tier/version qualifier). Wins over the config file. |
188
190
  | `AGY_DEFAULT_THINKING` | Default thinking tier for the `AskAntigravity` tool: `low`/`medium`/`high`. Anything else falls back to `medium`. Wins over the config file. |
189
- | `AGY_DEBUG` | `1`/`true`/`on` writes verbose debug records to the daily log (driver lifecycle, raw bridge traffic). Default off: only the light info/warn/error stream. |
191
+ | `AGY_DEBUG` | `1`/`true`/`on` writes the full trail to the daily log (driver lifecycle, raw bridge traffic). Default off: **only `error` records land on disk** - routine disk writes are zero for regular users, and warnings surface as UI toasts instead. |
190
192
 
191
193
  ## Debug logs
192
194
 
@@ -196,14 +198,14 @@ The extension keeps a daily log on your machine, sorted by day:
196
198
  ~/.pi/extensions-data/estebanforge/pi-antigravity-bridge/logs/<YYYY-MM-DD>.ndjson
197
199
  ```
198
200
 
199
- One JSON record per line. Two verbosity tiers keep the disk cost negligible for regular users: by default only `info`/`warn`/`error` records land on disk, which is the useful skeleton: turn starts and outcomes with error text (both engines), bridge tool calls and round-trip failures, `AskAntigravity` runs, `/agy` commands, ACP setup/self-heal, auth URLs, and every driver failure (stall, abort, timeout, nonzero exit). Set `AGY_DEBUG=1` before reproducing a problem for the full trail: per-event driver lifecycle (spawn, exit, session load/new, unparks), list-tools traffic, recycle causes, and the raw bridge chatter. `/agy doctor` prints the log directory.
201
+ One JSON record per line. Two verbosity tiers keep the disk cost at zero for regular users: by default only `error` records land on disk - everything routine (turn starts, tool calls, escalations, commands, driver failures) is withheld. Warnings are not lost: the extension toasts each one in the pi UI (bridge tool failures, stalls, timeouts, process exits), so users see them and can report them; deliberate aborts and failures pi already renders as the turn's own error block stay silent. Set `AGY_DEBUG=1` before reproducing a problem for the full trail: per-event driver lifecycle (spawn, exit, session load/new, unparks), turn starts and outcomes, bridge tool calls, escalations and poll traffic, late deliveries, list-tools traffic, recycle causes, and the raw bridge chatter. `/agy doctor` prints the log directory.
200
202
 
201
203
  Notes:
202
204
 
203
205
  - Retention: 14 days. Older files are pruned automatically.
204
206
  - Privacy: prompt text, tool arguments, and tool output never land in the log. Secret-shaped values (tokens, API keys, credentials, header blocks) are redacted, and long strings are truncated. The `auth-url` record strips the login URL's query string.
205
207
  - Logging never throws: an unwritable directory is skipped silently and retried on the next record.
206
- - SSD wear: default volume is a handful of records per turn. Verbose mode (`AGY_DEBUG=1`) writes more; turn it off after reproducing.
208
+ - SSD wear: default volume is effectively zero until something actually errors. Verbose mode (`AGY_DEBUG=1`) writes more; turn it off after reproducing.
207
209
 
208
210
  When you report an issue, attach the last day or two of files from that directory. For anything that needs reproduction, run with `AGY_DEBUG=1` once and attach that day's file. They usually contain the exact failure sequence (engine, session, bridge call, error) with no need to guess.
209
211
 
@@ -349,6 +349,7 @@ or `terminal/*` delegation occurred with capabilities off.
349
349
  | set_config_option response | < 1 s |
350
350
  | Prompt first chunk | ~1-2 s (Flash, low effort) |
351
351
  | OAuth onboarding window | minutes-scale; one timeout observed at ~8.5 min |
352
+ | Bridge `tools/call` HTTP request | agy's MCP client abandons the request at ~180s (observed twice at exactly 180.000s on 2026-09-05: AskClaude at 225.7s, then a follow-up exec_command). Mitigations: the bridge early-acks any call still running after ~20s with a poll handle (`bridge_poll_result`, `call-tool-escalated` log event) so the request never reaches the deadline; escalated parks re-arm at 30 min for human-gated latency; a result that outlives polling is re-delivered as a new same-conversation prompt (late delivery, `late-result` log event). `mcp-server` logs `progress-token` when a request carries `_meta.progressToken`: if agy ever sends one, progress notifications become a testable zero-UX fix |
352
353
  | Steady RSS | ~327 MB (5 min mixed load; VSZ ~5.3 GB is TCMalloc reservation) |
353
354
 
354
355
  ## Run 6 findings (2026-09-03, post-restart session; raw traffic
@@ -37,7 +37,14 @@ import {
37
37
  type AgyModelEntry,
38
38
  } from "../src/models.js";
39
39
  import { SessionStore } from "../src/sessions.js";
40
- import { ToolRoundTrips, WrapperReplay, createStreamSimple } from "../src/provider.js";
40
+ import {
41
+ POLL_TOOL_NAME,
42
+ ToolRoundTrips,
43
+ WrapperReplay,
44
+ createStreamSimple,
45
+ formatEscalatedAck,
46
+ formatPollAnswer,
47
+ } from "../src/provider.js";
41
48
  import { AgyDriver } from "../src/driver.js";
42
49
  import { AcpDriver } from "../src/acp/driver.js";
43
50
  import { runAcpAuth } from "../src/acp/auth.js";
@@ -109,6 +116,36 @@ export default async function (pi: ExtensionAPI): Promise<void> {
109
116
  // src/daily-log.ts). Fire-and-forget, secrets redacted, old days pruned.
110
117
  // Support flow: "attach the last days' files from that dir".
111
118
  const fileLog = createDailyLogger({ dir: logsDir() });
119
+ // Warn tier = user-facing: the default file log keeps ONLY errors (zero
120
+ // routine disk traffic), so a warn that never reaches the UI is lost.
121
+ // Every warn toasts here instead; AGY_DEBUG=1 restores the full file
122
+ // trail. Silent exceptions: deliberate aborts (pi already shows
123
+ // "Operation aborted"), connection exits (the turn's own error block
124
+ // carries real crashes), and call-tool-fail (same-instant duplicate of
125
+ // round-trip-fail). No UI (headless): warn text falls back to stderr.
126
+ const rawFileLog = fileLog.log.bind(fileLog);
127
+ fileLog.log = (event, data, level) => {
128
+ rawFileLog(event, data, level);
129
+ if (level !== "warn") return;
130
+ if (event.startsWith("abort:") || event === "connection-exited" || event === "call-tool-fail") return;
131
+ const d = (data ?? {}) as Record<string, unknown>;
132
+ let text: string;
133
+ if (event === "round-trip-fail") {
134
+ text = `Bridge tool call failed: ${String(d.name ?? "tool")} (${String(d.reason ?? "unknown")})`;
135
+ } else if (event.startsWith("stall:")) {
136
+ text = "Antigravity stalled with no output; the turn was stopped";
137
+ } else if (event.startsWith("timeout:")) {
138
+ text = "Antigravity turn timed out";
139
+ } else if (event.startsWith("exit:") && event !== "exit:0") {
140
+ text = "Antigravity process exited unexpectedly";
141
+ } else if (event === "turn-error") {
142
+ text = `Antigravity turn issue: ${String(d.reason ?? "unknown")}`;
143
+ } else {
144
+ text = `Antigravity warning: ${event}`;
145
+ }
146
+ if (activeUi) activeUi.notify(text, "warning");
147
+ else console.error(`[antigravity-bridge] ${text}`);
148
+ };
112
149
  fileLog.log(
113
150
  "extension-load",
114
151
  { engine, models: models.length, fallback: usingFallback, bridge: loadConfig().bridgeTools, askTool: loadConfig().askTool },
@@ -176,12 +213,19 @@ export default async function (pi: ExtensionAPI): Promise<void> {
176
213
  : data;
177
214
  // Failures warn; turn-start + auth-url are the always-on skeleton;
178
215
  // routine per-event lifecycle (spawn, session-load, unparked, ...) is
179
- // verbose-only.
180
- const level = acpFailures.has(msg)
181
- ? "warn"
182
- : msg === "turn-start" || msg === "auth-url"
183
- ? "info"
184
- : "debug";
216
+ // verbose-only. Deliberate teardown exits (Esc abort kill, shutdown,
217
+ // idle recycle) demote to debug: routine, and the warn tier stays the
218
+ // "what broke" grep.
219
+ const expectedExit =
220
+ msg === "connection-exited" &&
221
+ (data as { expected?: boolean } | undefined)?.expected === true;
222
+ const level = expectedExit
223
+ ? "debug"
224
+ : acpFailures.has(msg)
225
+ ? "warn"
226
+ : msg === "turn-start" || msg === "auth-url"
227
+ ? "info"
228
+ : "debug";
185
229
  fileLog.log(msg, fileData, level);
186
230
  if (msg === "auth-url") {
187
231
  const { url, port } = (data ?? {}) as { url?: string; port?: number | null };
@@ -202,7 +246,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
202
246
  return;
203
247
  }
204
248
  if (!acpFailures.has(msg)) return;
205
- console.error(`[antigravity-bridge acp] ${msg}${data !== undefined ? " " + JSON.stringify(data) : ""}`);
249
+ // Console noise is gone: warns toast through the fileLog wrapper (with
250
+ // a stderr fallback when no UI exists), and raw tails stay in the file
251
+ // log behind AGY_DEBUG.
206
252
  };
207
253
  const acpDriver = new AcpDriver({
208
254
  // Resolved per connection: the setup flow can install the binary and
@@ -459,6 +505,19 @@ export default async function (pi: ExtensionAPI): Promise<void> {
459
505
  inputSchema: activateSkillSchema(skills) as object,
460
506
  });
461
507
  }
508
+ // Bridge-local, like activate_skill: answered from the escalation
509
+ // registry without a pi round-trip. Pairs with the STILL RUNNING
510
+ // early-ack that keeps slow calls under agy's ~180s request deadline.
511
+ tools.push({
512
+ name: POLL_TOOL_NAME,
513
+ description:
514
+ "Fetch the result of a long-running bridge tool call that answered STILL RUNNING with a callId. Poll again if it still reports running; the result or an error arrives here.",
515
+ inputSchema: {
516
+ type: "object",
517
+ properties: { callId: { type: "string", description: "callId from the STILL RUNNING answer" } },
518
+ required: ["callId"],
519
+ },
520
+ });
462
521
  return tools;
463
522
  };
464
523
  // activate_skill never round-trips through pi: the bridge answers it
@@ -469,7 +528,22 @@ export default async function (pi: ExtensionAPI): Promise<void> {
469
528
  args: Record<string, unknown>,
470
529
  signal: AbortSignal,
471
530
  ) => {
472
- if (name !== ACTIVATE_SKILL_TOOL_NAME) return roundTrips.onToolCall(callId, name, args, signal);
531
+ // Bridge-local, like activate_skill: answered from the escalation
532
+ // registry, never parked into pi.
533
+ if (name === POLL_TOOL_NAME) {
534
+ const wanted = typeof args.callId === "string" ? args.callId : "";
535
+ mcpLog("poll-tool", { callId: wanted });
536
+ return Promise.resolve(formatPollAnswer(wanted, roundTrips.poll(wanted)));
537
+ }
538
+ if (name !== ACTIVATE_SKILL_TOOL_NAME) {
539
+ return roundTrips.onToolCall(callId, name, args, signal).then((r) => {
540
+ // Early-ack: answer the HTTP request before agy's ~180s client
541
+ // deadline with a poll handle; pi keeps executing meanwhile.
542
+ if (!("escalated" in r)) return r;
543
+ mcpLog("call-tool-escalated", { name, callId: r.callId });
544
+ return formatEscalatedAck(r);
545
+ });
546
+ }
473
547
  const wanted = typeof args.name === "string" ? args.name : "";
474
548
  const skill = findSkillByName(skills, wanted);
475
549
  const body = skill ? readSkillBody(skill) : `unknown skill: ${wanted || "(none given)"}`;
@@ -758,7 +832,7 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
758
832
  ` sessions: ${ctx.store.size} bound`,
759
833
  ` models: ${ctx.entries.length} ${ctx.usingFallback ? "FALLBACK (agy models failed)" : "discovered"}`,
760
834
  ` config: ${CONFIG_PATH}`,
761
- ` logs: ${logsDir()} (attach recent days' files when reporting issues)`,
835
+ ` logs: ${logsDir()} (attach recent days' files when reporting issues; set AGY_DEBUG=1 to capture details)`,
762
836
  ];
763
837
  if (snap.engine === "acp" && snap.acp) {
764
838
  lines.push(
@@ -766,6 +840,11 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
766
840
  ` acp server: ${snap.acp.serverVersion ?? "unknown"}${snap.acp.agentTitle ? ` (${snap.acp.agentTitle})` : ""}`,
767
841
  ` 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)"}`,
768
842
  );
843
+ // Gate B watch: silent while the server offers no token counts; one
844
+ // line the day it starts (then real usage mapping is worth wiring).
845
+ if (snap.acp.usageSeen) {
846
+ lines.push(" acp tokens: AVAILABLE in server payloads (wire real usage mapping next)");
847
+ }
769
848
  }
770
849
  if (snap.lifecycle.length > 0) {
771
850
  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.7",
3
+ "version": "1.4.9",
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
@@ -409,7 +409,12 @@ export class AcpDriver implements TurnDriver {
409
409
  const turn = this.#active;
410
410
  this.#conn = undefined;
411
411
  this.#state = "dead";
412
- this.#log("connection-exited", { tail: info.stderrTail.slice(-200) });
412
+ // Teardown exits (abort kill, shutdown, idle recycle) are deliberate:
413
+ // their stderr tail must never reach the UI. Only a live turn dying
414
+ // mid-flight is user-reportable, and it surfaces as a normal turn
415
+ // error, not as raw console noise.
416
+ const turnActive = Boolean(turn && !turn.closed && !turn.aborted && !turn.sawResult);
417
+ this.#log("connection-exited", { expected: !turnActive, tail: info.stderrTail.slice(-200) });
413
418
  if (!turn || turn.closed) return;
414
419
  if (turn.aborted || turn.sawResult) {
415
420
  this.#settle(turn, {
@@ -421,7 +426,7 @@ export class AcpDriver implements TurnDriver {
421
426
  });
422
427
  return;
423
428
  }
424
- this.#failTurn(turn, info.stderrTail.trim() || "ACP server exited mid-turn");
429
+ this.#failTurn(turn, "Antigravity session exited unexpectedly; it restarts on the next turn");
425
430
  }
426
431
 
427
432
  #ensureConnection(request: DriverTurnRequest): Promise<AcpConnection> {
@@ -721,6 +726,7 @@ export class AcpDriver implements TurnDriver {
721
726
  reconnects: Math.max(0, this.#stats.spawns - 1),
722
727
  agentName: this.#agentInfo?.name,
723
728
  agentTitle: this.#agentInfo?.title,
729
+ usageSeen: this.#conn?.usageSeen ?? false,
724
730
  },
725
731
  };
726
732
  }
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
+ }
package/src/daily-log.ts CHANGED
@@ -15,10 +15,10 @@
15
15
  // redacted and long strings are truncated before they reach disk.
16
16
  // - Retention: files older than `retentionDays` are pruned once per
17
17
  // process, so the dir cannot grow unbounded.
18
- // - Two tiers, to keep SSD wear negligible for regular users: only
19
- // info/warn/error records (failures, turn/tool boundaries, commands,
20
- // setup) are written by default. Full verbose trails (per-event driver
21
- // lifecycle, raw bridge traffic) require AGY_DEBUG=1.
18
+ // - Volume tiers: default installs write ONLY errors routine logging
19
+ // costs the disk nothing. Warns surface as UI toasts instead (the
20
+ // extension wraps this logger); AGY_DEBUG=1 restores the full trail
21
+ // (debug/info/warn/error).
22
22
 
23
23
  import { appendFile, mkdir, readdir, unlink } from "node:fs/promises";
24
24
  import path from "node:path";
@@ -30,9 +30,9 @@ export interface DailyLoggerOptions {
30
30
  dir: string;
31
31
  /** Files older than this many days are pruned once per process. Default 14. */
32
32
  retentionDays?: number;
33
- /** Verbose gate. When false (default), debug-level records are dropped:
34
- * only info/warn/error land on disk, the light stream regular users
35
- * keep. AGY_DEBUG=1 (or this option) restores the full trail. */
33
+ /** Verbose gate. When false (default), only error-level records land on
34
+ * disk; debug/info/warn are dropped so regular users write nothing
35
+ * routine. AGY_DEBUG=1 (or this option) restores the full trail. */
36
36
  debug?: boolean;
37
37
  /** Injectable clock for tests. */
38
38
  now?: () => Date;
@@ -166,9 +166,10 @@ export function createDailyLogger(opts: DailyLoggerOptions): DailyLogger {
166
166
 
167
167
  return {
168
168
  log(event, data, level = "debug") {
169
- // Volume gate: debug is the verbose tier. Default installs write
170
- // only info/warn/error so the disk cost stays negligible.
171
- if (level === "debug" && !verbose) return;
169
+ // Volume gate: default installs write only errors so routine disk
170
+ // traffic is zero for regular users; the extension toasts warns
171
+ // instead. AGY_DEBUG=1 restores the full trail.
172
+ if (!verbose && level !== "error") return;
172
173
  write(level, event, data);
173
174
  },
174
175
  flush() {
@@ -125,6 +125,10 @@ export interface DriverSnapshot {
125
125
  /** From the initialize handshake agentInfo block. */
126
126
  agentName?: string;
127
127
  agentTitle?: string;
128
+ /** Gate B watch: true once this server process sent usage/token fields
129
+ * in any session/update frame. /agy doctor surfaces it when true and
130
+ * stays silent otherwise. */
131
+ usageSeen: boolean;
128
132
  };
129
133
  }
130
134
 
package/src/mcp-server.ts CHANGED
@@ -256,6 +256,14 @@ export async function startMcpServer(
256
256
 
257
257
  const callHandler = async (request: { params: { name: string; arguments?: unknown } }, signal?: AbortSignal) => {
258
258
  const { name, arguments: args } = request.params;
259
+ // Progress probe: agy's MCP client killed long bridge calls at exactly
260
+ // ~180s (see ACP-PROTOCOL-REFERENCE). If its requests ever carry a
261
+ // progressToken, MCP progress notifications become a testable zero-UX
262
+ // fix for that deadline; log presence to find out.
263
+ const meta = (request.params as { _meta?: { progressToken?: unknown } })._meta;
264
+ if (meta && meta.progressToken !== undefined) {
265
+ log("progress-token", { name, token: String(meta.progressToken) });
266
+ }
259
267
  const callId = crypto.randomUUID();
260
268
  log("call-tool", { name, callId });
261
269
  try {
package/src/provider.ts CHANGED
@@ -114,12 +114,14 @@ export const SYSTEM_PROMPT_END = "[END SYSTEM PROMPT]";
114
114
 
115
115
  /** Brief tool-priority note appended inside every system prompt block: agy
116
116
  * runs embedded in pi, so its native interactive tools never reach the
117
- * user. Equivalent Pi Bridge tools must win. Concrete clash observed live:
118
- * agy picked its native ask_question over the bridge's ask_user_question
119
- * and the question never displayed. Rides the systemPrompt gate: the note
120
- * ships only when the system prompt ships. */
117
+ * user. Equivalent Pi Bridge tools must win. Concrete clashes observed
118
+ * live: agy picked its native ask_question over the bridge's
119
+ * ask_user_question and the question never displayed; on ACP its native
120
+ * view_file rejects real filesystem paths (artifact sandbox), so reads of
121
+ * the user's machine must go through the bridge. Rides the systemPrompt
122
+ * gate: the note ships only when the system prompt ships. */
121
123
  export const TOOL_PRIORITY_NOTE =
122
- "[Tool priority: this conversation runs inside pi, not as a standalone agy session; the user only sees what surfaces in pi. Native interactive tools, for example ask_question, never reach the user. When a Pi Bridge tool covers the same purpose, always use the Pi Bridge tool; for user questions use ask_user_question.]";
124
+ "[Tool priority: this conversation runs inside pi, not as a standalone agy session; the user only sees what surfaces in pi. Native interactive tools, for example ask_question, never reach the user. When a Pi Bridge tool covers the same purpose, always use the Pi Bridge tool; for user questions use ask_user_question. Your native file tools such as view_file only read brain artifacts and reject real filesystem paths; for any path on the user's machine use the Pi Bridge tools (read, ls, grep, find, edit, execute). Long-running bridge calls do not fail: after ~20 seconds the bridge answers STILL RUNNING with a callId; fetch the result with bridge_poll_result and poll until it lands. For work you already know is long, prefer exec_command's session-output pattern or background agents so you keep working while it runs.]";
123
125
 
124
126
  /** Assemble the full agy prompt: system prompt block, pi-side digest, user
125
127
  * prompt. Empty parts are dropped. Pure; exported for unit testing.
@@ -385,21 +387,138 @@ export function toAgyEffort(
385
387
  // agy continues its still-running turn. No pi patch, no privileged API.
386
388
 
387
389
  const BRIDGE_TIMEOUT_MS = 480_000;
390
+ /** Bounded memory of failed bridge parks (late-delivery tombstones). */
391
+ const MAX_PARK_TOMBSTONES = 64;
388
392
 
389
393
  export interface BridgeCallResultShape {
390
394
  content: Array<{ type: string; text?: string }>;
391
395
  isError: boolean;
392
396
  }
393
397
 
398
+ /** Early-ack sentinel: onToolCall settles with this when the pi tool is still
399
+ * running after escalateAfterMs (~20s). agy's MCP client abandons a
400
+ * tools/call HTTP request at ~180s (observed; see ACP-PROTOCOL-REFERENCE), so
401
+ * slow calls must not hold the request. The bridge answers with
402
+ * formatEscalatedAck and the real result arrives via bridge_poll_result (or,
403
+ * if agy never polls, the late-delivery path). */
404
+ export interface BridgeEscalation {
405
+ escalated: true;
406
+ callId: string;
407
+ name: string;
408
+ }
409
+
410
+ export const POLL_TOOL_NAME = "bridge_poll_result";
411
+
412
+ /** Default quiet period before a park escalates to a poll handle. Well under
413
+ * agy's ~180s request deadline; fast tools never see it. */
414
+ export const ESCALATE_AFTER_MS = 20_000;
415
+ /** Escalated parks carry a longer TTL: human-gated tools (commit previews,
416
+ * permission dialogs) legitimately block for many minutes. */
417
+ export const ESCALATED_TIMEOUT_MS = 1_800_000;
418
+
419
+ export interface PollView {
420
+ state: "running" | "done" | "failed";
421
+ name: string;
422
+ text?: string;
423
+ isError?: boolean;
424
+ reason?: string;
425
+ }
426
+
427
+ /** Escalated bridge calls. Bounded: past the cap, oldest settled entries
428
+ * evict first (a running call is never evicted while a newer one is). */
429
+ export class EscalationRegistry {
430
+ #calls = new Map<string, PollView>();
431
+ #trim(): void {
432
+ // Soft cap: only settled entries evict. Evicting a RUNNING call would
433
+ // strand its result (settle becomes a no-op, poll reports unknown), so
434
+ // saturating the cap with in-flight calls grows the map instead.
435
+ while (this.#calls.size > MAX_PARK_TOMBSTONES) {
436
+ const victim = [...this.#calls.entries()].find(([, e]) => e.state !== "running")?.[0];
437
+ if (victim === undefined) break;
438
+ this.#calls.delete(victim);
439
+ }
440
+ }
441
+ escalate(callId: string, name: string): void {
442
+ this.#calls.set(callId, { name, state: "running" });
443
+ this.#trim();
444
+ }
445
+ settleDone(callId: string, text: string, isError: boolean): void {
446
+ const e = this.#calls.get(callId);
447
+ if (!e) return;
448
+ e.state = "done";
449
+ e.text = text;
450
+ e.isError = isError;
451
+ this.#trim();
452
+ }
453
+ settleFailed(callId: string, reason: string): void {
454
+ const e = this.#calls.get(callId);
455
+ if (!e) return;
456
+ e.state = "failed";
457
+ e.reason = reason;
458
+ this.#trim();
459
+ }
460
+ poll(callId: string): PollView | undefined {
461
+ const e = this.#calls.get(callId);
462
+ return e ? { ...e } : undefined;
463
+ }
464
+ }
465
+
466
+ export function formatEscalatedAck(e: BridgeEscalation): BridgeCallResultShape {
467
+ return {
468
+ content: [
469
+ {
470
+ type: "text",
471
+ text: [
472
+ `STILL RUNNING: the pi tool "${e.name}" has not finished yet.`,
473
+ `Call ${POLL_TOOL_NAME} with callId "${e.callId}" to get the result. Poll again if it still reports running; you may do other work between polls.`,
474
+ "This is not an error and nothing is lost: if you stop polling, the bridge re-delivers the result in a later turn.",
475
+ ].join("\n"),
476
+ },
477
+ ],
478
+ isError: false,
479
+ };
480
+ }
481
+
482
+ export function formatPollAnswer(callId: string, view: PollView | undefined): BridgeCallResultShape {
483
+ if (!view) {
484
+ return {
485
+ content: [
486
+ {
487
+ type: "text",
488
+ text: `Error: no escalated bridge call "${callId}". It either finished within the first seconds (its result is in your original tool result) or the callId is wrong.`,
489
+ },
490
+ ],
491
+ isError: true,
492
+ };
493
+ }
494
+ if (view.state === "running") {
495
+ return {
496
+ content: [{ type: "text", text: `STILL RUNNING: "${view.name}" (callId ${callId}) has not finished. Poll again later.` }],
497
+ isError: false,
498
+ };
499
+ }
500
+ if (view.state === "failed") {
501
+ return {
502
+ content: [{ type: "text", text: `Error: bridge call "${view.name}" (callId ${callId}) failed: ${view.reason}` }],
503
+ isError: true,
504
+ };
505
+ }
506
+ return { content: [{ type: "text", text: view.text || "(no output)" }], isError: view.isError ?? false };
507
+ }
508
+
394
509
  interface PendingRoundTrip {
395
510
  /** "bridge": parked MCP HTTP call; resolve() completes it.
396
511
  * "rt": native re-exec / wrapper round-trip; pi already executed, the
397
512
  * toolResult only confirms continuation, nothing remote to settle. */
398
513
  kind: "bridge" | "rt";
399
514
  name: string;
400
- resolve?: (r: BridgeCallResultShape) => void;
515
+ resolve?: (r: BridgeCallResultShape | BridgeEscalation) => void;
401
516
  reject?: (e: Error) => void;
402
517
  timer?: NodeJS.Timeout;
518
+ /** Set when the early-ack fired: the HTTP request was answered with a poll
519
+ * handle, so the settling value must go to the registry, not the socket. */
520
+ escalated?: boolean;
521
+ escalateTimer?: NodeJS.Timeout;
403
522
  onAbort?: () => void;
404
523
  signal?: AbortSignal;
405
524
  }
@@ -430,23 +549,61 @@ export class WrapperReplay {
430
549
 
431
550
  export class ToolRoundTrips {
432
551
  #pending = new Map<string, PendingRoundTrip>();
552
+ /** Failed bridge parks: the pi tool keeps running and its toolResult will
553
+ * arrive with the park already gone. Bounded; consumed by the
554
+ * late-delivery path (see buildLateResultPrompt). */
555
+ #dead = new Map<string, { name: string; reason: string }>();
556
+ #escalations = new EscalationRegistry();
557
+ #escalateAfterMs: number;
433
558
  #getDriver: () => TurnDriver;
434
559
  #log: (s: string, d?: unknown) => void;
435
560
 
436
561
  /** Accepts a driver or a getter: with two engines wired, the ACTIVE driver
437
562
  * is resolved at call time from config (plan §9.5). */
438
- constructor(driver: TurnDriver | (() => TurnDriver), log?: (s: string, d?: unknown) => void) {
563
+ constructor(
564
+ driver: TurnDriver | (() => TurnDriver),
565
+ log?: (s: string, d?: unknown) => void,
566
+ opts: { escalateAfterMs?: number } = {},
567
+ ) {
439
568
  this.#getDriver = typeof driver === "function" ? driver : () => driver;
440
569
  this.#log = log ?? (() => {});
570
+ this.#escalateAfterMs = opts.escalateAfterMs ?? ESCALATE_AFTER_MS;
441
571
  }
442
572
 
443
573
  get pendingIds(): string[] {
444
574
  return [...this.#pending.keys()];
445
575
  }
446
576
 
447
- /** Fail all pending calls (driver recycle/shutdown path). */
577
+ /** Call ids whose park already failed (tombstones). */
578
+ get deadIds(): string[] {
579
+ return [...this.#dead.keys()];
580
+ }
581
+
582
+ /** Take and clear the tombstone for a failed park, if any. */
583
+ consumeDead(toolCallId: string): { name: string; reason: string } | undefined {
584
+ const dead = this.#dead.get(toolCallId);
585
+ if (!dead) return undefined;
586
+ this.#dead.delete(toolCallId);
587
+ return dead;
588
+ }
589
+
590
+ /** Poll view for an escalated call (undefined when the id never escalated:
591
+ * fast calls settle synchronously and need no handle). */
592
+ poll(callId: string): PollView | undefined {
593
+ return this.#escalations.poll(callId);
594
+ }
595
+
596
+ /** Fail all pending calls (driver recycle/shutdown path). Escalated bridge
597
+ * calls are skipped: their HTTP request was already answered with a poll
598
+ * handle, and the agy turn ending does NOT make the still-running pi tool
599
+ * a failure. They settle through resolve(), their own 30m timer, or an
600
+ * abort signal on the pi tool call. */
448
601
  failAll(reason: string): void {
449
- for (const id of [...this.#pending.keys()]) this.#fail(id, reason);
602
+ for (const id of [...this.#pending.keys()]) {
603
+ const entry = this.#pending.get(id);
604
+ if (entry?.kind === "bridge" && entry.escalated) continue;
605
+ this.#fail(id, reason);
606
+ }
450
607
  }
451
608
 
452
609
  #fail(callId: string, reason: string): void {
@@ -459,20 +616,33 @@ export class ToolRoundTrips {
459
616
  }
460
617
  this.#pending.delete(callId);
461
618
  clearTimeout(entry.timer);
619
+ if (entry.escalateTimer) clearTimeout(entry.escalateTimer);
462
620
  if (entry.onAbort && entry.signal) entry.signal.removeEventListener("abort", entry.onAbort);
621
+ this.#dead.set(callId, { name: entry.name, reason });
622
+ while (this.#dead.size > MAX_PARK_TOMBSTONES) {
623
+ const oldest = this.#dead.keys().next().value;
624
+ if (oldest === undefined) break;
625
+ this.#dead.delete(oldest);
626
+ }
627
+ if (entry.escalated) this.#escalations.settleFailed(callId, reason);
463
628
  entry.reject!(new Error(reason));
464
629
  this.#getDriver().kickIdle();
465
630
  this.#log("round-trip-fail", { callId, name: entry.name, reason });
466
631
  }
467
632
 
468
- /** Park the MCP call: inject into the live agy turn; the promise settles
469
- * when pi's toolResult lands (resolve) or fail-closed (timeout/abort). */
633
+ /** Park the MCP call: inject into the live agy turn. Fast calls settle
634
+ * with the real BridgeCallResultShape. Calls still running after
635
+ * escalateAfterMs settle with a BridgeEscalation sentinel instead: the
636
+ * bridge answers the HTTP request with a poll handle while pi keeps
637
+ * executing, so agy's ~180s request deadline is never hit. The real
638
+ * result reaches agy via bridge_poll_result, or via the late-delivery
639
+ * path if agy never polls. Fail-closed: timeout/abort still reject. */
470
640
  onToolCall = (
471
641
  callId: string,
472
642
  name: string,
473
643
  args: Record<string, unknown>,
474
644
  signal: AbortSignal,
475
- ): Promise<BridgeCallResultShape> => {
645
+ ): Promise<BridgeCallResultShape | BridgeEscalation> => {
476
646
  const handle = this.#getDriver().activeHandle;
477
647
  if (!handle) {
478
648
  return Promise.reject(
@@ -481,13 +651,32 @@ export class ToolRoundTrips {
481
651
  ),
482
652
  );
483
653
  }
484
- return new Promise<BridgeCallResultShape>((resolve, reject) => {
654
+ return new Promise<BridgeCallResultShape | BridgeEscalation>((resolve, reject) => {
485
655
  const timer = setTimeout(() => {
486
656
  this.#fail(callId, `pi tool round-trip timed out after ${BRIDGE_TIMEOUT_MS / 1000}s`);
487
657
  }, BRIDGE_TIMEOUT_MS);
488
658
  const onAbort = () => this.#fail(callId, "agy disconnected before the tool result arrived");
489
659
  signal.addEventListener("abort", onAbort, { once: true });
490
- this.#pending.set(callId, { kind: "bridge", name, resolve, reject, timer, onAbort, signal });
660
+ const entry: PendingRoundTrip = { kind: "bridge", name, resolve, reject, timer, onAbort, signal };
661
+ if (this.#escalateAfterMs > 0) {
662
+ entry.escalateTimer = setTimeout(() => {
663
+ const e = this.#pending.get(callId);
664
+ // Resolved (or failed) between arm and fire: nothing to escalate.
665
+ if (!e || e.kind !== "bridge") return;
666
+ e.escalated = true;
667
+ // Human-gated calls (commit previews, permission dialogs) can
668
+ // block far longer than the standard park TTL; re-arm generously.
669
+ if (e.timer) {
670
+ clearTimeout(e.timer);
671
+ e.timer = setTimeout(() => {
672
+ this.#fail(callId, `escalated bridge call timed out after ${ESCALATED_TIMEOUT_MS / 60_000} minutes`);
673
+ }, ESCALATED_TIMEOUT_MS);
674
+ }
675
+ this.#escalations.escalate(callId, e.name);
676
+ resolve({ escalated: true, callId, name: e.name });
677
+ }, this.#escalateAfterMs);
678
+ }
679
+ this.#pending.set(callId, entry);
491
680
  handle.pushExternal({ type: "bridge_call", callId, name, args });
492
681
  });
493
682
  };
@@ -505,12 +694,21 @@ export class ToolRoundTrips {
505
694
  if (!entry) return false;
506
695
  this.#pending.delete(toolCallId);
507
696
  clearTimeout(entry.timer);
697
+ if (entry.escalateTimer) clearTimeout(entry.escalateTimer);
508
698
  if (entry.onAbort && entry.signal) entry.signal.removeEventListener("abort", entry.onAbort);
509
699
  if (entry.kind === "rt") {
510
700
  this.#log("round-trip-rt-done", { callId: toolCallId, name: entry.name, isError });
511
701
  return true;
512
702
  }
513
- entry.resolve!({ content: [{ type: "text", text }], isError });
703
+ // Escalated call: the HTTP response already carried the poll handle, so
704
+ // the result lands in the registry for the next bridge_poll_result. The
705
+ // original promise settled with the sentinel; re-resolving is a silent
706
+ // no-op, so gate it to keep that explicit.
707
+ if (entry.escalated) {
708
+ this.#escalations.settleDone(toolCallId, text, isError);
709
+ } else {
710
+ entry.resolve!({ content: [{ type: "text", text }], isError });
711
+ }
514
712
  this.#getDriver().kickIdle();
515
713
  this.#log("round-trip-resolved", { callId: toolCallId, name: entry.name, isError });
516
714
  return true;
@@ -534,6 +732,29 @@ export function collectToolResults(
534
732
  return out;
535
733
  }
536
734
 
735
+ export interface LateToolResult {
736
+ name: string;
737
+ reason: string;
738
+ text: string;
739
+ isError: boolean;
740
+ }
741
+
742
+ /** Frame late tool results so agy treats them as the results its bridge calls
743
+ * never received (the round-trip died while the pi tool was still running,
744
+ * e.g. agy's ~180s MCP client timeout on tools/call). */
745
+ export function buildLateResultPrompt(late: LateToolResult[], userPrompt?: string): string {
746
+ const blocks = late.map((r) =>
747
+ [
748
+ `pi tool "${r.name}": the bridge round-trip expired before this result reached you (${r.reason}).`,
749
+ r.isError ? "The tool reported an error:" : "Result:",
750
+ r.text.trim() || "(no output)",
751
+ ].join("\n"),
752
+ );
753
+ const header = "Late tool delivery: treat the following as the results of your earlier tool calls.";
754
+ const body = [header, ...blocks].join("\n\n");
755
+ return userPrompt ? `${body}\n\n${userPrompt}` : body;
756
+ }
757
+
537
758
  // --- stream-json engine -------------------------------------------------------
538
759
 
539
760
  export interface DriverDeps {
@@ -739,12 +960,47 @@ async function runTurnDriver(
739
960
  // agy receives the result via the bridge's MCP HTTP response.
740
961
  const results = collectToolResults(context.messages, deps.roundTrips.pendingIds);
741
962
  const isContinuation = results.length > 0;
963
+ // Escalated calls answer through bridge_poll_result, not through an agy
964
+ // turn waiting on the park, so note them before resolving.
965
+ const escalatedNames = results
966
+ .map((r) => deps.roundTrips.poll(r.toolCallId)?.name)
967
+ .filter((n): n is string => Boolean(n));
742
968
  for (const r of results) deps.roundTrips.resolve(r.toolCallId, r.text, r.isError);
743
969
 
970
+ // Late delivery: a toolResult whose park already failed (the abort/timeout
971
+ // path failed the park while the pi tool kept running). The work is done,
972
+ // so re-route the result to agy as a new prompt in the same conversation
973
+ // instead of dropping it. Both drivers serialize run(), so delivery queues
974
+ // behind agy's own salvaged turn when one is still active.
975
+ // A pass that anchors a still-pending park (isContinuation) has nowhere to
976
+ // put a late result: it can neither ride the pending call's HTTP response
977
+ // nor start a new prompt. Leave the tombstone for the next fresh pass
978
+ // instead of consuming it blind.
979
+ const late: LateToolResult[] = [];
980
+ if (!isContinuation) {
981
+ for (const r of collectToolResults(context.messages, deps.roundTrips.deadIds)) {
982
+ const dead = deps.roundTrips.consumeDead(r.toolCallId);
983
+ if (dead) late.push({ name: dead.name, reason: dead.reason, text: r.text, isError: r.isError });
984
+ }
985
+ if (late.length > 0) {
986
+ deps.log?.("late-result", { tools: late.map((l) => l.name), freshConversation: !existing?.conversationId }, "info");
987
+ }
988
+ } else if (deps.roundTrips.deadIds.length > 0) {
989
+ deps.log?.("late-result-deferred", { count: deps.roundTrips.deadIds.length }, "info");
990
+ }
991
+
744
992
  let handle: TurnHandle;
745
993
  if (isContinuation) {
746
994
  const active = deps.driver.reentry();
747
995
  if (!active) {
996
+ // Escalated calls have no turn to re-enter BY DESIGN: agy already
997
+ // got the poll handle and the result lives in the registry. Settle
998
+ // quietly instead of erroring the turn.
999
+ if (escalatedNames.length > 0) {
1000
+ appendText(stream, blocks, `[bridge] ${escalatedNames.join(", ")} finished; the result is available via ${POLL_TOOL_NAME}.`);
1001
+ finalize(stream, blocks, "stop");
1002
+ return;
1003
+ }
748
1004
  deps.log?.("turn-error", { reason: "tool-result-no-active-turn" }, "warn");
749
1005
  finalize(stream, blocks, "error", "tool result arrived but no antigravity turn is running");
750
1006
  return;
@@ -754,8 +1010,9 @@ async function runTurnDriver(
754
1010
  const prompt = extractUserPrompt(context);
755
1011
  const images = extractImages(context);
756
1012
  // An image-only message (no text) is valid on the ACP engine; only fail
757
- // when there is nothing at all to send.
758
- if (!prompt && images.length === 0) {
1013
+ // when there is nothing at all to send (no text, no images, no late
1014
+ // tool results to deliver).
1015
+ if (!prompt && images.length === 0 && late.length === 0) {
759
1016
  deps.log?.("turn-error", { reason: "no-user-message" }, "debug");
760
1017
  finalize(stream, blocks, "error", "No user message to send to agy.");
761
1018
  return;
@@ -764,7 +1021,9 @@ async function runTurnDriver(
764
1021
  const agyModel = entry?.full ?? model.id;
765
1022
  const effort = entry?.efforts?.length ? toAgyEffort(options?.reasoning, entry.efforts) : undefined;
766
1023
  const watermark = existing?.lastMessageCount ?? 0;
767
- const digest = config.digest ? buildContextDigest(context.messages, watermark) : "";
1024
+ // Late turns re-open the conversation with a synthetic prompt; the digest
1025
+ // would re-send context agy already holds, so skip it.
1026
+ const digest = config.digest && late.length === 0 ? buildContextDigest(context.messages, watermark) : "";
768
1027
  // G1 delivery per engine. stream-json: digest rides inline in the prompt
769
1028
  // (the CLI has no context channel). ACP: the server advertises
770
1029
  // `embeddedContext`, so the digest ships as a native resource block
@@ -778,7 +1037,10 @@ async function runTurnDriver(
778
1037
  // re-sending it every turn would bloat each prompt and bust the cache.
779
1038
  const sysPrompt =
780
1039
  config.systemPrompt && !existing?.conversationId ? context.systemPrompt : undefined;
781
- const fullPrompt = buildFullPrompt(sysPrompt, embeddedDigest ? "" : digest, prompt ?? "");
1040
+ const fullPrompt =
1041
+ late.length > 0
1042
+ ? buildLateResultPrompt(late, prompt || undefined)
1043
+ : buildFullPrompt(sysPrompt, embeddedDigest ? "" : digest, prompt ?? "");
782
1044
  try {
783
1045
  handle = await deps.driver.run({
784
1046
  cwd,