@estebanforge/pi-antigravity-bridge 1.4.9 → 1.4.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/provider.ts CHANGED
@@ -36,6 +36,8 @@ import { mapAgyToolToNative } from "./native-tools.js";
36
36
  import { type AgyEffort, type AgyModelEntry } from "./models.js";
37
37
  import { SessionStore } from "./sessions.js";
38
38
  import { loadConfig } from "./config.js";
39
+ import { GATE_MARKER, mapNativeToShadow, stripMarkerFields } from "./approval-gate.js";
40
+ import type { ApprovalDecision, ApprovalPayload, ApprovalParkApi } from "./mcp-server.js";
39
41
  import path from "node:path";
40
42
  import { TurnDiffContext, createExecGitOps, formatInlineDiff, parseEditToolInput } from "./diff-render.js";
41
43
 
@@ -72,7 +74,7 @@ function extractUserPrompt(context: Context): string | null {
72
74
 
73
75
  /** Image blocks of the latest user message (pi-ai ImageContent: base64 data
74
76
  * + mimeType). The ACP engine forwards them as typed content blocks; the
75
- * legacy CLI prompt is text-only, so its driver simply ignores these. */
77
+ * stream-json CLI prompt is text-only, so its driver simply ignores these. */
76
78
  function extractImages(context: Context): Array<{ data: string; mimeType: string }> {
77
79
  const last = context.messages[context.messages.length - 1];
78
80
  if (!last || last.role !== "user" || typeof last.content === "string") return [];
@@ -294,8 +296,8 @@ function sessionKey(
294
296
  ): string {
295
297
  const sid = (options as { sessionId?: string } | undefined)?.sessionId;
296
298
  const base = sid && sid.length > 0 ? `sid:${sid}` : `cwd:${cwd}`;
297
- // Engine-scoped keys (plan 9.4): one ACP turn must never touch the legacy
298
- // binding and vice versa. Un-suffixed keys = legacy, byte-compatible with
299
+ // Engine-scoped keys (plan 9.4): one ACP turn must never touch the stream
300
+ // binding and vice versa. Un-suffixed keys = stream-json, byte-compatible with
299
301
  // every store that predates the ACP engine.
300
302
  return base + (engine === "acp" ? "@acp" : "");
301
303
  }
@@ -312,12 +314,12 @@ export interface BlockState {
312
314
  export interface StreamSimpleDeps {
313
315
  entries: AgyModelEntry[];
314
316
  store: SessionStore;
315
- /** Legacy stream-json driver (the tested default engine). Turns run on the
317
+ /** Stream-json driver (the tested default engine). Turns run on the
316
318
  * driver and bridge calls park as toolUse round-trips. Required with
317
319
  * roundTrips. */
318
320
  driver?: TurnDriver;
319
321
  /** Official-server ACP engine. Opt-in via config.engine = "acp"; when
320
- * absent the config switch falls back to the legacy driver. */
322
+ * absent the config switch falls back to the stream driver. */
321
323
  acpDriver?: TurnDriver;
322
324
  roundTrips?: ToolRoundTrips;
323
325
  /** Replay store for the display-only antigravity wrapper tool. Required
@@ -390,8 +392,19 @@ const BRIDGE_TIMEOUT_MS = 480_000;
390
392
  /** Bounded memory of failed bridge parks (late-delivery tombstones). */
391
393
  const MAX_PARK_TOMBSTONES = 64;
392
394
 
395
+ /** One MCP tool-result content block: text always; image blocks carry base64
396
+ * pixels and ride to the model on BOTH engines (ACP probe 2026-09-05;
397
+ * stream-json probe 2026-09-07: the CLI's MCP client delivers tool-result
398
+ * image content to the model). */
399
+ export interface BridgeContentBlock {
400
+ type: string;
401
+ text?: string;
402
+ data?: string;
403
+ mimeType?: string;
404
+ }
405
+
393
406
  export interface BridgeCallResultShape {
394
- content: Array<{ type: string; text?: string }>;
407
+ content: BridgeContentBlock[];
395
408
  isError: boolean;
396
409
  }
397
410
 
@@ -415,6 +428,11 @@ export const ESCALATE_AFTER_MS = 20_000;
415
428
  /** Escalated parks carry a longer TTL: human-gated tools (commit previews,
416
429
  * permission dialogs) legitimately block for many minutes. */
417
430
  export const ESCALATED_TIMEOUT_MS = 1_800_000;
431
+ /** Human-decision budget for one parked approval (docs/TODO.md 2.5). Same
432
+ * envelope as the G9 park; the staged hook timeout exceeds it with margin
433
+ * (approval-hook.stagedTimeoutSeconds). Exported: the extension needs the
434
+ * same number for hooks.json staging and the hook script deadline. */
435
+ export const APPROVAL_PARK_MS = BRIDGE_TIMEOUT_MS;
418
436
 
419
437
  export interface PollView {
420
438
  state: "running" | "done" | "failed";
@@ -422,6 +440,7 @@ export interface PollView {
422
440
  text?: string;
423
441
  isError?: boolean;
424
442
  reason?: string;
443
+ images?: Array<{ data: string; mimeType: string }>;
425
444
  }
426
445
 
427
446
  /** Escalated bridge calls. Bounded: past the cap, oldest settled entries
@@ -442,12 +461,13 @@ export class EscalationRegistry {
442
461
  this.#calls.set(callId, { name, state: "running" });
443
462
  this.#trim();
444
463
  }
445
- settleDone(callId: string, text: string, isError: boolean): void {
464
+ settleDone(callId: string, text: string, isError: boolean, images: Array<{ data: string; mimeType: string }> = []): void {
446
465
  const e = this.#calls.get(callId);
447
466
  if (!e) return;
448
467
  e.state = "done";
449
468
  e.text = text;
450
469
  e.isError = isError;
470
+ if (images.length > 0) e.images = images;
451
471
  this.#trim();
452
472
  }
453
473
  settleFailed(callId: string, reason: string): void {
@@ -503,15 +523,27 @@ export function formatPollAnswer(callId: string, view: PollView | undefined): Br
503
523
  isError: true,
504
524
  };
505
525
  }
506
- return { content: [{ type: "text", text: view.text || "(no output)" }], isError: view.isError ?? false };
526
+ return {
527
+ content: [
528
+ ...(view.images ?? []).map((i) => ({ type: "image", data: i.data, mimeType: i.mimeType })),
529
+ { type: "text", text: view.text || "(no output)" },
530
+ ],
531
+ isError: view.isError ?? false,
532
+ };
507
533
  }
508
534
 
509
535
  interface PendingRoundTrip {
510
536
  /** "bridge": parked MCP HTTP call; resolve() completes it.
511
537
  * "rt": native re-exec / wrapper round-trip; pi already executed, the
512
- * toolResult only confirms continuation, nothing remote to settle. */
513
- kind: "bridge" | "rt";
538
+ * toolResult only confirms continuation, nothing remote to settle.
539
+ * "approval": parked approval gate decision; resolve() maps the shadow
540
+ * tool result to allow/deny and completes the /approval ticket. */
541
+ kind: "bridge" | "rt" | "approval";
514
542
  name: string;
543
+ /** Approval entries only: the agy native tool this decision is for. */
544
+ nativeName?: string;
545
+ /** Approval entries only: park start, for the latency audit field. */
546
+ started?: number;
515
547
  resolve?: (r: BridgeCallResultShape | BridgeEscalation) => void;
516
548
  reject?: (e: Error) => void;
517
549
  timer?: NodeJS.Timeout;
@@ -556,13 +588,17 @@ export class ToolRoundTrips {
556
588
  #escalations = new EscalationRegistry();
557
589
  #escalateAfterMs: number;
558
590
  #getDriver: () => TurnDriver;
559
- #log: (s: string, d?: unknown) => void;
591
+ #log: (s: string, d?: unknown, level?: "debug" | "info" | "warn" | "error") => void;
592
+ /** Approval park controls (mcp-server handle). Assigned by the extension
593
+ * only after at least one shadow tool is registered, so an approval
594
+ * toolUse can never dispatch to the REAL builtin and execute locally. */
595
+ #approvalPark?: ApprovalParkApi;
560
596
 
561
597
  /** Accepts a driver or a getter: with two engines wired, the ACTIVE driver
562
598
  * is resolved at call time from config (plan §9.5). */
563
599
  constructor(
564
600
  driver: TurnDriver | (() => TurnDriver),
565
- log?: (s: string, d?: unknown) => void,
601
+ log?: (s: string, d?: unknown, level?: "debug" | "info" | "warn" | "error") => void,
566
602
  opts: { escalateAfterMs?: number } = {},
567
603
  ) {
568
604
  this.#getDriver = typeof driver === "function" ? driver : () => driver;
@@ -593,6 +629,79 @@ export class ToolRoundTrips {
593
629
  return this.#escalations.poll(callId);
594
630
  }
595
631
 
632
+ /** Wire the approval park (mcp-server handle.approvals). The extension
633
+ * assigns this AFTER the shadow tools are registered; see onApproval. */
634
+ set approvalPark(api: ApprovalParkApi | undefined) {
635
+ this.#approvalPark = api;
636
+ }
637
+
638
+ /** Approval gate (docs/TODO.md 2.5): a PreToolUse hook parked a native agy
639
+ * tool call. Interrupt the pi-side view of the still-running agy turn
640
+ * with a toolUse for the SHADOW tool (same bridge_call mechanism as G9,
641
+ * so both drivers pause their turn timers); pi's permission extensions
642
+ * gate it, the shadow execute() consults the fallback policy, and the
643
+ * arriving toolResult maps to the terminal decision (resolve()).
644
+ * Every failure path denies fail-closed: an approval must never be
645
+ * granted by accident. */
646
+ onApproval(ticket: string, payload: ApprovalPayload): void {
647
+ const deny = (reason: string): void => {
648
+ this.#log("approval-denied-pre-park", { ticket, reason });
649
+ this.#approvalPark?.resolve(ticket, { allow: false, reason });
650
+ };
651
+ if (!this.#approvalPark) return deny("shadow tools are not registered");
652
+ const native = payload?.toolCall?.name;
653
+ if (typeof native !== "string" || native.length === 0) return deny("approval payload has no tool name");
654
+ const handle = this.#getDriver().activeHandle;
655
+ if (!handle) return deny("no active antigravity turn");
656
+ const args = (payload.toolCall.args && typeof payload.toolCall.args === "object"
657
+ ? payload.toolCall.args
658
+ : {}) as Record<string, unknown>;
659
+ const mapped = mapNativeToShadow(native, args);
660
+ if (!mapped) return deny(`tool ${native} is not in the approval matcher set`);
661
+ const entry: PendingRoundTrip = {
662
+ kind: "approval",
663
+ name: mapped.shadow,
664
+ nativeName: native,
665
+ started: Date.now(),
666
+ timer: setTimeout(() => {
667
+ this.#failApproval(
668
+ ticket,
669
+ `approval gate timed out after ${Math.round(APPROVAL_PARK_MS / 1000)}s`,
670
+ "timeout",
671
+ );
672
+ }, APPROVAL_PARK_MS),
673
+ };
674
+ this.#pending.set(ticket, entry);
675
+ handle.pushExternal({
676
+ type: "bridge_call",
677
+ callId: ticket,
678
+ name: mapped.shadow,
679
+ args: {
680
+ ...mapped.input,
681
+ [GATE_MARKER]: true,
682
+ __agyTicket: ticket,
683
+ __agyTool: native,
684
+ },
685
+ });
686
+ this.#log("approval-parked", { ticket, native, shadow: mapped.shadow });
687
+ }
688
+
689
+ /** Settle a parked approval with a deny. Used by the park timeout and
690
+ * failAll; the ticket is answered (fail closed) and the pending entry
691
+ * dropped, so the late shadow tool result logs as approval-late. */
692
+ #failApproval(ticket: string, reason: string, cause: "timeout" | "shutdown"): void {
693
+ const entry = this.#pending.get(ticket);
694
+ this.#pending.delete(ticket);
695
+ if (entry?.timer) clearTimeout(entry.timer);
696
+ const resolved = this.#approvalPark?.resolve(ticket, { allow: false, reason }) ?? false;
697
+ this.#log(
698
+ resolved ? `approval-${cause}` : "approval-late",
699
+ { ticket, native: entry?.nativeName, shadow: entry?.name, reason },
700
+ resolved && cause === "timeout" ? "warn" : "debug",
701
+ );
702
+ this.#getDriver().kickIdle();
703
+ }
704
+
596
705
  /** Fail all pending calls (driver recycle/shutdown path). Escalated bridge
597
706
  * calls are skipped: their HTTP request was already answered with a poll
598
707
  * handle, and the agy turn ending does NOT make the still-running pi tool
@@ -601,6 +710,10 @@ export class ToolRoundTrips {
601
710
  failAll(reason: string): void {
602
711
  for (const id of [...this.#pending.keys()]) {
603
712
  const entry = this.#pending.get(id);
713
+ if (entry?.kind === "approval") {
714
+ this.#failApproval(id, reason, "shutdown");
715
+ continue;
716
+ }
604
717
  if (entry?.kind === "bridge" && entry.escalated) continue;
605
718
  this.#fail(id, reason);
606
719
  }
@@ -677,7 +790,10 @@ export class ToolRoundTrips {
677
790
  }, this.#escalateAfterMs);
678
791
  }
679
792
  this.#pending.set(callId, entry);
680
- handle.pushExternal({ type: "bridge_call", callId, name, args });
793
+ // Strip the internal marker fields from model-supplied args (peer
794
+ // review 2026-09-07): a real bridge call must never arrive at the
795
+ // shadow's gate branch with a forged __agyGate/__agyTicket.
796
+ handle.pushExternal({ type: "bridge_call", callId, name, args: stripMarkerFields(args) });
681
797
  });
682
798
  };
683
799
 
@@ -688,8 +804,15 @@ export class ToolRoundTrips {
688
804
  }
689
805
 
690
806
  /** Complete a parked call from a pi toolResult message. Returns false when
691
- * the id matches nothing pending. */
692
- resolve(toolCallId: string, text: string, isError: boolean): boolean {
807
+ * the id matches nothing pending. Image blocks ride the result on both
808
+ * engines (probe-verified on each); the late-delivery prompt stays
809
+ * text-only (see PI-BRIDGE-GAPS). */
810
+ resolve(
811
+ toolCallId: string,
812
+ text: string,
813
+ isError: boolean,
814
+ images: Array<{ data: string; mimeType: string }> = [],
815
+ ): boolean {
693
816
  const entry = this.#pending.get(toolCallId);
694
817
  if (!entry) return false;
695
818
  this.#pending.delete(toolCallId);
@@ -700,14 +823,47 @@ export class ToolRoundTrips {
700
823
  this.#log("round-trip-rt-done", { callId: toolCallId, name: entry.name, isError });
701
824
  return true;
702
825
  }
826
+ if (entry.kind === "approval") {
827
+ clearTimeout(entry.timer);
828
+ this.#pending.delete(toolCallId);
829
+ // Decision mapping (docs/TODO.md 2.5): block/error -> deny with the
830
+ // text (pi turns a tool_call block into an error tool result, so both
831
+ // paths land here); synthetic success -> allow.
832
+ const decision: ApprovalDecision = isError
833
+ ? { allow: false, reason: text || `blocked by approval gate (${entry.nativeName})` }
834
+ : { allow: true };
835
+ const delivered = this.#approvalPark?.resolve(toolCallId, decision) ?? false;
836
+ // Audit trail (docs/TODO.md 2.7): decision, source, latency.
837
+ this.#log(
838
+ delivered ? "approval-decision" : "approval-late",
839
+ {
840
+ ticket: toolCallId,
841
+ native: entry.nativeName,
842
+ shadow: entry.name,
843
+ decision: decision.allow ? "allow" : "deny",
844
+ source: isError ? "extension-block" : "policy",
845
+ reason: decision.allow ? undefined : decision.reason,
846
+ latencyMs: Date.now() - (entry.started ?? 0),
847
+ },
848
+ delivered ? "info" : "debug",
849
+ );
850
+ this.#getDriver().kickIdle();
851
+ return true;
852
+ }
703
853
  // Escalated call: the HTTP response already carried the poll handle, so
704
854
  // the result lands in the registry for the next bridge_poll_result. The
705
855
  // original promise settled with the sentinel; re-resolving is a silent
706
856
  // no-op, so gate it to keep that explicit.
707
857
  if (entry.escalated) {
708
- this.#escalations.settleDone(toolCallId, text, isError);
858
+ this.#escalations.settleDone(toolCallId, text, isError, images);
709
859
  } else {
710
- entry.resolve!({ content: [{ type: "text", text }], isError });
860
+ entry.resolve!({
861
+ content: [
862
+ ...images.map((i) => ({ type: "image", data: i.data, mimeType: i.mimeType })),
863
+ { type: "text", text },
864
+ ],
865
+ isError,
866
+ });
711
867
  }
712
868
  this.#getDriver().kickIdle();
713
869
  this.#log("round-trip-resolved", { callId: toolCallId, name: entry.name, isError });
@@ -715,19 +871,40 @@ export class ToolRoundTrips {
715
871
  }
716
872
  }
717
873
 
718
- /** Extract toolResult messages whose toolCallId is still parked, as text. */
874
+ /** Image blocks of a tool result (pi's read on an image file, screenshots).
875
+ * Forwarded to agy as MCP image content (see BridgeContentBlock) on both
876
+ * engines. Size relies on pi's own inline-image resize cap upstream; no
877
+ * second cap here. */
878
+ function extractResultImages(content: unknown): Array<{ data: string; mimeType: string }> {
879
+ if (!Array.isArray(content)) return [];
880
+ return content
881
+ .filter(
882
+ (b): b is { type: "image"; data: string; mimeType: string } =>
883
+ typeof b === "object" && b !== null && (b as { type?: string }).type === "image",
884
+ )
885
+ .map((b) => ({ data: b.data, mimeType: b.mimeType }))
886
+ .filter((i) => typeof i.mimeType === "string" && typeof i.data === "string" && i.data.length > 0);
887
+ }
888
+
889
+ /** Extract toolResult messages whose toolCallId is still parked, as text plus
890
+ * any image blocks (forwarded as MCP image content on both engines). */
719
891
  export function collectToolResults(
720
892
  messages: Message[],
721
893
  pendingIds: readonly string[],
722
- ): Array<{ toolCallId: string; text: string; isError: boolean }> {
894
+ ): Array<{ toolCallId: string; text: string; isError: boolean; images: Array<{ data: string; mimeType: string }> }> {
723
895
  if (pendingIds.length === 0) return [];
724
896
  const pending = new Set(pendingIds);
725
- const out: Array<{ toolCallId: string; text: string; isError: boolean }> = [];
897
+ const out: Array<{ toolCallId: string; text: string; isError: boolean; images: Array<{ data: string; mimeType: string }> }> = [];
726
898
  for (const m of messages) {
727
899
  if (m.role !== "toolResult") continue;
728
900
  const id = (m as { toolCallId?: string }).toolCallId;
729
901
  if (!id || !pending.has(id)) continue;
730
- out.push({ toolCallId: id, text: blocksToText(m.content).trim(), isError: m.isError === true });
902
+ out.push({
903
+ toolCallId: id,
904
+ text: blocksToText(m.content).trim(),
905
+ isError: m.isError === true,
906
+ images: extractResultImages(m.content),
907
+ });
731
908
  }
732
909
  return out;
733
910
  }
@@ -825,7 +1002,7 @@ export function consumeActivity(
825
1002
  appendText(stream, blocks, activity.delta);
826
1003
  return "continue";
827
1004
  case "thought":
828
- // Legacy: token count only (no body). ACP: thought TEXT deltas —
1005
+ // Stream-json: token count only (no body). ACP: thought TEXT deltas —
829
1006
  // rendered through the same thinking block pipeline (9.2).
830
1007
  if (typeof activity.delta === "string" && activity.delta.length > 0) {
831
1008
  appendThinking(stream, blocks, activity.delta);
@@ -965,7 +1142,13 @@ async function runTurnDriver(
965
1142
  const escalatedNames = results
966
1143
  .map((r) => deps.roundTrips.poll(r.toolCallId)?.name)
967
1144
  .filter((n): n is string => Boolean(n));
968
- for (const r of results) deps.roundTrips.resolve(r.toolCallId, r.text, r.isError);
1145
+ // Images ride tool results on BOTH engines (ACP probe 2026-09-05;
1146
+ // stream-json probe 2026-09-07: the CLI's MCP client delivers tool-result
1147
+ // image content to the model — two-tone PNG named from the result alone,
1148
+ // no decoders in the frame trail). The late-delivery prompt and the
1149
+ // stream-json prompt attachments stay text-only by design.
1150
+ for (const r of results)
1151
+ deps.roundTrips.resolve(r.toolCallId, r.text, r.isError, r.images);
969
1152
 
970
1153
  // Late delivery: a toolResult whose park already failed (the abort/timeout
971
1154
  // path failed the park while the pi tool kept running). The work is done,
@@ -1109,7 +1292,7 @@ async function runTurnDriver(
1109
1292
 
1110
1293
  /** Build the streamSimple closure. Captures the model catalog + session store
1111
1294
  * resolved at extension load. When a driver is provided, turns run on the
1112
- * persistent stream-json engine (config.engine selects; legacy remains as
1295
+ * persistent stream-json engine (config.engine selects; stream remains as
1113
1296
  * fallback). */
1114
1297
  export function createStreamSimple(
1115
1298
  deps: StreamSimpleDeps,
@@ -1141,8 +1324,8 @@ export function createStreamSimple(
1141
1324
  replay: deps.replay,
1142
1325
  nativeActive: deps.nativeActive,
1143
1326
  // Record the engine of the driver that will ACTUALLY run: if the
1144
- // ACP driver is absent, the config switch falls back to legacy,
1145
- // and keying the session as @acp would store a legacy
1327
+ // ACP driver is absent, the config switch falls back to stream,
1328
+ // and keying the session as @acp would store a stream
1146
1329
  // conversationId under the wrong engine scope.
1147
1330
  engine: selected === deps.acpDriver ? "acp" : "stream-json",
1148
1331
  log: deps.log,