@estebanforge/pi-antigravity-bridge 1.3.3 → 1.4.1

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/driver.ts CHANGED
@@ -16,81 +16,29 @@
16
16
 
17
17
  import { spawn, type ChildProcess } from "node:child_process";
18
18
  import { randomUUID } from "node:crypto";
19
- import { parseAgyLine, type AgyUsage } from "./stream-events.js";
19
+ import { parseAgyLine } from "./stream-events.js";
20
20
  import { bridgeMcpConfigDir, bridgeMcpConfigExists } from "./mcp-server.js";
21
-
22
- export type DriverState = "idle" | "starting" | "ready" | "running" | "dead";
23
-
24
- export interface DriverProfile {
25
- cwd: string;
26
- model: string;
27
- effort?: string;
28
- mode: string;
29
- skipPermissions: boolean;
30
- }
31
-
32
- export interface DriverTurnRequest extends DriverProfile {
33
- /** Existing agy conversation to resume (`--conversation`). */
34
- conversationId?: string | null;
35
- prompt: string;
36
- signal?: AbortSignal;
37
- /** Overall turn cap in minutes (default 10). */
38
- timeoutMin?: number;
39
- /** Stdout inactivity cap in minutes (default 5). */
40
- inactivityMin?: number;
41
- }
42
-
43
- export type DriverActivity =
44
- | { type: "text"; delta: string }
45
- | { type: "thought"; tokens: number }
46
- | { type: "tool_start"; stepId?: number; name: string; args: Record<string, unknown> }
47
- | {
48
- type: "tool_done";
49
- stepId?: number;
50
- name: string;
51
- args: Record<string, unknown>;
52
- output?: string;
53
- durationSeconds?: number;
54
- }
55
- | { type: "tool_error"; stepId?: number; name: string; message: string }
56
- | { type: "usage"; usage: AgyUsage }
57
- /** Synthetic: injected by the provider when the MCP bridge receives a call. */
58
- | { type: "bridge_call"; callId: string; name: string; args: Record<string, unknown> };
59
-
60
- export interface TurnOutcome {
61
- conversationId?: string;
62
- status: "OK" | "ERROR" | "UNKNOWN";
63
- response: string;
64
- error?: string;
65
- usage?: AgyUsage;
66
- finished: boolean;
67
- aborted: boolean;
68
- }
69
-
70
- export interface TurnHandle {
71
- id: string;
72
- /** Resolves when the turn settles (result event, exit, abort, recycle). */
73
- outcome: Promise<TurnOutcome>;
74
- /** Pull the next activity. Resolves null once the activity stream closes. */
75
- next(): Promise<DriverActivity | null>;
76
- /** Inject a synthetic activity (bridge inbox). No-op after settle. */
77
- pushExternal(activity: DriverActivity): void;
78
- }
79
-
80
- export interface DriverSnapshot {
81
- state: DriverState;
82
- pid?: number;
83
- conversationId?: string;
84
- stats: {
85
- spawns: number;
86
- turns: number;
87
- reused: number;
88
- recycles: number;
89
- lastRecycleReason?: string;
90
- recycleReasons: Record<string, number>;
91
- };
92
- lifecycle: string[];
93
- }
21
+ import type {
22
+ AgyUsage,
23
+ DriverActivity,
24
+ DriverProfile,
25
+ DriverSnapshot,
26
+ DriverState,
27
+ DriverTurnRequest,
28
+ TurnDriver,
29
+ TurnHandle,
30
+ TurnOutcome,
31
+ } from "./driver-types.js";
32
+
33
+ export type {
34
+ DriverActivity,
35
+ DriverProfile,
36
+ DriverSnapshot,
37
+ DriverState,
38
+ DriverTurnRequest,
39
+ TurnHandle,
40
+ TurnOutcome,
41
+ } from "./driver-types.js";
94
42
 
95
43
  const LIFECYCLE_LIMIT = 24;
96
44
  const THINKING_TOKEN_FLOOR = 64;
@@ -163,7 +111,18 @@ export function isCumulativeResend(accumulated: string, next: string): boolean {
163
111
  return accumulated.length > 0 && next.length > accumulated.length && next.startsWith(accumulated);
164
112
  }
165
113
 
166
- export class AgyDriver {
114
+ /** Flip threshold for the same guard: short accumulations ("**", "#", "\n")
115
+ * are trivially extended by ordinary markdown deltas, and flipping on them
116
+ * corrupts every remaining frame of the turn. Require a respectable
117
+ * accumulation before believing a resend. Exported for tests. */
118
+ export const CUMULATIVE_FLIP_MIN_CHARS = 32;
119
+
120
+ /** Mode decision for the text-dedupe guard. Extracted for tests. */
121
+ export function shouldFlipToCumulative(accumulated: string, next: string): boolean {
122
+ return accumulated.length >= CUMULATIVE_FLIP_MIN_CHARS && isCumulativeResend(accumulated, next);
123
+ }
124
+
125
+ export class AgyDriver implements TurnDriver {
167
126
  #state: DriverState = "idle";
168
127
  #child: ChildProcess | undefined;
169
128
  #generation = 0;
@@ -173,6 +132,10 @@ export class AgyDriver {
173
132
  #queueTail: Promise<void> = Promise.resolve();
174
133
  #shutdown = false;
175
134
  #stderrTail = "";
135
+ // Frames can split across pipe chunks; the trailing partial line lives here
136
+ // until its newline arrives (same scheme as JsonRpcSession.feed). Dropping
137
+ // it ate large tool frames and could hide the result frame of a turn.
138
+ #stdoutBuf = "";
176
139
  #lifecycle: string[] = [];
177
140
  #onTurnEnd: ((outcome: TurnOutcome) => void) | undefined;
178
141
  #stats = {
@@ -373,7 +336,16 @@ export class AgyDriver {
373
336
  windowsHide: true,
374
337
  });
375
338
  this.#child = child;
339
+ this.#stdoutBuf = "";
376
340
  this.#stats.spawns += 1;
341
+ // Pipe write failures surface asynchronously as stream 'error' events;
342
+ // the sync try/catch around stdin.write cannot see them. Without this
343
+ // listener an EPIPE (agy died mid-write) is uncaught and kills pi.
344
+ child.stdin!.on("error", (err) => {
345
+ if (generation !== this.#generation) return;
346
+ const turn = this.#active;
347
+ if (turn && !turn.closed) this.#failTurn(turn, `agy stdin write failed: ${err.message}`);
348
+ });
377
349
  this.#log(`spawn:${child.pid ?? "?"}:${request.conversationId ? "resume" : "fresh"}`);
378
350
  this.#state = "ready";
379
351
 
@@ -420,10 +392,15 @@ export class AgyDriver {
420
392
  }
421
393
 
422
394
  #onStdout(chunk: string): void {
395
+ // Buffer the trailing partial line: a frame split across pipe chunks is
396
+ // reassembled when its newline arrives (mirrors JsonRpcSession.feed).
397
+ this.#stdoutBuf += chunk;
398
+ const lines = this.#stdoutBuf.split("\n");
399
+ this.#stdoutBuf = lines.pop() ?? "";
423
400
  const turn = this.#active;
424
401
  if (!turn || turn.closed) return;
425
402
  if (turn.idleTimer) turn.idleTimer.refresh();
426
- for (const line of chunk.split("\n")) {
403
+ for (const line of lines) {
427
404
  if (!line.trim()) continue;
428
405
  this.#applyParsed(turn, parseAgyLine(line));
429
406
  if (turn.closed) return;
@@ -525,13 +502,26 @@ export class AgyDriver {
525
502
  #appendAgentText(turn: ActiveTurn, text: string): void {
526
503
  // response_text is observed as a delta stream; guard against builds that
527
504
  // resend the full text. A cumulative sender's second chunk CONTAINS
528
- // everything accumulated so far as a prefix.
505
+ // everything accumulated so far as a prefix. Two guards against
506
+ // misflips (round-7 review): short accumulations never flip, and a
507
+ // cumulative frame that no longer extends the accumulator is evidence
508
+ // of a misflip - fall back to append mode.
529
509
  if (turn.cumulativeText === undefined) {
530
510
  turn.cumulativeText = false;
531
- } else if (!turn.cumulativeText && isCumulativeResend(turn.response, text)) {
511
+ } else if (
512
+ !turn.cumulativeText &&
513
+ shouldFlipToCumulative(turn.response, text)
514
+ ) {
532
515
  turn.cumulativeText = true;
533
516
  }
534
517
  if (turn.cumulativeText) {
518
+ if (!text.startsWith(turn.response)) {
519
+ // Misflip evidence: back to deltas.
520
+ turn.cumulativeText = false;
521
+ turn.response += text;
522
+ emit(turn, { type: "text", delta: text });
523
+ return;
524
+ }
535
525
  if (text.length > turn.response.length) {
536
526
  const delta = text.slice(turn.response.length);
537
527
  turn.response = text;
@@ -613,6 +603,7 @@ export class AgyDriver {
613
603
  if (!child) return;
614
604
  this.#child = undefined;
615
605
  this.#generation += 1;
606
+ this.#stdoutBuf = "";
616
607
  try {
617
608
  child.stdout?.removeAllListeners();
618
609
  child.stderr?.removeAllListeners();
package/src/mcp-server.ts CHANGED
@@ -37,11 +37,17 @@ import {
37
37
  const SKIP_CIRCULAR = new Set(["AskAntigravity"]);
38
38
 
39
39
  const BRIDGE_MCP_KEY = "pi-antigravity-bridge";
40
- const TOKEN_HEADER = "x-bridge-token";
40
+ /** Shared-secret header every bridge request must carry. Exported: the ACP
41
+ * engine's mcpServers registration needs the same header name (the legacy
42
+ * engine gets it via .agents/mcp_config.json; ACP gets it via headers[]). */
43
+ export const TOKEN_HEADER = "x-bridge-token";
41
44
  const MAX_BODY_BYTES = 1_000_000;
42
45
 
43
46
  export interface McpServerHandle {
44
47
  port: number;
48
+ /** Shared secret for TOKEN_HEADER. Callers that register the bridge with
49
+ * an engine other than the legacy stream-json discovery file need it. */
50
+ token: string;
45
51
  close: () => Promise<void>;
46
52
  }
47
53
 
@@ -409,6 +415,7 @@ export async function startMcpServer(
409
415
  port,
410
416
  handle: {
411
417
  port,
418
+ token,
412
419
  close: async () => {
413
420
  await new Promise<void>((r) => httpServer.close(() => r()));
414
421
  removeBridgeMcpConfig();
package/src/models.ts CHANGED
@@ -273,8 +273,11 @@ function thinkingLevelMapFor(efforts: readonly AgyEffort[]): ThinkingLevelMap {
273
273
  return map as ThinkingLevelMap;
274
274
  }
275
275
 
276
- /** Project an agy entry to pi's Model shape. */
277
- export function toPiModel(entry: AgyModelEntry): Model<Api> {
276
+ /** Project an agy entry to pi's Model shape. `input` advertises accepted
277
+ * inputs: text-only (default) or text+image. The ACP engine forwards image
278
+ * blocks natively (probe 2026-09-03); the legacy CLI prompt is text-only, so
279
+ * the extension decides by engine at load time. */
280
+ export function toPiModel(entry: AgyModelEntry, input: Array<"text" | "image"> = ["text"]): Model<Api> {
278
281
  const effortDriven = !!entry.efforts && entry.efforts.length > 0;
279
282
  return {
280
283
  id: entry.id,
@@ -290,11 +293,10 @@ export function toPiModel(entry: AgyModelEntry): Model<Api> {
290
293
  // changed and agy rejects --effort for them.
291
294
  reasoning: effortDriven,
292
295
  ...(effortDriven ? { thinkingLevelMap: thinkingLevelMapFor(entry.efforts!) } : {}),
293
- // agy's -p prompt is text-only. Advertising image input would let pi
294
- // offer image attach, but extractUserPrompt silently drops image blocks,
295
- // so the user would be misled. Keep input text-only until agy supports
296
- // image passthrough in print mode.
297
- input: ["text"],
296
+ // Input advertising comes from the caller (engine-dependent): the ACP
297
+ // engine forwards image blocks; advertising images on the legacy engine
298
+ // would let pi offer image attach only for them to be dropped.
299
+ input,
298
300
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
299
301
  // Gemini long context. agy doesn't expose the real per-model window in
300
302
  // `agy models`; 1M is the documented Gemini ceiling.
package/src/provider.ts CHANGED
@@ -28,15 +28,16 @@ import {
28
28
  type ThinkingLevel,
29
29
  type Usage,
30
30
  } from "@earendil-works/pi-ai";
31
+ import fs from "node:fs";
31
32
  import type { Api } from "@earendil-works/pi-ai";
32
- import { AgyDriver, type DriverActivity, type TurnHandle } from "./driver.js";
33
+ import type { DriverActivity, TurnDriver, TurnHandle } from "./driver-types.js";
33
34
  import { toPiUsage } from "./stream-events.js";
34
35
  import { mapAgyToolToNative } from "./native-tools.js";
35
36
  import { type AgyEffort, type AgyModelEntry } from "./models.js";
36
37
  import { SessionStore } from "./sessions.js";
37
38
  import { loadConfig } from "./config.js";
38
39
  import path from "node:path";
39
- import { TurnDiffContext, createExecGitOps, parseEditToolInput } from "./diff-render.js";
40
+ import { TurnDiffContext, createExecGitOps, formatInlineDiff, parseEditToolInput } from "./diff-render.js";
40
41
 
41
42
  const DEFAULT_TIMEOUT_MIN = 10;
42
43
 
@@ -61,7 +62,7 @@ function extractUserPrompt(context: Context): string | null {
61
62
  if (!last || last.role !== "user") return null;
62
63
  const content = last.content;
63
64
  if (typeof content === "string") return content;
64
- // Flatten text blocks; drop images (agy CLI prompt is text-only via -p).
65
+ // Flatten text blocks; images ride separately via extractImages (ACP only).
65
66
  return content
66
67
  .filter((b): b is { type: "text"; text: string } => b.type === "text")
67
68
  .map((b) => b.text)
@@ -69,6 +70,17 @@ function extractUserPrompt(context: Context): string | null {
69
70
  .trim() || null;
70
71
  }
71
72
 
73
+ /** Image blocks of the latest user message (pi-ai ImageContent: base64 data
74
+ * + mimeType). The ACP engine forwards them as typed content blocks; the
75
+ * legacy CLI prompt is text-only, so its driver simply ignores these. */
76
+ function extractImages(context: Context): Array<{ data: string; mimeType: string }> {
77
+ const last = context.messages[context.messages.length - 1];
78
+ if (!last || last.role !== "user" || typeof last.content === "string") return [];
79
+ return last.content
80
+ .filter((b): b is { type: "image"; data: string; mimeType: string } => b.type === "image")
81
+ .map((b) => ({ data: b.data, mimeType: b.mimeType }));
82
+ }
83
+
72
84
  // --- G1: pi-side context digest --------------------------------------------------
73
85
  //
74
86
  // agy keeps its OWN conversation history (resumed via --conversation), so it
@@ -264,9 +276,17 @@ function newAssistant(model: Model<Api>): AssistantMessage {
264
276
 
265
277
  /** Session key: prefer pi's sessionId (stable per conversation), fall back to
266
278
  * cwd so a single pi process still resumes correctly when sessionId is absent. */
267
- function sessionKey(options: SimpleStreamOptions | undefined, cwd: string): string {
279
+ function sessionKey(
280
+ options: SimpleStreamOptions | undefined,
281
+ cwd: string,
282
+ engine: "stream-json" | "acp",
283
+ ): string {
268
284
  const sid = (options as { sessionId?: string } | undefined)?.sessionId;
269
- return sid && sid.length > 0 ? `sid:${sid}` : `cwd:${cwd}`;
285
+ const base = sid && sid.length > 0 ? `sid:${sid}` : `cwd:${cwd}`;
286
+ // Engine-scoped keys (plan 9.4): one ACP turn must never touch the legacy
287
+ // binding and vice versa. Un-suffixed keys = legacy, byte-compatible with
288
+ // every store that predates the ACP engine.
289
+ return base + (engine === "acp" ? "@acp" : "");
270
290
  }
271
291
 
272
292
  /** Track which content block is currently open so we close-on-switch.
@@ -281,9 +301,13 @@ export interface BlockState {
281
301
  export interface StreamSimpleDeps {
282
302
  entries: AgyModelEntry[];
283
303
  store: SessionStore;
284
- /** Persistent stream-json driver. Turns run on the driver and bridge
285
- * calls park as toolUse round-trips. Required with roundTrips. */
286
- driver?: AgyDriver;
304
+ /** Legacy stream-json driver (the tested default engine). Turns run on the
305
+ * driver and bridge calls park as toolUse round-trips. Required with
306
+ * roundTrips. */
307
+ driver?: TurnDriver;
308
+ /** Official-server ACP engine. Opt-in via config.engine = "acp"; when
309
+ * absent the config switch falls back to the legacy driver. */
310
+ acpDriver?: TurnDriver;
287
311
  roundTrips?: ToolRoundTrips;
288
312
  /** Replay store for the display-only antigravity wrapper tool. Required
289
313
  * for native re-exec and wrapper cards; without it tool steps render as
@@ -292,6 +316,10 @@ export interface StreamSimpleDeps {
292
316
  /** Whether a pi tool is active in the session; native re-exec toolCalls
293
317
  * are only emitted for active builtins (else the wrapper). */
294
318
  nativeActive?: (name: string) => boolean;
319
+ /** Engine latched at extension load. When omitted, the per-call config
320
+ * read decides (tests); production wiring always passes it so a
321
+ * mid-session config flip cannot move one side of a parked turn. */
322
+ engine?: "stream-json" | "acp";
295
323
  }
296
324
 
297
325
  /** pi thinking-effort order mirrors agy's, for clamping. */
@@ -390,11 +418,13 @@ export class WrapperReplay {
390
418
 
391
419
  export class ToolRoundTrips {
392
420
  #pending = new Map<string, PendingRoundTrip>();
393
- #driver: AgyDriver;
421
+ #getDriver: () => TurnDriver;
394
422
  #log: (s: string, d?: unknown) => void;
395
423
 
396
- constructor(driver: AgyDriver, log?: (s: string, d?: unknown) => void) {
397
- this.#driver = driver;
424
+ /** Accepts a driver or a getter: with two engines wired, the ACTIVE driver
425
+ * is resolved at call time from config (plan §9.5). */
426
+ constructor(driver: TurnDriver | (() => TurnDriver), log?: (s: string, d?: unknown) => void) {
427
+ this.#getDriver = typeof driver === "function" ? driver : () => driver;
398
428
  this.#log = log ?? (() => {});
399
429
  }
400
430
 
@@ -419,7 +449,7 @@ export class ToolRoundTrips {
419
449
  clearTimeout(entry.timer);
420
450
  if (entry.onAbort && entry.signal) entry.signal.removeEventListener("abort", entry.onAbort);
421
451
  entry.reject!(new Error(reason));
422
- this.#driver.kickIdle();
452
+ this.#getDriver().kickIdle();
423
453
  this.#log("round-trip-fail", { callId, name: entry.name, reason });
424
454
  }
425
455
 
@@ -431,7 +461,7 @@ export class ToolRoundTrips {
431
461
  args: Record<string, unknown>,
432
462
  signal: AbortSignal,
433
463
  ): Promise<BridgeCallResultShape> => {
434
- const handle = this.#driver.activeHandle;
464
+ const handle = this.#getDriver().activeHandle;
435
465
  if (!handle) {
436
466
  return Promise.reject(
437
467
  new Error(
@@ -469,7 +499,7 @@ export class ToolRoundTrips {
469
499
  return true;
470
500
  }
471
501
  entry.resolve!({ content: [{ type: "text", text }], isError });
472
- this.#driver.kickIdle();
502
+ this.#getDriver().kickIdle();
473
503
  this.#log("round-trip-resolved", { callId: toolCallId, name: entry.name, isError });
474
504
  return true;
475
505
  }
@@ -495,10 +525,12 @@ export function collectToolResults(
495
525
  // --- stream-json engine -------------------------------------------------------
496
526
 
497
527
  export interface DriverDeps {
498
- driver: AgyDriver;
528
+ driver: TurnDriver;
499
529
  roundTrips: ToolRoundTrips;
500
530
  replay?: WrapperReplay;
501
531
  nativeActive?: (name: string) => boolean;
532
+ /** Active engine (config), for engine-scoped session keys. */
533
+ engine: "stream-json" | "acp";
502
534
  }
503
535
 
504
536
  /** Map one DriverActivity onto the open pi stream. Returns "parked" when the
@@ -507,6 +539,7 @@ export interface ActivityFeatures {
507
539
  replay?: WrapperReplay;
508
540
  nativeActive?: (name: string) => boolean;
509
541
  roundTrips?: ToolRoundTrips;
542
+ engine?: "stream-json" | "acp";
510
543
  }
511
544
 
512
545
  /** Process-wide counter: round-trip ids must never repeat across turns in
@@ -537,6 +570,12 @@ function emitToolUse(
537
570
  stream.end();
538
571
  }
539
572
 
573
+ /** ACP edit-class tool names that carry a file-path arg and land the file on
574
+ * disk (observed live: edit_file, create_file). Read/execute tools also
575
+ * match the arg-shape heuristic but produce no diff (unchanged content), so
576
+ * the gate avoids mislabeling them as edits. */
577
+ const ACP_EDIT_TOOLS = new Set(["edit_file", "create_file", "write_to_file"]);
578
+
540
579
  export function consumeActivity(
541
580
  stream: AssistantMessageEventStream,
542
581
  blocks: BlockState,
@@ -551,7 +590,11 @@ export function consumeActivity(
551
590
  appendText(stream, blocks, activity.delta);
552
591
  return "continue";
553
592
  case "thought":
554
- // agy reports a token count only; no text body to render.
593
+ // Legacy: token count only (no body). ACP: thought TEXT deltas —
594
+ // rendered through the same thinking block pipeline (9.2).
595
+ if (typeof activity.delta === "string" && activity.delta.length > 0) {
596
+ appendThinking(stream, blocks, activity.delta);
597
+ }
555
598
  return "continue";
556
599
  case "usage":
557
600
  toPiUsage(activity.usage, partial.usage);
@@ -559,8 +602,51 @@ export function consumeActivity(
559
602
  case "tool_start":
560
603
  // Rendering happens on completion (output/diff available).
561
604
  return "continue";
605
+ /** File-path argument of an ACP edit tool (observed: `file_path`); other
606
+ * tool kinds (execute, read) don't carry one. Returns undefined for
607
+ * non-edit tools. */
608
+ function acpEditFileArg(args: Record<string, unknown>): string | undefined {
609
+ for (const [k, v] of Object.entries(args)) {
610
+ if (typeof v === "string" && v.trim() && /file|path/i.test(k)) return v;
611
+ }
612
+ return undefined;
613
+ }
614
+
562
615
  case "tool_done": {
563
- // G8: agy file edits surface a git-sourced diff in a thinking block.
616
+ // Gate C (ACP): the server already executed the tool; nothing parks.
617
+ // Edit display has TWO paths on ACP:
618
+ // a) native diff in tool_call content[] (future builds / the
619
+ // permission-request flow carries it; phase-2 probe shape);
620
+ // b) RC01 with the auto policy sends NO content: the file simply
621
+ // lands on disk. Read it and diff against git HEAD - the same
622
+ // output the stream-json engine produces, one readFileSync.
623
+ if (feats.engine === "acp") {
624
+ const d = activity.diff;
625
+ if (d) {
626
+ appendThinking(stream, blocks, `[agy edit: ${path.basename(d.path)}]\n`);
627
+ const diffText = formatInlineDiff(d.oldText ?? "", d.newText);
628
+ if (diffText) appendThinking(stream, blocks, `${diffText}\n`);
629
+ return "continue";
630
+ }
631
+ const editFile = ACP_EDIT_TOOLS.has(activity.name) ? acpEditFileArg(activity.args) : undefined;
632
+ if (editFile) {
633
+ const absFile = path.isAbsolute(editFile) ? editFile : path.resolve(cwd, editFile);
634
+ appendThinking(stream, blocks, `[agy edit: ${path.basename(absFile)}]\n`);
635
+ let disk = "";
636
+ try {
637
+ disk = fs.readFileSync(absFile, "utf8");
638
+ } catch {
639
+ /* deleted or unreadable: diffEdit degrades to a summary */
640
+ }
641
+ const outcome = diffCtx.diffEdit(absFile, disk);
642
+ if (outcome.text) appendThinking(stream, blocks, `${outcome.text}\n`);
643
+ return "continue";
644
+ }
645
+ appendThinking(stream, blocks, `[agy tool: ${activity.name}]\n`);
646
+ return "continue";
647
+ }
648
+ // G8 (stream-json): agy file edits surface a git-sourced diff in a
649
+ // thinking block. The server sends no diff here, so OLD comes from git.
564
650
  let inputJson: string | undefined;
565
651
  try {
566
652
  inputJson = JSON.stringify(activity.args);
@@ -629,7 +715,7 @@ async function runTurnDriver(
629
715
  const blocks: BlockState = { partial, textIdx: null, thinkingIdx: null, started: false };
630
716
 
631
717
  const cwd = (options as { cwd?: string } | undefined)?.cwd ?? process.cwd();
632
- const key = sessionKey(options, cwd);
718
+ const key = sessionKey(options, cwd, deps.engine);
633
719
  const existing = store.get(key);
634
720
  const messageCount = context.messages.length;
635
721
  const config = loadConfig();
@@ -651,7 +737,10 @@ async function runTurnDriver(
651
737
  handle = active;
652
738
  } else {
653
739
  const prompt = extractUserPrompt(context);
654
- if (!prompt) {
740
+ const images = extractImages(context);
741
+ // An image-only message (no text) is valid on the ACP engine; only fail
742
+ // when there is nothing at all to send.
743
+ if (!prompt && images.length === 0) {
655
744
  finalize(stream, blocks, "error", "No user message to send to agy.");
656
745
  return;
657
746
  }
@@ -660,11 +749,20 @@ async function runTurnDriver(
660
749
  const effort = entry?.efforts?.length ? toAgyEffort(options?.reasoning, entry.efforts) : undefined;
661
750
  const watermark = existing?.lastMessageCount ?? 0;
662
751
  const digest = config.digest ? buildContextDigest(context.messages, watermark) : "";
752
+ // G1 delivery per engine. stream-json: digest rides inline in the prompt
753
+ // (the CLI has no context channel). ACP: the server advertises
754
+ // `embeddedContext`, so the digest ships as a native resource block
755
+ // instead of prompt text (plan phase 3). The preamble framing goes INTO
756
+ // the block: an unlabeled blob of other-agent turns is a mild injection
757
+ // surface, and the model needs the use-for-continuity-only instruction.
758
+ // The uri is suffixed per turn so a deduping server cannot serve stale
759
+ // content on turn 2+.
760
+ const embeddedDigest = deps.engine === "acp" && digest ? digest : undefined;
663
761
  // Fresh conversation only: agy stores the block in its own history, so
664
762
  // re-sending it every turn would bloat each prompt and bust the cache.
665
763
  const sysPrompt =
666
764
  config.systemPrompt && !existing?.conversationId ? context.systemPrompt : undefined;
667
- const fullPrompt = buildFullPrompt(sysPrompt, digest, prompt);
765
+ const fullPrompt = buildFullPrompt(sysPrompt, embeddedDigest ? "" : digest, prompt ?? "");
668
766
  try {
669
767
  handle = await deps.driver.run({
670
768
  cwd,
@@ -674,6 +772,13 @@ async function runTurnDriver(
674
772
  skipPermissions: config.skipPermissions,
675
773
  conversationId: existing?.conversationId ?? null,
676
774
  prompt: fullPrompt,
775
+ images: images.length > 0 ? images : undefined,
776
+ contextBlock: embeddedDigest
777
+ ? {
778
+ uri: `urn:pi-bridge:context-digest/${messageCount}`,
779
+ text: `${DIGEST_PREAMBLE}\n\n${embeddedDigest}`,
780
+ }
781
+ : undefined,
677
782
  signal: options?.signal,
678
783
  });
679
784
  } catch (err) {
@@ -689,6 +794,7 @@ async function runTurnDriver(
689
794
  replay: deps.replay,
690
795
  nativeActive: deps.nativeActive,
691
796
  roundTrips: deps.roundTrips,
797
+ engine: deps.engine,
692
798
  };
693
799
 
694
800
  for (;;) {
@@ -729,17 +835,36 @@ async function runTurnDriver(
729
835
  export function createStreamSimple(
730
836
  deps: StreamSimpleDeps,
731
837
  ): (model: Model<Api>, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream {
732
- const { entries, store, driver, roundTrips } = deps;
838
+ const { entries, store, roundTrips } = deps;
733
839
 
734
840
  return function streamSimple(model, context, options) {
735
841
  const stream = createAssistantMessageEventStream();
736
842
  // Fire the async turn; return the stream synchronously per pi's contract.
737
- if (driver && roundTrips) {
843
+ // Engine selection: the latched load-time engine when the extension
844
+ // provides one, else the per-call config read (tests). One loadConfig()
845
+ // read per turn, shared with the session key below.
846
+ const config = loadConfig();
847
+ const engine = deps.engine ?? config.engine;
848
+ const selected = engine === "acp" && deps.acpDriver ? deps.acpDriver : deps.driver;
849
+ // RC01: ACP modes are permission modes, there is no review-only plan.
850
+ // A plan turn on ACP would silently run non-plan; fail visibly instead.
851
+ if (selected === deps.acpDriver && config.mode === "plan") {
852
+ const partial = newAssistant(model);
853
+ const blocks: BlockState = { partial, textIdx: null, thinkingIdx: null, started: false };
854
+ finalize(stream, blocks, "error", "ACP engine has no plan mode. /agy mode accept-edits, or /agy engine stream-json.");
855
+ return stream;
856
+ }
857
+ if (selected && roundTrips) {
738
858
  void runTurnDriver(stream, model, context, options, entries, store, {
739
- driver,
859
+ driver: selected,
740
860
  roundTrips,
741
861
  replay: deps.replay,
742
862
  nativeActive: deps.nativeActive,
863
+ // Record the engine of the driver that will ACTUALLY run: if the
864
+ // ACP driver is absent, the config switch falls back to legacy,
865
+ // and keying the session as @acp would store a legacy
866
+ // conversationId under the wrong engine scope.
867
+ engine: selected === deps.acpDriver ? "acp" : "stream-json",
743
868
  });
744
869
  } else {
745
870
  // Miswired extension: no driver means no engine. Fail the turn visibly