@estebanforge/pi-antigravity-bridge 1.3.2 → 1.4.0

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;
@@ -525,13 +484,26 @@ export class AgyDriver {
525
484
  #appendAgentText(turn: ActiveTurn, text: string): void {
526
485
  // response_text is observed as a delta stream; guard against builds that
527
486
  // resend the full text. A cumulative sender's second chunk CONTAINS
528
- // everything accumulated so far as a prefix.
487
+ // everything accumulated so far as a prefix. Two guards against
488
+ // misflips (round-7 review): short accumulations never flip, and a
489
+ // cumulative frame that no longer extends the accumulator is evidence
490
+ // of a misflip - fall back to append mode.
529
491
  if (turn.cumulativeText === undefined) {
530
492
  turn.cumulativeText = false;
531
- } else if (!turn.cumulativeText && isCumulativeResend(turn.response, text)) {
493
+ } else if (
494
+ !turn.cumulativeText &&
495
+ shouldFlipToCumulative(turn.response, text)
496
+ ) {
532
497
  turn.cumulativeText = true;
533
498
  }
534
499
  if (turn.cumulativeText) {
500
+ if (!text.startsWith(turn.response)) {
501
+ // Misflip evidence: back to deltas.
502
+ turn.cumulativeText = false;
503
+ turn.response += text;
504
+ emit(turn, { type: "text", delta: text });
505
+ return;
506
+ }
535
507
  if (text.length > turn.response.length) {
536
508
  const delta = text.slice(turn.response.length);
537
509
  turn.response = text;
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
@@ -84,6 +96,43 @@ const COMPACTION_MARKER = "compacted into the following summary";
84
96
  const DIGEST_PREAMBLE =
85
97
  "[The following is context from the broader pi session that this Antigravity turn was not directly spawned for: compaction summaries and turns handled by other providers or pi's own tools. Your own prior turns are already in your conversation history. Use this for continuity only.]";
86
98
 
99
+ // --- pi system prompt (G10) ----------------------------------------------------
100
+ //
101
+ // pi composes context.systemPrompt every turn: its own operating instructions
102
+ // plus every AGENTS.md/CLAUDE.md it loaded (global agent dir first, then
103
+ // ancestors). The provider used to drop it, so agy models never saw the user's
104
+ // machine-level or project-level instructions. agy has no system-prompt flag
105
+ // (verified against `agy --help`), so the only delivery path is the prompt
106
+ // text. We prepend it as a delimited block on the FIRST prompt of a fresh
107
+ // conversation only: agy keeps its own history, the block stays byte-identical
108
+ // afterwards, and agy's server-side prompt cache keeps hitting.
109
+
110
+ export const SYSTEM_PROMPT_PREAMBLE =
111
+ "[The following is the system prompt of the pi session that spawned this conversation: operating instructions plus project context (AGENTS.md files). Apply it for this whole conversation. Tool guidance may reference pi-side tools; use your own tools or the pi tool bridge for those actions.]";
112
+
113
+ export const SYSTEM_PROMPT_END = "[END SYSTEM PROMPT]";
114
+
115
+ /** Assemble the full agy prompt: system prompt block, pi-side digest, user
116
+ * prompt. Empty parts are dropped. Pure; exported for unit testing.
117
+ * Pass systemPrompt only on a fresh conversation (see runTurnDriver). */
118
+ export function buildFullPrompt(
119
+ systemPrompt: string | undefined,
120
+ digest: string,
121
+ prompt: string,
122
+ ): string {
123
+ const parts: string[] = [];
124
+ if (systemPrompt) {
125
+ parts.push(`${SYSTEM_PROMPT_PREAMBLE}\n\n${systemPrompt}\n\n${SYSTEM_PROMPT_END}`);
126
+ }
127
+ if (digest) {
128
+ parts.push(`${DIGEST_PREAMBLE}\n\n${digest}`);
129
+ }
130
+ if (prompt) {
131
+ parts.push(prompt);
132
+ }
133
+ return parts.join("\n\n---\n\n");
134
+ }
135
+
87
136
  /** Flatten any message content shape (string or content-block array) to text.
88
137
  * Drops images, thinking, and tool-call blocks. */
89
138
  function blocksToText(content: unknown): string {
@@ -227,9 +276,17 @@ function newAssistant(model: Model<Api>): AssistantMessage {
227
276
 
228
277
  /** Session key: prefer pi's sessionId (stable per conversation), fall back to
229
278
  * cwd so a single pi process still resumes correctly when sessionId is absent. */
230
- 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 {
231
284
  const sid = (options as { sessionId?: string } | undefined)?.sessionId;
232
- 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" : "");
233
290
  }
234
291
 
235
292
  /** Track which content block is currently open so we close-on-switch.
@@ -244,9 +301,13 @@ export interface BlockState {
244
301
  export interface StreamSimpleDeps {
245
302
  entries: AgyModelEntry[];
246
303
  store: SessionStore;
247
- /** Persistent stream-json driver. Turns run on the driver and bridge
248
- * calls park as toolUse round-trips. Required with roundTrips. */
249
- 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;
250
311
  roundTrips?: ToolRoundTrips;
251
312
  /** Replay store for the display-only antigravity wrapper tool. Required
252
313
  * for native re-exec and wrapper cards; without it tool steps render as
@@ -255,6 +316,10 @@ export interface StreamSimpleDeps {
255
316
  /** Whether a pi tool is active in the session; native re-exec toolCalls
256
317
  * are only emitted for active builtins (else the wrapper). */
257
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";
258
323
  }
259
324
 
260
325
  /** pi thinking-effort order mirrors agy's, for clamping. */
@@ -353,11 +418,13 @@ export class WrapperReplay {
353
418
 
354
419
  export class ToolRoundTrips {
355
420
  #pending = new Map<string, PendingRoundTrip>();
356
- #driver: AgyDriver;
421
+ #getDriver: () => TurnDriver;
357
422
  #log: (s: string, d?: unknown) => void;
358
423
 
359
- constructor(driver: AgyDriver, log?: (s: string, d?: unknown) => void) {
360
- 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;
361
428
  this.#log = log ?? (() => {});
362
429
  }
363
430
 
@@ -382,7 +449,7 @@ export class ToolRoundTrips {
382
449
  clearTimeout(entry.timer);
383
450
  if (entry.onAbort && entry.signal) entry.signal.removeEventListener("abort", entry.onAbort);
384
451
  entry.reject!(new Error(reason));
385
- this.#driver.kickIdle();
452
+ this.#getDriver().kickIdle();
386
453
  this.#log("round-trip-fail", { callId, name: entry.name, reason });
387
454
  }
388
455
 
@@ -394,7 +461,7 @@ export class ToolRoundTrips {
394
461
  args: Record<string, unknown>,
395
462
  signal: AbortSignal,
396
463
  ): Promise<BridgeCallResultShape> => {
397
- const handle = this.#driver.activeHandle;
464
+ const handle = this.#getDriver().activeHandle;
398
465
  if (!handle) {
399
466
  return Promise.reject(
400
467
  new Error(
@@ -432,7 +499,7 @@ export class ToolRoundTrips {
432
499
  return true;
433
500
  }
434
501
  entry.resolve!({ content: [{ type: "text", text }], isError });
435
- this.#driver.kickIdle();
502
+ this.#getDriver().kickIdle();
436
503
  this.#log("round-trip-resolved", { callId: toolCallId, name: entry.name, isError });
437
504
  return true;
438
505
  }
@@ -458,10 +525,12 @@ export function collectToolResults(
458
525
  // --- stream-json engine -------------------------------------------------------
459
526
 
460
527
  export interface DriverDeps {
461
- driver: AgyDriver;
528
+ driver: TurnDriver;
462
529
  roundTrips: ToolRoundTrips;
463
530
  replay?: WrapperReplay;
464
531
  nativeActive?: (name: string) => boolean;
532
+ /** Active engine (config), for engine-scoped session keys. */
533
+ engine: "stream-json" | "acp";
465
534
  }
466
535
 
467
536
  /** Map one DriverActivity onto the open pi stream. Returns "parked" when the
@@ -470,6 +539,7 @@ export interface ActivityFeatures {
470
539
  replay?: WrapperReplay;
471
540
  nativeActive?: (name: string) => boolean;
472
541
  roundTrips?: ToolRoundTrips;
542
+ engine?: "stream-json" | "acp";
473
543
  }
474
544
 
475
545
  /** Process-wide counter: round-trip ids must never repeat across turns in
@@ -500,6 +570,12 @@ function emitToolUse(
500
570
  stream.end();
501
571
  }
502
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
+
503
579
  export function consumeActivity(
504
580
  stream: AssistantMessageEventStream,
505
581
  blocks: BlockState,
@@ -514,7 +590,11 @@ export function consumeActivity(
514
590
  appendText(stream, blocks, activity.delta);
515
591
  return "continue";
516
592
  case "thought":
517
- // 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
+ }
518
598
  return "continue";
519
599
  case "usage":
520
600
  toPiUsage(activity.usage, partial.usage);
@@ -522,8 +602,51 @@ export function consumeActivity(
522
602
  case "tool_start":
523
603
  // Rendering happens on completion (output/diff available).
524
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
+
525
615
  case "tool_done": {
526
- // 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.
527
650
  let inputJson: string | undefined;
528
651
  try {
529
652
  inputJson = JSON.stringify(activity.args);
@@ -592,7 +715,7 @@ async function runTurnDriver(
592
715
  const blocks: BlockState = { partial, textIdx: null, thinkingIdx: null, started: false };
593
716
 
594
717
  const cwd = (options as { cwd?: string } | undefined)?.cwd ?? process.cwd();
595
- const key = sessionKey(options, cwd);
718
+ const key = sessionKey(options, cwd, deps.engine);
596
719
  const existing = store.get(key);
597
720
  const messageCount = context.messages.length;
598
721
  const config = loadConfig();
@@ -614,7 +737,10 @@ async function runTurnDriver(
614
737
  handle = active;
615
738
  } else {
616
739
  const prompt = extractUserPrompt(context);
617
- 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) {
618
744
  finalize(stream, blocks, "error", "No user message to send to agy.");
619
745
  return;
620
746
  }
@@ -623,7 +749,20 @@ async function runTurnDriver(
623
749
  const effort = entry?.efforts?.length ? toAgyEffort(options?.reasoning, entry.efforts) : undefined;
624
750
  const watermark = existing?.lastMessageCount ?? 0;
625
751
  const digest = config.digest ? buildContextDigest(context.messages, watermark) : "";
626
- const fullPrompt = digest ? `${DIGEST_PREAMBLE}\n\n${digest}\n\n---\n\n${prompt}` : prompt;
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;
761
+ // Fresh conversation only: agy stores the block in its own history, so
762
+ // re-sending it every turn would bloat each prompt and bust the cache.
763
+ const sysPrompt =
764
+ config.systemPrompt && !existing?.conversationId ? context.systemPrompt : undefined;
765
+ const fullPrompt = buildFullPrompt(sysPrompt, embeddedDigest ? "" : digest, prompt ?? "");
627
766
  try {
628
767
  handle = await deps.driver.run({
629
768
  cwd,
@@ -633,6 +772,13 @@ async function runTurnDriver(
633
772
  skipPermissions: config.skipPermissions,
634
773
  conversationId: existing?.conversationId ?? null,
635
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,
636
782
  signal: options?.signal,
637
783
  });
638
784
  } catch (err) {
@@ -648,6 +794,7 @@ async function runTurnDriver(
648
794
  replay: deps.replay,
649
795
  nativeActive: deps.nativeActive,
650
796
  roundTrips: deps.roundTrips,
797
+ engine: deps.engine,
651
798
  };
652
799
 
653
800
  for (;;) {
@@ -688,17 +835,28 @@ async function runTurnDriver(
688
835
  export function createStreamSimple(
689
836
  deps: StreamSimpleDeps,
690
837
  ): (model: Model<Api>, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream {
691
- const { entries, store, driver, roundTrips } = deps;
838
+ const { entries, store, roundTrips } = deps;
692
839
 
693
840
  return function streamSimple(model, context, options) {
694
841
  const stream = createAssistantMessageEventStream();
695
842
  // Fire the async turn; return the stream synchronously per pi's contract.
696
- 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
+ if (selected && roundTrips) {
697
850
  void runTurnDriver(stream, model, context, options, entries, store, {
698
- driver,
851
+ driver: selected,
699
852
  roundTrips,
700
853
  replay: deps.replay,
701
854
  nativeActive: deps.nativeActive,
855
+ // Record the engine of the driver that will ACTUALLY run: if the
856
+ // ACP driver is absent, the config switch falls back to legacy,
857
+ // and keying the session as @acp would store a legacy
858
+ // conversationId under the wrong engine scope.
859
+ engine: selected === deps.acpDriver ? "acp" : "stream-json",
702
860
  });
703
861
  } else {
704
862
  // Miswired extension: no driver means no engine. Fail the turn visibly