@estebanforge/pi-antigravity-bridge 1.3.3 → 1.4.1

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,355 @@
1
+ // ACP engine self-service setup: binary install from the official registry
2
+ // and auth bootstrap. Everything is automatic when possible; the caller
3
+ // surfaces `manual` only when a step fails. Instructions-as-first-resort is
4
+ // gone: /agy engine acp and session_start self-heal silently when ready.
5
+ //
6
+ // Sources: docs/ACP-ADOPTION-PLAN.md §2.1 (registry entry), §12 (pinned
7
+ // install layout: ~/.local/opt/agy-acp/<build>/ + current symlink, zip
8
+ // sha256 recorded at install time), docs/ACP-PROTOCOL-REFERENCE.md
9
+ // (settings.json auth shapes; the server opens the oauth browser itself).
10
+ // Credential rule: this module never reads or writes credential VALUES —
11
+ // acp_token.json is only stat()ed, gemini-api-key is read by the server
12
+ // from the environment.
13
+
14
+ import { spawn } from "node:child_process";
15
+ import { createHash } from "node:crypto";
16
+ import fs from "node:fs";
17
+ import os from "node:os";
18
+ import path from "node:path";
19
+
20
+ export const REGISTRY_URL =
21
+ "https://raw.githubusercontent.com/agentclientprotocol/registry/main/antigravity-acp/agent.json";
22
+
23
+ const INSTALL_TIMEOUT_MS = 30 * 60_000;
24
+
25
+ export interface SetupOptions {
26
+ /** Registry agent.json URL. Overridable for tests. */
27
+ registryUrl?: string;
28
+ /** Install root. Default ~/.local/opt/agy-acp. */
29
+ installRoot?: string;
30
+ /** Server settings dir. Default ~/.gemini/antigravity-acp. */
31
+ geminiDir?: string;
32
+ /** acp.bin from config (second resolution priority after AGY_ACP_BIN). */
33
+ configBin?: string;
34
+ /** Environment. Default process.env (test injection). */
35
+ env?: NodeJS.ProcessEnv;
36
+ /** Progress messages for the user (throttled by the caller's UI). */
37
+ onProgress?: (msg: string) => void;
38
+ fetchImpl?: typeof fetch;
39
+ /** Archive unpacker. Default: `unzip` subprocess (tests inject a copier). */
40
+ unpack?: (archive: string, dest: string) => Promise<void>;
41
+ /** Download when the binary is missing. Default true. */
42
+ install?: boolean;
43
+ }
44
+
45
+ export type AcpSetupStatus =
46
+ | {
47
+ ok: true;
48
+ bin: string;
49
+ binarySource: "env" | "config" | "installed" | "existing";
50
+ auth: string;
51
+ /** True when auth was just set to oauth-personal: the server opens
52
+ * Google login in the browser on the first ACP message. */
53
+ needsLogin: boolean;
54
+ /** Human-readable actions taken (empty when everything was ready). */
55
+ actions: string[];
56
+ }
57
+ | { ok: false; stage: "install" | "auth"; error: string; manual: string };
58
+
59
+ export const MANUAL_SETUP = [
60
+ "For some unknown reason, Google ships its ACP server as a separate",
61
+ "binary from the Agy CLI. So, as with any other part of its Antigravity",
62
+ "suite (Agy Desktop, Agy Editor, Agy CLI), you need to log in to",
63
+ "this one as well. Separately ¯\\_(ツ)_/¯",
64
+ "ACP manual setup:",
65
+ "1. Download the server zip for your platform from the antigravity-acp",
66
+ " registry entry (github.com/agentclientprotocol/registry, folder",
67
+ " antigravity-acp/agent.json) and unzip it, e.g. to",
68
+ " ~/.local/opt/agy-acp/<build>/; chmod +x agy_acp_server.par.",
69
+ "2. Point AGY_ACP_BIN or /agy config acp.bin at the binary",
70
+ " (~/.local/opt/agy-acp/current/agy_acp_server.par works; 'current' is a",
71
+ " symlink to the build dir).",
72
+ '3. Log in: put {"auth":{"type":"oauth-personal"}} in',
73
+ " ~/.gemini/antigravity-acp/settings.json and complete the Google login that",
74
+ " opens in your browser on your first ACP message. That login IS your",
75
+ " Antigravity subscription (Google AI Plus/Pro/Ultra).",
76
+ "Details: /agy acp-auth",
77
+ ].join("\n");
78
+
79
+ export function platformKey(): string {
80
+ const goos = process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "windows" : "linux";
81
+ const goarch = process.arch === "arm64" ? "aarch64" : "x86_64";
82
+ return `${goos}-${goarch}`;
83
+ }
84
+
85
+ /** Build id from a registry archive URL:
86
+ * …/agy-acp-server-<build>-<platform>-<arch>.zip. */
87
+ export function buildIdFromArchive(url: string): string {
88
+ const base = path.posix.basename(url, ".zip");
89
+ const m = base.match(/^agy-acp-server-(.+)-(darwin|linux|windows)-(?:arm64|x86_64)$/);
90
+ if (!m) throw new Error(`unrecognized registry archive name: ${base}`);
91
+ return m[1];
92
+ }
93
+
94
+ function defaultInstallRoot(): string {
95
+ return path.join(os.homedir(), ".local", "opt", "agy-acp");
96
+ }
97
+
98
+ function defaultGeminiDir(opts: SetupOptions): string {
99
+ return opts.geminiDir ?? path.join(os.homedir(), ".gemini", "antigravity-acp");
100
+ }
101
+
102
+ /** Expand a leading ~ (spawn does not). Mirrors resolveAcpBinary. */
103
+ function expandTilde(p: string): string {
104
+ return p === "~" || p.startsWith("~/") ? path.join(os.homedir(), p.slice(1)) : p;
105
+ }
106
+
107
+ export function executableFile(p: string): boolean {
108
+ try {
109
+ fs.accessSync(p, fs.constants.X_OK);
110
+ return fs.statSync(p).isFile();
111
+ } catch {
112
+ return false;
113
+ }
114
+ }
115
+
116
+ interface RegistryBinary {
117
+ archive: string;
118
+ cmd?: string;
119
+ }
120
+
121
+ async function fetchRegistryBinary(opts: SetupOptions, key: string): Promise<RegistryBinary> {
122
+ const fetchImpl = opts.fetchImpl ?? fetch;
123
+ const res = await fetchImpl(opts.registryUrl ?? REGISTRY_URL, { signal: AbortSignal.timeout(15_000) });
124
+ if (!res.ok) throw new Error(`registry fetch failed: HTTP ${res.status}`);
125
+ const json = (await res.json()) as { distribution?: { binary?: Record<string, RegistryBinary> } };
126
+ const entry = json.distribution?.binary?.[key];
127
+ if (!entry?.archive) throw new Error(`registry has no ${key} binary`);
128
+ return entry;
129
+ }
130
+
131
+ function defaultUnpack(archive: string, dest: string): Promise<void> {
132
+ return new Promise((resolve, reject) => {
133
+ const child = spawn("unzip", ["-o", "-q", archive, "-d", dest], { stdio: "ignore" });
134
+ child.on("error", (err) => reject(new Error(`unzip is not available: ${err.message}`)));
135
+ child.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`unzip exited with ${code}`))));
136
+ });
137
+ }
138
+
139
+ async function downloadTo(
140
+ url: string,
141
+ dest: string,
142
+ fetchImpl: typeof fetch,
143
+ onProgress?: (msg: string) => void,
144
+ ): Promise<string> {
145
+ const res = await fetchImpl(url, { signal: AbortSignal.timeout(INSTALL_TIMEOUT_MS) });
146
+ if (!res.ok || !res.body) throw new Error(`download failed: HTTP ${res.status}`);
147
+ const hash = createHash("sha256");
148
+ const total = Number(res.headers.get("content-length") ?? 0);
149
+ const out = fs.createWriteStream(dest);
150
+ let done = 0;
151
+ let lastPct = -1;
152
+ for await (const chunk of res.body) {
153
+ const buf = Buffer.from(chunk as unknown as Uint8Array);
154
+ hash.update(buf);
155
+ done += buf.length;
156
+ if (total > 0) {
157
+ const pct = Math.floor((done / total) * 100);
158
+ if (pct >= lastPct + 10) {
159
+ lastPct = pct;
160
+ onProgress?.(`downloading ACP server: ${pct}%`);
161
+ }
162
+ }
163
+ if (!out.write(buf)) await new Promise<void>((r) => out.once("drain", r));
164
+ }
165
+ await new Promise<void>((resolve, reject) => {
166
+ out.on("finish", () => resolve());
167
+ out.on("error", reject);
168
+ out.end();
169
+ });
170
+ return hash.digest("hex");
171
+ }
172
+
173
+ export interface InstallResult {
174
+ bin: string;
175
+ build: string;
176
+ sha256: string;
177
+ downloaded: boolean;
178
+ }
179
+
180
+ /** Fetch the pinned registry build for this platform and install it under
181
+ * <root>/<build>/ with a `current` symlink (plan §12). No-op when the
182
+ * build is already installed. Registry publishes no checksums; the zip
183
+ * sha256 is recorded at install time for drift detection. */
184
+ export async function installAcpBinary(opts: SetupOptions = {}): Promise<InstallResult> {
185
+ const root = opts.installRoot ?? defaultInstallRoot();
186
+ const entry = await fetchRegistryBinary(opts, platformKey());
187
+ const build = buildIdFromArchive(entry.archive);
188
+ const cmd = (entry.cmd ?? "./agy_acp_server.par").replace(/^\.\//, "");
189
+ const dir = path.join(root, build);
190
+ const bin = path.join(dir, cmd);
191
+ if (executableFile(bin)) {
192
+ return { bin, build, sha256: readRecordedSha(dir), downloaded: false };
193
+ }
194
+
195
+ fs.mkdirSync(root, { recursive: true });
196
+ fs.mkdirSync(dir, { recursive: true });
197
+ const tmpZip = path.join(root, `.download-${build}.zip`);
198
+ opts.onProgress?.(`downloading ACP server (build ${build})…`);
199
+ const sha = await downloadTo(entry.archive, tmpZip, opts.fetchImpl ?? fetch, opts.onProgress);
200
+ opts.onProgress?.("unpacking ACP server…");
201
+ const unpack = opts.unpack ?? defaultUnpack;
202
+ await unpack(tmpZip, dir);
203
+ fs.rmSync(tmpZip, { force: true });
204
+ if (!executableFile(bin)) throw new Error(`${cmd} not found in the archive after unpacking`);
205
+ fs.chmodSync(bin, 0o755);
206
+ fs.writeFileSync(path.join(dir, "zip.sha256"), `${sha} ${path.basename(entry.archive)}\n`);
207
+
208
+ // Re-point `current` at the new build. The link is ours (managed layout).
209
+ const link = path.join(root, "current");
210
+ fs.rmSync(link, { force: true });
211
+ fs.symlinkSync(build, link);
212
+ opts.onProgress?.("ACP server installed.");
213
+ return { bin, build, sha256: sha, downloaded: true };
214
+ }
215
+
216
+ function readRecordedSha(dir: string): string {
217
+ try {
218
+ return fs.readFileSync(path.join(dir, "zip.sha256"), "utf8").split(/\s+/)[0] ?? "";
219
+ } catch {
220
+ return "";
221
+ }
222
+ }
223
+
224
+ export interface AuthState {
225
+ configured: boolean;
226
+ /** settings.json auth.type, "token" (acp_token.json present), or undefined. */
227
+ type?: string;
228
+ }
229
+
230
+ /** Auth state WITHOUT reading any credential value: settings.json carries
231
+ * only auth.type (+ non-secret gcp placement), acp_token.json is stat()ed. */
232
+ export function readAuthState(dir?: string): AuthState {
233
+ const target = dir ?? path.join(os.homedir(), ".gemini", "antigravity-acp");
234
+ try {
235
+ const raw = JSON.parse(fs.readFileSync(path.join(target, "settings.json"), "utf8")) as {
236
+ auth?: { type?: unknown };
237
+ };
238
+ const type = typeof raw.auth?.type === "string" ? raw.auth.type : undefined;
239
+ if (type) return { configured: true, type };
240
+ } catch {
241
+ /* absent or garbage = unconfigured */
242
+ }
243
+ try {
244
+ fs.statSync(path.join(target, "acp_token.json"));
245
+ return { configured: true, type: "token" };
246
+ } catch {
247
+ return { configured: false };
248
+ }
249
+ }
250
+
251
+ /** Write a minimal auth block into settings.json. The server reads this file
252
+ * at STARTUP, so this must happen before the first server spawn. */
253
+ export function writeAuthType(type: "gemini-api-key" | "oauth-personal", dir?: string): void {
254
+ const target = dir ?? path.join(os.homedir(), ".gemini", "antigravity-acp");
255
+ fs.mkdirSync(target, { recursive: true, mode: 0o700 });
256
+ const file = path.join(target, "settings.json");
257
+ let settings: Record<string, unknown> = {};
258
+ try {
259
+ settings = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
260
+ } catch {
261
+ /* fresh file */
262
+ }
263
+ const prevAuth = (typeof settings.auth === "object" && settings.auth !== null ? settings.auth : {}) as Record<string, unknown>;
264
+ fs.writeFileSync(file, `${JSON.stringify({ ...settings, auth: { ...prevAuth, type } }, null, 2)}\n`, { mode: 0o600 });
265
+ }
266
+
267
+ export interface BinaryResolution {
268
+ bin: string | null;
269
+ source: "env" | "config" | "existing" | "installed" | null;
270
+ }
271
+
272
+ /** Read-only binary resolution: AGY_ACP_BIN > acp.bin > installed `current`
273
+ * layout. Does NOT install and does NOT fall back to the bare PATH name. */
274
+ export function resolveInstalledBinary(opts: SetupOptions = {}): BinaryResolution {
275
+ const env = opts.env ?? process.env;
276
+ const root = opts.installRoot ?? defaultInstallRoot();
277
+ const candidates: Array<[string, BinaryResolution["source"]]> = [
278
+ [env.AGY_ACP_BIN?.trim() ?? "", "env"],
279
+ [opts.configBin?.trim() ?? "", "config"],
280
+ [path.join(root, "current", "agy_acp_server.par"), "existing"],
281
+ ];
282
+ for (const [raw, source] of candidates) {
283
+ if (!raw) continue;
284
+ const bin = expandTilde(raw);
285
+ if (executableFile(bin)) return { bin, source };
286
+ }
287
+ return { bin: null, source: null };
288
+ }
289
+
290
+ /** Read-only setup state (for /agy doctor): no installs, no writes. */
291
+ export function inspectAcpSetup(opts: SetupOptions = {}): { bin: string | null; source: string | null; auth: string | null } {
292
+ const binary = resolveInstalledBinary(opts);
293
+ const auth = readAuthState(defaultGeminiDir(opts));
294
+ return { bin: binary.bin, source: binary.source, auth: auth.configured ? (auth.type ?? "token") : null };
295
+ }
296
+
297
+ /** Orchestrate everything the ACP engine needs, in order: binary (install
298
+ * from the registry when missing), then auth (oauth-personal: the user's
299
+ * own Antigravity subscription - the same Google account and plan as the
300
+ * agy CLI login; the server opens the browser itself on the first message).
301
+ * Every step is skipped when already satisfied; failures return `manual`
302
+ * text for the caller to surface. */
303
+ export async function ensureAcpReady(opts: SetupOptions = {}): Promise<AcpSetupStatus> {
304
+ const actions: string[] = [];
305
+
306
+ // 1. Binary.
307
+ let binary = resolveInstalledBinary(opts);
308
+ if (!binary.bin) {
309
+ if (opts.install === false) {
310
+ return { ok: false, stage: "install", error: "no ACP server binary found", manual: MANUAL_SETUP };
311
+ }
312
+ try {
313
+ const installed = await installAcpBinary(opts);
314
+ binary = { bin: installed.bin, source: "installed" };
315
+ actions.push(installed.downloaded ? `installed ACP server build ${installed.build}` : `found installed ACP server build ${installed.build}`);
316
+ } catch (err) {
317
+ return {
318
+ ok: false,
319
+ stage: "install",
320
+ error: err instanceof Error ? err.message : String(err),
321
+ manual: MANUAL_SETUP,
322
+ };
323
+ }
324
+ }
325
+
326
+ // 2. Auth. settings.json is read at server STARTUP; setup always runs
327
+ // before the first spawn, so a fresh write takes effect. oauth-personal is
328
+ // THE default: it is the Antigravity subscription (same Google account and
329
+ // plan as the agy CLI login). gemini-api-key (metered paid API) stays a
330
+ // manual option for headless boxes (/agy acp-auth); never the default.
331
+ const gdir = defaultGeminiDir(opts);
332
+ const auth = readAuthState(gdir);
333
+ if (!auth.configured) {
334
+ try {
335
+ writeAuthType("oauth-personal", gdir);
336
+ actions.push("configured oauth-personal auth (your Antigravity subscription login)");
337
+ return { ok: true, bin: binary.bin!, binarySource: binary.source!, auth: "oauth-personal", needsLogin: true, actions };
338
+ } catch (err) {
339
+ return {
340
+ ok: false,
341
+ stage: "auth",
342
+ error: err instanceof Error ? err.message : String(err),
343
+ manual: MANUAL_SETUP,
344
+ };
345
+ }
346
+ }
347
+ return {
348
+ ok: true,
349
+ bin: binary.bin!,
350
+ binarySource: binary.source!,
351
+ auth: auth.type ?? "token",
352
+ needsLogin: false,
353
+ actions,
354
+ };
355
+ }
package/src/config.ts CHANGED
@@ -22,11 +22,27 @@ const CONFIG_PATH = path.join(
22
22
  "config.json",
23
23
  );
24
24
 
25
+ /** Which turn engine drives turns. "stream-json" is the tested default;
26
+ * "acp" is the official-server engine, opt-in (plan §9.5). */
27
+ export type Engine = "stream-json" | "acp";
25
28
  export type AgyMode = "accept-edits" | "plan";
26
29
  export type ThinkingTier = "low" | "medium" | "high";
27
30
  export type BridgeTools = "none" | "mcp" | "all";
28
31
 
32
+ export interface AcpConfig {
33
+ /** Path to agy_acp_server.par. Empty = env AGY_ACP_BIN > PATH. */
34
+ bin: string;
35
+ /** Single policy today: auto-approve request_permission in-connection
36
+ * (parity with skipPermissions). Kept as a key so future policies do not
37
+ * change the config shape. */
38
+ permissions: "auto";
39
+ }
40
+
29
41
  export interface AgyConfig {
42
+ /** Turn engine. Switching requires a pi restart (drivers wire at load). */
43
+ engine: Engine;
44
+ /** Official-server ACP engine options (used when engine = "acp"). */
45
+ acp: AcpConfig;
30
46
  mode: AgyMode;
31
47
  /** Auto-approve all agy tool permission requests (--dangerously-skip-permissions).
32
48
  * Required for non-interactive use: without it, any `run_command` triggers an
@@ -73,6 +89,7 @@ export interface AgyConfig {
73
89
  }
74
90
 
75
91
  const DEFAULTS: AgyConfig = {
92
+ engine: "stream-json",
76
93
  mode: "accept-edits",
77
94
  skipPermissions: true,
78
95
  defaultModel: "flash",
@@ -80,6 +97,7 @@ const DEFAULTS: AgyConfig = {
80
97
  bridgeTools: "mcp",
81
98
  digest: false,
82
99
  systemPrompt: true,
100
+ acp: { bin: "", permissions: "auto" },
83
101
  };
84
102
 
85
103
  /** Load config merged over defaults. Env vars override the file when set. */
@@ -99,6 +117,11 @@ export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
99
117
  // The naive OR `env === "plan" || file.mode === "plan"` would ignore an
100
118
  // explicit AGY_MODE=accept-edits when the file says plan, violating the
101
119
  // documented precedence. Check env first.
120
+ // Engine: narrow to the known set; anything else (incl. the pre-1.3.2
121
+ // "sqlite" value) falls back to the tested default.
122
+ const engineRaw = String(process.env.AGY_ENGINE ?? file.engine ?? DEFAULTS.engine).toLowerCase();
123
+ const engine: Engine = engineRaw === "acp" ? "acp" : "stream-json";
124
+
102
125
  const mode: AgyMode =
103
126
  process.env.AGY_MODE !== undefined
104
127
  ? process.env.AGY_MODE === "plan"
@@ -137,7 +160,17 @@ export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
137
160
  ? ["1", "true", "on"].includes(envSys.toLowerCase())
138
161
  : file.systemPrompt ?? DEFAULTS.systemPrompt;
139
162
 
163
+ const fileAcp = (typeof file.acp === "object" && file.acp !== null ? file.acp : {}) as Partial<AcpConfig>;
164
+ const acp: AcpConfig = {
165
+ bin:
166
+ process.env.AGY_ACP_BIN ??
167
+ (typeof fileAcp.bin === "string" ? fileAcp.bin : DEFAULTS.acp.bin),
168
+ permissions: "auto",
169
+ };
170
+
140
171
  return {
172
+ engine,
173
+ acp,
141
174
  mode,
142
175
  skipPermissions,
143
176
  defaultModel,
@@ -188,3 +188,18 @@ function capLines(diff: string, max: number): string {
188
188
  const dropped = lines.length - max;
189
189
  return `${lines.slice(0, max).join("\n")}\n[... ${dropped} more diff lines]`;
190
190
  }
191
+
192
+ /** Format an in-memory before/after pair as a line-numbered diff with pi's
193
+ * own generateDiffString. No git subprocess: the ACP engine supplies
194
+ * oldText/newText directly in tool_call content[] (Gate C), so the provider
195
+ * renders the native diff without touching the repo. Exported for the
196
+ * provider; TurnDiffContext keeps the git-sourced path for stream-json. */
197
+ export function formatInlineDiff(
198
+ oldContent: string,
199
+ newContent: string,
200
+ maxDiffLines: number = DEFAULT_MAX_DIFF_LINES,
201
+ ): string {
202
+ if (oldContent === newContent) return "";
203
+ const { diff } = generateDiffString(oldContent, newContent);
204
+ return capLines(diff, maxDiffLines);
205
+ }
@@ -0,0 +1,144 @@
1
+ // Engine-agnostic turn-driver contract.
2
+ //
3
+ // Both turn engines (legacy stream-json driver in `driver.ts`, ACP driver in
4
+ // `acp/driver.ts`) implement `TurnDriver`, and everything above them — the
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
7
+ // deleted (phase 4) without breaking imports.
8
+ //
9
+ // The ACP driver implements the same surface with protocol-native mechanics:
10
+ // no process recycle on profile drift, teardown-based abort (Gate D), and
11
+ // `session/load` resume. See docs/ACP-ADOPTION-PLAN.md section 9.
12
+
13
+ export type DriverState = "idle" | "starting" | "ready" | "running" | "dead";
14
+
15
+ export interface DriverProfile {
16
+ cwd: string;
17
+ model: string;
18
+ effort?: string;
19
+ mode: string;
20
+ skipPermissions: boolean;
21
+ }
22
+
23
+ export interface DriverTurnRequest extends DriverProfile {
24
+ /** Existing conversation/session to resume. Legacy: agy conversation id via
25
+ * `--conversation`. ACP: sessionId via `session/load` (falls back to
26
+ * `session/new` when the server no longer knows it). */
27
+ conversationId?: string | null;
28
+ prompt: string;
29
+ /** Image blocks riding with the prompt. ACP forwards them as typed
30
+ * content blocks (probe 2026-09-03: 64x64 two-tone PNG answered
31
+ * correctly); the legacy CLI prompt is text-only and ignores them. */
32
+ images?: Array<{ data: string; mimeType: string }>;
33
+ /** ACP only: pi-side context delivered as a native `embeddedContext`
34
+ * resource block instead of inline prompt text (G1 on ACP). Legacy
35
+ * embeds the digest in the prompt string and ignores this. */
36
+ contextBlock?: { uri: string; text: string };
37
+ signal?: AbortSignal;
38
+ /** Overall turn cap in minutes (default 10). Fractional values are valid
39
+ * (tests use sub-minute caps). */
40
+ timeoutMin?: number;
41
+ /** Stdout-inactivity cap in minutes (default 5). */
42
+ inactivityMin?: number;
43
+ }
44
+
45
+ export type AgyUsage = {
46
+ input_tokens?: number;
47
+ output_tokens?: number;
48
+ thinking_tokens?: number;
49
+ cache_read_tokens?: number;
50
+ total_tokens?: number;
51
+ };
52
+
53
+ export type DriverActivity =
54
+ | { type: "text"; delta: string }
55
+ /** Legacy emits a token count only; ACP carries the actual thought text in
56
+ * `delta`. The provider renders whichever is present. */
57
+ | { type: "thought"; tokens?: number; delta?: string }
58
+ | { type: "tool_start"; stepId?: number; name: string; args: Record<string, unknown> }
59
+ | {
60
+ type: "tool_done";
61
+ stepId?: number;
62
+ name: string;
63
+ args: Record<string, unknown>;
64
+ output?: string;
65
+ durationSeconds?: number;
66
+ /** ACP only: the server's native edit diff from `tool_call`
67
+ * content[] ({type:"diff", path, oldText?, newText}). Legacy never
68
+ * sets it; the provider renders it without any git subprocess. */
69
+ diff?: { path: string; oldText?: string; newText: string };
70
+ }
71
+ | { type: "tool_error"; stepId?: number; name: string; message: string }
72
+ | { type: "usage"; usage: AgyUsage }
73
+ /** Synthetic: injected by the provider when the MCP bridge receives a call
74
+ * (G9). Parks the turn: output is expected to stall while pi executes the
75
+ * tool, so the driver suspends its idle/overall timers. */
76
+ | { type: "bridge_call"; callId: string; name: string; args: Record<string, unknown> };
77
+
78
+ export interface TurnOutcome {
79
+ conversationId?: string;
80
+ status: "OK" | "ERROR" | "UNKNOWN";
81
+ response: string;
82
+ error?: string;
83
+ usage?: AgyUsage;
84
+ finished: boolean;
85
+ aborted: boolean;
86
+ }
87
+
88
+ export interface TurnHandle {
89
+ id: string;
90
+ /** Resolves when the turn settles (result event, exit, abort, recycle). */
91
+ outcome: Promise<TurnOutcome>;
92
+ /** Pull the next activity. Resolves null once the activity stream closes. */
93
+ next(): Promise<DriverActivity | null>;
94
+ /** Inject a synthetic activity (bridge inbox). No-op after settle. */
95
+ pushExternal(activity: DriverActivity): void;
96
+ }
97
+
98
+ export interface DriverSnapshot {
99
+ state: DriverState;
100
+ pid?: number;
101
+ conversationId?: string;
102
+ stats: {
103
+ spawns: number;
104
+ turns: number;
105
+ reused: number;
106
+ recycles: number;
107
+ lastRecycleReason?: string;
108
+ recycleReasons: Record<string, number>;
109
+ };
110
+ lifecycle: string[];
111
+ /** Present on ACP snapshots; absent on legacy. */
112
+ engine?: "acp";
113
+ acp?: {
114
+ sessionId?: string;
115
+ prompts: number;
116
+ sessionsCreated: number;
117
+ sessionsLoaded: number;
118
+ kills: number;
119
+ /** null = never probed on this server process. */
120
+ cancelSupported: boolean | null;
121
+ serverVersion?: string;
122
+ /** Connections beyond the first this driver process made = server
123
+ * restarts (Gate D kills + stale-exit replacements). */
124
+ reconnects: number;
125
+ /** From the initialize handshake agentInfo block. */
126
+ agentName?: string;
127
+ agentTitle?: string;
128
+ };
129
+ }
130
+
131
+ /** The engine contract. Everything above the driver depends on this interface
132
+ * only; `AgyDriver` and `AcpDriver` both implement it. */
133
+ export interface TurnDriver {
134
+ readonly state: DriverState;
135
+ readonly activeHandle: TurnHandle | null;
136
+ run(request: DriverTurnRequest): Promise<TurnHandle>;
137
+ /** Re-attach to the active turn (pi toolUse continuation). */
138
+ reentry(): TurnHandle | null;
139
+ /** Resume turn timers after a parked G9 round-trip settles. */
140
+ kickIdle(): void;
141
+ set onTurnEnd(fn: ((outcome: TurnOutcome) => void) | undefined);
142
+ snapshot(): DriverSnapshot;
143
+ close(reason: "recycle" | "shutdown", cause?: string): Promise<void>;
144
+ }