@estebanforge/pi-antigravity-bridge 1.3.3 → 1.4.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,250 @@
1
+ // ACP `session/update` payload → DriverActivity mapping + stopReason mapping.
2
+ //
3
+ // Pure functions, no I/O: unit-tested against the captured probe transcripts
4
+ // (probe-logs/acp-traffic-run5.jsonl, run6-restart-load-tools.jsonl).
5
+ //
6
+ // Shapes verified live against agy_acp_server 20260818_01_RC01:
7
+ // session/update params = { sessionId, update: { sessionUpdate: <discriminator>, ... } }
8
+ // - agent_message_chunk { content: { type: "text", text } } (pure deltas)
9
+ // - agent_thought_chunk { content: { type: "text", text } } (thought TEXT)
10
+ // - user_message_chunk { content: { type: "text", text } } (load replay only)
11
+ // - tool_call { toolCallId, title, kind, status, content?, locations?, rawInput? }
12
+ // - tool_call_update { toolCallId, status: completed|failed, rawOutput? }
13
+ // - plan { entries: [...] }
14
+ // - available_commands_update { availableCommands: [...] }
15
+ // Usage: ABSENT on RC01 (Gate B) — mapped defensively should a future build add it.
16
+
17
+ /** Map one `update` object to a driver-level event, or null when the update
18
+ * carries nothing the driver consumes (plan/commands are doctor/phase-2
19
+ * material; user_message_chunk is load replay and must never reach pi as
20
+ * live text). */
21
+ /** Native edit diff carried in tool_call content[] (run 6: edits arrive AS
22
+ * DIFFS: {type:"diff", path, newText, optional oldText}). The provider
23
+ * formats it in memory — no git subprocess needed on the ACP engine. */
24
+ export interface AcpEditDiff {
25
+ path: string;
26
+ oldText?: string;
27
+ newText: string;
28
+ }
29
+
30
+ export type MappedUpdate =
31
+ | { kind: "text"; delta: string }
32
+ | { kind: "thought"; delta: string }
33
+ | { kind: "tool_start"; toolCallId: string; name: string; args: Record<string, unknown>; diff?: AcpEditDiff }
34
+ | { kind: "tool_done"; toolCallId: string; output?: string; diff?: AcpEditDiff }
35
+ | { kind: "tool_error"; toolCallId: string; message: string }
36
+ | { kind: "replay_user" }
37
+ | null;
38
+
39
+ export function mapUpdate(update: unknown): MappedUpdate {
40
+ if (typeof update !== "object" || update === null) return null;
41
+ const u = update as Record<string, unknown>;
42
+ const kind = u.sessionUpdate;
43
+ if (typeof kind !== "string") return null;
44
+ switch (kind) {
45
+ case "agent_message_chunk":
46
+ return { kind: "text", delta: chunkText(u.content) };
47
+ case "agent_thought_chunk":
48
+ return { kind: "thought", delta: chunkText(u.content) };
49
+ case "user_message_chunk":
50
+ return { kind: "replay_user" };
51
+ case "tool_call": {
52
+ const id = stringField(u.toolCallId);
53
+ if (!id) return null;
54
+ return {
55
+ kind: "tool_start",
56
+ toolCallId: id,
57
+ name: metaToolName(u._meta) ?? toolName(u.title, u.kind),
58
+ args: rawArguments(u.rawInput),
59
+ // Edits carry their native diff HERE, on the pending tool_call
60
+ // frame (run 6:10) — the completed update carries only display
61
+ // text. contentDiff on the update stays as a future-build
62
+ // fallback.
63
+ diff: contentDiff(u.content),
64
+ };
65
+ }
66
+ case "tool_call_update": {
67
+ const id = stringField(u.toolCallId);
68
+ if (!id) return null;
69
+ if (u.status === "failed") {
70
+ // RC01 echo (run 6, finding 7): after an allow, the APPROVED call
71
+ // id reports failed with this sentinel while the real work runs
72
+ // under a different id that reports its own lifecycle. Track
73
+ // effects, not ids: drop the echo instead of rendering a bogus
74
+ // failure next to a successful edit.
75
+ const raw = stringField(u.rawOutput);
76
+ if (raw?.includes("approved but never executed")) return null;
77
+ return { kind: "tool_error", toolCallId: id, message: raw ?? "tool failed" };
78
+ }
79
+ if (u.status === "completed") {
80
+ // content[] first (edits carry their diff there); rawOutput alone is
81
+ // often just the server's display title, not the result (probe
82
+ // 2026-09-03: completed MCP call, rawOutput "Call bridge_echo").
83
+ const diff = contentDiff(u.content);
84
+ return {
85
+ kind: "tool_done",
86
+ toolCallId: id,
87
+ output: contentText(u.content) ?? stringField(u.rawOutput),
88
+ ...(diff ? { diff } : {}),
89
+ };
90
+ }
91
+ return null; // in_progress or unknown status: nothing to render yet
92
+ }
93
+ default:
94
+ // plan / available_commands_update / current_mode_update: consumed by
95
+ // the driver snapshot / future phases, not mapped to activities.
96
+ return null;
97
+ }
98
+ }
99
+
100
+ /** MCP tools wrap their args in an `arguments` envelope
101
+ * ({arguments:{...}}); native tools carry them directly (probe 2026-09-03:
102
+ * bridge_echo rawInput {arguments:{text}}, edit_file rawInput {file_path}). */
103
+ function rawArguments(rawInput: unknown): Record<string, unknown> {
104
+ const rec = recordField(rawInput);
105
+ const args = rec.arguments;
106
+ return typeof args === "object" && args !== null && !Array.isArray(args)
107
+ ? (args as Record<string, unknown>)
108
+ : rec;
109
+ }
110
+
111
+ /** Clean tool name for MCP tools: the title is "<server>_<tool>" (e.g.
112
+ * "pi-bridge_bridge_echo") and the real name hides in _meta.mcp.tool. */
113
+ function metaToolName(meta: unknown): string | undefined {
114
+ const mcp = recordField(recordField(meta).mcp);
115
+ const tool = mcp.tool;
116
+ return typeof tool === "string" && tool.length > 0 ? tool : undefined;
117
+ }
118
+
119
+ /** First diff entry of a completed tool call: edits carry their native diff
120
+ * in content[] as {type:"diff", path, newText, optional oldText} (run 6). */
121
+ function contentDiff(content: unknown): AcpEditDiff | undefined {
122
+ if (!Array.isArray(content)) return undefined;
123
+ for (const entry of content) {
124
+ const e = recordField(entry);
125
+ if (e.type !== "diff") continue;
126
+ const path = stringField(e.path);
127
+ const newText = stringField(e.newText);
128
+ if (!path || newText === undefined) continue;
129
+ const oldText = stringField(e.oldText);
130
+ return oldText !== undefined ? { path, oldText, newText } : { path, newText };
131
+ }
132
+ return undefined;
133
+ }
134
+
135
+ /** Display text of a completed tool call. content[] entries wrap their
136
+ * payload ({type:"content", content:{type:"text", text}}); diff entries
137
+ * carry no text and are skipped. */
138
+ function contentText(content: unknown): string | undefined {
139
+ if (!Array.isArray(content)) return undefined;
140
+ const parts: string[] = [];
141
+ for (const entry of content) {
142
+ const inner = recordField(recordField(entry).content ?? entry);
143
+ if (typeof inner.text === "string" && inner.text.length > 0) parts.push(inner.text);
144
+ }
145
+ return parts.length > 0 ? parts.join("\n") : undefined;
146
+ }
147
+
148
+ /** Extract the text of a content block ({type:"text", text}). */
149
+ function chunkText(content: unknown): string {
150
+ if (typeof content === "object" && content !== null) {
151
+ const c = content as Record<string, unknown>;
152
+ if (typeof c.text === "string") return c.text;
153
+ }
154
+ return "";
155
+ }
156
+
157
+ function stringField(v: unknown): string | undefined {
158
+ return typeof v === "string" && v.length > 0 ? v : undefined;
159
+ }
160
+
161
+ function recordField(v: unknown): Record<string, unknown> {
162
+ return typeof v === "object" && v !== null && !Array.isArray(v)
163
+ ? (v as Record<string, unknown>)
164
+ : {};
165
+ }
166
+
167
+ /** Derive a tool name from the title ("Run create_file?", "Running edit_file")
168
+ * with the kind as fallback. Titles observed live: "Run create_file?",
169
+ * "Running edit_file", "Running view_file". */
170
+ export function toolName(title: unknown, kind: unknown): string {
171
+ if (typeof title === "string") {
172
+ const m = /(?:Run|Running)\s+([A-Za-z_][\w.]*)/i.exec(title);
173
+ if (m) return m[1] as string;
174
+ }
175
+ return typeof kind === "string" && kind.length > 0 ? kind : "tool";
176
+ }
177
+
178
+ /** Map a `session/prompt` result stopReason to a TurnOutcome shape.
179
+ * Verified live: `end_turn`. Schema enumerates cancelled / max_tokens /
180
+ * max_turn_requests / refusal. `cancelled` is unreachable on RC01 (no
181
+ * cancel method) but mapped for the day upstream ships it. */
182
+ export function mapStopReason(stopReason: unknown): {
183
+ status: "OK" | "ERROR";
184
+ aborted: boolean;
185
+ error?: string;
186
+ } {
187
+ switch (stopReason) {
188
+ case "end_turn":
189
+ return { status: "OK", aborted: false };
190
+ case "cancelled":
191
+ return { status: "OK", aborted: true };
192
+ case "max_tokens":
193
+ return { status: "OK", aborted: false, error: "ACP: response hit the token cap" };
194
+ case "refusal":
195
+ return { status: "ERROR", aborted: false, error: "ACP: the model refused the request" };
196
+ case "max_turn_requests":
197
+ return { status: "ERROR", aborted: false, error: "ACP: turn exceeded the request limit" };
198
+ default:
199
+ return { status: "OK", aborted: false };
200
+ }
201
+ }
202
+
203
+ /** Defensive accumulation with the cumulative-resend port. RC01 streams pure
204
+ * deltas (verified: mid-token splits across the run-5 stress streams), so on
205
+ * a compliant server the resend branch stays inert; it exists because the
206
+ * same backend's private protocol exhibited exactly this failure mode.
207
+ *
208
+ * Two guards keep a misflip from corrupting the rest of the turn:
209
+ * - the flip requires the accumulated text to be at least FLIP_MIN_CHARS,
210
+ * because short prefixes ("**", "\n", "#") are trivially extended by
211
+ * ordinary markdown deltas;
212
+ * - in cumulative mode, a frame that no longer extends the accumulator is
213
+ * evidence of a misflip: fall back to append mode instead of slicing. */
214
+ const FLIP_MIN_CHARS = 32;
215
+
216
+ export class TextAccumulator {
217
+ #acc = "";
218
+ #cumulative: boolean | undefined;
219
+
220
+ /** Returns the delta to emit, or null when nothing new should be emitted. */
221
+ append(delta: string): string | null {
222
+ if (this.#cumulative === undefined) this.#cumulative = false;
223
+ else if (
224
+ !this.#cumulative &&
225
+ this.#acc.length >= FLIP_MIN_CHARS &&
226
+ delta.length > this.#acc.length &&
227
+ delta.startsWith(this.#acc)
228
+ ) {
229
+ this.#cumulative = true;
230
+ }
231
+ if (this.#cumulative) {
232
+ if (!delta.startsWith(this.#acc)) {
233
+ // Misflip evidence: back to deltas.
234
+ this.#cumulative = false;
235
+ this.#acc += delta;
236
+ return delta;
237
+ }
238
+ if (delta.length <= this.#acc.length) return null; // duplicate frame
239
+ const emit = delta.slice(this.#acc.length);
240
+ this.#acc = delta;
241
+ return emit;
242
+ }
243
+ this.#acc += delta;
244
+ return delta;
245
+ }
246
+
247
+ get text(): string {
248
+ return this.#acc;
249
+ }
250
+ }
@@ -0,0 +1,185 @@
1
+ // Newline-delimited JSON-RPC 2.0 session over a stdio transport.
2
+ //
3
+ // Transport-agnostic by design: the caller supplies a `send` sink (writes one
4
+ // serialized frame + newline to the child's stdin) and feeds incoming bytes to
5
+ // `feed()`. Everything ACP-specific lives in connection.ts.
6
+ //
7
+ // Semantics verified live against agy_acp_server (20260818_01_RC01):
8
+ // - requests are correlated by numeric id; responses reject the pending
9
+ // promise on JSON-RPC `error` results;
10
+ // - server-to-client REQUESTS (session/request_permission, fs/*, terminal/*)
11
+ // arrive as messages with both `method` and `id` and are answered through
12
+ // the handler registered via setRequestHandler;
13
+ // - notifications (session/update, auth_required) carry no id;
14
+ // - malformed lines are counted and dropped, never fatal (the parseAgyLine
15
+ // lesson: a chatty banner must not kill the reader loop);
16
+ // - `abortAll()` rejects every pending request (Gate D teardown: no promise
17
+ // survives a killed connection).
18
+
19
+ export interface JsonRpcErrorShape {
20
+ code: number;
21
+ message: string;
22
+ data?: unknown;
23
+ }
24
+
25
+ export interface JsonRpcIncoming {
26
+ id?: number | string | null;
27
+ method?: string;
28
+ params?: unknown;
29
+ result?: unknown;
30
+ error?: JsonRpcErrorShape;
31
+ }
32
+
33
+ export type SendFn = (frame: string) => void;
34
+
35
+ export interface JsonRpcSessionOptions {
36
+ send: SendFn;
37
+ /** Server-to-client request (has method + id). Return the result value, or
38
+ * throw to answer with a JSON-RPC error. */
39
+ onRequest?: (method: string, params: unknown) => Promise<unknown>;
40
+ /** Server-to-client notification (method, no id). */
41
+ onNotification?: (method: string, params: unknown) => void;
42
+ /** Unparseable line (logged, never fatal). */
43
+ onParseError?: (line: string) => void;
44
+ /** Default timeout for requests without an explicit one (ms). 0 = none. */
45
+ defaultTimeoutMs?: number;
46
+ }
47
+
48
+ interface Pending {
49
+ resolve: (value: unknown) => void;
50
+ reject: (err: Error) => void;
51
+ timer?: ReturnType<typeof setTimeout>;
52
+ method: string;
53
+ }
54
+
55
+ export class JsonRpcSession {
56
+ #send: SendFn;
57
+ #opts: JsonRpcSessionOptions;
58
+ #pending = new Map<number, Pending>();
59
+ #nextId = 1;
60
+ #aborted = false;
61
+ #lineBuf = "";
62
+ parseErrors = 0;
63
+
64
+ constructor(opts: JsonRpcSessionOptions) {
65
+ this.#opts = opts;
66
+ this.#send = opts.send;
67
+ }
68
+
69
+ /** Feed raw transport bytes. Stdio chunks are NOT newline-aligned: bytes
70
+ * are buffered and only complete lines are parsed (a frame split across a
71
+ * 64KB pipe boundary must never be dropped). */
72
+ feed(chunk: string): void {
73
+ this.#lineBuf += chunk;
74
+ const lines = this.#lineBuf.split("\n");
75
+ this.#lineBuf = lines.pop() ?? "";
76
+ for (const raw of lines) {
77
+ const line = raw.trim();
78
+ if (!line) continue;
79
+ let msg: JsonRpcIncoming;
80
+ try {
81
+ msg = JSON.parse(line) as JsonRpcIncoming;
82
+ } catch {
83
+ this.parseErrors += 1;
84
+ this.#opts.onParseError?.(line.slice(0, 200));
85
+ continue;
86
+ }
87
+ this.#handleMessage(msg);
88
+ }
89
+ }
90
+
91
+ #handleMessage(msg: JsonRpcIncoming): void {
92
+ // Response to one of our requests.
93
+ if (msg.method === undefined && msg.id !== undefined && msg.id !== null) {
94
+ const id = Number(msg.id);
95
+ const pending = this.#pending.get(id);
96
+ if (!pending) return; // late response to an aborted request: drop
97
+ this.#pending.delete(id);
98
+ if (pending.timer) clearTimeout(pending.timer);
99
+ if (msg.error) {
100
+ pending.reject(new JsonRpcResponseError(msg.error));
101
+ } else {
102
+ pending.resolve(msg.result);
103
+ }
104
+ return;
105
+ }
106
+ if (msg.method === undefined) return;
107
+ // Server-to-client request: must be answered with the same id. The
108
+ // handler is always invoked through Promise.resolve() so a synchronous
109
+ // throw can never escape into the reader loop.
110
+ if (msg.id !== undefined && msg.id !== null) {
111
+ const id = msg.id;
112
+ const handler = this.#opts.onRequest;
113
+ if (!handler) {
114
+ this.#sendError(id, -32601, `client does not support method: ${msg.method}`);
115
+ return;
116
+ }
117
+ void Promise.resolve()
118
+ .then(() => handler(msg.method as string, msg.params))
119
+ .then((result) => {
120
+ if (this.#aborted) return;
121
+ this.#send(JSON.stringify({ jsonrpc: "2.0", id, result: result ?? {} }));
122
+ })
123
+ .catch((err: unknown) => {
124
+ if (this.#aborted) return;
125
+ const message = err instanceof Error ? err.message : String(err);
126
+ this.#sendError(id, -32000, message);
127
+ });
128
+ return;
129
+ }
130
+ // Notification.
131
+ this.#opts.onNotification?.(msg.method, msg.params);
132
+ }
133
+
134
+ #sendError(id: number | string, code: number, message: string): void {
135
+ if (this.#aborted) return;
136
+ this.#send(JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } }));
137
+ }
138
+
139
+ /** Send a request. Resolves with the result value; rejects on error
140
+ * responses (JsonRpcResponseError), timeouts, or abortAll(). */
141
+ request(method: string, params?: unknown, timeoutMs?: number): Promise<unknown> {
142
+ if (this.#aborted) return Promise.reject(new Error("connection aborted"));
143
+ const id = this.#nextId++;
144
+ const effective = timeoutMs ?? this.#opts.defaultTimeoutMs ?? 0;
145
+ return new Promise<unknown>((resolve, reject) => {
146
+ const pending: Pending = { resolve, reject, method };
147
+ if (effective > 0) {
148
+ pending.timer = setTimeout(() => {
149
+ this.#pending.delete(id);
150
+ reject(new Error(`ACP request timed out after ${effective}ms: ${method}`));
151
+ }, effective);
152
+ }
153
+ this.#pending.set(id, pending);
154
+ this.#send(JSON.stringify({ jsonrpc: "2.0", id, method, params: params ?? {} }));
155
+ });
156
+ }
157
+
158
+ /** Reject every pending request (Gate D teardown). The transport is going
159
+ * away; nothing will ever be written again. */
160
+ abortAll(reason: string): void {
161
+ this.#aborted = true;
162
+ for (const [id, pending] of this.#pending) {
163
+ if (pending.timer) clearTimeout(pending.timer);
164
+ pending.reject(new Error(`${reason} (request: ${pending.method})`));
165
+ this.#pending.delete(id);
166
+ }
167
+ }
168
+
169
+ get pendingCount(): number {
170
+ return this.#pending.size;
171
+ }
172
+ }
173
+
174
+ /** A JSON-RPC `error` result, preserving code + data (the -32602 `loc` paths
175
+ * and the -32601 method name are correction oracles; see the reference doc). */
176
+ export class JsonRpcResponseError extends Error {
177
+ readonly code: number;
178
+ readonly data: unknown;
179
+ constructor(error: JsonRpcErrorShape) {
180
+ super(error.message);
181
+ this.name = "JsonRpcResponseError";
182
+ this.code = error.code;
183
+ this.data = error.data;
184
+ }
185
+ }
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
+ }