@estebanforge/pi-antigravity-bridge 1.4.8 → 1.4.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 [];
@@ -114,12 +116,14 @@ export const SYSTEM_PROMPT_END = "[END SYSTEM PROMPT]";
114
116
 
115
117
  /** Brief tool-priority note appended inside every system prompt block: agy
116
118
  * 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. */
119
+ * user. Equivalent Pi Bridge tools must win. Concrete clashes observed
120
+ * live: agy picked its native ask_question over the bridge's
121
+ * ask_user_question and the question never displayed; on ACP its native
122
+ * view_file rejects real filesystem paths (artifact sandbox), so reads of
123
+ * the user's machine must go through the bridge. Rides the systemPrompt
124
+ * gate: the note ships only when the system prompt ships. */
121
125
  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. 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.]";
126
+ "[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
127
 
124
128
  /** Assemble the full agy prompt: system prompt block, pi-side digest, user
125
129
  * prompt. Empty parts are dropped. Pure; exported for unit testing.
@@ -292,8 +296,8 @@ function sessionKey(
292
296
  ): string {
293
297
  const sid = (options as { sessionId?: string } | undefined)?.sessionId;
294
298
  const base = sid && sid.length > 0 ? `sid:${sid}` : `cwd:${cwd}`;
295
- // Engine-scoped keys (plan 9.4): one ACP turn must never touch the legacy
296
- // 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
297
301
  // every store that predates the ACP engine.
298
302
  return base + (engine === "acp" ? "@acp" : "");
299
303
  }
@@ -310,12 +314,12 @@ export interface BlockState {
310
314
  export interface StreamSimpleDeps {
311
315
  entries: AgyModelEntry[];
312
316
  store: SessionStore;
313
- /** Legacy stream-json driver (the tested default engine). Turns run on the
317
+ /** Stream-json driver (the tested default engine). Turns run on the
314
318
  * driver and bridge calls park as toolUse round-trips. Required with
315
319
  * roundTrips. */
316
320
  driver?: TurnDriver;
317
321
  /** Official-server ACP engine. Opt-in via config.engine = "acp"; when
318
- * absent the config switch falls back to the legacy driver. */
322
+ * absent the config switch falls back to the stream driver. */
319
323
  acpDriver?: TurnDriver;
320
324
  roundTrips?: ToolRoundTrips;
321
325
  /** Replay store for the display-only antigravity wrapper tool. Required
@@ -388,8 +392,19 @@ const BRIDGE_TIMEOUT_MS = 480_000;
388
392
  /** Bounded memory of failed bridge parks (late-delivery tombstones). */
389
393
  const MAX_PARK_TOMBSTONES = 64;
390
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
+
391
406
  export interface BridgeCallResultShape {
392
- content: Array<{ type: string; text?: string }>;
407
+ content: BridgeContentBlock[];
393
408
  isError: boolean;
394
409
  }
395
410
 
@@ -413,6 +428,11 @@ export const ESCALATE_AFTER_MS = 20_000;
413
428
  /** Escalated parks carry a longer TTL: human-gated tools (commit previews,
414
429
  * permission dialogs) legitimately block for many minutes. */
415
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;
416
436
 
417
437
  export interface PollView {
418
438
  state: "running" | "done" | "failed";
@@ -420,6 +440,7 @@ export interface PollView {
420
440
  text?: string;
421
441
  isError?: boolean;
422
442
  reason?: string;
443
+ images?: Array<{ data: string; mimeType: string }>;
423
444
  }
424
445
 
425
446
  /** Escalated bridge calls. Bounded: past the cap, oldest settled entries
@@ -440,12 +461,13 @@ export class EscalationRegistry {
440
461
  this.#calls.set(callId, { name, state: "running" });
441
462
  this.#trim();
442
463
  }
443
- settleDone(callId: string, text: string, isError: boolean): void {
464
+ settleDone(callId: string, text: string, isError: boolean, images: Array<{ data: string; mimeType: string }> = []): void {
444
465
  const e = this.#calls.get(callId);
445
466
  if (!e) return;
446
467
  e.state = "done";
447
468
  e.text = text;
448
469
  e.isError = isError;
470
+ if (images.length > 0) e.images = images;
449
471
  this.#trim();
450
472
  }
451
473
  settleFailed(callId: string, reason: string): void {
@@ -501,15 +523,27 @@ export function formatPollAnswer(callId: string, view: PollView | undefined): Br
501
523
  isError: true,
502
524
  };
503
525
  }
504
- 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
+ };
505
533
  }
506
534
 
507
535
  interface PendingRoundTrip {
508
536
  /** "bridge": parked MCP HTTP call; resolve() completes it.
509
537
  * "rt": native re-exec / wrapper round-trip; pi already executed, the
510
- * toolResult only confirms continuation, nothing remote to settle. */
511
- 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";
512
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;
513
547
  resolve?: (r: BridgeCallResultShape | BridgeEscalation) => void;
514
548
  reject?: (e: Error) => void;
515
549
  timer?: NodeJS.Timeout;
@@ -554,13 +588,17 @@ export class ToolRoundTrips {
554
588
  #escalations = new EscalationRegistry();
555
589
  #escalateAfterMs: number;
556
590
  #getDriver: () => TurnDriver;
557
- #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;
558
596
 
559
597
  /** Accepts a driver or a getter: with two engines wired, the ACTIVE driver
560
598
  * is resolved at call time from config (plan §9.5). */
561
599
  constructor(
562
600
  driver: TurnDriver | (() => TurnDriver),
563
- log?: (s: string, d?: unknown) => void,
601
+ log?: (s: string, d?: unknown, level?: "debug" | "info" | "warn" | "error") => void,
564
602
  opts: { escalateAfterMs?: number } = {},
565
603
  ) {
566
604
  this.#getDriver = typeof driver === "function" ? driver : () => driver;
@@ -591,6 +629,79 @@ export class ToolRoundTrips {
591
629
  return this.#escalations.poll(callId);
592
630
  }
593
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
+
594
705
  /** Fail all pending calls (driver recycle/shutdown path). Escalated bridge
595
706
  * calls are skipped: their HTTP request was already answered with a poll
596
707
  * handle, and the agy turn ending does NOT make the still-running pi tool
@@ -599,6 +710,10 @@ export class ToolRoundTrips {
599
710
  failAll(reason: string): void {
600
711
  for (const id of [...this.#pending.keys()]) {
601
712
  const entry = this.#pending.get(id);
713
+ if (entry?.kind === "approval") {
714
+ this.#failApproval(id, reason, "shutdown");
715
+ continue;
716
+ }
602
717
  if (entry?.kind === "bridge" && entry.escalated) continue;
603
718
  this.#fail(id, reason);
604
719
  }
@@ -675,7 +790,10 @@ export class ToolRoundTrips {
675
790
  }, this.#escalateAfterMs);
676
791
  }
677
792
  this.#pending.set(callId, entry);
678
- 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) });
679
797
  });
680
798
  };
681
799
 
@@ -686,8 +804,15 @@ export class ToolRoundTrips {
686
804
  }
687
805
 
688
806
  /** Complete a parked call from a pi toolResult message. Returns false when
689
- * the id matches nothing pending. */
690
- 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 {
691
816
  const entry = this.#pending.get(toolCallId);
692
817
  if (!entry) return false;
693
818
  this.#pending.delete(toolCallId);
@@ -698,14 +823,47 @@ export class ToolRoundTrips {
698
823
  this.#log("round-trip-rt-done", { callId: toolCallId, name: entry.name, isError });
699
824
  return true;
700
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
+ }
701
853
  // Escalated call: the HTTP response already carried the poll handle, so
702
854
  // the result lands in the registry for the next bridge_poll_result. The
703
855
  // original promise settled with the sentinel; re-resolving is a silent
704
856
  // no-op, so gate it to keep that explicit.
705
857
  if (entry.escalated) {
706
- this.#escalations.settleDone(toolCallId, text, isError);
858
+ this.#escalations.settleDone(toolCallId, text, isError, images);
707
859
  } else {
708
- 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
+ });
709
867
  }
710
868
  this.#getDriver().kickIdle();
711
869
  this.#log("round-trip-resolved", { callId: toolCallId, name: entry.name, isError });
@@ -713,19 +871,40 @@ export class ToolRoundTrips {
713
871
  }
714
872
  }
715
873
 
716
- /** 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). */
717
891
  export function collectToolResults(
718
892
  messages: Message[],
719
893
  pendingIds: readonly string[],
720
- ): Array<{ toolCallId: string; text: string; isError: boolean }> {
894
+ ): Array<{ toolCallId: string; text: string; isError: boolean; images: Array<{ data: string; mimeType: string }> }> {
721
895
  if (pendingIds.length === 0) return [];
722
896
  const pending = new Set(pendingIds);
723
- 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 }> }> = [];
724
898
  for (const m of messages) {
725
899
  if (m.role !== "toolResult") continue;
726
900
  const id = (m as { toolCallId?: string }).toolCallId;
727
901
  if (!id || !pending.has(id)) continue;
728
- 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
+ });
729
908
  }
730
909
  return out;
731
910
  }
@@ -823,7 +1002,7 @@ export function consumeActivity(
823
1002
  appendText(stream, blocks, activity.delta);
824
1003
  return "continue";
825
1004
  case "thought":
826
- // Legacy: token count only (no body). ACP: thought TEXT deltas —
1005
+ // Stream-json: token count only (no body). ACP: thought TEXT deltas —
827
1006
  // rendered through the same thinking block pipeline (9.2).
828
1007
  if (typeof activity.delta === "string" && activity.delta.length > 0) {
829
1008
  appendThinking(stream, blocks, activity.delta);
@@ -963,7 +1142,13 @@ async function runTurnDriver(
963
1142
  const escalatedNames = results
964
1143
  .map((r) => deps.roundTrips.poll(r.toolCallId)?.name)
965
1144
  .filter((n): n is string => Boolean(n));
966
- 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);
967
1152
 
968
1153
  // Late delivery: a toolResult whose park already failed (the abort/timeout
969
1154
  // path failed the park while the pi tool kept running). The work is done,
@@ -1107,7 +1292,7 @@ async function runTurnDriver(
1107
1292
 
1108
1293
  /** Build the streamSimple closure. Captures the model catalog + session store
1109
1294
  * resolved at extension load. When a driver is provided, turns run on the
1110
- * persistent stream-json engine (config.engine selects; legacy remains as
1295
+ * persistent stream-json engine (config.engine selects; stream remains as
1111
1296
  * fallback). */
1112
1297
  export function createStreamSimple(
1113
1298
  deps: StreamSimpleDeps,
@@ -1139,8 +1324,8 @@ export function createStreamSimple(
1139
1324
  replay: deps.replay,
1140
1325
  nativeActive: deps.nativeActive,
1141
1326
  // Record the engine of the driver that will ACTUALLY run: if the
1142
- // ACP driver is absent, the config switch falls back to legacy,
1143
- // 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
1144
1329
  // conversationId under the wrong engine scope.
1145
1330
  engine: selected === deps.acpDriver ? "acp" : "stream-json",
1146
1331
  log: deps.log,