@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/CHANGELOG.md +30 -0
- package/README.md +42 -7
- package/docs/ACP-ADOPTION-PLAN.md +83 -18
- package/docs/ACP-PROTOCOL-REFERENCE.md +4 -3
- package/docs/ARCHITECTURE.md +12 -5
- package/docs/DEVELOPMENT.md +16 -0
- package/docs/PI-BRIDGE-GAPS.md +26 -5
- package/docs/TODO.md +21 -0
- package/extensions/index.ts +221 -22
- package/package.json +1 -1
- package/src/acp/connection.ts +12 -0
- package/src/acp/driver.ts +17 -10
- package/src/acp/events.ts +17 -0
- package/src/approval-detect.ts +146 -0
- package/src/approval-gate.ts +208 -0
- package/src/approval-hook.ts +252 -0
- package/src/config.ts +43 -0
- package/src/daily-log.ts +11 -10
- package/src/driver-types.ts +13 -9
- package/src/driver.ts +6 -5
- package/src/mcp-registration.ts +127 -0
- package/src/mcp-server.ts +192 -10
- package/src/models.ts +2 -2
- package/src/provider.ts +216 -31
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
|
-
*
|
|
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
|
|
118
|
-
* agy picked its native ask_question over the bridge's
|
|
119
|
-
* and the question never displayed
|
|
120
|
-
*
|
|
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
|
|
296
|
-
// binding and vice versa. Un-suffixed keys =
|
|
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
|
-
/**
|
|
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
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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!({
|
|
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
|
-
/**
|
|
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({
|
|
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
|
-
//
|
|
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
|
-
|
|
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;
|
|
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
|
|
1143
|
-
// and keying the session as @acp would store a
|
|
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,
|