@estebanforge/pi-antigravity-bridge 1.7.2 → 1.7.4

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,33 @@
1
+ // agy per-engine state dirs ("brain" trees).
2
+ //
3
+ // Both engines keep per-conversation state on disk; the roots differ
4
+ // (live-probed 2026-09-24, this machine):
5
+ // stream-json CLI: ~/.gemini/antigravity-cli/brain/<conversationId>/
6
+ // ACP server: ~/.gemini/antigravity-acp/brain/<conversationId>/
7
+ // The ACP tree mirrors the CLI layout (.system_generated/tasks/,
8
+ // .system_generated/steps/), so downstream scans (tasks, artifacts) work
9
+ // unchanged once the right root is picked. A third tree exists —
10
+ // ~/.gemini/antigravity-cli/conversations/<uuid>.db — but it serves
11
+ // one-shot print-mode calls (AskAntigravity), NOT these features; do not
12
+ // conflate the two. Conversation ids are the raw ids agy reports, no
13
+ // hashing; the ids our session store persists match these dir names.
14
+
15
+ import os from "node:os";
16
+ import path from "node:path";
17
+ import type { Engine } from "./config.js";
18
+
19
+ /** Brain root for one engine. `home` injectable for tests. */
20
+ export function agyBrainRoot(engine: Engine, home: string = os.homedir()): string {
21
+ const scope = engine === "acp" ? "antigravity-acp" : "antigravity-cli";
22
+ return path.join(home, ".gemini", scope, "brain");
23
+ }
24
+
25
+ /** Per-conversation state dir. `conversationId` is the raw id agy reports
26
+ * (and what the session store persists). */
27
+ export function agyConversationDir(
28
+ engine: Engine,
29
+ conversationId: string,
30
+ home: string = os.homedir(),
31
+ ): string {
32
+ return path.join(agyBrainRoot(engine, home), conversationId);
33
+ }
@@ -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.
@@ -536,7 +536,16 @@ export async function registerAskAntigravityTool(
536
536
  // Honor the shared permissions setting (same knob as the provider). Non-
537
537
  // interactive -p can't answer a permission prompt, so when this is off
538
538
  // any run_command will hang - but the setting must mean what it says.
539
- if (config.skipPermissions !== false) args.push("--dangerously-skip-permissions");
539
+ // Plan mode never gets the flag even when the knob is on: the flag
540
+ // auto-approves ALL permission requests including plan mode's own
541
+ // approval gate, which would silently turn "review-only" into full
542
+ // write access inside --add-dir (probed 2026-09-25: with the flag a
543
+ // plan run wrote files; without it, file and command attempts end
544
+ // exit 0 in ~20-30s with a "confirm plan" message - fail-visible,
545
+ // never a hang). Omitting it is what makes "plan = review-only" true.
546
+ if (mode !== "plan" && config.skipPermissions !== false) {
547
+ args.push("--dangerously-skip-permissions");
548
+ }
540
549
  if (isContinuation) args.push("--conversation", rawConvId as string);
541
550
  args.push("--print-timeout", `${timeoutMin}m`);
542
551
  if (contextFile) args.push("--add-dir", askContextDir());
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
package/src/driver.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  //
8
8
  // Recycle semantics: the child is killed and respawned when the next turn's
9
9
  // process profile (model / effort / mode / cwd / conversation) drifts from the
10
- // running one, mirroring tianzuo/pi-antigravity's driver. Stats and a bounded
10
+ // running one. Stats and a bounded
11
11
  // lifecycle log feed /agy doctor.
12
12
  //
13
13
  // The driver never talks to the MCP bridge directly: the provider owns the
@@ -20,6 +20,7 @@ import { parseAgyLine } from "./stream-events.js";
20
20
  import { isKnownNoiseLine, MAX_FRAME_BYTES, stripGluedNoise } from "./frame-guard.js";
21
21
  import { bridgeMcpConfigDir, bridgeMcpConfigExists } from "./mcp-server.js";
22
22
  import { gateHooksStaged } from "./approval-hook.js";
23
+ import { parkedTurnAnswer } from "./parked-turn.js";
23
24
  import { redactText } from "./redact.js";
24
25
  import type {
25
26
  AgyUsage,
@@ -62,6 +63,9 @@ interface ActiveTurn {
62
63
  response: string;
63
64
  usage?: AgyUsage;
64
65
  conversationId?: string;
66
+ /** Wall clock at turn creation; the parked-turn probe filters
67
+ * transcript steps to those written after this. */
68
+ startedAt: number;
65
69
  sawResult: boolean;
66
70
  /** Text-dedupe guard state (delta vs cumulative response_text). */
67
71
  cumulativeText: boolean | undefined;
@@ -71,6 +75,15 @@ interface ActiveTurn {
71
75
  parks: number;
72
76
  }
73
77
 
78
+ /** The skip-permissions flag actually passed to agy. Plan mode never
79
+ * carries it: the flag auto-approves every permission request including
80
+ * plan mode's own approval gate, silently turning review-only into full
81
+ * write access. #start (argv + profile) and #recycleCause (comparison)
82
+ * must both go through this, or plan turns recycle on every turn. */
83
+ function effectiveSkipPermissions(mode: DriverTurnRequest["mode"], skipPermissions: boolean): boolean {
84
+ return mode !== "plan" && skipPermissions;
85
+ }
86
+
74
87
  function emit(turn: ActiveTurn, activity: DriverActivity): void {
75
88
  if (turn.closed) return;
76
89
  if (turn.wake.length > 0) turn.wake.shift()!();
@@ -171,10 +184,7 @@ export class StreamDriver implements TurnDriver {
171
184
  // 0 disables the stall guard (config inactivityTimeoutMin: 0).
172
185
  if (idleMin > 0) {
173
186
  turn.idleTimer = setTimeout(() => {
174
- if (turn.closed) return;
175
- this.#log(`stall:${turn.id}`);
176
- this.#killChild();
177
- this.#failTurn(turn, `agy stalled for ${idleMin}m with no output`);
187
+ void this.#turnDeadlineGuard(turn, "stall", `agy stalled for ${idleMin}m with no output`);
178
188
  }, idleMin * 60_000);
179
189
  }
180
190
  }
@@ -279,6 +289,7 @@ export class StreamDriver implements TurnDriver {
279
289
  sawResult: false,
280
290
  cumulativeText: undefined,
281
291
  parks: 0,
292
+ startedAt: Date.now(),
282
293
  };
283
294
  if (request.signal) {
284
295
  turn.onAbort = () => {
@@ -306,19 +317,13 @@ export class StreamDriver implements TurnDriver {
306
317
  const totalMin = turn.request.timeoutMin ?? 10;
307
318
  if (totalMin > 0) {
308
319
  turn.overallTimer = setTimeout(() => {
309
- if (turn.closed) return;
310
- this.#log(`timeout:${turn.id}`);
311
- this.#killChild();
312
- this.#failTurn(turn, `agy exceeded the ${totalMin}m turn timeout`);
320
+ void this.#turnDeadlineGuard(turn, "timeout", `agy exceeded the ${totalMin}m turn timeout`);
313
321
  }, totalMin * 60_000);
314
322
  }
315
323
  const idleMin = turn.request.inactivityMin ?? 5;
316
324
  if (idleMin > 0) {
317
325
  turn.idleTimer = setTimeout(() => {
318
- if (turn.closed) return;
319
- this.#log(`stall:${turn.id}`);
320
- this.#killChild();
321
- this.#failTurn(turn, `agy stalled for ${idleMin}m with no output`);
326
+ void this.#turnDeadlineGuard(turn, "stall", `agy stalled for ${idleMin}m with no output`);
322
327
  }, idleMin * 60_000);
323
328
  }
324
329
  }
@@ -327,12 +332,21 @@ export class StreamDriver implements TurnDriver {
327
332
  this.#state = "starting";
328
333
  this.#generation += 1;
329
334
  const generation = this.#generation;
335
+ // Plan mode never carries the skip flag: the flag auto-approves ALL
336
+ // permission requests including plan mode's own approval gate, which
337
+ // would silently turn "review-only" into full write access (probed
338
+ // 2026-09-25: with the flag a plan session wrote files; without it,
339
+ // file and command attempts end exit 0 in ~20-30s with a "confirm
340
+ // plan" message). The profile stores the EFFECTIVE value so the
341
+ // recycle comparison stays consistent with the argv.
342
+ const skipPermissions = effectiveSkipPermissions(request.mode, request.skipPermissions);
330
343
  this.#profile = {
331
344
  cwd: request.cwd,
332
345
  model: request.model,
333
346
  effort: request.effort,
334
347
  mode: request.mode,
335
- skipPermissions: request.skipPermissions,
348
+ skipPermissions,
349
+ agent: request.agent,
336
350
  };
337
351
  this.#boundConversation = request.conversationId ?? undefined;
338
352
  this.#stderrTail = "";
@@ -346,7 +360,8 @@ export class StreamDriver implements TurnDriver {
346
360
  args.push("--model", request.model);
347
361
  if (request.effort) args.push("--effort", request.effort);
348
362
  args.push("--mode", request.mode);
349
- if (request.skipPermissions) args.push("--dangerously-skip-permissions");
363
+ if (request.agent) args.push("--agent", request.agent);
364
+ if (skipPermissions) args.push("--dangerously-skip-permissions");
350
365
  if (request.conversationId) args.push("--conversation", request.conversationId);
351
366
  args.push(
352
367
  "--input-format",
@@ -607,6 +622,40 @@ export class StreamDriver implements TurnDriver {
607
622
  }
608
623
  }
609
624
 
625
+ /** Stall / total-timeout guard shared by both timer sites. Kills the
626
+ * child FIRST (bumping the generation so a racing exit handler cannot
627
+ * settle the turn ERROR mid-probe), then probes the conversation's
628
+ * brain transcript for a clean final-response step written during the
629
+ * turn (parked turn: agy finished its answer but never streamed it).
630
+ * One found, the turn settles OK with the withheld answer instead of
631
+ * discarding finished work; either way the next turn respawns into a
632
+ * known state. */
633
+ async #turnDeadlineGuard(turn: ActiveTurn, label: string, message: string): Promise<void> {
634
+ if (turn.closed) return;
635
+ this.#log(`${label}:${turn.id}`);
636
+ this.#killChild();
637
+ let parked: string | undefined;
638
+ try {
639
+ parked = turn.conversationId
640
+ ? await parkedTurnAnswer(turn.conversationId, turn.startedAt)
641
+ : undefined;
642
+ } catch {
643
+ parked = undefined; // the guard must never throw
644
+ }
645
+ if (parked !== undefined) {
646
+ this.#settle(turn, {
647
+ conversationId: turn.conversationId,
648
+ status: "OK",
649
+ response: parked,
650
+ usage: turn.usage,
651
+ finished: true,
652
+ aborted: false,
653
+ });
654
+ return;
655
+ }
656
+ this.#failTurn(turn, message);
657
+ }
658
+
610
659
  #failTurn(turn: ActiveTurn, message: string): void {
611
660
  this.#settle(turn, {
612
661
  conversationId: turn.conversationId,
@@ -626,7 +675,10 @@ export class StreamDriver implements TurnDriver {
626
675
  if (cur.model !== next.model) return "model";
627
676
  if (cur.effort !== next.effort) return "effort";
628
677
  if (cur.mode !== next.mode) return "mode";
629
- if (cur.skipPermissions !== next.skipPermissions) return "permissions";
678
+ if (cur.skipPermissions !== effectiveSkipPermissions(next.mode, next.skipPermissions)) {
679
+ return "permissions";
680
+ }
681
+ if (cur.agent !== next.agent) return "agent";
630
682
  if (!next.conversationId) return this.#boundConversation ? "conversation-reset" : undefined;
631
683
  return next.conversationId === this.#boundConversation ? undefined : "conversation";
632
684
  }