@firespoon/pi-antigravity-bridge 0.0.0-stage → 1.10.7

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +902 -0
  2. package/LICENSE +21 -0
  3. package/README.md +235 -3
  4. package/extensions/index.ts +1993 -0
  5. package/package.json +71 -6
  6. package/src/acp/connection.ts +623 -0
  7. package/src/acp/driver.ts +953 -0
  8. package/src/acp/events.ts +331 -0
  9. package/src/acp/home.ts +238 -0
  10. package/src/acp/jsonrpc.ts +232 -0
  11. package/src/acp/setup.ts +107 -0
  12. package/src/acp/usage-estimate.ts +61 -0
  13. package/src/agents.ts +64 -0
  14. package/src/agy-install.ts +85 -0
  15. package/src/agy-paths.ts +30 -0
  16. package/src/agy-setup.ts +72 -0
  17. package/src/agy-version.ts +283 -0
  18. package/src/approval-detect.ts +146 -0
  19. package/src/approval-gate.ts +208 -0
  20. package/src/approval-hook.ts +270 -0
  21. package/src/artifacts-ui.ts +88 -0
  22. package/src/artifacts.ts +144 -0
  23. package/src/ask-tool.ts +1222 -0
  24. package/src/background-watch.ts +225 -0
  25. package/src/bridge-catalog.ts +27 -0
  26. package/src/config.ts +485 -0
  27. package/src/daily-log.ts +196 -0
  28. package/src/dialog-lock.ts +48 -0
  29. package/src/diff-render.ts +205 -0
  30. package/src/driver-types.ts +172 -0
  31. package/src/driver.ts +907 -0
  32. package/src/engine-picker.ts +160 -0
  33. package/src/frame-guard.ts +38 -0
  34. package/src/mcp-registration.ts +382 -0
  35. package/src/mcp-server.ts +620 -0
  36. package/src/models.ts +407 -0
  37. package/src/native-tools.ts +104 -0
  38. package/src/parked-turn.ts +125 -0
  39. package/src/patch-cleanup.ts +519 -0
  40. package/src/poll-fold.ts +55 -0
  41. package/src/prompt-images.ts +92 -0
  42. package/src/provider.ts +2066 -0
  43. package/src/queued-messages.ts +152 -0
  44. package/src/redact.ts +27 -0
  45. package/src/sessions.ts +185 -0
  46. package/src/skills.ts +192 -0
  47. package/src/stream-events.ts +122 -0
  48. package/src/stream-sanitizer.ts +180 -0
  49. package/src/subagent-roster.ts +245 -0
  50. package/src/sysprompt-agent.ts +163 -0
  51. package/src/tasks-ui.ts +90 -0
  52. package/src/tasks.ts +174 -0
  53. package/src/usage.ts +174 -0
  54. package/src/web-tools.ts +320 -0
package/package.json CHANGED
@@ -1,6 +1,71 @@
1
- {
2
- "name": "@firespoon/pi-antigravity-bridge",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "@firespoon/pi-antigravity-bridge",
3
+ "version": "1.10.7",
4
+ "description": "Gemini provider and AskAntigravity tool for Pi on the Antigravity ACP server (restricted custom agent with Pi tools via MCP) or the stream-json agy CLI fallback. Use antigravity/* models in Pi with /model picker support.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "antigravity",
9
+ "antigravity-acp",
10
+ "agy",
11
+ "gemini",
12
+ "google",
13
+ "google-acp",
14
+ "acp",
15
+ "agent-client-protocol",
16
+ "mcp",
17
+ "mcp-server",
18
+ "provider",
19
+ "streaming"
20
+ ],
21
+ "license": "MIT",
22
+ "author": {
23
+ "name": "FireSpoonYZ",
24
+ "url": "https://github.com/FireSpoonYZ"
25
+ },
26
+ "contributors": [
27
+ {
28
+ "name": "EstebanForge",
29
+ "email": "esteban@attitude.cl",
30
+ "url": "https://actitud.xyz"
31
+ }
32
+ ],
33
+ "homepage": "https://github.com/FireSpoonYZ/pi-extensions#readme",
34
+ "bugs": {
35
+ "url": "https://github.com/FireSpoonYZ/pi-extensions/issues"
36
+ },
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/FireSpoonYZ/pi-extensions.git",
40
+ "directory": "packages/pi-antigravity-bridge"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "type": "module",
46
+ "files": [
47
+ "extensions",
48
+ "src",
49
+ "README.md",
50
+ "CHANGELOG.md",
51
+ "LICENSE"
52
+ ],
53
+ "pi": {
54
+ "extensions": [
55
+ "./extensions"
56
+ ]
57
+ },
58
+ "peerDependencies": {
59
+ "@earendil-works/pi-coding-agent": "^1.5.1"
60
+ },
61
+ "peerDependenciesMeta": {
62
+ "@earendil-works/pi-coding-agent": {
63
+ "optional": true
64
+ }
65
+ },
66
+ "dependencies": {
67
+ "@estebanforge/pi-ask-shared": "^0.1.6",
68
+ "@estebanforge/pi-tui-dialogs": "^0.1.3",
69
+ "@modelcontextprotocol/sdk": "^1.31.0"
70
+ }
71
+ }
@@ -0,0 +1,623 @@
1
+ // ACP connection: owns the agy acp process, the JSON-RPC session over its
2
+ // stdio, and the protocol methods the driver needs.
3
+ //
4
+ // Verified live against agy 1.5.3:
5
+ // - initialize: {protocolVersion:1, clientCapabilities{fs:false,terminal:false}}
6
+ // - model and thought_level are separate session config options
7
+ // - session/cancel is a notification; the prompt settles with stopReason cancelled
8
+ // - session/request_permission: skipPermissions turns answer the first
9
+ // allow option synchronously; otherwise a wired human handler PARKS the
10
+ // request (the JSON-RPC reply waits for the decision), and everything
11
+ // else fail-closes to the first reject option
12
+ // - session/load replays history as notifications BEFORE its response; the
13
+ // driver suppresses updates while the load is in flight
14
+ // - mcpServers entries: {name, type:"http", url, headers:[]} — headers is a
15
+ // LIST; a dead URL is accepted at session time (lazy connect)
16
+
17
+ import { spawn, type ChildProcess } from "node:child_process";
18
+ import { frameCarriesUsage } from "./events.js";
19
+ import type { AgyUsage } from "../driver-types.js";
20
+ import { redactText } from "../redact.js";
21
+ import { JsonRpcResponseError, JsonRpcSession } from "./jsonrpc.js";
22
+ import { removeStagedAgyHome, stageAgyHome } from "./home.js";
23
+
24
+ export interface AcpMcpServer {
25
+ name: string;
26
+ type: "http" | "sse";
27
+ url: string;
28
+ headers: Array<{ name: string; value: string }>;
29
+ }
30
+
31
+ export interface AcpSessionInfo {
32
+ sessionId: string;
33
+ }
34
+
35
+ /** Auth failed (-32000 family). Message carries the remediation the server
36
+ * provided plus the /agy auth-manual pointer. */
37
+ export class AcpAuthError extends Error {
38
+ constructor(message: string) {
39
+ super(message);
40
+ this.name = "AcpAuthError";
41
+ }
42
+ }
43
+
44
+ export interface AcpConnectionOptions {
45
+ /** Process working directory for the spawned server. */
46
+ cwd: string;
47
+ /** Resolved server binary (or node executable when binArgs carries the
48
+ * fake-server script for tests). */
49
+ bin: string;
50
+ binArgs?: string[];
51
+ /** Native agy only: isolate inherited global MCP from other pi sessions. */
52
+ isolateHome?: boolean | { geminiHome?: string; parentDir?: string; agentsRoot?: string };
53
+ /** Carrier agent name passed as `--agent <name>`. */
54
+ agent?: string;
55
+ /** When false, passes `--include-default-customizations=false`. */
56
+ includeDefaultCustomizations?: boolean;
57
+ /** Explicit agent discovery root for isolated home staging (tests). */
58
+ agentsRoot?: string;
59
+ extraEnv?: Record<string, string>;
60
+ log: (msg: string, data?: unknown) => void;
61
+ /** Verified `session/update` payloads (post-suppression). */
62
+ onUpdate: (sessionId: string | null, update: unknown) => void; /** Process exit. Fires exactly once. */
63
+ onExit: (info: { code: number | null; signal: string | null; stderrTail: string }) => void;
64
+ /** mcpServers entries for session/new AND session/load (run 6: load takes
65
+ * the same param). Evaluated lazily per call. */
66
+ mcpServers?: () => AcpMcpServer[];
67
+ /** Permission policy for session/request_permission, evaluated per request:
68
+ * "auto" selects the first allow option; "deny" consults the human
69
+ * handler (parking) or fail-closes to the first reject option. Absent =
70
+ * deny (fail closed). */
71
+ permissions?: () => "auto" | "deny";
72
+ /** Human gate for "deny"-policy turns. The request is PARKED: the JSON-RPC
73
+ * reply waits until the handler settles. The returned optionId is
74
+ * validated against the server's options; undefined/null deny. Absent =
75
+ * synchronous deny (fail closed). */
76
+ onPermissionRequest?: AcpPermissionHandler;
77
+ /** Park budget for one permission dialog. Tests shrink it. */
78
+ permissionParkMs?: number;
79
+ }
80
+
81
+ const INIT_TIMEOUT_MS = 30_000;
82
+ const SESSION_OP_TIMEOUT_MS = 20_000;
83
+
84
+ /** Human-decision budget for one parked permission request. Mirrors the
85
+ * approval-gate park budget (APPROVAL_PARK_TIMEOUT_MS): the dialog must
86
+ * deny by itself, never hold the agy prompt open forever. */
87
+ const PERMISSION_PARK_MS = 480_000;
88
+
89
+ /** One session/request_permission option block (the subset we consume). */
90
+ export interface AcpPermissionOption {
91
+ optionId?: string;
92
+ kind?: string;
93
+ name?: string;
94
+ }
95
+
96
+ /** Payload handed to the permission handler when the server asks for a human
97
+ * decision. Options are server-defined data; the handler returns the chosen
98
+ * optionId, or undefined/null to deny. */
99
+ export interface AcpPermissionRequest {
100
+ sessionId?: string;
101
+ options: AcpPermissionOption[];
102
+ toolCall?: { toolCallId?: string; kind?: string; title?: string };
103
+ }
104
+
105
+ export type AcpPermissionHandler = (req: AcpPermissionRequest) => Promise<string | null | undefined>;
106
+
107
+
108
+ export class AcpConnection {
109
+ #opts: AcpConnectionOptions;
110
+ #child: ChildProcess | undefined;
111
+ #rpc: JsonRpcSession | undefined;
112
+ #stderrTail = "";
113
+ #stagedHome: string | undefined;
114
+ #usageSeen = false;
115
+ /** Set ONLY by a parsed PromptResponse.usage; this gates estimate
116
+ * suppression. #usageSeen stays diagnostic: any usage-shaped frame trips
117
+ * it, and the connection outlives turns. */
118
+ #exactUsageSeen = false;
119
+ #exited = false;
120
+ #killed = false;
121
+ #suppressUpdates = false;
122
+ #updateSessionId: string | null = null;
123
+ agentInfo: Record<string, unknown> | undefined;
124
+ /** Last known mode/model config echo from set_config_option results. */
125
+ lastConfigOptions: unknown = undefined;
126
+ /** Parked permission dialogs: each entry settles its own request as
127
+ * cancelled. Cancellation and connection death drain the set. */
128
+ #permissionPending = new Set<() => void>();
129
+ /** allow_always / reject_always answers, keyed by toolCall kind|title for
130
+ * the life of this connection: identical requests skip the dialog. */
131
+ #permissionMemory = new Map<string, string>();
132
+
133
+ constructor(opts: AcpConnectionOptions) {
134
+ this.#opts = opts;
135
+ }
136
+
137
+ get alive(): boolean {
138
+ return this.#child !== undefined && !this.#exited && !this.#killed;
139
+ }
140
+
141
+ get pid(): number | undefined {
142
+ return this.#child?.pid;
143
+ }
144
+
145
+ get stderrTail(): string {
146
+ // Redacted at the boundary: the raw tail stays internal, every consumer
147
+ // (doctor display, driver exit logs, failure messages) sees redacted text.
148
+ return redactText(this.#stderrTail);
149
+ }
150
+
151
+ /** Gate B watch: latched true once any session/update frame carried
152
+ * usage/token fields. /agy doctor surfaces it the day upstream starts
153
+ * sending token counts; silent until then. */
154
+ get usageSeen(): boolean {
155
+ return this.#usageSeen;
156
+ }
157
+
158
+ /** True once the server sent real per-turn usage (PromptResponse.usage). */
159
+ get exactUsageSeen(): boolean {
160
+ return this.#exactUsageSeen;
161
+ }
162
+
163
+ /** While true, session/update notifications are dropped: they are the
164
+ * full-text history replay that precedes a session/load response, never
165
+ * live generation (run 6). */
166
+ set updateSuppression(flag: boolean) {
167
+ this.#suppressUpdates = flag;
168
+ }
169
+
170
+ /** Spawn the server and perform the initialize handshake. Idempotent per
171
+ * instance: call once. */
172
+ async start(): Promise<void> {
173
+ if (this.#child) return;
174
+ const args = [...(this.#opts.binArgs ?? [])];
175
+ if (this.#opts.agent) {
176
+ args.push("--agent", this.#opts.agent);
177
+ }
178
+ if (this.#opts.includeDefaultCustomizations === false) {
179
+ args.push("--include-default-customizations=false");
180
+ }
181
+ if (this.#opts.isolateHome) {
182
+ const homeOpts = typeof this.#opts.isolateHome === "object" ? this.#opts.isolateHome : {};
183
+ const sessionMcp = this.#opts.mcpServers?.() ?? [];
184
+ const agentMcpServers =
185
+ this.#opts.agent && sessionMcp.length > 0
186
+ ? sessionMcp.map((s) => ({
187
+ name: s.name,
188
+ serverUrl: s.url,
189
+ headers: Object.fromEntries(s.headers.map((h) => [h.name, h.value])),
190
+ }))
191
+ : undefined;
192
+ this.#stagedHome = stageAgyHome({
193
+ ...homeOpts,
194
+ inheritGlobalMcp: false,
195
+ ...(this.#opts.agent ? { agent: this.#opts.agent } : {}),
196
+ ...(this.#opts.agentsRoot ? { agentsRoot: this.#opts.agentsRoot } : {}),
197
+ ...(agentMcpServers ? { agentMcpServers } : {}),
198
+ });
199
+ args.push("--gemini-dir", this.#stagedHome);
200
+ }
201
+ let child: ChildProcess;
202
+ try {
203
+ child = spawn(this.#opts.bin, args, {
204
+ cwd: this.#opts.cwd,
205
+ stdio: ["pipe", "pipe", "pipe"],
206
+ detached: process.platform !== "win32",
207
+ windowsHide: true,
208
+ ...(this.#opts.extraEnv ? { env: { ...process.env, ...this.#opts.extraEnv } } : {}),
209
+ });
210
+ } catch (err) {
211
+ this.#releaseHome();
212
+ throw err;
213
+ }
214
+ this.#child = child;
215
+ child.stdout?.setEncoding("utf8");
216
+ child.stderr?.setEncoding("utf8");
217
+ // Pipe failures arrive asynchronously as stream 'error' events; the sync
218
+ // try/catch in #write cannot see them. Without this listener an EPIPE
219
+ // (server died mid-handshake) is uncaught and kills pi.
220
+ child.stdin?.on("error", (err) => {
221
+ this.#opts.log("stdin-error", { message: redactText(err.message) });
222
+ if (!this.#exited && !this.#killed) this.#finish(redactText(err.message));
223
+ });
224
+
225
+ const rpc = new JsonRpcSession({
226
+ send: (frame) => this.#write(frame),
227
+ onRequest: (method, params) => this.#onServerRequest(method, params),
228
+ onNotification: (method, params) => this.#onNotification(method, params),
229
+ onParseError: (line) => this.#opts.log("parse-error", { line }),
230
+ onOverflow: (detail) => {
231
+ // The session already aborted its pending requests with the typed
232
+ // reason; finish so later events are ignored, then take the whole
233
+ // process GROUP down: a flooder is broken or hostile, and the
234
+ // binary may be a wrapper script with live descendants.
235
+ this.#opts.log("frame-overflow", { detail });
236
+ this.#finish(`stdout frame overflow: ${detail}`);
237
+ const child = this.#child;
238
+ try {
239
+ if (child?.pid && process.platform !== "win32") {
240
+ process.kill(-child.pid, "SIGKILL");
241
+ } else {
242
+ child?.kill("SIGKILL");
243
+ }
244
+ } catch {
245
+ /* already gone */
246
+ }
247
+ },
248
+ });
249
+ this.#rpc = rpc;
250
+
251
+ child.stdout?.on("data", (chunk: string) => rpc.feed(chunk));
252
+ child.stderr?.on("data", (chunk: string) => {
253
+ this.#stderrTail = (this.#stderrTail + chunk).slice(-8192);
254
+ });
255
+ child.on("error", (err) => {
256
+ this.#opts.log("spawn-error", { message: redactText(err.message) });
257
+ this.#finish(redactText(err.message));
258
+ });
259
+ child.on("exit", (code, signal) => {
260
+ this.#opts.log("exit", { code: code ?? signal ?? "?" });
261
+ // The reason rides into pending-request rejections, so it must be
262
+ // redacted here (server stderr can carry auth material). When the
263
+ // server dies with no output the exit code/signal is the only clue,
264
+ // so it goes into the reason ahead of the stderr tail.
265
+ const where = signal ? `signal ${signal}` : code !== null ? `code ${code}` : "";
266
+ const tail = redactText(this.#stderrTail.trim());
267
+ const detail = [where, tail || "process gone"].filter(Boolean).join(": ");
268
+ this.#finish(detail, code, signal);
269
+ });
270
+
271
+ const result = (await this.request(
272
+ "initialize",
273
+ {
274
+ protocolVersion: 1,
275
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
276
+ },
277
+ INIT_TIMEOUT_MS,
278
+ )) as Record<string, unknown> | undefined;
279
+ const info = result?.agentInfo;
280
+ if (typeof info === "object" && info !== null) this.agentInfo = info as Record<string, unknown>;
281
+ this.#opts.log("initialized", { version: this.serverVersion() });
282
+ }
283
+
284
+ serverVersion(): string | undefined {
285
+ const v = this.agentInfo?.version;
286
+ return typeof v === "string" ? v : undefined;
287
+ }
288
+
289
+ async newSession(cwd: string): Promise<AcpSessionInfo> {
290
+ const result = (await this.guarded(
291
+ "session/new",
292
+ { cwd, mcpServers: this.#mcpServers() },
293
+ SESSION_OP_TIMEOUT_MS,
294
+ )) as Record<string, unknown>;
295
+ const sessionId = result.sessionId;
296
+ if (typeof sessionId !== "string" || sessionId.length === 0) {
297
+ throw new Error("ACP session/new returned no sessionId");
298
+ }
299
+ this.#updateSessionId = sessionId;
300
+ this.lastConfigOptions = result.configOptions;
301
+ return { sessionId };
302
+ }
303
+
304
+ async loadSession(sessionId: string, cwd: string): Promise<void> {
305
+ this.#updateSessionId = sessionId;
306
+ this.updateSuppression = true;
307
+ try {
308
+ // History replay arrives as notifications BEFORE this resolves; the
309
+ // suppression flag drops it (never live text).
310
+ const result = (await this.request(
311
+ "session/load",
312
+ { sessionId, cwd, mcpServers: this.#mcpServers() },
313
+ SESSION_OP_TIMEOUT_MS,
314
+ )) as Record<string, unknown> | undefined;
315
+ this.lastConfigOptions = result?.configOptions;
316
+ } finally {
317
+ this.updateSuppression = false;
318
+ }
319
+ }
320
+
321
+ async setConfigOption(sessionId: string, configId: string, value: string): Promise<void> {
322
+ const result = (await this.guarded(
323
+ "session/set_config_option",
324
+ { sessionId, configId, value },
325
+ SESSION_OP_TIMEOUT_MS,
326
+ )) as Record<string, unknown> | undefined;
327
+ this.lastConfigOptions = result?.configOptions;
328
+ }
329
+
330
+ /** No request timeout on purpose: the driver's overall/idle timers govern
331
+ * the turn and fire abortAll on breach. */
332
+ async prompt(
333
+ sessionId: string,
334
+ text: string,
335
+ images: Array<{ data: string; mimeType: string }> = [],
336
+ contextBlock?: { uri: string; text: string },
337
+ ): Promise<{ stopReason: string; usage?: AgyUsage }> {
338
+ // Block order: images, then the embedded-context resource (pi-side
339
+ // digest), then the text question last (it refers to everything before
340
+ // it). Shapes per the ACP v1 content-block schema; embeddedContext is
341
+ // advertised in promptCapabilities (verified run 5).
342
+ const blocks: Array<Record<string, unknown>> = [
343
+ ...images.map((i) => ({ type: "image", data: i.data, mimeType: i.mimeType })),
344
+ ];
345
+ if (contextBlock) {
346
+ blocks.push({
347
+ type: "resource",
348
+ resource: {
349
+ uri: contextBlock.uri,
350
+ mimeType: "text/markdown",
351
+ text: contextBlock.text,
352
+ },
353
+ });
354
+ }
355
+ blocks.push({ type: "text", text });
356
+ const result = (await this.request("session/prompt", {
357
+ sessionId,
358
+ prompt: blocks,
359
+ })) as Record<string, unknown> | undefined;
360
+ const stopReason = result?.stopReason;
361
+ if (typeof stopReason !== "string") {
362
+ throw new Error("ACP session/prompt returned no stopReason");
363
+ }
364
+ // Exact PromptResponse.usage is distinct from context-window occupancy
365
+ // updates; never infer token counts from {used,size}.
366
+ const usage = parseAcpTurnUsage(result?.usage);
367
+ if (usage) {
368
+ this.#usageSeen = true;
369
+ this.#exactUsageSeen = true;
370
+ }
371
+ return { stopReason, ...(usage ? { usage } : {}) };
372
+ }
373
+
374
+ cancel(sessionId: string): void {
375
+ if (!this.#rpc || !this.alive) throw new Error("ACP connection is not running");
376
+ this.#rpc.notify("session/cancel", { sessionId });
377
+ for (const cancel of [...this.#permissionPending]) cancel();
378
+ }
379
+
380
+ async closeSession(sessionId: string): Promise<void> {
381
+ await this.guarded("session/close", { sessionId }, SESSION_OP_TIMEOUT_MS);
382
+ }
383
+
384
+ /** Protocol-level permission answering: policy "auto" selects the first
385
+ * allow option synchronously; "deny" consults the parked human handler
386
+ * (#answerPermission) or fail-closes. See there for the full matrix. */
387
+ #onServerRequest(method: string, params: unknown): Promise<unknown> {
388
+ if (method === "session/request_permission") {
389
+ return this.#answerPermission(params as AcpPermissionRequest);
390
+ }
391
+ // fs/* and terminal/* are declined: our client capabilities are off and
392
+ // agy keeps executing its own tools (plan §8 capability posture).
393
+ this.#opts.log("unsupported-server-request", { method });
394
+ return Promise.reject(new Error(`client capability not enabled: ${method}`));
395
+ }
396
+
397
+ /** Answer session/request_permission. "auto" (skipPermissions turns)
398
+ * selects the first allow option synchronously. Otherwise a wired handler
399
+ * parks the request — the reply waits for the human — while every other
400
+ * path fail-closes synchronously: no handler, esc, throw, timeout,
401
+ * connection death. A chosen *_always option is remembered per connection
402
+ * for identical later requests (same toolCall kind+title). */
403
+ #answerPermission(params: AcpPermissionRequest): Promise<unknown> {
404
+ const options = Array.isArray(params?.options) ? params.options : [];
405
+ const allow = options.find((o) => typeof o.kind === "string" && o.kind.startsWith("allow"));
406
+ const denyId = options.find((o) => typeof o.kind === "string" && o.kind.startsWith("reject"))?.optionId;
407
+ const policy = this.#opts.permissions?.() ?? "deny";
408
+ if (policy === "auto") {
409
+ const chosen = (allow ?? options[0])?.optionId;
410
+ this.#opts.log("permission", { optionId: chosen, policy });
411
+ return Promise.resolve({ outcome: { outcome: "selected", optionId: chosen } });
412
+ }
413
+ const handler = this.#opts.onPermissionRequest;
414
+ if (!handler) {
415
+ this.#opts.log("permission", { optionId: denyId, policy });
416
+ return Promise.resolve(
417
+ denyId !== undefined
418
+ ? { outcome: { outcome: "selected", optionId: denyId } }
419
+ : { outcome: { outcome: "cancelled" } },
420
+ );
421
+ }
422
+ // Always-memory needs a stable identity: the title. Without one, every
423
+ // untitled call would collapse onto one key and inherit its answer.
424
+ const memKey = params?.toolCall?.title ? `${params.toolCall.kind ?? ""}|${params.toolCall.title}` : undefined;
425
+ const remembered = memKey !== undefined ? this.#permissionMemory.get(memKey) : undefined;
426
+ if (remembered !== undefined && options.some((o) => o.optionId === remembered)) {
427
+ this.#opts.log("permission", { optionId: remembered, policy: "memory" });
428
+ return Promise.resolve({ outcome: { outcome: "selected", optionId: remembered } });
429
+ }
430
+ const parkMs = this.#opts.permissionParkMs ?? PERMISSION_PARK_MS;
431
+ return new Promise((resolve) => {
432
+ let timer: NodeJS.Timeout | undefined;
433
+ // First settle wins: a late dialog answer after a timeout/deny must
434
+ // not re-log or record an always-memory from a dead request.
435
+ let settled = false;
436
+ const settle = (optionId: string | undefined, why: string): void => {
437
+ if (settled) return;
438
+ settled = true;
439
+ if (timer) clearTimeout(timer);
440
+ this.#permissionPending.delete(settleCancel);
441
+ this.#opts.log("permission", { optionId, policy: why });
442
+ // undefined optionId (no reject option on the table) cancels
443
+ // instead of selecting nothing.
444
+ resolve(
445
+ optionId !== undefined
446
+ ? { outcome: { outcome: "selected", optionId } }
447
+ : { outcome: { outcome: "cancelled" } },
448
+ );
449
+ };
450
+ const settleDeny = (): void => settle(denyId, "dialog-deny");
451
+ const settleCancel = (): void => settle(undefined, "cancel");
452
+ timer = setTimeout(() => {
453
+ this.#opts.log("permission-timeout", { parkMs });
454
+ settleDeny();
455
+ }, parkMs);
456
+ this.#permissionPending.add(settleCancel);
457
+ Promise.resolve()
458
+ .then(() => handler(params))
459
+ .then((picked) => {
460
+ if (settled) return;
461
+ const chosen = picked != null ? options.find((o) => o.optionId === picked) : undefined;
462
+ if (!chosen?.optionId) {
463
+ settle(denyId, "dialog-deny");
464
+ return;
465
+ }
466
+ if (chosen.kind === "allow_always" || chosen.kind === "reject_always") {
467
+ if (memKey !== undefined) this.#permissionMemory.set(memKey, chosen.optionId);
468
+ }
469
+ settle(chosen.optionId, "dialog");
470
+ })
471
+ .catch((err: unknown) => {
472
+ this.#opts.log("permission-error", { message: err instanceof Error ? err.message : String(err) });
473
+ settle(denyId, "dialog-error");
474
+ });
475
+ });
476
+ }
477
+
478
+ #onNotification(method: string, params: unknown): void {
479
+ if (method === "session/update") {
480
+ // Latched before the suppression gate: history-replay frames are the
481
+ // server's payloads too, and they are the cheapest signal source.
482
+ if (!this.#usageSeen && frameCarriesUsage(params)) this.#usageSeen = true;
483
+ if (this.#suppressUpdates) return; // load replay: history, not live text
484
+ const p = typeof params === "object" && params !== null ? (params as Record<string, unknown>) : {};
485
+ const sessionId = typeof p.sessionId === "string" ? p.sessionId : this.#updateSessionId;
486
+ this.#opts.onUpdate(sessionId, p.update);
487
+ return;
488
+ }
489
+ if (method === "auth_required") {
490
+ this.#opts.log("auth-required", params);
491
+ return;
492
+ }
493
+ this.#opts.log("notification", { method });
494
+ }
495
+
496
+ /** Wrap a request: -32000 family becomes AcpAuthError with the remediation
497
+ * the server provided. */
498
+ private async guarded(method: string, params: unknown, timeoutMs: number): Promise<unknown> {
499
+ try {
500
+ return await this.request(method, params, timeoutMs);
501
+ } catch (err) {
502
+ throw translateError(method, err);
503
+ }
504
+ }
505
+
506
+ async request(method: string, params?: unknown, timeoutMs?: number): Promise<unknown> {
507
+ if (!this.#rpc || !this.alive) throw new Error("ACP connection is not running");
508
+ try {
509
+ return await this.#rpc.request(method, params, timeoutMs);
510
+ } catch (err) {
511
+ throw translateError(method, err);
512
+ }
513
+ }
514
+
515
+ #mcpServers(): AcpMcpServer[] {
516
+ try {
517
+ return this.#opts.mcpServers?.() ?? [];
518
+ } catch {
519
+ return [];
520
+ }
521
+ }
522
+
523
+ #write(frame: string): void {
524
+ const stdin = this.#child?.stdin;
525
+ if (!stdin || !stdin.writable || this.#exited) {
526
+ this.abortAll("connection closed");
527
+ return;
528
+ }
529
+ try {
530
+ stdin.write(frame + "\n");
531
+ } catch (err) {
532
+ this.#opts.log("write-failed", { message: err instanceof Error ? err.message : String(err) });
533
+ this.abortAll("connection closed");
534
+ }
535
+ }
536
+
537
+ /** Gate D teardown: reject every pending request, then kill the process.
538
+ * Nothing is ever written to the dying transport; no promise survives. */
539
+ abortAll(reason: string): void {
540
+ this.#rpc?.abortAll(reason);
541
+ }
542
+ kill(): void {
543
+ if (this.#killed) return;
544
+ this.#killed = true;
545
+ this.abortAll("connection killed");
546
+ const child = this.#child;
547
+ if (!child || this.#exited) return;
548
+ this.#opts.log("kill", { pid: child.pid ?? "?" });
549
+ try {
550
+ if (child.pid && process.platform !== "win32") {
551
+ process.kill(-child.pid, "SIGTERM");
552
+ setTimeout(() => {
553
+ try {
554
+ if (!this.#exited && child.pid) process.kill(-child.pid, "SIGKILL");
555
+ } catch {
556
+ /* already gone */
557
+ }
558
+ }, 750);
559
+ } else {
560
+ child.kill("SIGTERM");
561
+ }
562
+ } catch {
563
+ /* already gone */
564
+ }
565
+ }
566
+
567
+ #finish(reason: string, code: number | null = null, signal: NodeJS.Signals | null = null): void {
568
+ if (this.#exited) return;
569
+ this.#exited = true;
570
+ // Pending permission dialogs must not outlive the connection: cancel them
571
+ // so their replies race the teardown instead of hanging the server.
572
+ const pendings = [...this.#permissionPending];
573
+ this.#permissionPending.clear();
574
+ for (const deny of pendings) deny();
575
+ this.abortAll(`connection exited: ${reason || "process gone"}`);
576
+ this.#releaseHome();
577
+ this.#opts.onExit({ code, signal, stderrTail: redactText(this.#stderrTail) });
578
+ }
579
+
580
+ #releaseHome(): void {
581
+ if (this.#stagedHome) removeStagedAgyHome(this.#stagedHome);
582
+ this.#stagedHome = undefined;
583
+ }
584
+ }
585
+
586
+ /** ACP's optional (unstable) per-turn PromptResponse.usage, not the
587
+ * session/usage_update context-window occupancy notification. */
588
+ export function parseAcpTurnUsage(value: unknown): AgyUsage | undefined {
589
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
590
+ const u = value as Record<string, unknown>;
591
+ const count = (v: unknown): number | undefined =>
592
+ typeof v === "number" && Number.isSafeInteger(v) && v >= 0 ? v : undefined;
593
+ const input = count(u.inputTokens);
594
+ const output = count(u.outputTokens);
595
+ const total = count(u.totalTokens);
596
+ const thought = count(u.thoughtTokens);
597
+ const cacheRead = count(u.cachedReadTokens);
598
+ if (input === undefined || output === undefined || total === undefined) return undefined;
599
+ return {
600
+ input_tokens: input,
601
+ output_tokens: output,
602
+ total_tokens: total,
603
+ ...(thought === undefined ? {} : { thinking_tokens: thought }),
604
+ ...(cacheRead === undefined ? {} : { cache_read_tokens: cacheRead }),
605
+ };
606
+ }
607
+
608
+ export function translateError(method: string, err: unknown): Error {
609
+ if (err instanceof AcpAuthError) return err;
610
+ if (err instanceof JsonRpcResponseError) {
611
+ if (err.code === -32000) {
612
+ const detail = typeof err.data === "object" && err.data !== null ? (err.data as { message?: unknown }).message : undefined;
613
+ const detailText = typeof detail === "string" ? `: ${detail}` : `: ${err.message}`;
614
+ return new AcpAuthError(`ACP authentication required for ${method}${detailText}. Run agy in your terminal and complete Google sign-in, then restart pi.`);
615
+ }
616
+ // Typed protocol errors survive: callers inspect .code for parameter
617
+ // and capability failures. The method prefix is lost — describe()
618
+ // callers that need it log the method at the call site.
619
+ return err;
620
+ }
621
+ if (err instanceof Error) return err;
622
+ return new Error(`ACP ${method} failed: ${String(err)}`);
623
+ }