@estebanforge/pi-antigravity-bridge 1.4.0 → 1.4.2

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.
@@ -11,10 +11,12 @@
11
11
  // ("[agy tool: editing foo.ts]") for visibility, but the edits already landed
12
12
  // on disk and pi's inline diff review does not engage.
13
13
  //
14
- // /agy command: status, mode picker (plan / accept-edits),
15
- // and session clear. Config persists to ~/.pi/agent/antigravity-bridge/
16
- // config.json so toggles survive restarts.
14
+ // /agy command: full runtime config surface (engine, mode, permissions,
15
+ // bridge tools, model, thinking, digest, system prompt, acp binary) plus
16
+ // doctor, auth, patch-cleanup, and session clear. Config persists to
17
+ // ~/.pi/agent/antigravity-bridge/config.json so toggles survive restarts.
17
18
 
19
+ import os from "node:os";
18
20
  import {
19
21
  type ExtensionAPI,
20
22
  type ExtensionCommandContext,
@@ -38,7 +40,7 @@ import { SessionStore } from "../src/sessions.js";
38
40
  import { ToolRoundTrips, WrapperReplay, createStreamSimple } from "../src/provider.js";
39
41
  import { AgyDriver } from "../src/driver.js";
40
42
  import { AcpDriver } from "../src/acp/driver.js";
41
- import { resolveAcpBinary } from "../src/acp/connection.js";
43
+ import { ensureAcpReady, inspectAcpSetup } from "../src/acp/setup.js";
42
44
  import type { TurnDriver } from "../src/driver-types.js";
43
45
  import { CONFIG_PATH, loadConfig, saveConfig, type AgyMode, type BridgeTools, type Engine, type ThinkingTier } from "../src/config.js";
44
46
  import { registerAskAntigravityTool, toolModelsFromRaw } from "../src/ask-tool.js";
@@ -97,14 +99,32 @@ export default async function (pi: ExtensionAPI): Promise<void> {
97
99
  // MCP bridge handle, declared early: the ACP engine reads the bridge port
98
100
  // at session/new / session/load time.
99
101
  let mcpHandle: McpServerHandle | null = null;
102
+ // ACP self-heal runs once per process (session_start re-fires on /reload;
103
+ // a ready setup is two file stats, so re-running is harmless anyway).
104
+ let acpSelfHealRan = false;
100
105
  // Two turn engines behind one contract (plan §9): stream-json (tested
101
106
  // default) and the official ACP server (opt-in via config.engine, off by
102
107
  // default). Neither spawns anything until its first turn.
108
+ // ACP log routing: only genuine failures reach stderr. Routine lifecycle
109
+ // (driver-created, spawn, session-new, ...) stays in the driver's
110
+ // #lifecycle ring buffer, visible via /agy doctor. An unfiltered sink fired
111
+ // console.error at extension load ("driver-created"), before any UI exists,
112
+ // and leaked raw driver lines into the terminal on every startup.
113
+ const acpFailures = new Set([
114
+ "start-failed", "spawn-error", "parse-error", "write-failed",
115
+ "mode-apply-failed", "timeout", "stall", "auth-required",
116
+ "session-load-failed-creating-fresh", "connection-exited", "cancel-failed",
117
+ "unsupported-server-request",
118
+ ]);
103
119
  const legacyDriver = new AgyDriver();
104
120
  const acpDriver = new AcpDriver({
105
- bin: resolveAcpBinary(loadConfig().acp.bin),
106
- log: (msg, data) =>
107
- console.error(`[antigravity-bridge acp] ${msg}${data !== undefined ? " " + JSON.stringify(data) : ""}`),
121
+ // Resolved per connection: the setup flow can install the binary and
122
+ // update acp.bin mid-session; the next turn picks it up (no restart).
123
+ bin: () => loadConfig().acp.bin,
124
+ log: (msg, data) => {
125
+ if (!acpFailures.has(msg)) return;
126
+ console.error(`[antigravity-bridge acp] ${msg}${data !== undefined ? " " + JSON.stringify(data) : ""}`);
127
+ },
108
128
  mcpServers: () => {
109
129
  const handle = mcpHandle;
110
130
  if (!handle) return [];
@@ -233,29 +253,52 @@ export default async function (pi: ExtensionAPI): Promise<void> {
233
253
  } catch {
234
254
  /* detection is best-effort */
235
255
  }
236
- // Bridge lifecycle/error logger. Routes through ctx.ui.notify (an
237
- // ephemeral toast that fades) instead of stderr: pi's TUI captures stderr
238
- // and pins it above the input for the whole session, which left the
239
- // startup "bridge-config-written" / "listening" lines stuck on screen all
240
- // session. Headless modes (print/json, hasUI === false) have no toast, so
241
- // fall back to stderr there. Per-turn success events (list-tools /
242
- // call-tool) stay silent either way.
256
+ // ACP self-heal: engine=acp needs a server binary + auth. Silent when
257
+ // everything is ready; installs from the registry and bootstraps auth
258
+ // otherwise; manual instructions only on failure. Fire-and-forget: it
259
+ // must not delay session start (and nothing spawns until the first turn).
260
+ if (engine === "acp" && !acpSelfHealRan) {
261
+ acpSelfHealRan = true;
262
+ void ensureAcpReady({ configBin: loadConfig().acp.bin }).then((status) => {
263
+ if (status.ok) {
264
+ if (status.binarySource === "installed" || status.binarySource === "existing") {
265
+ saveConfig({ acp: { bin: status.bin, permissions: loadConfig().acp.permissions } });
266
+ }
267
+ if (status.needsLogin) {
268
+ const msg =
269
+ "ACP: one-time Google login pending. Your next antigravity message opens the browser; sign in with your Antigravity subscription account (the same login as the agy CLI). Tokens stay on your machine.";
270
+ if (ctx.hasUI) ctx.ui.notify(msg, "info");
271
+ else console.error(`[antigravity-bridge] ${msg}`);
272
+ }
273
+ return;
274
+ }
275
+ const msg = `ACP auto-setup failed (${status.error}).\n${status.manual}`;
276
+ if (ctx.hasUI) ctx.ui.notify(msg, "warning");
277
+ else console.error(`[antigravity-bridge] ${msg}`);
278
+ });
279
+ }
280
+ // Bridge failure logger. Routine lifecycle (listening,
281
+ // bridge-config-written/removed, closed) is normal startup/teardown
282
+ // traffic: toasting it every session, or pinning it via stderr in
283
+ // headless mode, was noise. Only genuine failures surface - as a
284
+ // warning toast (ctx.ui.notify, ephemeral) or stderr when headless.
285
+ // Per-turn success events (list-tools / call-tool) stay silent.
243
286
  const mcpLog = (s: string, d?: unknown) => {
244
- const surfaced = new Set([
245
- "listening", "capability-missing", "http-error", "closed",
246
- "bridge-config-written", "bridge-config-removed", "bridge-config-write-failed",
247
- "call-tool-fail", "transport-error", "handleRequest-error",
248
- "request-error", "request-handler-error", "unauthorized", "self-patch-error",
287
+ // Routine abort traffic: failAll fires on turn end / session shutdown
288
+ // and the bridge answers every parked call with an error. Not a fault.
289
+ if (s === "call-tool-fail") {
290
+ const detail = (d as { msg?: string } | undefined)?.msg ?? "";
291
+ if (detail.includes("unresolved pi tool call") || detail.includes("session shut down")) return;
292
+ }
293
+ const failures = new Set([
294
+ "http-error", "bridge-config-write-failed", "call-tool-fail",
295
+ "transport-error", "handleRequest-error", "request-error",
296
+ "request-handler-error", "unauthorized",
249
297
  ]);
250
- if (!surfaced.has(s)) return;
298
+ if (!failures.has(s)) return;
251
299
  const msg = `[antigravity-bridge mcp] ${s}${d !== undefined ? " " + JSON.stringify(d) : ""}`;
252
- if (ctx.hasUI) {
253
- const ok = s === "listening" || s === "bridge-config-written"
254
- || s === "bridge-config-removed" || s === "closed";
255
- ctx.ui.notify(msg, ok ? "info" : "warning");
256
- } else {
257
- console.error(msg);
258
- }
300
+ if (ctx.hasUI) ctx.ui.notify(msg, "warning");
301
+ else console.error(msg);
259
302
  };
260
303
  // Start the bridge unless the user turned it off. No patch gate, no
261
304
  // consent flow: calls route through pi's normal toolUse loop.
@@ -263,7 +306,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
263
306
  if (bridgeMode === "none") return; // user opted out
264
307
  if (mcpHandle) return; // already running (reload re-fires session_start)
265
308
  const SKIP = new Set(["AskAntigravity"]);
266
- const skills: SkillLite[] = scanSkills(process.cwd());
309
+ // pi loads project skill locations only after the project is trusted;
310
+ // mirror that gate. Global skill dirs are always scanned.
311
+ const skills: SkillLite[] = scanSkills(ctx.isProjectTrusted() ? process.cwd() : undefined);
267
312
  const getAll = (pi as unknown as {
268
313
  getAllTools: () => Array<{ name: string; description?: string; parameters?: object; sourceInfo?: { source?: string } }>;
269
314
  }).getAllTools.bind(pi);
@@ -350,6 +395,9 @@ interface PendingConfig {
350
395
  skipPermissions?: boolean;
351
396
  defaultModel?: string;
352
397
  defaultThinking?: ThinkingTier;
398
+ bridgeTools?: BridgeTools;
399
+ digest?: boolean;
400
+ systemPrompt?: boolean;
353
401
  }
354
402
 
355
403
  function statusText(ctx: AgyCommandCtx): string {
@@ -370,7 +418,7 @@ function statusText(ctx: AgyCommandCtx): string {
370
418
  ` digest: ${config.digest ? "on" : "off"}`,
371
419
  ` system prompt: ${config.systemPrompt ? "on" : "off"}`,
372
420
  "",
373
- "Subcommands: /agy engine stream-json|acp, /agy mode plan|accept-edits, /agy permissions on|off, /agy model flash|pro|gemini, /agy thinking low|medium|high, /agy digest on|off, /agy system-prompt on|off, /agy acp-auth, /agy patch-cleanup, /agy clear",
421
+ "Subcommands: /agy engine stream-json|acp, /agy mode plan|accept-edits, /agy permissions on|off, /agy bridge all|mcp|none, /agy model <alias>, /agy thinking low|medium|high, /agy digest on|off, /agy system-prompt on|off, /agy acp-bin <path|auto>, /agy acp-auth, /agy patch-cleanup, /agy clear",
374
422
  ].join("\n");
375
423
  }
376
424
 
@@ -378,7 +426,7 @@ function statusText(ctx: AgyCommandCtx): string {
378
426
  function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
379
427
  pi.registerCommand("agy", {
380
428
  description:
381
- "Antigravity provider: status, doctor, engine picker, mode picker, clear sessions. Usage: /agy [status|doctor|engine stream-json|acp|mode [plan|accept-edits]|permissions on|off|digest on|off|system-prompt on|off|acp-auth|patch-cleanup|clear]",
429
+ "Antigravity provider: status, doctor, settings picker, clear sessions. Usage: /agy [status|doctor|engine stream-json|acp|mode plan|accept-edits|permissions on|off|bridge all|mcp|none|model <alias>|thinking low|medium|high|digest on|off|system-prompt on|off|acp-bin <path|auto>|acp-auth|patch-cleanup|clear]",
382
430
  handler: async (args, cmdCtx: ExtensionCommandContext) => {
383
431
  const ui = cmdCtx.ui;
384
432
  const mode = cmdCtx.mode;
@@ -413,9 +461,32 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
413
461
  }
414
462
  if (sub === "engine") {
415
463
  if (val === "acp" || val === "stream-json") {
464
+ if (val === "acp" && loadConfig().mode === "plan") {
465
+ ui?.notify("mode is plan; the ACP engine has no plan mode. /agy mode accept-edits first.", "warning");
466
+ return;
467
+ }
416
468
  const next = saveConfig({ engine: val });
469
+ if (next.engine !== "acp") {
470
+ ui?.notify("engine set to stream-json. Takes effect on the next pi start (or /reload).", "info");
471
+ return;
472
+ }
473
+ // Self-service setup: install the server from the official
474
+ // registry and bootstrap auth now, so the restart just works.
475
+ // Manual instructions only when a step fails.
476
+ ui?.notify("engine set to acp. Preparing the server (binary + auth)…", "info");
477
+ const status = await ensureAcpReady({
478
+ configBin: loadConfig().acp.bin,
479
+ onProgress: (m) => ui?.notify(m, "info"),
480
+ });
481
+ if (!status.ok) {
482
+ ui?.notify(`ACP auto-setup failed (${status.error}).\n${status.manual}`, "warning");
483
+ return;
484
+ }
485
+ saveConfig({ acp: { bin: status.bin, permissions: loadConfig().acp.permissions } });
417
486
  ui?.notify(
418
- `engine set to ${next.engine}. Takes effect on the next pi start (or /reload).${next.engine === "acp" ? "\nRequires the official ACP server binary (AGY_ACP_BIN or acp.bin) and a one-time auth: /agy acp-auth" : ""}`,
487
+ status.needsLogin
488
+ ? "ACP engine ready. Your first ACP message opens the Google login in your browser: sign in with the account of your Antigravity subscription (the same login as the Antigravity CLI, agy). One-time; tokens stay on your machine and this extension never sees them. Takes effect on the next pi start (or /reload)."
489
+ : `ACP engine ready (auth: ${status.auth}). Takes effect on the next pi start (or /reload).`,
419
490
  "info",
420
491
  );
421
492
  } else {
@@ -423,20 +494,47 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
423
494
  }
424
495
  return;
425
496
  }
497
+ if (sub === "acp-bin") {
498
+ const rest = (args ?? "").trim().split(/\s+/).slice(1).join(" ");
499
+ if (rest.length > 0) {
500
+ // Only the keyword compares case-insensitively; the path keeps its case.
501
+ const bin = rest.toLowerCase() === "auto" ? "" : rest.replace(/^~(?=\/|$)/, os.homedir());
502
+ saveConfig({ acp: { bin, permissions: loadConfig().acp.permissions } });
503
+ ui?.notify(
504
+ bin
505
+ ? `acp.bin set to ${bin}. The next ACP turn (re)connects with it.`
506
+ : "acp.bin cleared. Auto-setup (or AGY_ACP_BIN) picks the binary on the next ACP turn.",
507
+ "info",
508
+ );
509
+ } else {
510
+ const cur = loadConfig().acp.bin;
511
+ ui?.notify(`acp.bin: ${cur || "(auto: setup installs, or AGY_ACP_BIN)"}\nusage: /agy acp-bin <path|auto>`, "info");
512
+ }
513
+ return;
514
+ }
426
515
  if (sub === "acp-auth") {
427
516
  ui?.notify(
428
517
  [
429
- "ACP engine authentication (one-time):",
430
- "1. Install the server binary (agy_acp_server.par from the ACP registry",
431
- " build URL) and point acp.bin or AGY_ACP_BIN at it.",
432
- "2. Pick one credential path:",
433
- ' - oauth-personal: put {"auth":{"type":"oauth-personal"}} in',
434
- " ~/.gemini/antigravity-acp/settings.json, run one turn, and open the",
435
- " login URL the server produces (headless: tunnel 127.0.0.1:<port>",
436
- " over ssh, then open the URL on your machine).",
437
- ' - gemini-api-key: export GEMINI_API_KEY and set',
438
- ' {"auth":{"type":"gemini-api-key"}} (headless-friendly; metered).',
439
- " The agent never reads or writes your credentials.",
518
+ "ACP engine authentication (one-time; usually automatic -",
519
+ "/agy engine acp and session start set this up for you):",
520
+ "",
521
+ "The server is Google's official Antigravity ACP, installed from Google's",
522
+ "own registry. Logging in uses your Antigravity subscription: the same",
523
+ "Google account and plan as the Antigravity CLI (agy). It is no different",
524
+ "from logging into the CLI; the server just keeps its own token file on",
525
+ "your machine, like any Google tool. This extension never sees your",
526
+ "credentials.",
527
+ "",
528
+ "1. Server binary: auto-setup installs it. Manual: agy_acp_server.par from",
529
+ " the antigravity-acp registry; point acp.bin or AGY_ACP_BIN at it.",
530
+ '2. Default: put {"auth":{"type":"oauth-personal"}} in',
531
+ " ~/.gemini/antigravity-acp/settings.json, run one turn, and complete the",
532
+ " Google login that opens in your browser (headless: tunnel 127.0.0.1:<port>",
533
+ " over ssh, then open the URL on your machine).",
534
+ ' Headless alternative: GEMINI_API_KEY + {"auth":{"type":"gemini-api-key"}}',
535
+ " (metered paid API - not your Antigravity plan). The key is used only",
536
+ " when that type is selected; with the default oauth-personal in place,",
537
+ " an exported key is ignored.",
440
538
  "3. Run one turn; /agy doctor shows the server version when auth is OK.",
441
539
  ].join("\n"),
442
540
  "info",
@@ -469,11 +567,22 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
469
567
  lines.push(" lifecycle (last 5):");
470
568
  for (const entry of snap.lifecycle.slice(-5)) lines.push(` ${entry}`);
471
569
  }
570
+ if (engine === "acp") {
571
+ const setup = inspectAcpSetup({ configBin: config.acp.bin });
572
+ lines.push(
573
+ ` acp binary: ${setup.bin ?? "not found (auto-setup offers install)"}${setup.source ? ` (${setup.source})` : ""}`,
574
+ ` acp auth: ${setup.auth ?? "not configured (auto-setup bootstraps)"}`,
575
+ );
576
+ }
472
577
  ui?.notify(lines.join("\n"), "info");
473
578
  return;
474
579
  }
475
580
  if (sub === "mode") {
476
581
  if (val === "plan" || val === "accept-edits") {
582
+ if (val === "plan" && ctx.engine === "acp") {
583
+ ui?.notify("the ACP engine has no plan mode (RC01). /agy engine stream-json first, or /agy mode accept-edits.", "warning");
584
+ return;
585
+ }
477
586
  const next = saveConfig({ mode: val as AgyMode });
478
587
  ui?.notify(`mode set to ${next.mode}`, "info");
479
588
  } else {
@@ -491,6 +600,20 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
491
600
  }
492
601
  return;
493
602
  }
603
+ if (sub === "bridge") {
604
+ if (val === "all" || val === "mcp" || val === "none") {
605
+ const next = saveConfig({ bridgeTools: val });
606
+ ui?.notify(
607
+ next.bridgeTools === "none"
608
+ ? "bridge off. The MCP tool bridge will not start on the next pi start (or /reload)."
609
+ : `bridge tools set to ${next.bridgeTools}. The catalog rebuilds on the next pi start (or /reload).`,
610
+ "info",
611
+ );
612
+ } else {
613
+ ui?.notify(`bridge: ${loadConfig().bridgeTools}\nusage: /agy bridge all|mcp|none\n all: every non-builtin pi tool (default). mcp: pi-mcp-adapter tools + skills only. none: bridge off.`, "info");
614
+ }
615
+ return;
616
+ }
494
617
  if (sub === "model") {
495
618
  if (val && val.length > 0) {
496
619
  const next = saveConfig({ defaultModel: val });
@@ -556,7 +679,10 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
556
679
  });
557
680
  }
558
681
 
559
- /** Interactive settings picker (TUI only). Rows: mode + permissions + model + thinking. */
682
+ /** Interactive settings picker (TUI only). Rows: the full runtime config
683
+ * surface (mode, permissions, model, thinking, bridge, digest, system
684
+ * prompt). Engine stays a subcommand: switching runs install + auth setup
685
+ * and needs a restart. */
560
686
  async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promise<void> {
561
687
  const config = loadConfig();
562
688
  const pending: PendingConfig = {};
@@ -594,6 +720,30 @@ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promis
594
720
  currentValue: config.defaultThinking,
595
721
  values: ["low", "medium", "high"],
596
722
  },
723
+ {
724
+ id: "bridge",
725
+ label: "Bridge tools",
726
+ description:
727
+ "Which pi tools the MCP bridge exposes to agy. all: every non-builtin tool (default). mcp: pi-mcp-adapter tools + skills. none: bridge off.",
728
+ currentValue: config.bridgeTools,
729
+ values: ["all", "mcp", "none"],
730
+ },
731
+ {
732
+ id: "digest",
733
+ label: "Context digest",
734
+ description:
735
+ "Inject a delta of pi-side context into each agy prompt. Defeats agy's prompt cache (~25-30k tokens re-billed per turn).",
736
+ currentValue: config.digest ? "on" : "off",
737
+ values: ["on", "off"],
738
+ },
739
+ {
740
+ id: "system-prompt",
741
+ label: "System prompt",
742
+ description:
743
+ "Prepend pi's system prompt (incl. AGENTS.md files) to the first prompt of each new agy conversation.",
744
+ currentValue: config.systemPrompt ? "on" : "off",
745
+ values: ["on", "off"],
746
+ },
597
747
  ];
598
748
 
599
749
  await ui.custom((tui, theme, _kb, done) => {
@@ -614,6 +764,12 @@ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promis
614
764
  pending.defaultModel = newValue;
615
765
  } else if (id === "thinking") {
616
766
  pending.defaultThinking = newValue as ThinkingTier;
767
+ } else if (id === "bridge") {
768
+ pending.bridgeTools = newValue as BridgeTools;
769
+ } else if (id === "digest") {
770
+ pending.digest = newValue === "on";
771
+ } else if (id === "system-prompt") {
772
+ pending.systemPrompt = newValue === "on";
617
773
  }
618
774
  },
619
775
  () => done(undefined),
@@ -630,13 +786,12 @@ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promis
630
786
  };
631
787
  });
632
788
 
633
- if (
634
- pending.mode === undefined &&
635
- pending.skipPermissions === undefined &&
636
- pending.defaultModel === undefined &&
637
- pending.defaultThinking === undefined
638
- )
789
+ if (Object.keys(pending).length === 0) return;
790
+
791
+ if (pending.mode === "plan" && ctx.engine === "acp") {
792
+ ui.notify("the ACP engine has no plan mode (RC01). Switch /agy engine stream-json first.", "warning");
639
793
  return;
794
+ }
640
795
 
641
796
  try {
642
797
  const next = saveConfig(pending);
@@ -647,6 +802,9 @@ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promis
647
802
  : null,
648
803
  pending.defaultModel !== undefined ? `tool model=${next.defaultModel}` : null,
649
804
  pending.defaultThinking !== undefined ? `tool thinking=${next.defaultThinking}` : null,
805
+ pending.bridgeTools !== undefined ? `bridge=${next.bridgeTools}` : null,
806
+ pending.digest !== undefined ? `digest=${next.digest ? "on" : "off"}` : null,
807
+ pending.systemPrompt !== undefined ? `system-prompt=${next.systemPrompt ? "on" : "off"}` : null,
650
808
  ]
651
809
  .filter(Boolean)
652
810
  .join(", ");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estebanforge/pi-antigravity-bridge",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "description": "Gemini provider for Pi on the Antigravity ACP server (official Google ACP) or the stream-json agy CLI. antigravity/* models in Pi's /model picker, no-patch MCP bridge: agy runs Pi's tools. ToS safe to use.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -59,9 +59,10 @@
59
59
  }
60
60
  },
61
61
  "devDependencies": {
62
- "@earendil-works/pi-ai": "^0.84.3",
63
- "@earendil-works/pi-coding-agent": "^0.84.3",
64
- "@earendil-works/pi-tui": "^0.84.3",
62
+ "@earendil-works/pi-ai": "^0.85.0",
63
+ "@earendil-works/pi-coding-agent": "^0.85.0",
64
+ "@earendil-works/pi-server": "^0.85.0",
65
+ "@earendil-works/pi-tui": "^0.85.0",
65
66
  "@types/node": "^22.0.0",
66
67
  "tsx": "^4.19.0",
67
68
  "typebox": "^1.1.38",
@@ -11,8 +11,9 @@
11
11
  // the effort tier baked in, e.g. gemini-3.8-flash-low)
12
12
  // - session/cancel returns -32601 on RC01 (not implemented) — the driver
13
13
  // treats that as "cancel unsupported" and falls back to teardown+kill
14
- // - session/request_permission is answered in-connection: single `auto`
15
- // policy, first allow option selected (plan §9.3)
14
+ // - session/request_permission is answered in-connection: policy per turn
15
+ // (skipPermissions on -> first allow option; off -> first reject option,
16
+ // fail-closed)
16
17
  // - session/load replays history as notifications BEFORE its response; the
17
18
  // driver suppresses updates while the load is in flight
18
19
  // - mcpServers entries: {name, type:"http", url, headers:[]} — headers is a
@@ -58,6 +59,10 @@ export interface AcpConnectionOptions {
58
59
  /** mcpServers entries for session/new AND session/load (run 6: load takes
59
60
  * the same param). Evaluated lazily per call. */
60
61
  mcpServers?: () => AcpMcpServer[];
62
+ /** Permission policy for session/request_permission, evaluated per request:
63
+ * "auto" selects the first allow option; "deny" fail-closes to the first
64
+ * reject option. Absent = deny (fail closed). */
65
+ permissions?: () => "auto" | "deny";
61
66
  }
62
67
 
63
68
  const INIT_TIMEOUT_MS = 30_000;
@@ -114,6 +119,13 @@ export class AcpConnection {
114
119
  this.#child = child;
115
120
  child.stdout?.setEncoding("utf8");
116
121
  child.stderr?.setEncoding("utf8");
122
+ // Pipe failures arrive asynchronously as stream 'error' events; the sync
123
+ // try/catch in #write cannot see them. Without this listener an EPIPE
124
+ // (server died mid-handshake) is uncaught and kills pi.
125
+ child.stdin?.on("error", (err) => {
126
+ this.#opts.log("stdin-error", { message: err.message });
127
+ if (!this.#exited && !this.#killed) this.#finish(err.message);
128
+ });
117
129
 
118
130
  const rpc = new JsonRpcSession({
119
131
  send: (frame) => this.#write(frame),
@@ -253,9 +265,10 @@ export class AcpConnection {
253
265
  await this.guarded("session/close", { sessionId }, SESSION_OP_TIMEOUT_MS);
254
266
  }
255
267
 
256
- /** Protocol-level abort: single `auto` policy (plan §9.3) — select the
257
- * first allow option. Never hangs: options are data from the server and
258
- * the answer is computed synchronously. */
268
+ /** Protocol-level permission answering: policy from the driver ("auto"
269
+ * selects the first allow option; "deny" fail-closes to the first reject
270
+ * option). Never hangs: options are data from the server and the answer
271
+ * is computed synchronously. */
259
272
  #onServerRequest(method: string, params: unknown): Promise<unknown> {
260
273
  if (method === "session/request_permission") {
261
274
  const options = (
@@ -263,9 +276,10 @@ export class AcpConnection {
263
276
  ) as Array<{ optionId?: string; kind?: string }> | undefined;
264
277
  const allow = options?.find((o) => typeof o.kind === "string" && o.kind.startsWith("allow"));
265
278
  const deny = options?.find((o) => typeof o.kind === "string" && o.kind.startsWith("reject"));
266
- const chosen = allow ?? deny ?? options?.[0];
267
- this.#opts.log("permission", { optionId: chosen?.optionId ?? "allow", policy: "auto" });
268
- return Promise.resolve({ outcome: { outcome: "selected", optionId: chosen?.optionId ?? "allow" } });
279
+ const policy = this.#opts.permissions?.() ?? "deny";
280
+ const chosen = policy === "auto" ? (allow ?? deny ?? options?.[0]) : (deny ?? options?.[0]);
281
+ this.#opts.log("permission", { optionId: chosen?.optionId, policy });
282
+ return Promise.resolve({ outcome: { outcome: "selected", optionId: chosen?.optionId } });
269
283
  }
270
284
  // fs/* and terminal/* are declined: our client capabilities are off and
271
285
  // agy keeps executing its own tools (plan §8 capability posture).
package/src/acp/driver.ts CHANGED
@@ -34,8 +34,10 @@ import type {
34
34
  const LIFECYCLE_LIMIT = 24;
35
35
 
36
36
  export interface AcpDriverOptions {
37
- /** Config acp.bin value (may be empty). Env AGY_ACP_BIN wins. */
38
- bin: string;
37
+ /** Config acp.bin value (may be empty). Env AGY_ACP_BIN wins. A function
38
+ * is resolved per connection: setup can install the binary and update
39
+ * config mid-session, and the next turn picks it up without a restart. */
40
+ bin: string | (() => string);
39
41
  /** Extra argv for the binary (tests: node + fake-server script). */
40
42
  binArgs?: string[];
41
43
  extraEnv?: Record<string, string>;
@@ -88,11 +90,11 @@ export function acpModelSlug(model: string, effort?: string): string {
88
90
  return `${model}-${effort}`;
89
91
  }
90
92
 
91
- /** Map our config knobs onto ACP session modes. Under the single `auto`
92
- * policy the practical effect of the modes converges (everything is
93
- * approved); the mapping keeps the server-side counters honest.
94
- * Known gap: the CLI's `--mode plan` has no ACP equivalent (review 4,
95
- * finding 4) — plan delegations stay on the legacy path. */
93
+ /** Map our config knobs onto ACP session modes. skipPermissions=false also
94
+ * fail-closes the in-connection permission handler (reject options), so the
95
+ * modes keep their server-side meaning. Known gap: the CLI's `--mode plan`
96
+ * has no ACP equivalent (review 4, finding 4) — plan + acp is refused at the
97
+ * command level and fails the turn visibly. */
96
98
  export function acpMode(mode: string, skipPermissions: boolean): string {
97
99
  if (skipPermissions) return "yolo";
98
100
  return mode === "plan" ? "default" : "auto_edit";
@@ -122,7 +124,7 @@ export class AcpDriver implements TurnDriver {
122
124
 
123
125
  constructor(opts: AcpDriverOptions) {
124
126
  this.#opts = opts;
125
- this.#log("driver-created", { bin: resolveAcpBinary(opts.bin) });
127
+ this.#log("driver-created", { bin: typeof opts.bin === "function" ? "(resolved per turn)" : resolveAcpBinary(opts.bin) });
126
128
  }
127
129
 
128
130
  get state(): DriverState {
@@ -417,17 +419,19 @@ export class AcpDriver implements TurnDriver {
417
419
  this.#state = "starting";
418
420
  this.#stats.spawns += 1;
419
421
  const conn = new AcpConnection({
420
- bin: resolveAcpBinary(this.#opts.bin),
422
+ bin: resolveAcpBinary(typeof this.#opts.bin === "function" ? this.#opts.bin() : this.#opts.bin),
421
423
  binArgs: this.#opts.binArgs,
422
424
  extraEnv: this.#opts.extraEnv,
423
425
  cwd: request.cwd,
424
426
  mcpServers: this.#opts.mcpServers,
425
427
  log: (msg, data) => this.#log(msg, data),
428
+ // Fail-closed permissions: only turns with skipPermissions answer allow.
429
+ permissions: () => (this.#active?.request.skipPermissions ? "auto" : "deny"),
426
430
  onUpdate: (sessionId, update) => this.#onConnectionUpdate(sessionId, update),
427
431
  onExit: (info) => this.#onConnectionExit(conn, info),
428
432
  });
429
433
  this.#conn = conn;
430
- this.#log("spawn", { bin: resolveAcpBinary(this.#opts.bin) });
434
+ this.#log("spawn", { bin: resolveAcpBinary(typeof this.#opts.bin === "function" ? this.#opts.bin() : this.#opts.bin) });
431
435
  return conn
432
436
  .start()
433
437
  .then(() => {