@estebanforge/pi-antigravity-bridge 1.7.3 → 1.7.5

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.
@@ -0,0 +1,137 @@
1
+ // agy CLI version gate.
2
+ //
3
+ // An agy older than MIN_AGY_VERSION fails stream-json turns with raw stream
4
+ // errors that look like bridge bugs, so we detect it once per process and
5
+ // say so plainly (session-start warning + /agy doctor) instead of letting
6
+ // users debug parse noise. Pure functions here; the only subprocess is
7
+ // `agy --version`, memoized per binary because discovery already spawns agy
8
+ // once per load and the version will not change mid-process.
9
+ //
10
+ // The ACP server reports its own version at initialize (agentInfo.version,
11
+ // e.g. "agy_acp_server_1.1.1") and /agy doctor already prints it. ACP build
12
+ // numbering is a different lineage from the CLI, so no floor is enforced
13
+ // there: the one known-good build must not be locked out by a CLI number.
14
+
15
+ import { spawn } from "node:child_process";
16
+
17
+ /** Floor for the stream-json CLI. Matches the protocol grammar our driver
18
+ * and the event mapping rely on; older agy predates verified fields. */
19
+ export const MIN_AGY_VERSION = "1.1.22";
20
+
21
+ const VERSION_TIMEOUT_MS = 5_000;
22
+
23
+ export type AgyVersionStatus =
24
+ | "ok"
25
+ | "unsupported"
26
+ | "development"
27
+ | "invalid"
28
+ | "unavailable";
29
+
30
+ export interface AgyVersionCheck {
31
+ status: AgyVersionStatus;
32
+ /** Extracted triple when status is ok/unsupported; raw dev token for development. */
33
+ version?: string;
34
+ /** Full --version output, for doctor detail. */
35
+ raw: string;
36
+ }
37
+
38
+ /** Pull the first strict triple (`1.2.10`) out of arbitrary version output.
39
+ * Prefixed forms like "agy_acp_server_1.1.1" and banner text both match;
40
+ * anything without a triple is not a version we can gate on. */
41
+ export function parseAgyVersionTriple(text: string): string | undefined {
42
+ return /\d+\.\d+\.\d+/.exec(text)?.[0];
43
+ }
44
+
45
+ /** Numeric triple compare; 1.10.0 > 1.9.9, unlike string compare. */
46
+ export function compareVersionTuples(a: string, b: string): number {
47
+ const pa = a.split(".").map(Number);
48
+ const pb = b.split(".").map(Number);
49
+ for (let i = 0; i < 3; i++) {
50
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
51
+ if (d !== 0) return d;
52
+ }
53
+ return 0;
54
+ }
55
+
56
+ /** Classify raw `--version` output against a floor. Pure: unit-tested for
57
+ * every status; checkAgyCliVersion wraps this with the subprocess. */
58
+ export function agyVersionVerdict(
59
+ raw: string,
60
+ floor: string = MIN_AGY_VERSION,
61
+ ): AgyVersionCheck {
62
+ // Development builds (agy from source) self-identify as dev, HEAD, or the
63
+ // spelled-out word; never gate any of them.
64
+ if (/\b(dev|head|development)\b/i.test(raw))
65
+ return { status: "development", version: "dev", raw };
66
+ const triple = parseAgyVersionTriple(raw);
67
+ if (!triple) return { status: "invalid", raw };
68
+ if (compareVersionTuples(triple, floor) < 0) return { status: "unsupported", version: triple, raw };
69
+ return { status: "ok", version: triple, raw };
70
+ }
71
+
72
+ const checkCache = new Map<string, Promise<AgyVersionCheck>>();
73
+
74
+ /** Run `binary --version` and classify the result. Memoized per binary for
75
+ * the process lifetime (resetAgyVersionCache clears it for tests). Fail
76
+ * paths: spawn error (ENOENT etc.) and watchdog timeout are "unavailable",
77
+ * a distinct status so doctor can say "could not run" instead of blaming
78
+ * the version string. */
79
+ export function checkAgyCliVersion(binary: string = "agy"): Promise<AgyVersionCheck> {
80
+ const cached = checkCache.get(binary);
81
+ if (cached) return cached;
82
+ const run = new Promise<AgyVersionCheck>((resolve) => {
83
+ let out = "";
84
+ let done = false;
85
+ const finish = (check: AgyVersionCheck) => {
86
+ if (done) return;
87
+ done = true;
88
+ clearTimeout(watchdog);
89
+ resolve(check);
90
+ };
91
+ let proc: ReturnType<typeof spawn>;
92
+ try {
93
+ proc = spawn(binary, ["--version"], { stdio: ["ignore", "pipe", "ignore"], shell: false });
94
+ } catch {
95
+ finish({ status: "unavailable", raw: "" });
96
+ return;
97
+ }
98
+ proc.stdout?.setEncoding("utf8");
99
+ proc.stdout?.on("data", (d: string) => (out = (out + d).slice(0, 4096)));
100
+ proc.on("error", () => finish({ status: "unavailable", raw: "" }));
101
+ // Exit code is ignored on purpose: `agy --version` failures print to
102
+ // stderr (ignored) and leave junk or empty stdout, which classifies as
103
+ // invalid — close enough in effect to unavailable for a warn-only gate.
104
+ proc.on("close", () => finish(agyVersionVerdict(out)));
105
+ const watchdog = setTimeout(() => {
106
+ try {
107
+ proc.kill("SIGKILL");
108
+ } catch {
109
+ /* already gone */
110
+ }
111
+ finish({ status: "unavailable", raw: out });
112
+ }, VERSION_TIMEOUT_MS);
113
+ });
114
+ checkCache.set(binary, run);
115
+ return run;
116
+ }
117
+
118
+ /** Test seam: drop all memoized checks. */
119
+ export function resetAgyVersionCache(): void {
120
+ checkCache.clear();
121
+ }
122
+
123
+ /** One-line doctor rendering. */
124
+ export function describeAgyVersionCheck(check: AgyVersionCheck): string {
125
+ switch (check.status) {
126
+ case "ok":
127
+ return check.version ?? "ok";
128
+ case "unsupported":
129
+ return `${check.version} TOO OLD`;
130
+ case "development":
131
+ return "development build";
132
+ case "invalid":
133
+ return "unreadable version output";
134
+ case "unavailable":
135
+ return "could not run --version";
136
+ }
137
+ }
@@ -0,0 +1,86 @@
1
+ // /agy artifacts overlay: SelectList over one conversation's artifacts.
2
+ //
3
+ // UI mirrors the engine picker (tui.md "Pattern 1"): SelectList framed by
4
+ // DynamicBorder inside pi's native overlay. Read-only on purpose: enter
5
+ // opens the selected file with the OS default handler, r rescans (the list
6
+ // is always a fresh scan), esc closes. No delete keys — agy's files are
7
+ // agy's to manage.
8
+
9
+ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
10
+ import { DynamicBorder } from "@earendil-works/pi-coding-agent";
11
+ import { Container, type SelectItem, SelectList, Spacer, Text } from "@earendil-works/pi-tui";
12
+ import type { AgyArtifact } from "./artifacts.js";
13
+
14
+ export type ArtifactsBrowserAction =
15
+ | { type: "open"; artifact: AgyArtifact }
16
+ | { type: "rescan" }
17
+ | { type: "close" };
18
+
19
+ function humanBytes(n: number): string {
20
+ if (n < 1024) return `${n}B`;
21
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`;
22
+ return `${(n / (1024 * 1024)).toFixed(1)}MB`;
23
+ }
24
+
25
+ export function artifactItems(artifacts: AgyArtifact[]): SelectItem[] {
26
+ return artifacts.map((a, i) => {
27
+ const when = new Date(a.modifiedMs);
28
+ const stamp = `${when.getFullYear()}-${String(when.getMonth() + 1).padStart(2, "0")}-${String(when.getDate()).padStart(2, "0")} ${String(when.getHours()).padStart(2, "0")}:${String(when.getMinutes()).padStart(2, "0")}`;
29
+ return {
30
+ value: String(i),
31
+ label: a.name,
32
+ description: `${a.kind} · ${a.mediaType} · ${humanBytes(a.bytes)} · ${stamp}`,
33
+ };
34
+ });
35
+ }
36
+
37
+ /** Render the overlay. Resolves with what the user asked for: open (with the
38
+ * selected artifact), rescan, or close. */
39
+ export async function showArtifactsBrowser(
40
+ ctx: ExtensionUIContext,
41
+ artifacts: AgyArtifact[],
42
+ ): Promise<ArtifactsBrowserAction> {
43
+ const items = artifactItems(artifacts);
44
+ return ctx.custom<ArtifactsBrowserAction>((tui, theme, _keybindings, done) => {
45
+ const container = new Container();
46
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
47
+ container.addChild(
48
+ new Text(theme.fg("accent", theme.bold(`Antigravity artifacts (${artifacts.length})`)), 1, 0),
49
+ );
50
+ container.addChild(new Spacer(1));
51
+
52
+ const list = new SelectList(items, Math.min(items.length, 12), {
53
+ selectedPrefix: (t: string) => theme.fg("accent", t),
54
+ selectedText: (t: string) => theme.fg("accent", t),
55
+ description: (t: string) => theme.fg("muted", t),
56
+ scrollInfo: (t: string) => theme.fg("dim", t),
57
+ noMatch: (t: string) => theme.fg("warning", t),
58
+ });
59
+ list.onSelect = (item) => done({ type: "open", artifact: artifacts[Number(item.value)] });
60
+ list.onCancel = () => done({ type: "close" });
61
+ container.addChild(list);
62
+
63
+ container.addChild(new Spacer(1));
64
+ container.addChild(
65
+ new Text(theme.fg("dim", "↑↓ navigate · enter open · r rescan · esc close"), 1, 0),
66
+ );
67
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
68
+
69
+ return {
70
+ render: (width: number) => container.render(width),
71
+ invalidate: () => container.invalidate(),
72
+ handleInput: (data: string) => {
73
+ // r rescan; enter opens (via onSelect), esc cancels (via onCancel).
74
+ if (data.trim().toLowerCase() === "r") {
75
+ done({ type: "rescan" });
76
+ return;
77
+ }
78
+ list.handleInput(data);
79
+ tui.requestRender();
80
+ },
81
+ };
82
+ }, {
83
+ overlay: true,
84
+ overlayOptions: { anchor: "center" as const, width: 84, maxHeight: "85%" as const, margin: 1 },
85
+ });
86
+ }
@@ -0,0 +1,144 @@
1
+ // Read-only artifact scan over one agy conversation's brain dir.
2
+ //
3
+ // agy drops files in three places under brain/<conversationId>/: the
4
+ // conversation root itself, .tempmediaStorage/ (media agy generated), and
5
+ // .user_uploaded/ (files the user handed it). Nothing else surfaces them,
6
+ // so this module lists them for pi (live-probed 2026-09-24: both trees
7
+ // exist per engine; media dirs are created on demand and may be absent).
8
+ //
9
+ // Read-only by design: list and open, never delete. Each entry is
10
+ // realpath'd and containment-checked canonical-to-canonical, which defeats
11
+ // symlinks pointing outside the conversation root and stays correct when
12
+ // the conversation dir itself is reached through a symlinked ancestor
13
+ // (macOS /var -> /private/var, symlinked homes). A file could still be
14
+ // swapped for a symlink between listing and a later `open` — the OS opener
15
+ // would follow it; that is outside this module's guarantee and acceptable
16
+ // for a single-user, own-home tool.
17
+
18
+ import { readdir, realpath, stat } from "node:fs/promises";
19
+ import path from "node:path";
20
+
21
+ export type AgyArtifactKind = "conversation" | "generated" | "uploaded";
22
+ export type AgyArtifactMediaType =
23
+ | "image"
24
+ | "audio"
25
+ | "video"
26
+ | "pdf"
27
+ | "markdown"
28
+ | "text"
29
+ | "other";
30
+
31
+ export interface AgyArtifact {
32
+ name: string;
33
+ absolutePath: string;
34
+ kind: AgyArtifactKind;
35
+ mediaType: AgyArtifactMediaType;
36
+ bytes: number;
37
+ modifiedMs: number;
38
+ }
39
+
40
+ const EXT_TO_MEDIA: Record<string, AgyArtifactMediaType> = {
41
+ png: "image",
42
+ jpg: "image",
43
+ jpeg: "image",
44
+ gif: "image",
45
+ webp: "image",
46
+ svg: "image",
47
+ bmp: "image",
48
+ mp3: "audio",
49
+ wav: "audio",
50
+ ogg: "audio",
51
+ m4a: "audio",
52
+ flac: "audio",
53
+ mp4: "video",
54
+ mov: "video",
55
+ webm: "video",
56
+ mkv: "video",
57
+ pdf: "pdf",
58
+ md: "markdown",
59
+ markdown: "markdown",
60
+ txt: "text",
61
+ csv: "text",
62
+ json: "text",
63
+ };
64
+
65
+ function mediaTypeFor(name: string): AgyArtifactMediaType {
66
+ return EXT_TO_MEDIA[path.extname(name).slice(1).toLowerCase()] ?? "other";
67
+ }
68
+
69
+ /** Files that are agy bookkeeping, not user-facing artifacts. */
70
+ function isSkippedName(name: string): boolean {
71
+ return name.startsWith(".") || name.endsWith(".metadata.json");
72
+ }
73
+
74
+ const SCAN_ROOTS: Array<{ sub: string | null; kind: AgyArtifactKind }> = [
75
+ { sub: null, kind: "conversation" },
76
+ { sub: ".tempmediaStorage", kind: "generated" },
77
+ { sub: ".user_uploaded", kind: "uploaded" },
78
+ ];
79
+
80
+ /** List artifacts under one conversation dir. Missing subdirs are normal
81
+ * (created on demand) and skip silently; a missing conversation dir
82
+ * returns []. Newest first. */
83
+ export async function listAgyArtifacts(conversationDir: string): Promise<AgyArtifact[]> {
84
+ // Canonicalize ONCE and derive everything from the canonical root: a
85
+ // raw-vs-canonical containment check silently rejects every entry when
86
+ // any ancestor is a symlink (the relative prefix comparison crosses
87
+ // resolved/unresolved segments and yields a bogus "..").
88
+ let root: string;
89
+ try {
90
+ root = await realpath(conversationDir);
91
+ } catch {
92
+ return [];
93
+ }
94
+ const byPath = new Map<string, AgyArtifact>();
95
+ for (const { sub, kind } of SCAN_ROOTS) {
96
+ const dir = sub === null ? root : path.join(root, sub);
97
+ let entries;
98
+ try {
99
+ entries = await readdir(dir, { withFileTypes: true });
100
+ } catch {
101
+ continue;
102
+ }
103
+ for (const entry of entries) {
104
+ if (!entry.isFile()) continue; // Dirent type: symlinks excluded here
105
+ if (isSkippedName(entry.name)) continue;
106
+ // Re-resolve per entry and re-check containment on the canonical
107
+ // path: catches anything swapped in between readdir and use.
108
+ let canonical: string;
109
+ let stats;
110
+ try {
111
+ canonical = await realpath(path.join(dir, entry.name));
112
+ if (!containedIn(root, canonical)) continue;
113
+ stats = await stat(canonical);
114
+ } catch {
115
+ continue;
116
+ }
117
+ if (!stats.isFile()) continue;
118
+ const key = canonical;
119
+ if (byPath.has(key)) continue;
120
+ byPath.set(key, {
121
+ name: entry.name,
122
+ absolutePath: canonical,
123
+ kind,
124
+ mediaType: mediaTypeFor(entry.name),
125
+ bytes: stats.size,
126
+ modifiedMs: stats.mtimeMs,
127
+ });
128
+ }
129
+ }
130
+ return [...byPath.values()].sort((a, b) => b.modifiedMs - a.modifiedMs);
131
+ }
132
+
133
+ function containedIn(root: string, candidate: string): boolean {
134
+ const rel = path.relative(root, candidate);
135
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
136
+ }
137
+
138
+ /** OS open command for the user's default handler. Undefined on unknown
139
+ * platforms (caller renders a message instead of spawning blind). */
140
+ export function artifactOpenCommand(): { cmd: string } | undefined {
141
+ if (process.platform === "darwin") return { cmd: "open" };
142
+ if (process.platform === "linux") return { cmd: "xdg-open" };
143
+ return undefined;
144
+ }
package/src/ask-tool.ts CHANGED
@@ -78,7 +78,7 @@ TWO MODES (you choose):
78
78
  - **Continued conversation**: pass the conversationId returned in the PREVIOUS call's details (details.conversationId). agy resumes that conversation with full context intact.
79
79
 
80
80
  EXECUTION MODES (param: mode):
81
- - **plan**: agy reviews and plans without writing. Use for cross-review and read-only tasks.
81
+ - **plan**: agy reviews and plans without writing. Use for cross-review and read-only tasks. Enforced: plan runs never receive the skip-permissions flag, so a write or command attempt ends the run quickly with a "confirm plan" message instead of executing.
82
82
  - **accept-edits** (default): agy applies edits directly inside the workspace.
83
83
 
84
84
  COMPACT OUTPUT (param: digest): when true, the prompt is prefixed to request compact digests instead of full file contents. Defaults on for plan, off for accept-edits.
@@ -390,7 +390,8 @@ export async function registerAskAntigravityTool(
390
390
  }
391
391
 
392
392
  const body = result.content[0]?.type === "text" ? result.content[0].text : "";
393
- const errored = d?.exitCode !== 0 || !!d?.aborted || !!d?.timedOut;
393
+ const errored =
394
+ d?.exitCode !== 0 || !!d?.aborted || !!d?.timedOut || !!d?.empty;
394
395
 
395
396
  let text = errored
396
397
  ? theme.fg("error", "✗ AskAntigravity error")
@@ -536,7 +537,16 @@ export async function registerAskAntigravityTool(
536
537
  // Honor the shared permissions setting (same knob as the provider). Non-
537
538
  // interactive -p can't answer a permission prompt, so when this is off
538
539
  // any run_command will hang - but the setting must mean what it says.
539
- if (config.skipPermissions !== false) args.push("--dangerously-skip-permissions");
540
+ // Plan mode never gets the flag even when the knob is on: the flag
541
+ // auto-approves ALL permission requests including plan mode's own
542
+ // approval gate, which would silently turn "review-only" into full
543
+ // write access inside --add-dir (probed 2026-09-25: with the flag a
544
+ // plan run wrote files; without it, file and command attempts end
545
+ // exit 0 in ~20-30s with a "confirm plan" message - fail-visible,
546
+ // never a hang). Omitting it is what makes "plan = review-only" true.
547
+ if (mode !== "plan" && config.skipPermissions !== false) {
548
+ args.push("--dangerously-skip-permissions");
549
+ }
540
550
  if (isContinuation) args.push("--conversation", rawConvId as string);
541
551
  args.push("--print-timeout", `${timeoutMin}m`);
542
552
  if (contextFile) args.push("--add-dir", askContextDir());
@@ -701,10 +711,11 @@ export async function registerAskAntigravityTool(
701
711
  details.aborted = outcome.aborted;
702
712
  details.timedOut = outcome.timedOut;
703
713
  details.durationMs = Date.now() - start;
714
+ const text = out.trim();
704
715
  log?.(
705
716
  "ask-end",
706
- { exitCode: outcome.exitCode, aborted: outcome.aborted, timedOut: outcome.timedOut, durationMs: details.durationMs, conversationId: details.conversationId },
707
- outcome.exitCode !== 0 || outcome.aborted || outcome.timedOut ? "warn" : "info",
717
+ { exitCode: outcome.exitCode, aborted: outcome.aborted, timedOut: outcome.timedOut, empty: !text, durationMs: details.durationMs, conversationId: details.conversationId },
718
+ outcome.exitCode !== 0 || outcome.aborted || outcome.timedOut || !text ? "warn" : "info",
708
719
  );
709
720
 
710
721
  if (!isContinuation && !details.conversationId && snapshot) {
@@ -718,8 +729,6 @@ export async function registerAskAntigravityTool(
718
729
  }
719
730
  }
720
731
 
721
- const text = out.trim();
722
-
723
732
  if (outcome.aborted) {
724
733
  return {
725
734
  content: [
@@ -754,6 +763,25 @@ export async function registerAskAntigravityTool(
754
763
  };
755
764
  }
756
765
 
766
+ // Exit 0 with nothing on stdout is still a failure for the
767
+ // delegator: headless agy auto-denies a permission-gated tool call
768
+ // (e.g. the command gate in plan mode), prints the reason only to
769
+ // stderr, and ends cleanly. Falling through to the success path here
770
+ // returned just the conversation footer, which read as an empty
771
+ // success (silent-failure bug found 2026-09-25).
772
+ if (!text) {
773
+ details.empty = true;
774
+ const stderr = redactText(details.stderr.trim());
775
+ const note = [
776
+ "agy exited cleanly but produced no output.",
777
+ stderr ? `stderr: ${stderr}` : null,
778
+ "Common cause: a tool call needed a permission that headless mode cannot prompt for (typically the command gate in plan mode), so it was auto-denied. Allow-list it under permissions.allow in ~/.gemini/antigravity-cli/settings.json, or rerun outside plan mode with skipPermissions.",
779
+ ]
780
+ .filter(Boolean)
781
+ .join(" ");
782
+ return { content: [{ type: "text", text: note }], details };
783
+ }
784
+
757
785
  onUpdate?.({ content: [{ type: "text", text: "" }], details: { ...details } });
758
786
  const footer = details.conversationId
759
787
  ? `\n\n[agy conversationId: ${details.conversationId} - pass as conversationId to continue this conversation]`
@@ -857,6 +885,9 @@ interface AgyDetails {
857
885
  exitCode: number;
858
886
  aborted: boolean;
859
887
  timedOut: boolean;
888
+ // Exit 0 with no answer on stdout (the empty-output failure branch).
889
+ // renderResult flips to the error glyph on it.
890
+ empty?: boolean;
860
891
  durationMs: number;
861
892
  stderr: string;
862
893
  }
package/src/config.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  import fs from "node:fs";
14
14
  import os from "node:os";
15
15
  import path from "node:path";
16
+ import { normalizeAgyAgentName } from "./agents.js";
16
17
 
17
18
  const CONFIG_PATH = path.join(
18
19
  os.homedir(),
@@ -112,6 +113,9 @@ export interface AgyConfig {
112
113
  * on the installed pi. The notice never repeats; /agy patch-cleanup is
113
114
  * always available. */
114
115
  patchCleanupNotified?: boolean;
116
+ /** Custom agy agent for stream-json turns (`--agent`, /agy agent).
117
+ * Absent = agy's default agent. ACP turns ignore it (no protocol slot). */
118
+ agent?: string;
115
119
  /** Which pi tools the MCP bridge exposes to agy: "none" (bridge off),
116
120
  * "all" (every registered non-builtin tool incl. other Ask* delegations;
117
121
  * default - users expect the bridge working out of the box, and the
@@ -316,6 +320,11 @@ export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
316
320
  usageEstimate,
317
321
  };
318
322
 
323
+ // Custom agent for stream-json turns (--agent). Env wins over the file
324
+ // (same pattern as mode). Normalized (trim/cap/control-strip); an empty
325
+ // result means agy's default agent.
326
+ const agent = normalizeAgyAgentName(process.env.AGY_AGENT ?? file.agent);
327
+
319
328
  return {
320
329
  engine,
321
330
  acp,
@@ -330,6 +339,7 @@ export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
330
339
  systemPrompt,
331
340
  turnTimeoutMin,
332
341
  inactivityTimeoutMin,
342
+ agent,
333
343
  approvals: { gateMode, mode: gateAskMode },
334
344
  patchCleanupNotified: file.patchCleanupNotified === true,
335
345
  };
@@ -18,6 +18,10 @@ export interface DriverProfile {
18
18
  effort?: string;
19
19
  mode: string;
20
20
  skipPermissions: boolean;
21
+ /** Custom agy agent (stream-json `--agent`). ACP has no agent slot in the
22
+ * protocol (RC01) and ignores it; the /agy agent command refuses to set
23
+ * one while the ACP engine is active. */
24
+ agent?: string;
21
25
  }
22
26
 
23
27
  export interface DriverTurnRequest extends DriverProfile {
@@ -55,13 +59,15 @@ export type DriverActivity =
55
59
  /** Stream-json emits a token count only; ACP carries the actual thought text in
56
60
  * `delta`. The provider renders whichever is present. */
57
61
  | { type: "thought"; tokens?: number; delta?: string }
58
- | { type: "tool_start"; stepId?: number; name: string; args: Record<string, unknown>; mcpServer?: string }
62
+ | { type: "tool_start"; stepId?: number; name: string; args: Record<string, unknown>; mcpServer?: string; /** ACP only: the protocol toolCallId (stream-json carries numeric stepId instead). */ toolCallId?: string }
59
63
  | {
60
64
  type: "tool_done";
61
65
  stepId?: number;
62
66
  name: string;
63
67
  args: Record<string, unknown>;
64
68
  mcpServer?: string;
69
+ /** ACP only: the protocol toolCallId; pairs done/error with their start. */
70
+ toolCallId?: string;
65
71
  output?: string;
66
72
  durationSeconds?: number;
67
73
  /** ACP only: the server's native edit diff from `tool_call`
@@ -69,7 +75,7 @@ export type DriverActivity =
69
75
  * sets it; the provider renders it without any git subprocess. */
70
76
  diff?: { path: string; oldText?: string; newText: string };
71
77
  }
72
- | { type: "tool_error"; stepId?: number; name: string; message: string; mcpServer?: string }
78
+ | { type: "tool_error"; stepId?: number; name: string; message: string; mcpServer?: string; toolCallId?: string }
73
79
  | { type: "usage"; usage: AgyUsage }
74
80
  /** Synthetic: injected by the provider when the MCP bridge receives a call
75
81
  * (G9). Parks the turn: output is expected to stall while pi executes the