@estebanforge/pi-antigravity-bridge 1.8.7 → 1.9.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.
@@ -32,7 +32,8 @@ export interface DriverTurnRequest extends DriverProfile {
32
32
  prompt: string;
33
33
  /** Image blocks riding with the prompt. ACP forwards them as typed
34
34
  * content blocks (probe 2026-09-03: 64x64 two-tone PNG answered
35
- * correctly); the stream-json CLI prompt is text-only and ignores them. */
35
+ * correctly); on stream-json the provider stages them to files, so the
36
+ * driver itself ignores this field. */
36
37
  images?: Array<{ data: string; mimeType: string }>;
37
38
  /** ACP only: pi-side context delivered as a native `embeddedContext`
38
39
  * resource block instead of inline prompt text (G1 on ACP). Stream-json
package/src/driver.ts CHANGED
@@ -21,7 +21,9 @@ import { isKnownNoiseLine, MAX_FRAME_BYTES, stripGluedNoise } from "./frame-guar
21
21
  import { bridgeMcpConfigDir, bridgeMcpConfigExists } from "./mcp-server.js";
22
22
  import { gateHooksStaged } from "./approval-hook.js";
23
23
  import { parkedTurnAnswer } from "./parked-turn.js";
24
+ import { defaultCliSettingsPath, ensureQueuedMessagesInFile } from "./queued-messages.js";
24
25
  import { redactText } from "./redact.js";
26
+ import { StreamSanitizer, sanitizeSystemMessage } from "./stream-sanitizer.js";
25
27
  import type {
26
28
  AgyUsage,
27
29
  DriverActivity,
@@ -44,6 +46,8 @@ export type {
44
46
  TurnOutcome,
45
47
  } from "./driver-types.js";
46
48
 
49
+ export { StreamSanitizer, sanitizeSystemMessage };
50
+
47
51
  const LIFECYCLE_LIMIT = 24;
48
52
  const THINKING_TOKEN_FLOOR = 64;
49
53
 
@@ -61,6 +65,8 @@ interface ActiveTurn {
61
65
  idleTimer?: NodeJS.Timeout;
62
66
  onAbort?: () => void;
63
67
  response: string;
68
+ rawResponse: string;
69
+ sanitizer: StreamSanitizer;
64
70
  usage?: AgyUsage;
65
71
  conversationId?: string;
66
72
  /** Wall clock at turn creation; the parked-turn probe filters
@@ -305,6 +311,8 @@ export class StreamDriver implements TurnDriver {
305
311
  resolve,
306
312
  outcome,
307
313
  response: "",
314
+ rawResponse: "",
315
+ sanitizer: new StreamSanitizer(),
308
316
  sawResult: false,
309
317
  sawModelStep: false,
310
318
  cumulativeText: undefined,
@@ -370,6 +378,8 @@ export class StreamDriver implements TurnDriver {
370
378
  this.#boundConversation = request.conversationId ?? undefined;
371
379
  this.#stderrTail = "";
372
380
 
381
+ ensureQueuedMessagesInFile(defaultCliSettingsPath());
382
+
373
383
  const args: string[] = ["--add-dir", request.cwd];
374
384
  // The bridge dir carries .agents/mcp_config.json (tool bridge) and, when
375
385
  // the approval gate is on, .agents/hooks.json (gate staging). Only this
@@ -571,7 +581,11 @@ export class StreamDriver implements TurnDriver {
571
581
  const ok = r.status === "OK" || r.status === "SUCCESS";
572
582
  const status = ok ? "OK" : "ERROR";
573
583
  // Prefer the streamed accumulation, fall back to the result body.
574
- const response = turn.response || (typeof r.response === "string" ? r.response : "");
584
+ const fallback = typeof r.response === "string" ? sanitizeSystemMessage(r.response) : "";
585
+ const response = turn.response || fallback;
586
+ if (!turn.response && fallback) {
587
+ turn.sanitizer.reset();
588
+ }
575
589
  turn.response = response;
576
590
  this.#settle(turn, {
577
591
  conversationId: turn.conversationId,
@@ -596,36 +610,54 @@ export class StreamDriver implements TurnDriver {
596
610
  // misflips (round-7 review): short accumulations never flip, and a
597
611
  // cumulative frame that no longer extends the accumulator is evidence
598
612
  // of a misflip - fall back to append mode.
613
+ let rawDelta = "";
599
614
  if (turn.cumulativeText === undefined) {
600
615
  turn.cumulativeText = false;
601
616
  } else if (
602
617
  !turn.cumulativeText &&
603
- shouldFlipToCumulative(turn.response, text)
618
+ shouldFlipToCumulative(turn.rawResponse, text)
604
619
  ) {
605
620
  turn.cumulativeText = true;
606
621
  }
607
622
  if (turn.cumulativeText) {
608
- if (!text.startsWith(turn.response)) {
623
+ if (!text.startsWith(turn.rawResponse)) {
609
624
  // Misflip evidence: back to deltas.
610
625
  turn.cumulativeText = false;
611
- turn.response += text;
612
- emit(turn, { type: "text", delta: text });
626
+ turn.rawResponse += text;
627
+ rawDelta = text;
628
+ } else if (text.length > turn.rawResponse.length) {
629
+ rawDelta = text.slice(turn.rawResponse.length);
630
+ turn.rawResponse = text;
631
+ } else {
613
632
  return;
614
633
  }
615
- if (text.length > turn.response.length) {
616
- const delta = text.slice(turn.response.length);
617
- turn.response = text;
618
- emit(turn, { type: "text", delta });
634
+ } else {
635
+ turn.rawResponse += text;
636
+ rawDelta = text;
637
+ }
638
+
639
+ if (rawDelta.length > 0) {
640
+ const sanitizedDelta = turn.sanitizer.push(rawDelta);
641
+ if (sanitizedDelta.length > 0) {
642
+ turn.response += sanitizedDelta;
643
+ emit(turn, { type: "text", delta: sanitizedDelta });
619
644
  }
620
- return;
621
645
  }
622
- turn.response += text;
623
- emit(turn, { type: "text", delta: text });
624
646
  }
625
647
 
626
648
  #settle(turn: ActiveTurn, outcome: TurnOutcome): void {
627
649
  if (turn.closed) return;
628
650
  turn.closed = true;
651
+ const flushed = turn.sanitizer.flush();
652
+ if (flushed.length > 0) {
653
+ turn.response += flushed;
654
+ emit(turn, { type: "text", delta: flushed });
655
+ }
656
+ if (outcome.deadline && outcome.status === "OK") {
657
+ outcome.response = outcome.response || turn.response;
658
+ } else {
659
+ outcome.response = turn.response || outcome.response;
660
+ }
629
661
  if (turn.overallTimer) clearTimeout(turn.overallTimer);
630
662
  if (turn.idleTimer) clearTimeout(turn.idleTimer);
631
663
  if (turn.onAbort && turn.request.signal) {
@@ -680,7 +712,7 @@ export class StreamDriver implements TurnDriver {
680
712
  this.#settle(turn, {
681
713
  conversationId: turn.conversationId,
682
714
  status: "OK",
683
- response: parked,
715
+ response: sanitizeSystemMessage(parked),
684
716
  usage: turn.usage,
685
717
  finished: true,
686
718
  aborted: false,
@@ -221,7 +221,12 @@ function bestEffortWriteMarker(file: string, marker: SuppressionMarker): void {
221
221
  }
222
222
  }
223
223
 
224
- const suppressionRefs = new Map<string, number>();
224
+ // Pi replaces the runtime on /new,/resume,/fork,/reload: module state dies
225
+ // but globalThis survives within the process. The refcount must live there,
226
+ // or an old runtime's late release re-enables entries under a new runtime's
227
+ // live delegation.
228
+ const SUPPRESSION_REFS_KEY = Symbol.for("pi-antigravity-bridge.suppression-refs");
229
+ const suppressionRefs: Map<string, number> = ((globalThis as Record<symbol, unknown>)[SUPPRESSION_REFS_KEY] ??= new Map()) as Map<string, number>;
225
230
 
226
231
  /** Reference-counted suppression for a self-spawned agy process (AskAntigravity
227
232
  * delegation). First acquire disables every pi-bridge-* entry AND records
package/src/models.ts CHANGED
@@ -361,9 +361,8 @@ function thinkingLevelMapFor(efforts: readonly AgyEffort[]): ThinkingLevelMap {
361
361
  }
362
362
 
363
363
  /** Project an agy entry to pi's Model shape. `input` advertises accepted
364
- * inputs: text-only (default) or text+image. The ACP engine forwards image
365
- * blocks natively (probe 2026-09-03); the stream-json CLI prompt is text-only, so
366
- * the extension decides by engine at load time. */
364
+ * inputs; both engines accept images (ACP native content blocks,
365
+ * stream-json staged files), so callers pass ["text", "image"] uniformly. */
367
366
  export function toPiModel(entry: AgyModelEntry, input: Array<"text" | "image"> = ["text"]): Model<Api> {
368
367
  const effortDriven = !!entry.efforts && entry.efforts.length > 0;
369
368
  return {
@@ -380,9 +379,8 @@ export function toPiModel(entry: AgyModelEntry, input: Array<"text" | "image"> =
380
379
  // none is ever sent.
381
380
  reasoning: effortDriven,
382
381
  ...(effortDriven ? { thinkingLevelMap: thinkingLevelMapFor(entry.efforts!) } : {}),
383
- // Input advertising comes from the caller (engine-dependent): the ACP
384
- // engine forwards image blocks; advertising images on the stream engine
385
- // would let pi offer image attach only for them to be dropped.
382
+ // Input advertising comes from the caller: both engines deliver
383
+ // user-attached images now.
386
384
  input,
387
385
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
388
386
  // Gemini long context. agy doesn't expose the real per-model window in
@@ -0,0 +1,92 @@
1
+ // Stream-json prompt staging: user-attached image blocks cannot ride the CLI's
2
+ // text-only turn input, so they are staged to temp files and the prompt points
3
+ // the model at them. The model views them through the bridge read tool, whose
4
+ // tool-result image content the CLI's MCP client delivers (probe 2026-09-07).
5
+ // The ACP engine does not use this module: image blocks ride session/prompt
6
+ // natively.
7
+
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+
12
+ export const STAGE_DIR_PREFIX = "pi-bridge-img-";
13
+
14
+ /** Retention for staged files: the model may re-read them on later turns of
15
+ * the same conversation, so sweeps cannot delete a dir this turn's prompt
16
+ * still references. */
17
+ export const STAGE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
18
+
19
+ export interface StagedImage {
20
+ data: string;
21
+ mimeType: string;
22
+ }
23
+
24
+ const EXT_BY_MIME: Record<string, string> = {
25
+ "image/png": ".png",
26
+ "image/jpeg": ".jpg",
27
+ "image/gif": ".gif",
28
+ "image/webp": ".webp",
29
+ "image/bmp": ".bmp",
30
+ };
31
+
32
+ function sweepStale(root: string): void {
33
+ let entries: string[];
34
+ try {
35
+ entries = fs.readdirSync(root);
36
+ } catch {
37
+ return;
38
+ }
39
+ const cutoff = Date.now() - STAGE_MAX_AGE_MS;
40
+ for (const entry of entries) {
41
+ if (!entry.startsWith(STAGE_DIR_PREFIX)) continue;
42
+ const full = path.join(root, entry);
43
+ try {
44
+ // lstat: never follow a symlink planted at our prefix in a shared tmp.
45
+ if (!fs.lstatSync(full).isDirectory()) continue;
46
+ if (fs.statSync(full).mtimeMs < cutoff) fs.rmSync(full, { recursive: true, force: true });
47
+ } catch {
48
+ // Raced with another process's sweep or deletion: nothing to do.
49
+ }
50
+ }
51
+ }
52
+
53
+ /** Write image blocks to a fresh staging dir under `root` (default the OS tmp
54
+ * dir). Returns null on any failure (including an empty/malformed payload):
55
+ * the turn then runs text-only instead of failing. */
56
+ export function stagePromptImages(
57
+ images: Array<StagedImage>,
58
+ root?: string,
59
+ ): { dir: string; paths: string[] } | null {
60
+ const base = root ?? os.tmpdir();
61
+ let dir: string | null = null;
62
+ try {
63
+ sweepStale(base);
64
+ dir = fs.mkdtempSync(path.join(base, STAGE_DIR_PREFIX));
65
+ const paths = images.map((img, i) => {
66
+ const bytes = Buffer.from(img.data, "base64");
67
+ if (bytes.length === 0) throw new Error("empty image payload");
68
+ const ext = EXT_BY_MIME[img.mimeType] ?? ".bin";
69
+ const file = path.join(dir!, `image-${i + 1}${ext}`);
70
+ fs.writeFileSync(file, bytes, { mode: 0o600 });
71
+ return file;
72
+ });
73
+ return { dir, paths };
74
+ } catch {
75
+ // A mid-write failure must not leak a partial dir; the 24h sweep would
76
+ // reap it anyway, but the next staging call in this process is likely
77
+ // seconds away.
78
+ if (dir) {
79
+ try {
80
+ fs.rmSync(dir, { recursive: true, force: true });
81
+ } catch {
82
+ // Sweep reaps it later.
83
+ }
84
+ }
85
+ return null;
86
+ }
87
+ }
88
+
89
+ export function buildImageNote(paths: string[]): string {
90
+ const list = paths.map((p) => `- ${p}`).join("\n");
91
+ return `[The user attached ${paths.length} image(s) to this message. They are staged at:\n${list}\nView them by calling the pi read tool on these exact paths; the image content is delivered to you directly.]`;
92
+ }
package/src/provider.ts CHANGED
@@ -20,6 +20,7 @@
20
20
  import {
21
21
  createAssistantMessageEventStream,
22
22
  getCurrentSystemPrompt,
23
+ getCurrentTools,
23
24
  type AssistantMessage,
24
25
  type AssistantMessageEventStream,
25
26
  type JsonValue,
@@ -44,6 +45,7 @@ import type { ApprovalDecision, ApprovalPayload, ApprovalParkApi } from "./mcp-s
44
45
  import path from "node:path";
45
46
  import { TurnDiffContext, createExecGitOps, formatInlineDiff, parseEditToolInput } from "./diff-render.js";
46
47
  import { stageSyspromptAgent } from "./sysprompt-agent.js";
48
+ import { buildImageNote, stagePromptImages } from "./prompt-images.js";
47
49
 
48
50
  const DEFAULT_TIMEOUT_MIN = 10;
49
51
 
@@ -51,6 +53,29 @@ const DEFAULT_TIMEOUT_MIN = 10;
51
53
  * the staged carrier agent (issue #2 inline-block fallback). */
52
54
  let warnedUserAgentConflict = false;
53
55
 
56
+ /** Set of caller-scoped tool names declared by pi-observational-memory background workers.
57
+ * Antigravity models run a closed tool loop on the backend CLI/ACP process and do not
58
+ * support caller-scoped local tools. */
59
+ export const OM_WORKER_TOOL_NAMES = new Set([
60
+ "record_observations",
61
+ "record_reflections",
62
+ "drop_observations",
63
+ ]);
64
+
65
+ /** Detect if a turn belongs to a pi-observational-memory background worker. */
66
+ export function isObservationalMemoryContext(context: TranscriptContext): boolean {
67
+ const currentTools = getCurrentTools(context.messages);
68
+ return currentTools.some((t) => OM_WORKER_TOOL_NAMES.has(t.name));
69
+ }
70
+
71
+ /** Track whether we have warned about observational-memory tool mismatch in this process. */
72
+ let warnedObservationalMemoryMismatch = false;
73
+
74
+ /** Reset latch for unit testing. */
75
+ export function _resetObservationalMemoryWarnedForTesting(): void {
76
+ warnedObservationalMemoryMismatch = false;
77
+ }
78
+
54
79
  /** conversationId -> staged carrier agent name. Gate-closed turns look the
55
80
  * name up here so a conversation's agent field never flips: a change would
56
81
  * recycle the agy process mid-conversation (profile drift), and a respawn
@@ -80,7 +105,8 @@ function extractUserPrompt(context: TranscriptContext): string | null {
80
105
  if (!last || last.role !== "user") return null;
81
106
  const content = last.content;
82
107
  if (typeof content === "string") return content;
83
- // Flatten text blocks; images ride separately via extractImages (ACP only).
108
+ // Flatten text blocks; images ride separately via extractImages (native
109
+ // blocks on ACP, staged files on stream-json via prompt-images.ts).
84
110
  return content
85
111
  .filter((b): b is { type: "text"; text: string } => b.type === "text")
86
112
  .map((b) => b.text)
@@ -89,8 +115,9 @@ function extractUserPrompt(context: TranscriptContext): string | null {
89
115
  }
90
116
 
91
117
  /** Image blocks of the latest user message (pi-ai ImageContent: base64 data
92
- * + mimeType). The ACP engine forwards them as typed content blocks; the
93
- * stream-json CLI prompt is text-only, so its driver simply ignores these. */
118
+ * + mimeType). On the ACP engine they ride session/prompt as native
119
+ * content blocks; on stream-json they are staged to temp files and the
120
+ * prompt names the paths (see prompt-images.ts). */
94
121
  function extractImages(context: TranscriptContext): Array<{ data: string; mimeType: string }> {
95
122
  const last = context.messages[context.messages.length - 1];
96
123
  if (!last || last.role !== "user" || typeof last.content === "string") return [];
@@ -140,7 +167,7 @@ export const SYSTEM_PROMPT_END = "[END SYSTEM PROMPT]";
140
167
  * the user's machine must go through the bridge. Rides the systemPrompt
141
168
  * gate: the note ships only when the system prompt ships. */
142
169
  export const TOOL_PRIORITY_NOTE =
143
- "[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). Delegate subagent work the same way: when pi's tool list includes a delegation tool (for example subagent, Agent, agent_spawn, agent_wait, or subagent_run), always delegate through it and never through the native invoke_subagent, run_subagent, or manage_subagents; use native ones only if no pi delegation tool is listed, because native subagents run inside agy and are invisible to pi and anything watching it. 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.]";
170
+ "[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). Image files are visible to you: the Pi Bridge read tool returns image files as visible images (png, jpg, webp, gif, bmp), so never claim you cannot view images. When a bridge call answers STILL RUNNING, poll bridge_poll_result before doing other work: until you poll, the result (including any image) stays undelivered to you. Delegate subagent work the same way: when pi's tool list includes a delegation tool (for example subagent, Agent, agent_spawn, agent_wait, or subagent_run), always delegate through it and never through the native invoke_subagent, run_subagent, or manage_subagents; use native ones only if no pi delegation tool is listed, because native subagents run inside agy and are invisible to pi and anything watching it. 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.]";
144
171
 
145
172
  /** Assemble the full agy prompt: system prompt block, pi-side digest, user
146
173
  * prompt. Empty parts are dropped. Pure; exported for unit testing.
@@ -922,7 +949,8 @@ export class ToolRoundTrips {
922
949
  /** Complete a parked call from a pi toolResult message. Returns false when
923
950
  * the id matches nothing pending. Image blocks ride the result on both
924
951
  * engines (probe-verified on each); the late-delivery prompt stays
925
- * text-only (see PI-BRIDGE-GAPS). */
952
+ * text-only and its images re-attach through driver.run (native blocks on
953
+ * ACP, staged files on stream-json). */
926
954
  resolve(
927
955
  toolCallId: string,
928
956
  text: string,
@@ -1033,6 +1061,9 @@ export interface LateToolResult {
1033
1061
  reason: string;
1034
1062
  text: string;
1035
1063
  isError: boolean;
1064
+ /** Image blocks of the stranded result (read on an image file). The
1065
+ * redelivery prompt is text-only, so they ride driver.run images. */
1066
+ images?: Array<{ data: string; mimeType: string }>;
1036
1067
  }
1037
1068
 
1038
1069
  /** Frame late tool results so agy treats them as the results its bridge calls
@@ -1308,8 +1339,10 @@ async function runTurnDriver(
1308
1339
  // Images ride tool results on BOTH engines (ACP probe 2026-09-05;
1309
1340
  // stream-json probe 2026-09-07: the CLI's MCP client delivers tool-result
1310
1341
  // image content to the model — two-tone PNG named from the result alone,
1311
- // no decoders in the frame trail). The late-delivery prompt and the
1312
- // stream-json prompt attachments stay text-only by design.
1342
+ // no decoders in the frame trail). The late-delivery PROMPT stays
1343
+ // text-only: image blocks ride driver.run images instead, so both
1344
+ // engines deliver them (ACP native prompt blocks; stream-json staged
1345
+ // files).
1313
1346
  for (const r of results)
1314
1347
  deps.roundTrips.resolve(r.toolCallId, r.text, r.isError, r.images);
1315
1348
 
@@ -1326,7 +1359,14 @@ async function runTurnDriver(
1326
1359
  if (!isContinuation) {
1327
1360
  for (const r of collectToolResults(context.messages, deps.roundTrips.deadIds)) {
1328
1361
  const dead = deps.roundTrips.consumeDead(r.toolCallId);
1329
- if (dead) late.push({ name: dead.name, reason: dead.reason, text: r.text, isError: r.isError });
1362
+ if (dead)
1363
+ late.push({
1364
+ name: dead.name,
1365
+ reason: dead.reason,
1366
+ text: r.text,
1367
+ isError: r.isError,
1368
+ images: r.images.length > 0 ? r.images : undefined,
1369
+ });
1330
1370
  }
1331
1371
  if (late.length > 0) {
1332
1372
  deps.log?.("late-result", { tools: late.map((l) => l.name), freshConversation: !existing?.conversationId }, "info");
@@ -1353,8 +1393,25 @@ async function runTurnDriver(
1353
1393
  reason: "you never received this result: you ended your turn without calling bridge_poll_result",
1354
1394
  text: view.text ?? "(no output)",
1355
1395
  isError: view.isError ?? false,
1396
+ images: view.images,
1356
1397
  };
1357
1398
  });
1399
+ /** Stream-json has no image channel in the turn input: stage the blocks
1400
+ * to temp files and let the note point the model at the bridge read
1401
+ * tool, whose result images the CLI delivers (probe 2026-09-07). ACP
1402
+ * rides native content blocks; no staging there. */
1403
+ const stageImageNote = (imgs: Array<{ data: string; mimeType: string }>): string => {
1404
+ if (deps.engine === "acp" || imgs.length === 0) return "";
1405
+ const staged = stagePromptImages(imgs);
1406
+ if (!staged) {
1407
+ deps.log?.("turn-warning", { reason: "image-staging-failed", count: imgs.length }, "warn");
1408
+ // Silence would strand the pixels forever: on a redelivery the claim
1409
+ // is already consumed, so the model must at least learn the image
1410
+ // existed and re-request it.
1411
+ return `\n\n[${imgs.length} image(s) could not be delivered to this turn; ask the user to re-attach or re-read the original path.]`;
1412
+ }
1413
+ return `\n\n${buildImageNote(staged.paths)}`;
1414
+ };
1358
1415
 
1359
1416
  const entry = entries.find((e) => e.id === model.id) ?? null;
1360
1417
  const agyModel = entry?.full ?? model.id;
@@ -1457,10 +1514,15 @@ async function runTurnDriver(
1457
1514
  agent = stagedAgentByConversation.get(existing.conversationId);
1458
1515
  }
1459
1516
  }
1517
+ // Late/stranded image results ride driver.run images (the redelivery
1518
+ // prompt is text-only); user-message images ride them on ACP natively
1519
+ // and staged files on stream-json.
1520
+ const runImages = [...images, ...late.flatMap((l) => l.images ?? [])];
1460
1521
  const fullPrompt =
1461
- late.length > 0
1522
+ (late.length > 0
1462
1523
  ? buildLateResultPrompt(late, prompt || undefined)
1463
- : buildFullPrompt(sysPrompt, embeddedDigest ? "" : digest, prompt ?? "");
1524
+ : buildFullPrompt(sysPrompt, embeddedDigest ? "" : digest, prompt ?? "")) +
1525
+ stageImageNote(runImages);
1464
1526
  try {
1465
1527
  handle = await deps.driver.run({
1466
1528
  cwd,
@@ -1472,7 +1534,7 @@ async function runTurnDriver(
1472
1534
  inactivityMin: config.inactivityTimeoutMin,
1473
1535
  conversationId: redeliveryConversation ?? existing?.conversationId ?? null,
1474
1536
  prompt: fullPrompt,
1475
- images: images.length > 0 ? images : undefined,
1537
+ images: runImages.length > 0 ? runImages : undefined,
1476
1538
  agent,
1477
1539
  contextBlock: embeddedDigest
1478
1540
  ? {
@@ -1569,6 +1631,7 @@ async function runTurnDriver(
1569
1631
  if (more.length > 0) {
1570
1632
  const conv = redeliveryConversation ?? outcome.conversationId ?? existing?.conversationId ?? null;
1571
1633
  deps.log?.("escalated-redelivery-turn", { tools: more.map((s) => s.name), conversation: conv }, "info");
1634
+ const moreImages = more.flatMap((s) => s.images ?? []);
1572
1635
  try {
1573
1636
  const lateHandle = await deps.driver.run({
1574
1637
  cwd,
@@ -1579,7 +1642,8 @@ async function runTurnDriver(
1579
1642
  timeoutMin: config.turnTimeoutMin,
1580
1643
  inactivityMin: config.inactivityTimeoutMin,
1581
1644
  conversationId: conv,
1582
- prompt: buildLateResultPrompt(more),
1645
+ prompt: buildLateResultPrompt(more) + stageImageNote(moreImages),
1646
+ images: moreImages.length > 0 ? moreImages : undefined,
1583
1647
  agent: config.agent ?? (conv ? stagedAgentByConversation.get(conv) : undefined),
1584
1648
  signal: options?.signal,
1585
1649
  });
@@ -1650,6 +1714,34 @@ export function createStreamSimple(
1650
1714
 
1651
1715
  return function streamSimple(model, context, options) {
1652
1716
  const stream = createAssistantMessageEventStream();
1717
+
1718
+ // Short-circuit pi-observational-memory background workers.
1719
+ // Observational memory workers declare caller-scoped tools (record_observations,
1720
+ // record_reflections, drop_observations). Antigravity models run in a closed backend
1721
+ // process with their own tool execution loop and do not support caller-scoped tools.
1722
+ // If dispatched to agy, the model sees tool instructions without tools and enters
1723
+ // an autonomous loop searching the disk for their implementation.
1724
+ // Returning a clean stop with empty content triggers OM's empty-observer backoff,
1725
+ // preventing high-frequency retries on subsequent turns.
1726
+ if (isObservationalMemoryContext(context)) {
1727
+ if (!warnedObservationalMemoryMismatch) {
1728
+ warnedObservationalMemoryMismatch = true;
1729
+ deps.log?.(
1730
+ "turn-warning",
1731
+ {
1732
+ reason: "observational-memory-unsupported",
1733
+ message:
1734
+ "Observational memory background workers cannot run against Antigravity models (caller-scoped tools unsupported). Configure a dedicated model under 'observational-memory.model' in settings.json.",
1735
+ },
1736
+ "warn",
1737
+ );
1738
+ }
1739
+ const partial = newAssistant(model);
1740
+ const blocks: BlockState = { partial, textIdx: null, thinkingIdx: null, started: false };
1741
+ finalize(stream, blocks, "stop");
1742
+ return stream;
1743
+ }
1744
+
1653
1745
  // Fire the async turn; return the stream synchronously per pi's contract.
1654
1746
  // Engine selection: the latched load-time engine when the extension
1655
1747
  // provides one, else the per-call config read (tests). One loadConfig()
@@ -0,0 +1,171 @@
1
+ // Settings manager for Antigravity's "Queued Messages" delivery strategy.
2
+ //
3
+ // In agy v1.2.14+, agy introduced the "Queued Messages" option:
4
+ // - "queue" (agy default): hold follow-up / steering messages until current turn ends
5
+ // - "send-immediately": interrupt the agent with follow-up / steering messages immediately
6
+ //
7
+ // Pi expects immediate message delivery / steering during interactive chat.
8
+ // This module ensures that both the stream-json engine (~/.gemini/antigravity-cli/settings.json)
9
+ // and ACP engine (~/.gemini/antigravity-acp/settings.json) default to "send-immediately"
10
+ // so user steering works as expected in Pi without manual user configuration.
11
+
12
+ import fs from "node:fs";
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+
16
+ export const QUEUED_MESSAGES_SEND_IMMEDIATELY = "send-immediately";
17
+
18
+ export function defaultCliSettingsPath(): string {
19
+ return path.join(os.homedir(), ".gemini", "antigravity-cli", "settings.json");
20
+ }
21
+
22
+ export function defaultAcpSettingsPath(): string {
23
+ return path.join(os.homedir(), ".gemini", "antigravity-acp", "settings.json");
24
+ }
25
+
26
+ export interface EnsureFileResult {
27
+ path: string;
28
+ updated: boolean;
29
+ value: string;
30
+ error?: string;
31
+ }
32
+
33
+ export interface EnsureFileOptions {
34
+ defaultValue?: string;
35
+ onlyIfDirExists?: boolean;
36
+ }
37
+
38
+ /**
39
+ * Ensure `queuedMessages` is present in the specified settings.json file.
40
+ * If unset or empty, writes `send-immediately`.
41
+ *
42
+ * Safety guards:
43
+ * - If the file exists but has invalid JSON or is not an object, does NOT clobber; returns updated: false.
44
+ * - If the file already specifies a non-empty `queuedMessages`, leaves it untouched.
45
+ * - Resolves symlinks before writing to protect dotfiles managers.
46
+ * - Writes via temp file and atomic rename.
47
+ * - Traps any I/O exceptions so callers never crash on read-only or permission-denied files.
48
+ */
49
+ export function ensureQueuedMessagesInFile(
50
+ filePath: string,
51
+ opts: EnsureFileOptions | string = {},
52
+ ): EnsureFileResult {
53
+ const options: EnsureFileOptions =
54
+ typeof opts === "string" ? { defaultValue: opts } : opts;
55
+ const defaultValue = options.defaultValue ?? QUEUED_MESSAGES_SEND_IMMEDIATELY;
56
+ const onlyIfDirExists = options.onlyIfDirExists ?? false;
57
+
58
+ let resolvedPath = filePath;
59
+ try {
60
+ if (fs.existsSync(filePath)) {
61
+ resolvedPath = fs.realpathSync(filePath);
62
+ }
63
+ } catch {
64
+ // Keep original path if realpath fails
65
+ }
66
+
67
+ const dir = path.dirname(resolvedPath);
68
+ if (onlyIfDirExists && !fs.existsSync(dir)) {
69
+ return { path: resolvedPath, updated: false, value: "" };
70
+ }
71
+
72
+ let settings: Record<string, unknown> = {};
73
+ let fileExists = false;
74
+
75
+ try {
76
+ const raw = fs.readFileSync(resolvedPath, "utf8");
77
+ fileExists = true;
78
+ let parsed: unknown;
79
+ try {
80
+ parsed = JSON.parse(raw);
81
+ } catch (parseErr) {
82
+ // Malformed JSON: do NOT clobber the user's file.
83
+ return {
84
+ path: resolvedPath,
85
+ updated: false,
86
+ value: "",
87
+ error: `malformed JSON in ${resolvedPath}: ${String(parseErr)}`,
88
+ };
89
+ }
90
+
91
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
92
+ return {
93
+ path: resolvedPath,
94
+ updated: false,
95
+ value: "",
96
+ error: `settings in ${resolvedPath} is not a JSON object`,
97
+ };
98
+ }
99
+ settings = parsed as Record<string, unknown>;
100
+ } catch (err: unknown) {
101
+ const code = (err as { code?: string })?.code;
102
+ if (code !== "ENOENT") {
103
+ // Read error other than absent file (e.g. EACCES): do not overwrite.
104
+ return {
105
+ path: resolvedPath,
106
+ updated: false,
107
+ value: "",
108
+ error: `failed reading ${resolvedPath}: ${String(err)}`,
109
+ };
110
+ }
111
+ }
112
+
113
+ const current = settings.queuedMessages;
114
+ if (typeof current === "string" && current.trim().length > 0) {
115
+ return { path: resolvedPath, updated: false, value: current };
116
+ }
117
+
118
+ settings.queuedMessages = defaultValue;
119
+
120
+ let tmpPath = "";
121
+ try {
122
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
123
+ tmpPath = path.join(dir, `.${path.basename(resolvedPath)}.${process.pid}.${Date.now()}.tmp`);
124
+ fs.writeFileSync(tmpPath, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600 });
125
+ fs.renameSync(tmpPath, resolvedPath);
126
+ return { path: resolvedPath, updated: true, value: defaultValue };
127
+ } catch (err) {
128
+ if (tmpPath) {
129
+ try {
130
+ fs.unlinkSync(tmpPath);
131
+ } catch {
132
+ /* ignore */
133
+ }
134
+ }
135
+ return {
136
+ path: resolvedPath,
137
+ updated: false,
138
+ value: "",
139
+ error: `failed writing ${resolvedPath}: ${String(err)}`,
140
+ };
141
+ }
142
+ }
143
+
144
+ export interface EnsureQueuedMessagesOptions {
145
+ cliSettingsPath?: string;
146
+ acpSettingsPath?: string;
147
+ defaultValue?: string;
148
+ onlyIfDirExists?: boolean;
149
+ }
150
+
151
+ export interface EnsureQueuedMessagesReport {
152
+ cli: EnsureFileResult;
153
+ acp: EnsureFileResult;
154
+ }
155
+
156
+ /**
157
+ * Ensure both stream-json (CLI) and ACP engines default to "send-immediately".
158
+ */
159
+ export function ensureQueuedMessagesDefaults(
160
+ opts: EnsureQueuedMessagesOptions = {},
161
+ ): EnsureQueuedMessagesReport {
162
+ const cliPath = opts.cliSettingsPath ?? defaultCliSettingsPath();
163
+ const acpPath = opts.acpSettingsPath ?? defaultAcpSettingsPath();
164
+ const defVal = opts.defaultValue ?? QUEUED_MESSAGES_SEND_IMMEDIATELY;
165
+ const onlyIfDirExists = opts.onlyIfDirExists ?? false;
166
+
167
+ const cli = ensureQueuedMessagesInFile(cliPath, { defaultValue: defVal, onlyIfDirExists });
168
+ const acp = ensureQueuedMessagesInFile(acpPath, { defaultValue: defVal, onlyIfDirExists });
169
+
170
+ return { cli, acp };
171
+ }