@estebanforge/pi-antigravity-bridge 1.4.7 → 1.4.8

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,25 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.4.8] - 2026-09-05
6
+
7
+ ### Added
8
+
9
+ - 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.
10
+ - 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.
11
+ - 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.
12
+
13
+ ### Fixed
14
+
15
+ - 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.
16
+ - `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).
17
+ - `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.
18
+
19
+ ### Changed
20
+
21
+ - 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.
22
+ - 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.
23
+
5
24
  ## [1.4.7] - 2026-09-05
6
25
 
7
26
  ### Added
package/README.md CHANGED
@@ -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.
@@ -196,7 +198,7 @@ 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 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, escalations and poll traffic, late deliveries, 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.
200
202
 
201
203
  Notes:
202
204
 
@@ -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";
@@ -459,6 +466,19 @@ export default async function (pi: ExtensionAPI): Promise<void> {
459
466
  inputSchema: activateSkillSchema(skills) as object,
460
467
  });
461
468
  }
469
+ // Bridge-local, like activate_skill: answered from the escalation
470
+ // registry without a pi round-trip. Pairs with the STILL RUNNING
471
+ // early-ack that keeps slow calls under agy's ~180s request deadline.
472
+ tools.push({
473
+ name: POLL_TOOL_NAME,
474
+ description:
475
+ "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.",
476
+ inputSchema: {
477
+ type: "object",
478
+ properties: { callId: { type: "string", description: "callId from the STILL RUNNING answer" } },
479
+ required: ["callId"],
480
+ },
481
+ });
462
482
  return tools;
463
483
  };
464
484
  // activate_skill never round-trips through pi: the bridge answers it
@@ -469,7 +489,22 @@ export default async function (pi: ExtensionAPI): Promise<void> {
469
489
  args: Record<string, unknown>,
470
490
  signal: AbortSignal,
471
491
  ) => {
472
- if (name !== ACTIVATE_SKILL_TOOL_NAME) return roundTrips.onToolCall(callId, name, args, signal);
492
+ // Bridge-local, like activate_skill: answered from the escalation
493
+ // registry, never parked into pi.
494
+ if (name === POLL_TOOL_NAME) {
495
+ const wanted = typeof args.callId === "string" ? args.callId : "";
496
+ mcpLog("poll-tool", { callId: wanted });
497
+ return Promise.resolve(formatPollAnswer(wanted, roundTrips.poll(wanted)));
498
+ }
499
+ if (name !== ACTIVATE_SKILL_TOOL_NAME) {
500
+ return roundTrips.onToolCall(callId, name, args, signal).then((r) => {
501
+ // Early-ack: answer the HTTP request before agy's ~180s client
502
+ // deadline with a poll handle; pi keeps executing meanwhile.
503
+ if (!("escalated" in r)) return r;
504
+ mcpLog("call-tool-escalated", { name, callId: r.callId });
505
+ return formatEscalatedAck(r);
506
+ });
507
+ }
473
508
  const wanted = typeof args.name === "string" ? args.name : "";
474
509
  const skill = findSkillByName(skills, wanted);
475
510
  const body = skill ? readSkillBody(skill) : `unknown skill: ${wanted || "(none given)"}`;
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.8",
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",
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
@@ -119,7 +119,7 @@ export const SYSTEM_PROMPT_END = "[END SYSTEM PROMPT]";
119
119
  * and the question never displayed. Rides the systemPrompt gate: the note
120
120
  * ships only when the system prompt ships. */
121
121
  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.]";
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. 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
123
 
124
124
  /** Assemble the full agy prompt: system prompt block, pi-side digest, user
125
125
  * prompt. Empty parts are dropped. Pure; exported for unit testing.
@@ -385,21 +385,138 @@ export function toAgyEffort(
385
385
  // agy continues its still-running turn. No pi patch, no privileged API.
386
386
 
387
387
  const BRIDGE_TIMEOUT_MS = 480_000;
388
+ /** Bounded memory of failed bridge parks (late-delivery tombstones). */
389
+ const MAX_PARK_TOMBSTONES = 64;
388
390
 
389
391
  export interface BridgeCallResultShape {
390
392
  content: Array<{ type: string; text?: string }>;
391
393
  isError: boolean;
392
394
  }
393
395
 
396
+ /** Early-ack sentinel: onToolCall settles with this when the pi tool is still
397
+ * running after escalateAfterMs (~20s). agy's MCP client abandons a
398
+ * tools/call HTTP request at ~180s (observed; see ACP-PROTOCOL-REFERENCE), so
399
+ * slow calls must not hold the request. The bridge answers with
400
+ * formatEscalatedAck and the real result arrives via bridge_poll_result (or,
401
+ * if agy never polls, the late-delivery path). */
402
+ export interface BridgeEscalation {
403
+ escalated: true;
404
+ callId: string;
405
+ name: string;
406
+ }
407
+
408
+ export const POLL_TOOL_NAME = "bridge_poll_result";
409
+
410
+ /** Default quiet period before a park escalates to a poll handle. Well under
411
+ * agy's ~180s request deadline; fast tools never see it. */
412
+ export const ESCALATE_AFTER_MS = 20_000;
413
+ /** Escalated parks carry a longer TTL: human-gated tools (commit previews,
414
+ * permission dialogs) legitimately block for many minutes. */
415
+ export const ESCALATED_TIMEOUT_MS = 1_800_000;
416
+
417
+ export interface PollView {
418
+ state: "running" | "done" | "failed";
419
+ name: string;
420
+ text?: string;
421
+ isError?: boolean;
422
+ reason?: string;
423
+ }
424
+
425
+ /** Escalated bridge calls. Bounded: past the cap, oldest settled entries
426
+ * evict first (a running call is never evicted while a newer one is). */
427
+ export class EscalationRegistry {
428
+ #calls = new Map<string, PollView>();
429
+ #trim(): void {
430
+ // Soft cap: only settled entries evict. Evicting a RUNNING call would
431
+ // strand its result (settle becomes a no-op, poll reports unknown), so
432
+ // saturating the cap with in-flight calls grows the map instead.
433
+ while (this.#calls.size > MAX_PARK_TOMBSTONES) {
434
+ const victim = [...this.#calls.entries()].find(([, e]) => e.state !== "running")?.[0];
435
+ if (victim === undefined) break;
436
+ this.#calls.delete(victim);
437
+ }
438
+ }
439
+ escalate(callId: string, name: string): void {
440
+ this.#calls.set(callId, { name, state: "running" });
441
+ this.#trim();
442
+ }
443
+ settleDone(callId: string, text: string, isError: boolean): void {
444
+ const e = this.#calls.get(callId);
445
+ if (!e) return;
446
+ e.state = "done";
447
+ e.text = text;
448
+ e.isError = isError;
449
+ this.#trim();
450
+ }
451
+ settleFailed(callId: string, reason: string): void {
452
+ const e = this.#calls.get(callId);
453
+ if (!e) return;
454
+ e.state = "failed";
455
+ e.reason = reason;
456
+ this.#trim();
457
+ }
458
+ poll(callId: string): PollView | undefined {
459
+ const e = this.#calls.get(callId);
460
+ return e ? { ...e } : undefined;
461
+ }
462
+ }
463
+
464
+ export function formatEscalatedAck(e: BridgeEscalation): BridgeCallResultShape {
465
+ return {
466
+ content: [
467
+ {
468
+ type: "text",
469
+ text: [
470
+ `STILL RUNNING: the pi tool "${e.name}" has not finished yet.`,
471
+ `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.`,
472
+ "This is not an error and nothing is lost: if you stop polling, the bridge re-delivers the result in a later turn.",
473
+ ].join("\n"),
474
+ },
475
+ ],
476
+ isError: false,
477
+ };
478
+ }
479
+
480
+ export function formatPollAnswer(callId: string, view: PollView | undefined): BridgeCallResultShape {
481
+ if (!view) {
482
+ return {
483
+ content: [
484
+ {
485
+ type: "text",
486
+ 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.`,
487
+ },
488
+ ],
489
+ isError: true,
490
+ };
491
+ }
492
+ if (view.state === "running") {
493
+ return {
494
+ content: [{ type: "text", text: `STILL RUNNING: "${view.name}" (callId ${callId}) has not finished. Poll again later.` }],
495
+ isError: false,
496
+ };
497
+ }
498
+ if (view.state === "failed") {
499
+ return {
500
+ content: [{ type: "text", text: `Error: bridge call "${view.name}" (callId ${callId}) failed: ${view.reason}` }],
501
+ isError: true,
502
+ };
503
+ }
504
+ return { content: [{ type: "text", text: view.text || "(no output)" }], isError: view.isError ?? false };
505
+ }
506
+
394
507
  interface PendingRoundTrip {
395
508
  /** "bridge": parked MCP HTTP call; resolve() completes it.
396
509
  * "rt": native re-exec / wrapper round-trip; pi already executed, the
397
510
  * toolResult only confirms continuation, nothing remote to settle. */
398
511
  kind: "bridge" | "rt";
399
512
  name: string;
400
- resolve?: (r: BridgeCallResultShape) => void;
513
+ resolve?: (r: BridgeCallResultShape | BridgeEscalation) => void;
401
514
  reject?: (e: Error) => void;
402
515
  timer?: NodeJS.Timeout;
516
+ /** Set when the early-ack fired: the HTTP request was answered with a poll
517
+ * handle, so the settling value must go to the registry, not the socket. */
518
+ escalated?: boolean;
519
+ escalateTimer?: NodeJS.Timeout;
403
520
  onAbort?: () => void;
404
521
  signal?: AbortSignal;
405
522
  }
@@ -430,23 +547,61 @@ export class WrapperReplay {
430
547
 
431
548
  export class ToolRoundTrips {
432
549
  #pending = new Map<string, PendingRoundTrip>();
550
+ /** Failed bridge parks: the pi tool keeps running and its toolResult will
551
+ * arrive with the park already gone. Bounded; consumed by the
552
+ * late-delivery path (see buildLateResultPrompt). */
553
+ #dead = new Map<string, { name: string; reason: string }>();
554
+ #escalations = new EscalationRegistry();
555
+ #escalateAfterMs: number;
433
556
  #getDriver: () => TurnDriver;
434
557
  #log: (s: string, d?: unknown) => void;
435
558
 
436
559
  /** Accepts a driver or a getter: with two engines wired, the ACTIVE driver
437
560
  * is resolved at call time from config (plan §9.5). */
438
- constructor(driver: TurnDriver | (() => TurnDriver), log?: (s: string, d?: unknown) => void) {
561
+ constructor(
562
+ driver: TurnDriver | (() => TurnDriver),
563
+ log?: (s: string, d?: unknown) => void,
564
+ opts: { escalateAfterMs?: number } = {},
565
+ ) {
439
566
  this.#getDriver = typeof driver === "function" ? driver : () => driver;
440
567
  this.#log = log ?? (() => {});
568
+ this.#escalateAfterMs = opts.escalateAfterMs ?? ESCALATE_AFTER_MS;
441
569
  }
442
570
 
443
571
  get pendingIds(): string[] {
444
572
  return [...this.#pending.keys()];
445
573
  }
446
574
 
447
- /** Fail all pending calls (driver recycle/shutdown path). */
575
+ /** Call ids whose park already failed (tombstones). */
576
+ get deadIds(): string[] {
577
+ return [...this.#dead.keys()];
578
+ }
579
+
580
+ /** Take and clear the tombstone for a failed park, if any. */
581
+ consumeDead(toolCallId: string): { name: string; reason: string } | undefined {
582
+ const dead = this.#dead.get(toolCallId);
583
+ if (!dead) return undefined;
584
+ this.#dead.delete(toolCallId);
585
+ return dead;
586
+ }
587
+
588
+ /** Poll view for an escalated call (undefined when the id never escalated:
589
+ * fast calls settle synchronously and need no handle). */
590
+ poll(callId: string): PollView | undefined {
591
+ return this.#escalations.poll(callId);
592
+ }
593
+
594
+ /** Fail all pending calls (driver recycle/shutdown path). Escalated bridge
595
+ * calls are skipped: their HTTP request was already answered with a poll
596
+ * handle, and the agy turn ending does NOT make the still-running pi tool
597
+ * a failure. They settle through resolve(), their own 30m timer, or an
598
+ * abort signal on the pi tool call. */
448
599
  failAll(reason: string): void {
449
- for (const id of [...this.#pending.keys()]) this.#fail(id, reason);
600
+ for (const id of [...this.#pending.keys()]) {
601
+ const entry = this.#pending.get(id);
602
+ if (entry?.kind === "bridge" && entry.escalated) continue;
603
+ this.#fail(id, reason);
604
+ }
450
605
  }
451
606
 
452
607
  #fail(callId: string, reason: string): void {
@@ -459,20 +614,33 @@ export class ToolRoundTrips {
459
614
  }
460
615
  this.#pending.delete(callId);
461
616
  clearTimeout(entry.timer);
617
+ if (entry.escalateTimer) clearTimeout(entry.escalateTimer);
462
618
  if (entry.onAbort && entry.signal) entry.signal.removeEventListener("abort", entry.onAbort);
619
+ this.#dead.set(callId, { name: entry.name, reason });
620
+ while (this.#dead.size > MAX_PARK_TOMBSTONES) {
621
+ const oldest = this.#dead.keys().next().value;
622
+ if (oldest === undefined) break;
623
+ this.#dead.delete(oldest);
624
+ }
625
+ if (entry.escalated) this.#escalations.settleFailed(callId, reason);
463
626
  entry.reject!(new Error(reason));
464
627
  this.#getDriver().kickIdle();
465
628
  this.#log("round-trip-fail", { callId, name: entry.name, reason });
466
629
  }
467
630
 
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). */
631
+ /** Park the MCP call: inject into the live agy turn. Fast calls settle
632
+ * with the real BridgeCallResultShape. Calls still running after
633
+ * escalateAfterMs settle with a BridgeEscalation sentinel instead: the
634
+ * bridge answers the HTTP request with a poll handle while pi keeps
635
+ * executing, so agy's ~180s request deadline is never hit. The real
636
+ * result reaches agy via bridge_poll_result, or via the late-delivery
637
+ * path if agy never polls. Fail-closed: timeout/abort still reject. */
470
638
  onToolCall = (
471
639
  callId: string,
472
640
  name: string,
473
641
  args: Record<string, unknown>,
474
642
  signal: AbortSignal,
475
- ): Promise<BridgeCallResultShape> => {
643
+ ): Promise<BridgeCallResultShape | BridgeEscalation> => {
476
644
  const handle = this.#getDriver().activeHandle;
477
645
  if (!handle) {
478
646
  return Promise.reject(
@@ -481,13 +649,32 @@ export class ToolRoundTrips {
481
649
  ),
482
650
  );
483
651
  }
484
- return new Promise<BridgeCallResultShape>((resolve, reject) => {
652
+ return new Promise<BridgeCallResultShape | BridgeEscalation>((resolve, reject) => {
485
653
  const timer = setTimeout(() => {
486
654
  this.#fail(callId, `pi tool round-trip timed out after ${BRIDGE_TIMEOUT_MS / 1000}s`);
487
655
  }, BRIDGE_TIMEOUT_MS);
488
656
  const onAbort = () => this.#fail(callId, "agy disconnected before the tool result arrived");
489
657
  signal.addEventListener("abort", onAbort, { once: true });
490
- this.#pending.set(callId, { kind: "bridge", name, resolve, reject, timer, onAbort, signal });
658
+ const entry: PendingRoundTrip = { kind: "bridge", name, resolve, reject, timer, onAbort, signal };
659
+ if (this.#escalateAfterMs > 0) {
660
+ entry.escalateTimer = setTimeout(() => {
661
+ const e = this.#pending.get(callId);
662
+ // Resolved (or failed) between arm and fire: nothing to escalate.
663
+ if (!e || e.kind !== "bridge") return;
664
+ e.escalated = true;
665
+ // Human-gated calls (commit previews, permission dialogs) can
666
+ // block far longer than the standard park TTL; re-arm generously.
667
+ if (e.timer) {
668
+ clearTimeout(e.timer);
669
+ e.timer = setTimeout(() => {
670
+ this.#fail(callId, `escalated bridge call timed out after ${ESCALATED_TIMEOUT_MS / 60_000} minutes`);
671
+ }, ESCALATED_TIMEOUT_MS);
672
+ }
673
+ this.#escalations.escalate(callId, e.name);
674
+ resolve({ escalated: true, callId, name: e.name });
675
+ }, this.#escalateAfterMs);
676
+ }
677
+ this.#pending.set(callId, entry);
491
678
  handle.pushExternal({ type: "bridge_call", callId, name, args });
492
679
  });
493
680
  };
@@ -505,12 +692,21 @@ export class ToolRoundTrips {
505
692
  if (!entry) return false;
506
693
  this.#pending.delete(toolCallId);
507
694
  clearTimeout(entry.timer);
695
+ if (entry.escalateTimer) clearTimeout(entry.escalateTimer);
508
696
  if (entry.onAbort && entry.signal) entry.signal.removeEventListener("abort", entry.onAbort);
509
697
  if (entry.kind === "rt") {
510
698
  this.#log("round-trip-rt-done", { callId: toolCallId, name: entry.name, isError });
511
699
  return true;
512
700
  }
513
- entry.resolve!({ content: [{ type: "text", text }], isError });
701
+ // Escalated call: the HTTP response already carried the poll handle, so
702
+ // the result lands in the registry for the next bridge_poll_result. The
703
+ // original promise settled with the sentinel; re-resolving is a silent
704
+ // no-op, so gate it to keep that explicit.
705
+ if (entry.escalated) {
706
+ this.#escalations.settleDone(toolCallId, text, isError);
707
+ } else {
708
+ entry.resolve!({ content: [{ type: "text", text }], isError });
709
+ }
514
710
  this.#getDriver().kickIdle();
515
711
  this.#log("round-trip-resolved", { callId: toolCallId, name: entry.name, isError });
516
712
  return true;
@@ -534,6 +730,29 @@ export function collectToolResults(
534
730
  return out;
535
731
  }
536
732
 
733
+ export interface LateToolResult {
734
+ name: string;
735
+ reason: string;
736
+ text: string;
737
+ isError: boolean;
738
+ }
739
+
740
+ /** Frame late tool results so agy treats them as the results its bridge calls
741
+ * never received (the round-trip died while the pi tool was still running,
742
+ * e.g. agy's ~180s MCP client timeout on tools/call). */
743
+ export function buildLateResultPrompt(late: LateToolResult[], userPrompt?: string): string {
744
+ const blocks = late.map((r) =>
745
+ [
746
+ `pi tool "${r.name}": the bridge round-trip expired before this result reached you (${r.reason}).`,
747
+ r.isError ? "The tool reported an error:" : "Result:",
748
+ r.text.trim() || "(no output)",
749
+ ].join("\n"),
750
+ );
751
+ const header = "Late tool delivery: treat the following as the results of your earlier tool calls.";
752
+ const body = [header, ...blocks].join("\n\n");
753
+ return userPrompt ? `${body}\n\n${userPrompt}` : body;
754
+ }
755
+
537
756
  // --- stream-json engine -------------------------------------------------------
538
757
 
539
758
  export interface DriverDeps {
@@ -739,12 +958,47 @@ async function runTurnDriver(
739
958
  // agy receives the result via the bridge's MCP HTTP response.
740
959
  const results = collectToolResults(context.messages, deps.roundTrips.pendingIds);
741
960
  const isContinuation = results.length > 0;
961
+ // Escalated calls answer through bridge_poll_result, not through an agy
962
+ // turn waiting on the park, so note them before resolving.
963
+ const escalatedNames = results
964
+ .map((r) => deps.roundTrips.poll(r.toolCallId)?.name)
965
+ .filter((n): n is string => Boolean(n));
742
966
  for (const r of results) deps.roundTrips.resolve(r.toolCallId, r.text, r.isError);
743
967
 
968
+ // Late delivery: a toolResult whose park already failed (the abort/timeout
969
+ // path failed the park while the pi tool kept running). The work is done,
970
+ // so re-route the result to agy as a new prompt in the same conversation
971
+ // instead of dropping it. Both drivers serialize run(), so delivery queues
972
+ // behind agy's own salvaged turn when one is still active.
973
+ // A pass that anchors a still-pending park (isContinuation) has nowhere to
974
+ // put a late result: it can neither ride the pending call's HTTP response
975
+ // nor start a new prompt. Leave the tombstone for the next fresh pass
976
+ // instead of consuming it blind.
977
+ const late: LateToolResult[] = [];
978
+ if (!isContinuation) {
979
+ for (const r of collectToolResults(context.messages, deps.roundTrips.deadIds)) {
980
+ const dead = deps.roundTrips.consumeDead(r.toolCallId);
981
+ if (dead) late.push({ name: dead.name, reason: dead.reason, text: r.text, isError: r.isError });
982
+ }
983
+ if (late.length > 0) {
984
+ deps.log?.("late-result", { tools: late.map((l) => l.name), freshConversation: !existing?.conversationId }, "info");
985
+ }
986
+ } else if (deps.roundTrips.deadIds.length > 0) {
987
+ deps.log?.("late-result-deferred", { count: deps.roundTrips.deadIds.length }, "info");
988
+ }
989
+
744
990
  let handle: TurnHandle;
745
991
  if (isContinuation) {
746
992
  const active = deps.driver.reentry();
747
993
  if (!active) {
994
+ // Escalated calls have no turn to re-enter BY DESIGN: agy already
995
+ // got the poll handle and the result lives in the registry. Settle
996
+ // quietly instead of erroring the turn.
997
+ if (escalatedNames.length > 0) {
998
+ appendText(stream, blocks, `[bridge] ${escalatedNames.join(", ")} finished; the result is available via ${POLL_TOOL_NAME}.`);
999
+ finalize(stream, blocks, "stop");
1000
+ return;
1001
+ }
748
1002
  deps.log?.("turn-error", { reason: "tool-result-no-active-turn" }, "warn");
749
1003
  finalize(stream, blocks, "error", "tool result arrived but no antigravity turn is running");
750
1004
  return;
@@ -754,8 +1008,9 @@ async function runTurnDriver(
754
1008
  const prompt = extractUserPrompt(context);
755
1009
  const images = extractImages(context);
756
1010
  // 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) {
1011
+ // when there is nothing at all to send (no text, no images, no late
1012
+ // tool results to deliver).
1013
+ if (!prompt && images.length === 0 && late.length === 0) {
759
1014
  deps.log?.("turn-error", { reason: "no-user-message" }, "debug");
760
1015
  finalize(stream, blocks, "error", "No user message to send to agy.");
761
1016
  return;
@@ -764,7 +1019,9 @@ async function runTurnDriver(
764
1019
  const agyModel = entry?.full ?? model.id;
765
1020
  const effort = entry?.efforts?.length ? toAgyEffort(options?.reasoning, entry.efforts) : undefined;
766
1021
  const watermark = existing?.lastMessageCount ?? 0;
767
- const digest = config.digest ? buildContextDigest(context.messages, watermark) : "";
1022
+ // Late turns re-open the conversation with a synthetic prompt; the digest
1023
+ // would re-send context agy already holds, so skip it.
1024
+ const digest = config.digest && late.length === 0 ? buildContextDigest(context.messages, watermark) : "";
768
1025
  // G1 delivery per engine. stream-json: digest rides inline in the prompt
769
1026
  // (the CLI has no context channel). ACP: the server advertises
770
1027
  // `embeddedContext`, so the digest ships as a native resource block
@@ -778,7 +1035,10 @@ async function runTurnDriver(
778
1035
  // re-sending it every turn would bloat each prompt and bust the cache.
779
1036
  const sysPrompt =
780
1037
  config.systemPrompt && !existing?.conversationId ? context.systemPrompt : undefined;
781
- const fullPrompt = buildFullPrompt(sysPrompt, embeddedDigest ? "" : digest, prompt ?? "");
1038
+ const fullPrompt =
1039
+ late.length > 0
1040
+ ? buildLateResultPrompt(late, prompt || undefined)
1041
+ : buildFullPrompt(sysPrompt, embeddedDigest ? "" : digest, prompt ?? "");
782
1042
  try {
783
1043
  handle = await deps.driver.run({
784
1044
  cwd,