@estebanforge/pi-antigravity-bridge 1.7.11 → 1.7.13

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,20 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [Unreleased]
6
+
7
+ ## [1.7.13] - 2026-10-12
8
+
9
+ ### Changed
10
+
11
+ - The `AskAntigravity` tool's default `timeoutMinutes` is now 30 (was 10). Delegations carry feedback, research, review, and analysis work that legitimately runs long; a short default kills the run mid-task and the caller re-pays the whole prompt at a higher timeout. Callers still override per call, lower or higher. The provider's separate `turnTimeoutMin` cap is unchanged.
12
+
13
+ ## [1.7.12] - 2026-10-12
14
+
15
+ ### Fixed
16
+
17
+ - **Unpolled escalated results are re-delivered instead of stranding.** Delivery of an escalated bridge call depends on the model polling `bridge_poll_result`, and the model sometimes ends its turn after the `STILL RUNNING` ack instead — observed live with `ask_user_question`: the user's answer settled into the escalation registry, the bridge printed a human-only `[bridge] ... available via bridge_poll_result` note, and the conversation dead-ended (`git_commit` worked only because the model happened to poll). The ack itself licensed stopping with a promise the bridge did not implement ("if you stop polling, the bridge re-delivers the result in a later turn"). Three changes close it: the ack now forbids ending the turn while a call is unresolved; the escalation registry tracks delivery (`markDelivered` when a poll returns a terminal answer, `stampConversation` at turn end so first-turn parks — which return before the provider's `store.set` — still know their conversation, first stamp wins) and exposes a single-use registry-wide sweep for done-but-undelivered results; and the provider runs that sweep at every turn boundary — the toolResult pass when the turn is dead, after a live turn's outcome, and any later pass, so a strand cannot outlive the next one — re-delivering as a late prompt in the stamped conversation through the existing `buildLateResultPrompt` path. A result whose conversation is unknowable settles quietly and stays pollable instead of prompting a brand-new conversation, and a delivery whose `driver.run` fails to start releases its claim for the next pass. Claims are single-use and entries stay pollable, so a compliant model sees no change; late delivery stays text-only on this path (image blocks are dropped, same as dead-park late delivery).
18
+
5
19
  ## [1.7.11] - 2026-10-10
6
20
 
7
21
  ### Added
@@ -434,6 +434,10 @@ export default async function (pi: ExtensionAPI): Promise<void> {
434
434
  outcome.status === "OK" ? "info" : "warn",
435
435
  );
436
436
  roundTrips.failAll(FAIL_REASON_TURN_END);
437
+ // Stamp live escalations with the ended turn's conversation: the park
438
+ // pass returns before the provider's store.set, so a first-turn park
439
+ // has no other record of where its result must be re-delivered.
440
+ if (outcome.conversationId) roundTrips.stampConversation(outcome.conversationId);
437
441
  };
438
442
  streamDriver.onTurnEnd = onTurnEnd;
439
443
  acpDriver.onTurnEnd = onTurnEnd;
@@ -817,7 +821,15 @@ export default async function (pi: ExtensionAPI): Promise<void> {
817
821
  if (name === POLL_TOOL_NAME) {
818
822
  const wanted = typeof args.callId === "string" ? args.callId : "";
819
823
  mcpLog("poll-tool", { callId: wanted });
820
- return Promise.resolve(formatPollAnswer(wanted, roundTrips.poll(wanted)));
824
+ const view = roundTrips.poll(wanted);
825
+ // A terminal answer means the model RECEIVED the result; mark it
826
+ // so the redelivery path never double-delivers. poll() itself
827
+ // stays pure (internal peeks must not mark). Accepted gap: the
828
+ // mark fires when the poll ANSWERS, not when the HTTP response
829
+ // lands; a drop inside that one-write window would mark a result
830
+ // the model never saw.
831
+ if (view && view.state !== "running") roundTrips.markDelivered(wanted);
832
+ return Promise.resolve(formatPollAnswer(wanted, view));
821
833
  }
822
834
  if (name !== ACTIVATE_SKILL_TOOL_NAME) {
823
835
  // A cached MCP catalog is not authorization. An inactive/hidden tool
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estebanforge/pi-antigravity-bridge",
3
- "version": "1.7.11",
3
+ "version": "1.7.13",
4
4
  "description": "Gemini provider for Pi on the Antigravity ACP server (official Google ACP) or the stream-json agy CLI. Use antigravity/* models in Pi. Full /model picker support. Models runs Pi's tools. ToS safe to use.",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/ask-tool.ts CHANGED
@@ -61,7 +61,9 @@ THINKING LEVEL (params: thinking, effort - SYNONYMS for one knob):
61
61
  - Values (pi vocabulary): minimal|low|medium|high|xhigh|max. Clamped to agy's low|medium|high; unknown values fall back to low. "peer review on high thinking" -> thinking: "high".
62
62
  - An explicit level beats a tier embedded in model ("flash high") and the configured default. Omit both for the configured default.`;
63
63
 
64
- const DEFAULT_TIMEOUT_MIN = 10;
64
+ // Delegations legitimately run long (research, review, analysis); a short
65
+ // default kills the run mid-task and the caller re-pays the whole prompt.
66
+ const DEFAULT_TIMEOUT_MIN = 30;
65
67
  const GRACE_AFTER_TIMEOUT_MS = 5000;
66
68
  const STATUS_INTERVAL_MS = 1000;
67
69
  const STATUS_TAIL_CHARS = 160;
package/src/provider.ts CHANGED
@@ -433,6 +433,14 @@ export interface PollView {
433
433
  isError?: boolean;
434
434
  reason?: string;
435
435
  images?: Array<{ data: string; mimeType: string }>;
436
+ /** A terminal result the model already received via a poll. Set only by
437
+ * markDelivered (the real poll path); poll() stays pure so internal
438
+ * peeks (escalatedNames) never mark. */
439
+ delivered?: boolean;
440
+ /** The agy conversation this call belongs to. Stamped at turn end so an
441
+ * unpolled result can be re-delivered to the right conversation
442
+ * (first-turn parks never reach the provider's store.set). */
443
+ conversationId?: string;
436
444
  }
437
445
 
438
446
  /** Escalated bridge calls. Bounded: past the cap, oldest settled entries
@@ -443,8 +451,11 @@ export class EscalationRegistry {
443
451
  // Soft cap: only settled entries evict. Evicting a RUNNING call would
444
452
  // strand its result (settle becomes a no-op, poll reports unknown), so
445
453
  // saturating the cap with in-flight calls grows the map instead.
454
+ // Delivered entries evict first: an undelivered result still has a
455
+ // recovery path (redelivery), a delivered one only serves late polls.
446
456
  while (this.#calls.size > MAX_PARK_TOMBSTONES) {
447
- const victim = [...this.#calls.entries()].find(([, e]) => e.state !== "running")?.[0];
457
+ const settled = [...this.#calls.entries()].filter(([, e]) => e.state !== "running");
458
+ const victim = (settled.find(([, e]) => e.delivered) ?? settled[0])?.[0];
448
459
  if (victim === undefined) break;
449
460
  this.#calls.delete(victim);
450
461
  }
@@ -473,6 +484,63 @@ export class EscalationRegistry {
473
484
  const e = this.#calls.get(callId);
474
485
  return e ? { ...e } : undefined;
475
486
  }
487
+
488
+ /** Record that the model received this terminal result (the real poll
489
+ * path calls this when the view is not running). Running entries and
490
+ * unknown ids are a no-op. */
491
+ markDelivered(callId: string): boolean {
492
+ const e = this.#calls.get(callId);
493
+ if (!e || e.state === "running") return false;
494
+ e.delivered = true;
495
+ return true;
496
+ }
497
+
498
+ /** Single-use claim of a done-but-undelivered result: returns the view
499
+ * and marks it delivered in one step, so a later pass can never deliver
500
+ * it twice. The entry stays pollable for a late model poll. Failed
501
+ * escalations are excluded: their pending entry is gone, so the
502
+ * tombstone path is their single delivery channel. */
503
+ claimUndelivered(callId: string): (PollView & { callId: string }) | undefined {
504
+ const e = this.#calls.get(callId);
505
+ if (!e || e.state !== "done" || e.delivered) return undefined;
506
+ e.delivered = true;
507
+ return { ...e, callId };
508
+ }
509
+
510
+ /** Registry-wide sweep: claim every done-but-undelivered result. A
511
+ * toolResult is consumed by its own pass (resolve deletes the pending
512
+ * entry), so a strand that survives its first pass can only be found
513
+ * here, not in a later pass's results. Failed escalations stay with the
514
+ * tombstone path. */
515
+ claimAllUndelivered(): Array<PollView & { callId: string }> {
516
+ const out: Array<PollView & { callId: string }> = [];
517
+ for (const [callId, e] of this.#calls) {
518
+ const claimed = e.state === "done" && !e.delivered ? { ...e, callId } : undefined;
519
+ if (claimed) {
520
+ e.delivered = true;
521
+ out.push(claimed);
522
+ }
523
+ }
524
+ return out;
525
+ }
526
+
527
+ /** Release a claim whose delivery failed to start (driver.run threw): the
528
+ * result returns to the undelivered pool for the next recovery pass. */
529
+ unclaim(callId: string): void {
530
+ const e = this.#calls.get(callId);
531
+ if (e && e.state === "done") e.delivered = false;
532
+ }
533
+
534
+ /** Stamp every running escalation with the ended turn's conversation id
535
+ * (extensions fire this from onTurnEnd). First stamp wins: the ended
536
+ * turn is the conversation the call was made in; a later turn-end (the
537
+ * call can outlive several turns while a human answers) must not
538
+ * re-route the delivery. Entries that escalate later stay unstamped. */
539
+ stampConversation(conversationId: string): void {
540
+ for (const e of this.#calls.values()) {
541
+ if (e.state === "running") e.conversationId ??= conversationId;
542
+ }
543
+ }
476
544
  }
477
545
 
478
546
  export function formatEscalatedAck(e: BridgeEscalation): BridgeCallResultShape {
@@ -483,7 +551,7 @@ export function formatEscalatedAck(e: BridgeEscalation): BridgeCallResultShape {
483
551
  text: [
484
552
  `STILL RUNNING: the pi tool "${e.name}" has not finished yet.`,
485
553
  `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.`,
486
- "This is not an error and nothing is lost: if you stop polling, the bridge re-delivers the result in a later turn.",
554
+ "Do NOT end your turn while this call is unresolved: keep polling until it reports done or failed. Ending the turn strands the result.",
487
555
  ].join("\n"),
488
556
  },
489
557
  ],
@@ -621,6 +689,36 @@ export class ToolRoundTrips {
621
689
  return this.#escalations.poll(callId);
622
690
  }
623
691
 
692
+ /** Mark a terminal escalated result as received by the model. Only the
693
+ * real poll path (extensions) calls this; poll() stays pure. */
694
+ markDelivered(callId: string): boolean {
695
+ return this.#escalations.markDelivered(callId);
696
+ }
697
+
698
+ /** Single-use claim of a settled-but-undelivered escalated result (the
699
+ * model ended its turn instead of polling). */
700
+ claimUndelivered(callId: string): (PollView & { callId: string }) | undefined {
701
+ return this.#escalations.claimUndelivered(callId);
702
+ }
703
+
704
+ /** Registry-wide sweep for done-but-undelivered results: a toolResult is
705
+ * consumed by its own pass, so multi-pass strands are only findable
706
+ * here. */
707
+ claimAllUndelivered(): Array<PollView & { callId: string }> {
708
+ return this.#escalations.claimAllUndelivered();
709
+ }
710
+
711
+ /** Release a claim whose delivery failed to start. */
712
+ unclaim(callId: string): void {
713
+ this.#escalations.unclaim(callId);
714
+ }
715
+
716
+ /** Stamp running escalations with the ended turn's conversation id so an
717
+ * unpolled result can be re-delivered to the right conversation. */
718
+ stampConversation(conversationId: string): void {
719
+ this.#escalations.stampConversation(conversationId);
720
+ }
721
+
624
722
  /** Wire the approval park (mcp-server handle.approvals). The extension
625
723
  * assigns this AFTER the shadow tools are registered; see onApproval. */
626
724
  set approvalPark(api: ApprovalParkApi | undefined) {
@@ -902,6 +1000,9 @@ export function collectToolResults(
902
1000
  }
903
1001
 
904
1002
  export interface LateToolResult {
1003
+ /** Escalated results only: the registry callId, so a failed delivery can
1004
+ * unclaim it. Tombstone-borne results have none (already consumed). */
1005
+ callId?: string;
905
1006
  name: string;
906
1007
  reason: string;
907
1008
  text: string;
@@ -909,12 +1010,12 @@ export interface LateToolResult {
909
1010
  }
910
1011
 
911
1012
  /** Frame late tool results so agy treats them as the results its bridge calls
912
- * never received (the round-trip died while the pi tool was still running,
913
- * e.g. agy's ~180s MCP client timeout on tools/call). */
1013
+ * never received: a park whose round-trip died while the pi tool ran, or an
1014
+ * escalated result the model never polled. The reason carries the framing. */
914
1015
  export function buildLateResultPrompt(late: LateToolResult[], userPrompt?: string): string {
915
1016
  const blocks = late.map((r) =>
916
1017
  [
917
- `pi tool "${r.name}": the bridge round-trip expired before this result reached you (${r.reason}).`,
1018
+ `pi tool "${r.name}": ${r.reason}.`,
918
1019
  r.isError ? "The tool reported an error:" : "Result:",
919
1020
  r.text.trim() || "(no output)",
920
1021
  ].join("\n"),
@@ -1194,28 +1295,67 @@ async function runTurnDriver(
1194
1295
  deps.log?.("late-result-deferred", { count: deps.roundTrips.deadIds.length }, "info");
1195
1296
  }
1196
1297
 
1197
- let handle: TurnHandle;
1298
+ // Stranded escalated results: done but never delivered — the model ended
1299
+ // its turn instead of polling bridge_poll_result (observed live 2026-10:
1300
+ // the model stopped after the STILL RUNNING ack on an ask_user_question
1301
+ // and the user's answer stranded in the registry). The sweep is
1302
+ // registry-wide: a toolResult is consumed by its own pass, so a strand
1303
+ // that survives its first pass is only findable here. Claims are
1304
+ // single-use; entries stay pollable for a late model poll.
1305
+ let redeliveryConversation: string | undefined;
1306
+ const collectStranded = (): LateToolResult[] =>
1307
+ deps.roundTrips.claimAllUndelivered().map((view) => {
1308
+ redeliveryConversation ??= view.conversationId;
1309
+ deps.log?.("escalated-redelivery", { tool: view.name, callId: view.callId }, "info");
1310
+ return {
1311
+ callId: view.callId,
1312
+ name: view.name,
1313
+ reason: "you never received this result: you ended your turn without calling bridge_poll_result",
1314
+ text: view.text ?? "(no output)",
1315
+ isError: view.isError ?? false,
1316
+ };
1317
+ });
1318
+
1319
+ const entry = entries.find((e) => e.id === model.id) ?? null;
1320
+ const agyModel = entry?.full ?? model.id;
1321
+ const effort = entry?.efforts?.length ? toAgyEffort(options?.reasoning, entry.efforts) : undefined;
1322
+
1323
+ let handle: TurnHandle | undefined;
1198
1324
  // Name of the carrier agent staged for THIS turn's fresh conversation, if
1199
1325
  // any. Recorded into stagedAgentByConversation once the outcome binds the
1200
1326
  // conversation id, so later gate-closed turns pass the same agent.
1201
1327
  let stagedAgentName: string | undefined;
1202
- if (isContinuation) {
1203
- const active = deps.driver.reentry();
1204
- if (!active) {
1205
- // Escalated calls have no turn to re-enter BY DESIGN: agy already
1206
- // got the poll handle and the result lives in the registry. Settle
1207
- // quietly instead of erroring the turn.
1208
- if (escalatedNames.length > 0) {
1209
- appendText(stream, blocks, `[bridge] ${escalatedNames.join(", ")} finished; the result is available via ${POLL_TOOL_NAME}.`);
1210
- finalize(stream, blocks, "stop");
1328
+ const active = isContinuation ? deps.driver.reentry() : null;
1329
+ if (active) {
1330
+ handle = active;
1331
+ } else {
1332
+ // Dead turn: claim NOW (single-use). A live turn claims after outcome
1333
+ // instead — claiming here would eat the post-outcome recovery.
1334
+ late.push(...collectStranded());
1335
+ if (isContinuation && late.length === 0) {
1336
+ if (escalatedNames.length === 0) {
1337
+ deps.log?.("turn-error", { reason: "tool-result-no-active-turn" }, "warn");
1338
+ finalize(stream, blocks, "error", "tool result arrived but no antigravity turn is running");
1211
1339
  return;
1212
1340
  }
1213
- deps.log?.("turn-error", { reason: "tool-result-no-active-turn" }, "warn");
1214
- finalize(stream, blocks, "error", "tool result arrived but no antigravity turn is running");
1341
+ // Escalated results, all already delivered through polls: the model
1342
+ // has them in context. Settle quietly instead of erroring.
1343
+ appendText(stream, blocks, `[bridge] ${escalatedNames.join(", ")} finished; the result is available via ${POLL_TOOL_NAME}.`);
1344
+ finalize(stream, blocks, "stop");
1215
1345
  return;
1216
1346
  }
1217
- handle = active;
1218
- } else {
1347
+ if (isContinuation && late.length > 0 && redeliveryConversation === undefined && !existing?.conversationId) {
1348
+ // The owning turn ended before binding a conversation (no stamp, no
1349
+ // store record): a context-free late prompt would hit a brand-new
1350
+ // agy conversation that never asked the question. Release the
1351
+ // claims and settle quietly; the result stays pollable and a later
1352
+ // pass with a bound conversation can still sweep it.
1353
+ for (const l of late) if (l.callId) deps.roundTrips.unclaim(l.callId);
1354
+ appendText(stream, blocks, `[bridge] ${escalatedNames.join(", ")} finished, but no conversation is bound to re-deliver into; the result stays available via ${POLL_TOOL_NAME}.`);
1355
+ finalize(stream, blocks, "stop");
1356
+ return;
1357
+ }
1358
+ // Fresh pass, or a stranded escalated result being re-delivered.
1219
1359
  const prompt = extractUserPrompt(context);
1220
1360
  const images = extractImages(context);
1221
1361
  // An image-only message (no text) is valid on the ACP engine; only fail
@@ -1226,9 +1366,6 @@ async function runTurnDriver(
1226
1366
  finalize(stream, blocks, "error", "No user message to send to agy.");
1227
1367
  return;
1228
1368
  }
1229
- const entry = entries.find((e) => e.id === model.id) ?? null;
1230
- const agyModel = entry?.full ?? model.id;
1231
- const effort = entry?.efforts?.length ? toAgyEffort(options?.reasoning, entry.efforts) : undefined;
1232
1369
  const watermark = existing?.lastMessageCount ?? 0;
1233
1370
  // Late turns re-open the conversation with a synthetic prompt; the digest
1234
1371
  // would re-send context agy already holds, so skip it.
@@ -1293,7 +1430,7 @@ async function runTurnDriver(
1293
1430
  skipPermissions: config.skipPermissions,
1294
1431
  timeoutMin: config.turnTimeoutMin,
1295
1432
  inactivityMin: config.inactivityTimeoutMin,
1296
- conversationId: existing?.conversationId ?? null,
1433
+ conversationId: redeliveryConversation ?? existing?.conversationId ?? null,
1297
1434
  prompt: fullPrompt,
1298
1435
  images: images.length > 0 ? images : undefined,
1299
1436
  agent,
@@ -1307,6 +1444,10 @@ async function runTurnDriver(
1307
1444
  });
1308
1445
  } catch (err) {
1309
1446
  const msg = err instanceof Error ? err.message : String(err);
1447
+ // Release stranded claims: the delivery never started, so the next
1448
+ // pass must find them undelivered (tombstone-borne items have no
1449
+ // callId and stay consumed, as before).
1450
+ for (const l of late) if (l.callId) deps.roundTrips.unclaim(l.callId);
1310
1451
  deps.log?.("turn-error", { reason: "driver-start-failed", error: msg }, "error");
1311
1452
  finalize(stream, blocks, "error", `agy failed to start: ${msg}`);
1312
1453
  return;
@@ -1324,13 +1465,16 @@ async function runTurnDriver(
1324
1465
  roster: deps.roster,
1325
1466
  };
1326
1467
 
1327
- for (;;) {
1328
- const activity = await handle.next();
1329
- if (!activity) break;
1330
- if (consumeActivity(stream, blocks, activity, diffCtx, cwd, feats) === "parked") return;
1331
- }
1468
+ const drainTurn = async (h: TurnHandle): Promise<"parked" | undefined> => {
1469
+ for (;;) {
1470
+ const activity = await h.next();
1471
+ if (!activity) break;
1472
+ if (consumeActivity(stream, blocks, activity, diffCtx, cwd, feats) === "parked") return "parked";
1473
+ }
1474
+ };
1475
+ if ((await drainTurn(handle)) === "parked") return;
1332
1476
 
1333
- const outcome = await handle.outcome;
1477
+ let outcome = await handle.outcome;
1334
1478
  // Issue #2 recovery paths. agy's stream-json prompt path silently drops an
1335
1479
  // over-cap turn (SUCCESS, empty response, zero usage, NO model step) and a
1336
1480
  // dropped turn leaves the conversation permanently unresponsive, so both
@@ -1374,6 +1518,43 @@ async function runTurnDriver(
1374
1518
  );
1375
1519
  return;
1376
1520
  }
1521
+ // Gap closer: the result landed while the turn was still live (reentry
1522
+ // above), the turn ended, and the model never polled. Same strand, same
1523
+ // fix: sweep and deliver as a late run in the same conversation. Single
1524
+ // shot: claims are single-use, so a re-delivered strand cannot reappear
1525
+ // here; a late run that parks hands recovery to the next pi pass.
1526
+ if (isContinuation && outcome.status === "OK" && !outcome.aborted) {
1527
+ const more = collectStranded();
1528
+ if (more.length > 0) {
1529
+ const conv = redeliveryConversation ?? outcome.conversationId ?? existing?.conversationId ?? null;
1530
+ deps.log?.("escalated-redelivery-turn", { tools: more.map((s) => s.name), conversation: conv }, "info");
1531
+ try {
1532
+ const lateHandle = await deps.driver.run({
1533
+ cwd,
1534
+ model: agyModel,
1535
+ effort,
1536
+ mode: config.mode,
1537
+ skipPermissions: config.skipPermissions,
1538
+ timeoutMin: config.turnTimeoutMin,
1539
+ inactivityMin: config.inactivityTimeoutMin,
1540
+ conversationId: conv,
1541
+ prompt: buildLateResultPrompt(more),
1542
+ agent: config.agent ?? (conv ? stagedAgentByConversation.get(conv) : undefined),
1543
+ signal: options?.signal,
1544
+ });
1545
+ if ((await drainTurn(lateHandle)) === "parked") return;
1546
+ outcome = await lateHandle.outcome;
1547
+ } catch (err) {
1548
+ // Release the claims so the next pass can retry the delivery.
1549
+ for (const l of more) if (l.callId) deps.roundTrips.unclaim(l.callId);
1550
+ deps.log?.(
1551
+ "turn-error",
1552
+ { reason: "redelivery-start-failed", error: err instanceof Error ? err.message : String(err) },
1553
+ "error",
1554
+ );
1555
+ }
1556
+ }
1557
+ }
1377
1558
  if (outcome.conversationId) {
1378
1559
  // A different id than the stored one means the driver recreated the
1379
1560
  // conversation under us (ACP session-load fallback, stream-json CLI