@estebanforge/pi-antigravity-bridge 1.4.9 → 1.5.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.
@@ -0,0 +1,252 @@
1
+ // Staging for the approval-gate hooks.json (docs/TODO.md 2.5).
2
+ //
3
+ // The ACP server and the agy CLI fire workspace `.agents/hooks.json`
4
+ // PreToolUse hooks (V2: deny honored, reason reaches the model; V3: hook
5
+ // TIMEOUT = soft-pass, agy proceeds ungated). Therefore:
6
+ // - the staged handler timeout must exceed the whole park budget with
7
+ // margin (never rely on timeout as a deny), and
8
+ // - the staged command delegates to the bundled poll script, which
9
+ // early-acks and polls the bridge for the terminal decision.
10
+ //
11
+ // Merge rules: never clobber a foreign hooks.json. Parse failures abort
12
+ // staging; first-time modification of an existing file writes a
13
+ // timestamped backup next to it (the 2026-09-05 incident rule: every
14
+ // destructive path gets a guard).
15
+ //
16
+ // Run: npm test
17
+
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+
21
+ export const HOOK_GROUP = "pi-bridge-gate";
22
+
23
+ /** Group-key namespace. Each pi session stages its OWN group keyed per-pid
24
+ * (`pi-bridge-gate-<pid>`): hooks.json lives in the SHARED workspace, and
25
+ * two concurrent sessions must never remove or overwrite each other's gate
26
+ * (audit 2026-09-07: a single shared key let a gate-off session silently
27
+ * strip a gate-on session's PreToolUse matchers). */
28
+ export const GATE_GROUP_PREFIX = "pi-bridge-gate";
29
+
30
+ /** This session's group key. */
31
+ export function gateGroupKey(pid: number = process.pid): string {
32
+ return `${GATE_GROUP_PREFIX}-${pid}`;
33
+ }
34
+
35
+ /** True if the process is running (EPERM counts: alive but not ours). */
36
+ function pidAlive(pid: number): boolean {
37
+ if (!Number.isInteger(pid) || pid <= 0) return false;
38
+ try {
39
+ process.kill(pid, 0);
40
+ return true;
41
+ } catch (e) {
42
+ return (e as NodeJS.ErrnoException).code === "EPERM";
43
+ }
44
+ }
45
+
46
+ /** Which session a gate group belongs to, parsed from the script path its
47
+ * command embeds (`.../approval-hook-<pid>.js`). The pid is the ownership
48
+ * proof: the script is per-pid (0600, written by that session). Returns
49
+ * null for groups we cannot attribute (foreign/future formats - never
50
+ * touched). */
51
+ function gateGroupPid(group: unknown): number | null {
52
+ const m = /approval-hook-(\d+)\.js/.exec(JSON.stringify(group));
53
+ return m ? Number(m[1]) : null;
54
+ }
55
+
56
+ /** agy native tools worth gating: everything that mutates the machine. */
57
+ export const GATED_AGY_TOOLS =
58
+ "create_file|write_to_file|replace_file_content|multi_replace_file_content|edit_file|run_command";
59
+
60
+ /** The same list as a set, for the bridge's POST /approval validation: a
61
+ * payload for anything else is answered with a direct deny (defense in
62
+ * depth - the hooks matcher should never let one through). */
63
+ export const GATED_AGY_TOOL_SET: ReadonlySet<string> = new Set(GATED_AGY_TOOLS.split("|"));
64
+
65
+ export interface StageOptions {
66
+ /** Bridge HTTP port (the approval endpoints live on the bridge server). */
67
+ port: number;
68
+ /** Bridge shared secret (x-bridge-token). */
69
+ token: string;
70
+ /** Path to the bundled poll script (written by the caller). */
71
+ scriptPath: string;
72
+ /** Full park budget in ms; the staged hook timeout exceeds it. */
73
+ parkBudgetMs: number;
74
+ }
75
+
76
+ /** Source of the staged poll script. Written to disk by the caller (data
77
+ * dir), referenced by absolute path from the staged hooks.json. Early-acks
78
+ * via POST /approval, then polls GET /approval/<ticket> until a terminal
79
+ * decision or the deadline. Terminal: prints the JSON decision on stdout.
80
+ * Deadline hit: prints {"decision":"deny", ...} (fail closed) - agy may
81
+ * still soft-pass a timed-out hook, but a printed deny is honored (V2). */
82
+ export function hookScriptSource(opts: { port: number; token: string; deadlineMs: number }): string {
83
+ return `#!/usr/bin/env node
84
+ // Bridge approval hook (generated; do not edit). Polls the pi-antigravity-bridge.
85
+ const PORT = ${opts.port};
86
+ const TOKEN = ${JSON.stringify(opts.token)};
87
+ const DEADLINE = Date.now() + ${opts.deadlineMs};
88
+ let body = "";
89
+ process.stdin.setEncoding("utf8");
90
+ for await (const chunk of process.stdin) body += chunk;
91
+ let ticket = "";
92
+ try {
93
+ const res = await fetch(\`http://127.0.0.1:\${PORT}/approval\`, {
94
+ method: "POST",
95
+ headers: { "content-type": "application/json", "x-bridge-token": TOKEN },
96
+ body,
97
+ });
98
+ const json = await res.json();
99
+ // Ungated payloads get a terminal decision right on the POST (no park).
100
+ if (json && typeof json === "object" && "decision" in json) {
101
+ console.log(JSON.stringify(json.decision));
102
+ process.exit(0);
103
+ }
104
+ ticket = json.ticket ?? "";
105
+ } catch {}
106
+ if (!ticket) {
107
+ console.log(JSON.stringify({ decision: "deny", reason: "approval gate unreachable (bridge down?)" }));
108
+ process.exit(0);
109
+ }
110
+ while (Date.now() < DEADLINE) {
111
+ await new Promise((r) => setTimeout(r, 500));
112
+ try {
113
+ const res = await fetch(\`http://127.0.0.1:\${PORT}/approval/\${encodeURIComponent(ticket)}\`, {
114
+ headers: { "x-bridge-token": TOKEN },
115
+ });
116
+ const json = await res.json();
117
+ if (json.status !== "pending") {
118
+ console.log(JSON.stringify(json.decision ?? { decision: "deny", reason: "gate returned no decision" }));
119
+ process.exit(0);
120
+ }
121
+ } catch {}
122
+ }
123
+ console.log(JSON.stringify({ decision: "deny", reason: "approval gate deadline exceeded" }));
124
+ `;
125
+ }
126
+
127
+ /** Hook timeout (seconds) staged for a given park budget: the budget plus a
128
+ * 60s margin, minimum 60s. V3: a timed-out hook soft-passes, so this must
129
+ * never be smaller than the human can plausibly need. */
130
+ export function stagedTimeoutSeconds(parkBudgetMs: number): number {
131
+ return Math.max(60, Math.ceil(parkBudgetMs / 1000) + 60);
132
+ }
133
+
134
+ /** Build our hooks.json group for one workspace staging. */
135
+ export function buildGateGroup(opts: StageOptions): Record<string, unknown> {
136
+ const command = `node ${JSON.stringify(opts.scriptPath)}`;
137
+ return {
138
+ enabled: true,
139
+ PreToolUse: [
140
+ {
141
+ matcher: GATED_AGY_TOOLS,
142
+ hooks: [
143
+ {
144
+ type: "command",
145
+ command,
146
+ timeout: stagedTimeoutSeconds(opts.parkBudgetMs),
147
+ },
148
+ ],
149
+ },
150
+ ],
151
+ };
152
+ }
153
+
154
+ export interface StageResult {
155
+ wrote: boolean;
156
+ /** Backup file written before first modification of a foreign file. */
157
+ backup?: string;
158
+ /** Why nothing was written (parse failure, already current, ...). */
159
+ reason?: string;
160
+ }
161
+
162
+ /** Stage the gate group into <workspaceDir>/.agents/hooks.json under THIS
163
+ * session's per-pid key. Merge-safe:
164
+ * - foreign groups are preserved;
165
+ * - gate groups of DEAD sessions are swept (their bridge is gone; the hook
166
+ * would fail closed forever), groups of live sessions never touched;
167
+ * - a foreign file is backed up before its first modification;
168
+ * - unparseable files are never touched. */
169
+ export function stageGateHooks(workspaceDir: string, opts: StageOptions): StageResult {
170
+ const dir = path.join(workspaceDir, ".agents");
171
+ const file = path.join(dir, "hooks.json");
172
+ const group = buildGateGroup(opts);
173
+ const ownKey = gateGroupKey();
174
+ let current: Record<string, unknown> = {};
175
+ const existed = fs.existsSync(file);
176
+ if (existed) {
177
+ try {
178
+ if (fs.lstatSync(file).isSymbolicLink()) {
179
+ return { wrote: false, reason: "hooks.json is a symlink; refusing to follow it" };
180
+ }
181
+ } catch {
182
+ return { wrote: false, reason: "hooks.json vanished while staging" };
183
+ }
184
+ try {
185
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
186
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
187
+ return { wrote: false, reason: "hooks.json is not an object; refusing to touch it" };
188
+ }
189
+ current = parsed as Record<string, unknown>;
190
+ } catch {
191
+ return { wrote: false, reason: "hooks.json is not valid JSON; refusing to touch it" };
192
+ }
193
+ // Sweep gate groups whose owning session is gone. Never touch groups of
194
+ // live sessions (concurrent pi sessions share this workspace) or groups
195
+ // we cannot attribute.
196
+ let swept = 0;
197
+ for (const key of Object.keys(current)) {
198
+ if (key === ownKey) continue;
199
+ const isGateGroup = key === GATE_GROUP_PREFIX || key.startsWith(`${GATE_GROUP_PREFIX}-`);
200
+ if (!isGateGroup) continue;
201
+ const pid = gateGroupPid(current[key]);
202
+ if (pid === null || pidAlive(pid)) continue;
203
+ delete current[key];
204
+ swept += 1;
205
+ }
206
+ if (swept > 0 && JSON.stringify(current[ownKey]) === JSON.stringify(group)) {
207
+ // Own group already current; the pass only swept dead peers.
208
+ fs.writeFileSync(file, JSON.stringify(current, null, 2) + "\n");
209
+ return { wrote: true, reason: `swept ${swept} dead gate group(s)` };
210
+ }
211
+ if (JSON.stringify(current[ownKey]) === JSON.stringify(group)) {
212
+ return { wrote: false, reason: "already staged" };
213
+ }
214
+ }
215
+ const backup =
216
+ existed && current[ownKey] === undefined
217
+ ? `${file}.backup-${new Date().toISOString().replace(/[:.]/g, "-")}`
218
+ : undefined;
219
+ if (backup) fs.copyFileSync(file, backup);
220
+ current[ownKey] = group;
221
+ fs.mkdirSync(dir, { recursive: true });
222
+ fs.writeFileSync(file, JSON.stringify(current, null, 2) + "\n");
223
+ return { wrote: true, backup };
224
+ }
225
+
226
+ /** Remove ONLY this session's gate group (or the given pid's). Other
227
+ * sessions' groups - including live ones in a shared workspace - are never
228
+ * touched: a gate-off session must not strip a gate-on session's matchers
229
+ * (audit 2026-09-07). Foreign content stays; an object file is left in
230
+ * place (harmless). */
231
+ export function removeGateHooks(workspaceDir: string, pid: number = process.pid): StageResult {
232
+ const file = path.join(workspaceDir, ".agents", "hooks.json");
233
+ if (!fs.existsSync(file)) return { wrote: false, reason: "no hooks.json" };
234
+ try {
235
+ if (fs.lstatSync(file).isSymbolicLink()) {
236
+ return { wrote: false, reason: "hooks.json is a symlink; refusing to follow it" };
237
+ }
238
+ } catch {
239
+ return { wrote: false, reason: "hooks.json vanished while removing" };
240
+ }
241
+ try {
242
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
243
+ if (!parsed || typeof parsed !== "object") return { wrote: false, reason: "not an object; refusing" };
244
+ const ownKey = gateGroupKey(pid);
245
+ if (parsed[ownKey] === undefined) return { wrote: false, reason: "not staged" };
246
+ delete parsed[ownKey];
247
+ fs.writeFileSync(file, JSON.stringify(parsed, null, 2) + "\n");
248
+ return { wrote: true };
249
+ } catch {
250
+ return { wrote: false, reason: "not valid JSON; refusing" };
251
+ }
252
+ }
package/src/config.ts CHANGED
@@ -44,6 +44,35 @@ export type AgyMode = "accept-edits" | "plan";
44
44
  export type ThinkingTier = "low" | "medium" | "high";
45
45
  export type BridgeTools = "none" | "mcp" | "all";
46
46
 
47
+ /** How ACP turns report token usage while Gate B stands (agy sends none).
48
+ * "estimate" (default): word-boundary regex over prompt/response/thought
49
+ * text (pi-token-speed's mechanism). "direct": 1 token per streamed delta.
50
+ * "off": keep zero-usage. Real server usage (usageSeen latch) always wins. */
51
+ export type UsageEstimate = "estimate" | "direct" | "off";
52
+
53
+ /** How the approval gate activates (docs/TODO.md section 2.5).
54
+ *
55
+ * "auto" (default): OFF until a third-party pi permission extension is
56
+ * detected (src/approval-detect.ts). When one is found, the gate runs in
57
+ * "shadow" mode so that extension gates agy's native tool calls with zero
58
+ * configuration on its side.
59
+ *
60
+ * "shadow" / "dedicated": force the gate on in that shape. "off": never
61
+ * gate, even when a permission extension is installed. NOTE: "dedicated"
62
+ * currently stages the same shadow tools as "shadow" (warn-logged remap);
63
+ * the explicit antigravity_approve variant is planned - see docs/TODO.md. */
64
+ export type GateMode = "auto" | "shadow" | "dedicated" | "off";
65
+
66
+ /** Fallback decision when no extension blocked a gated call: "ask" uses
67
+ * ctx.ui.confirm (headless = deny, fail-closed), "allow" auto-approves,
68
+ * "deny" auto-rejects. */
69
+ export type GateAskMode = "ask" | "allow" | "deny";
70
+
71
+ export interface GateConfig {
72
+ gateMode: GateMode;
73
+ mode: GateAskMode;
74
+ }
75
+
47
76
  export interface AcpConfig {
48
77
  /** Path to agy_acp_server.par. Empty = env AGY_ACP_BIN > PATH. */
49
78
  bin: string;
@@ -51,6 +80,10 @@ export interface AcpConfig {
51
80
  * (parity with skipPermissions). Kept as a key so future policies do not
52
81
  * change the config shape. */
53
82
  permissions: "auto";
83
+ /** Gate B stopgap: client-side token estimates for ACP turns so pi's
84
+ * usage surfaces show nonzero numbers. Estimates are labeled as such in
85
+ * /agy doctor and auto-disable when the server sends real usage. */
86
+ usageEstimate: UsageEstimate;
54
87
  }
55
88
 
56
89
  export interface AgyConfig {
@@ -108,6 +141,11 @@ export interface AgyConfig {
108
141
  * digest (per-turn) is not. Turn off for agy-native behavior (agy's own
109
142
  * system prompt only). */
110
143
  systemPrompt: boolean;
144
+ /** Approval gate over agy NATIVE tool calls (create_file, run_command,
145
+ * ...). pi tools agy calls already pass through pi's gates via the G9
146
+ * round-trip; this covers the rest. Default auto (off until a
147
+ * third-party permission extension is detected). See docs/TODO.md 2.5. */
148
+ approvals: GateConfig;
111
149
  }
112
150
 
113
151
  const DEFAULTS: AgyConfig = {
@@ -120,7 +158,8 @@ const DEFAULTS: AgyConfig = {
120
158
  bridgeTools: "all",
121
159
  digest: false,
122
160
  systemPrompt: true,
123
- acp: { bin: "", permissions: "auto" },
161
+ approvals: { gateMode: "auto", mode: "ask" },
162
+ acp: { bin: "", permissions: "auto", usageEstimate: "estimate" },
124
163
  };
125
164
 
126
165
  /** Load config merged over defaults. Env vars override the file when set. */
@@ -189,12 +228,33 @@ export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
189
228
  ? ["1", "true", "on"].includes(envSys.toLowerCase())
190
229
  : file.systemPrompt ?? DEFAULTS.systemPrompt;
191
230
 
231
+ // Approval gate (docs/TODO.md 2.5). Unknown values fall back to "auto"
232
+ // so a typo can never silently force the gate on.
233
+ const gateRaw = (process.env.AGY_APPROVALS ?? file.approvals?.gateMode ?? DEFAULTS.approvals.gateMode).toLowerCase();
234
+ const gateMode: GateMode =
235
+ gateRaw === "shadow" || gateRaw === "dedicated" || gateRaw === "off"
236
+ ? gateRaw
237
+ : "auto";
238
+ const askRaw = (process.env.AGY_APPROVALS_MODE ?? file.approvals?.mode ?? DEFAULTS.approvals.mode).toLowerCase();
239
+ const gateAskMode: GateAskMode =
240
+ askRaw === "allow" || askRaw === "deny"
241
+ ? askRaw
242
+ : "ask";
243
+
192
244
  const fileAcp = (typeof file.acp === "object" && file.acp !== null ? file.acp : {}) as Partial<AcpConfig>;
245
+ // Unknown values fall back to "estimate" (same narrow-parse pattern as
246
+ // gateMode: a typo must never silently change behavior).
247
+ const usageRaw = String(
248
+ process.env.AGY_USAGE_ESTIMATE ?? fileAcp.usageEstimate ?? DEFAULTS.acp.usageEstimate,
249
+ ).toLowerCase();
250
+ const usageEstimate: UsageEstimate =
251
+ usageRaw === "direct" || usageRaw === "off" ? usageRaw : "estimate";
193
252
  const acp: AcpConfig = {
194
253
  bin:
195
254
  process.env.AGY_ACP_BIN ??
196
255
  (typeof fileAcp.bin === "string" ? fileAcp.bin : DEFAULTS.acp.bin),
197
256
  permissions: "auto",
257
+ usageEstimate,
198
258
  };
199
259
 
200
260
  return {
@@ -208,6 +268,7 @@ export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
208
268
  bridgeTools,
209
269
  digest,
210
270
  systemPrompt,
271
+ approvals: { gateMode, mode: gateAskMode },
211
272
  patchCleanupNotified: file.patchCleanupNotified === true,
212
273
  };
213
274
  }
@@ -1,9 +1,9 @@
1
1
  // Engine-agnostic turn-driver contract.
2
2
  //
3
- // Both turn engines (legacy stream-json driver in `driver.ts`, ACP driver in
3
+ // Both turn engines (stream-json driver in `driver.ts`, ACP driver in
4
4
  // `acp/driver.ts`) implement `TurnDriver`, and everything above them — the
5
5
  // provider's stream loop, the G9 round-trip store, the extension wiring —
6
- // depends on this interface only. Types live here so the legacy module can be
6
+ // depends on this interface only. Types live here so the stream module can be
7
7
  // deleted (phase 4) without breaking imports.
8
8
  //
9
9
  // The ACP driver implements the same surface with protocol-native mechanics:
@@ -21,17 +21,17 @@ export interface DriverProfile {
21
21
  }
22
22
 
23
23
  export interface DriverTurnRequest extends DriverProfile {
24
- /** Existing conversation/session to resume. Legacy: agy conversation id via
24
+ /** Existing conversation/session to resume. Stream-json: agy conversation id via
25
25
  * `--conversation`. ACP: sessionId via `session/load` (falls back to
26
26
  * `session/new` when the server no longer knows it). */
27
27
  conversationId?: string | null;
28
28
  prompt: string;
29
29
  /** Image blocks riding with the prompt. ACP forwards them as typed
30
30
  * content blocks (probe 2026-09-03: 64x64 two-tone PNG answered
31
- * correctly); the legacy CLI prompt is text-only and ignores them. */
31
+ * correctly); the stream-json CLI prompt is text-only and ignores them. */
32
32
  images?: Array<{ data: string; mimeType: string }>;
33
33
  /** ACP only: pi-side context delivered as a native `embeddedContext`
34
- * resource block instead of inline prompt text (G1 on ACP). Legacy
34
+ * resource block instead of inline prompt text (G1 on ACP). Stream-json
35
35
  * embeds the digest in the prompt string and ignores this. */
36
36
  contextBlock?: { uri: string; text: string };
37
37
  signal?: AbortSignal;
@@ -52,7 +52,7 @@ export type AgyUsage = {
52
52
 
53
53
  export type DriverActivity =
54
54
  | { type: "text"; delta: string }
55
- /** Legacy emits a token count only; ACP carries the actual thought text in
55
+ /** Stream-json emits a token count only; ACP carries the actual thought text in
56
56
  * `delta`. The provider renders whichever is present. */
57
57
  | { type: "thought"; tokens?: number; delta?: string }
58
58
  | { type: "tool_start"; stepId?: number; name: string; args: Record<string, unknown> }
@@ -64,7 +64,7 @@ export type DriverActivity =
64
64
  output?: string;
65
65
  durationSeconds?: number;
66
66
  /** ACP only: the server's native edit diff from `tool_call`
67
- * content[] ({type:"diff", path, oldText?, newText}). Legacy never
67
+ * content[] ({type:"diff", path, oldText?, newText}). Stream-json never
68
68
  * sets it; the provider renders it without any git subprocess. */
69
69
  diff?: { path: string; oldText?: string; newText: string };
70
70
  }
@@ -108,7 +108,7 @@ export interface DriverSnapshot {
108
108
  recycleReasons: Record<string, number>;
109
109
  };
110
110
  lifecycle: string[];
111
- /** Present on ACP snapshots; absent on legacy. */
111
+ /** Present on ACP snapshots; absent on stream-json. */
112
112
  engine?: "acp";
113
113
  acp?: {
114
114
  sessionId?: string;
@@ -133,7 +133,7 @@ export interface DriverSnapshot {
133
133
  }
134
134
 
135
135
  /** The engine contract. Everything above the driver depends on this interface
136
- * only; `AgyDriver` and `AcpDriver` both implement it. */
136
+ * only; `StreamDriver` and `AcpDriver` both implement it. */
137
137
  export interface TurnDriver {
138
138
  readonly state: DriverState;
139
139
  readonly activeHandle: TurnHandle | null;
package/src/driver.ts CHANGED
@@ -122,7 +122,7 @@ export function shouldFlipToCumulative(accumulated: string, next: string): boole
122
122
  return accumulated.length >= CUMULATIVE_FLIP_MIN_CHARS && isCumulativeResend(accumulated, next);
123
123
  }
124
124
 
125
- export class AgyDriver implements TurnDriver {
125
+ export class StreamDriver implements TurnDriver {
126
126
  #state: DriverState = "idle";
127
127
  #child: ChildProcess | undefined;
128
128
  #generation = 0;
@@ -130,7 +130,6 @@ export class AgyDriver implements TurnDriver {
130
130
  #boundConversation: string | undefined;
131
131
  #active: ActiveTurn | undefined;
132
132
  #queueTail: Promise<void> = Promise.resolve();
133
- #shutdown = false;
134
133
  #stderrTail = "";
135
134
  // Frames can split across pipe chunks; the trailing partial line lives here
136
135
  // until its newline arrives (same scheme as JsonRpcSession.feed). Dropping
@@ -218,7 +217,10 @@ export class AgyDriver implements TurnDriver {
218
217
  }
219
218
 
220
219
  async #runExclusive(request: DriverTurnRequest): Promise<TurnHandle> {
221
- if (this.#shutdown) throw new Error("agy driver is shut down.");
220
+ // No shutdown latch: pi fires session_shutdown on /new, /resume and
221
+ // /fork (not only process exit), so a closed driver must respawn on the
222
+ // next turn instead of rejecting forever. Parity with the ACP driver
223
+ // fix (regression 2026-09-07).
222
224
  if (request.signal?.aborted) throw new Error("aborted before start");
223
225
 
224
226
  const cause = this.#recycleCause(request);
@@ -588,7 +590,6 @@ export class AgyDriver implements TurnDriver {
588
590
  }
589
591
 
590
592
  async close(reason: "recycle" | "shutdown", cause?: string): Promise<void> {
591
- if (reason === "shutdown") this.#shutdown = true;
592
593
  const child = this.#child;
593
594
  if (!child) {
594
595
  this.#state = reason === "shutdown" ? "dead" : "idle";
@@ -602,7 +603,7 @@ export class AgyDriver implements TurnDriver {
602
603
  this.#log(`close:${reason}${cause ? `:${cause}` : ""}`);
603
604
  const turn = this.#active;
604
605
  if (turn && !turn.closed) {
605
- this.#failTurn(turn, `agy driver ${reason}ed mid-turn${cause ? ` (${cause})` : ""}`);
606
+ this.#failTurn(turn, `agy driver ${reason === "recycle" ? "recycled" : "shut down"} mid-turn${cause ? ` (${cause})` : ""}`);
606
607
  }
607
608
  this.#killChild();
608
609
  this.#state = reason === "shutdown" ? "dead" : "idle";
@@ -0,0 +1,155 @@
1
+ // First-run onboarding: the engine picker plus the agy-presence warning.
2
+ //
3
+ // On the first interactive start (no config file yet, no AGY_ENGINE env) pi
4
+ // asks which turn engine to use: the stream-json `agy` CLI or Google's
5
+ // official ACP server. The choice persists via saveConfig({ engine }) and,
6
+ // like /agy engine, takes effect on the next pi start (drivers wire at load).
7
+ // Every start with the stream-json engine active also re-checks that the
8
+ // `agy` binary exists and warns until it does (re-auth is out of scope).
9
+ //
10
+ // UI: tui.md "Pattern 1" - SelectList framed by DynamicBorder inside pi's
11
+ // native overlay (the window feel pi-rtk builds its modal on).
12
+
13
+ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
14
+ import { DynamicBorder } from "@earendil-works/pi-coding-agent";
15
+ import {
16
+ Container,
17
+ type SelectItem,
18
+ SelectList,
19
+ Spacer,
20
+ Text,
21
+ } from "@earendil-works/pi-tui";
22
+ import fs from "node:fs";
23
+ import path from "node:path";
24
+ import type { Engine } from "./config.js";
25
+
26
+ /** Picker order is the default answer order: stream-json first. */
27
+ export const ENGINE_PICKER_ITEMS: SelectItem[] = [
28
+ {
29
+ value: "stream-json",
30
+ label: "Stream-JSON CLI",
31
+ description: "default, recommended",
32
+ },
33
+ {
34
+ value: "acp",
35
+ label: "ACP server (official Google)",
36
+ description: "second sign-in, ~1.5 GB binary download",
37
+ },
38
+ ];
39
+
40
+ /** Intro paragraph above the list. Text wraps, so explanations stay here and
41
+ * SelectItem descriptions stay one-liners (SelectList truncates long lines). */
42
+ export const ENGINE_PICKER_INTRO = [
43
+ "Pick the engine that runs your Antigravity turns. Switch anytime with /agy engine (restart applies it).",
44
+ "",
45
+ "stream-json: the `agy` CLI you already installed and authenticated. Persistent process, streamed output. Tested default.",
46
+ "ACP: Google's official server (agy_acp_server.par). Needs a second Google sign-in and a ~1.5 GB server binary downloaded from Google (automatic, one-time, unavoidable: the server is not part of the agy CLI).",
47
+ ].join("\n");
48
+
49
+ /** True only for a genuine first interactive run: no saved config yet (any
50
+ * existing file means the user has been here before) and no AGY_ENGINE env
51
+ * (env wins over the file, so the wizard would fight it). Fails closed to
52
+ * false - an fs error must never nag the user with a dialog. */
53
+ export function shouldOfferEnginePicker(
54
+ configPath: string,
55
+ env: NodeJS.ProcessEnv = process.env,
56
+ ): boolean {
57
+ if (env.AGY_ENGINE !== undefined) return false;
58
+ try {
59
+ return !fs.existsSync(configPath);
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ /** Narrow a picker value to an Engine. Items are our own constants, but an
66
+ * unknown value must never reach config as a cast string: it falls back to
67
+ * the tested default. */
68
+ export function toEngine(value: string): Engine {
69
+ return value === "acp" ? "acp" : "stream-json";
70
+ }
71
+
72
+ /** Toast copy shown after the choice is saved. ACP names the promise that
73
+ * matters: the binary download starts NOW (not on restart), sign-in follows
74
+ * when it lands, restart applies the engine. */
75
+ export function savedEngineMessage(engine: Engine): string {
76
+ return engine === "acp"
77
+ ? "Engine saved: acp. The ~1.5 GB server binary downloads now; the Google sign-in opens when it lands. Restart applies the engine."
78
+ : "Engine saved: stream-json. Restart pi to apply.";
79
+ }
80
+
81
+ /** True when the `agy` CLI binary can be found. A binRef with a path
82
+ * separator (AGY_BIN=/opt/agy/agy) must exist as a file; a bare name is
83
+ * searched on PATH. statSync cannot throw through the guards, but a race
84
+ * (file removed between listing and stat) fails closed to false. */
85
+ export function isAgyInstalled(binRef: string, env: NodeJS.ProcessEnv = process.env): boolean {
86
+ if (binRef.includes("/")) {
87
+ try {
88
+ return fs.statSync(binRef).isFile();
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+ return (env.PATH ?? "")
94
+ .split(path.delimiter)
95
+ .filter(Boolean)
96
+ .some((dir) => {
97
+ try {
98
+ return fs.statSync(path.join(dir, binRef)).isFile();
99
+ } catch {
100
+ return false;
101
+ }
102
+ });
103
+ }
104
+
105
+ /** Toast copy for the missing-CLI warning. Fires every pi start while the
106
+ * stream-json engine is active and the binary is absent. */
107
+ export function agyMissingMessage(): string {
108
+ return "The `agy` CLI is not installed. Install it from https://antigravity.google/product/antigravity-cli and log in on it to use Antigravity models.";
109
+ }
110
+
111
+ /** Render the picker overlay. Resolves with the chosen engine, or null when
112
+ * the user pressed esc (decide later - nothing is persisted). */
113
+ export async function showEnginePicker(ctx: ExtensionUIContext): Promise<Engine | null> {
114
+ return ctx.custom<Engine | null>(
115
+ (tui, theme, _keybindings, done) => {
116
+ const container = new Container();
117
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
118
+ container.addChild(
119
+ new Text(theme.fg("accent", theme.bold("Antigravity Bridge: choose your engine")), 1, 0),
120
+ );
121
+ container.addChild(new Text(theme.fg("muted", ENGINE_PICKER_INTRO), 1, 0));
122
+ container.addChild(new Spacer(1));
123
+
124
+ const list = new SelectList(ENGINE_PICKER_ITEMS, ENGINE_PICKER_ITEMS.length, {
125
+ selectedPrefix: (t: string) => theme.fg("accent", t),
126
+ selectedText: (t: string) => theme.fg("accent", t),
127
+ description: (t: string) => theme.fg("muted", t),
128
+ scrollInfo: (t: string) => theme.fg("dim", t),
129
+ noMatch: (t: string) => theme.fg("warning", t),
130
+ });
131
+ list.onSelect = (item) => done(toEngine(item.value));
132
+ list.onCancel = () => done(null);
133
+ container.addChild(list);
134
+
135
+ container.addChild(new Spacer(1));
136
+ container.addChild(
137
+ new Text(theme.fg("dim", "↑↓ navigate · enter select · esc decide later"), 1, 0),
138
+ );
139
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
140
+
141
+ return {
142
+ render: (width: number) => container.render(width),
143
+ invalidate: () => container.invalidate(),
144
+ handleInput: (data: string) => {
145
+ list.handleInput(data);
146
+ tui.requestRender();
147
+ },
148
+ };
149
+ },
150
+ {
151
+ overlay: true,
152
+ overlayOptions: { anchor: "center" as const, width: 80, maxHeight: "85%" as const, margin: 1 },
153
+ },
154
+ );
155
+ }