@estebanforge/pi-antigravity-bridge 1.4.9 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,10 +17,15 @@
17
17
  // ~/.pi/agent/antigravity-bridge/config.json so toggles survive restarts.
18
18
 
19
19
  import os from "node:os";
20
+ import fs from "node:fs";
21
+ import path from "node:path";
20
22
  import {
21
23
  type ExtensionAPI,
22
24
  type ExtensionCommandContext,
23
25
  type ExtensionUIContext,
26
+ createBashToolDefinition,
27
+ createEditToolDefinition,
28
+ createWriteToolDefinition,
24
29
  getSettingsListTheme,
25
30
  } from "@earendil-works/pi-coding-agent";
26
31
  import {
@@ -38,6 +43,7 @@ import {
38
43
  } from "../src/models.js";
39
44
  import { SessionStore } from "../src/sessions.js";
40
45
  import {
46
+ APPROVAL_PARK_MS,
41
47
  POLL_TOOL_NAME,
42
48
  ToolRoundTrips,
43
49
  WrapperReplay,
@@ -45,16 +51,30 @@ import {
45
51
  formatEscalatedAck,
46
52
  formatPollAnswer,
47
53
  } from "../src/provider.js";
48
- import { AgyDriver } from "../src/driver.js";
54
+ import {
55
+ createShadowTool,
56
+ stripMarkerFields,
57
+ type AnyToolDefinition,
58
+ type GatePolicy,
59
+ } from "../src/approval-gate.js";
60
+ import { detectPermissionGateExtensions, resolveGateMode } from "../src/approval-detect.js";
61
+ import { hookScriptSource, removeGateHooks, stageGateHooks } from "../src/approval-hook.js";
62
+ import { StreamDriver } from "../src/driver.js";
49
63
  import { AcpDriver } from "../src/acp/driver.js";
50
64
  import { runAcpAuth } from "../src/acp/auth.js";
51
65
  import { setupAuthUrlCapture } from "../src/acp/browser-capture.js";
52
66
  import { ensureAcpReady, inspectAcpSetup } from "../src/acp/setup.js";
53
67
  import type { TurnDriver, TurnOutcome } from "../src/driver-types.js";
54
68
  import { CONFIG_PATH, loadConfig, logsDir, saveConfig, type AgyMode, type BridgeTools, type Engine, type ThinkingTier } from "../src/config.js";
69
+ import { agyMissingMessage, isAgyInstalled, savedEngineMessage, showEnginePicker, shouldOfferEnginePicker } from "../src/engine-picker.js";
55
70
  import { createDailyLogger, type DailyLogger } from "../src/daily-log.js";
56
71
  import { registerAskAntigravityTool, toolModelsFromRaw } from "../src/ask-tool.js";
57
72
  import { startMcpServer, TOKEN_HEADER, type McpServerHandle } from "../src/mcp-server.js";
73
+ import {
74
+ registerBridgeServer,
75
+ sweepStaleBridgeServers,
76
+ unregisterBridgeServer,
77
+ } from "../src/mcp-registration.js";
58
78
  import {
59
79
  ACTIVATE_SKILL_TOOL_NAME,
60
80
  activateSkillSchema,
@@ -107,7 +127,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
107
127
  const engine: Engine = loadConfig().engine;
108
128
  // Engine switching requires a restart, so the catalog-time engine read is
109
129
  // authoritative for input advertising: image attach rides only when turns
110
- // will run on the ACP engine (the legacy CLI prompt is text-only).
130
+ // will run on the ACP engine (the stream-json CLI prompt is text-only).
111
131
  const modelInput: Array<"text" | "image"> = engine === "acp" ? ["text", "image"] : ["text"];
112
132
  const models = entries.map((e) => toPiModel(e, modelInput));
113
133
 
@@ -156,9 +176,14 @@ export default async function (pi: ExtensionAPI): Promise<void> {
156
176
  // MCP bridge handle, declared early: the ACP engine reads the bridge port
157
177
  // at session/new / session/load time.
158
178
  let mcpHandle: McpServerHandle | null = null;
179
+ // Approval-gate hook script (per-pid, token embedded). Written at session
180
+ // start when the gate is active; removed at session_shutdown.
181
+ let gateScriptPath: string | null = null;
159
182
  // ACP self-heal runs once per process (session_start re-fires on /reload;
160
183
  // a ready setup is two file stats, so re-running is harmless anyway).
161
184
  let acpSelfHealRan = false;
185
+ // Warn-once-per-process flag for the missing-agy-CLI toast (stream-json).
186
+ let agyMissingWarned = false;
162
187
  // OAuth URL capture: the server hands the login URL only to the
163
188
  // browser-open call (nothing on stdio), so a BROWSER wrapper records it
164
189
  // and the driver logs it as "auth-url". Local users keep the automatic
@@ -184,11 +209,11 @@ export default async function (pi: ExtensionAPI): Promise<void> {
184
209
  "session-load-failed-creating-fresh", "connection-exited", "cancel-failed",
185
210
  "unsupported-server-request",
186
211
  ]);
187
- const legacyDriver = new AgyDriver();
188
- // Mirror the legacy driver's lifecycle ring into the daily file log
212
+ const streamDriver = new StreamDriver();
213
+ // Mirror the stream driver's lifecycle ring into the daily file log
189
214
  // (spawn/exit/abort/stall/recycle). The ACP driver reaches the same file
190
215
  // through acpLog below.
191
- legacyDriver.log = (msg, data) => {
216
+ streamDriver.log = (msg, data) => {
192
217
  // Level classification mirrors acpLog's failure set: stalls, aborts,
193
218
  // timeouts and nonzero exits are the "what broke" greps (warn);
194
219
  // turn-start is the per-turn skeleton (info); everything else is
@@ -254,13 +279,17 @@ export default async function (pi: ExtensionAPI): Promise<void> {
254
279
  // Resolved per connection: the setup flow can install the binary and
255
280
  // update acp.bin mid-session; the next turn picks it up (no restart).
256
281
  bin: () => loadConfig().acp.bin,
282
+ // Resolved per turn: /agy and AGY_USAGE_ESTIMATE changes apply without
283
+ // a restart. Without this the driver defaults to "estimate" and the
284
+ // config knob (incl. "off") is dead.
285
+ usageEstimate: () => loadConfig().acp.usageEstimate,
257
286
  ...(authCapture ? { extraEnv: authCapture.browserEnv, authUrlFile: authCapture.file } : {}),
258
287
  log: acpLog,
259
288
  mcpServers: () => {
260
289
  const handle = mcpHandle;
261
290
  if (!handle) return [];
262
291
  // The bridge 403s any request without the shared-secret header; the
263
- // legacy engine carries it via mcp_config.json, ACP via headers[].
292
+ // stream engine carries it via mcp_config.json, ACP via headers[].
264
293
  return [
265
294
  {
266
295
  name: "pi-bridge",
@@ -272,15 +301,23 @@ export default async function (pi: ExtensionAPI): Promise<void> {
272
301
  },
273
302
  });
274
303
  // The active engine is resolved from the latched load-time value.
275
- const activeDriver = (): TurnDriver => (engine === "acp" ? acpDriver : legacyDriver);
276
- // The provider's stream-json slot gets the LEGACY driver explicitly - never
304
+ const activeDriver = (): TurnDriver => (engine === "acp" ? acpDriver : streamDriver);
305
+ // The provider's stream-json slot gets the STREAM driver explicitly - never
277
306
  // activeDriver(), or a load-time acp engine would make deps.driver and
278
307
  // deps.acpDriver the same object and break the engine identity check.
279
- const driver = legacyDriver;
308
+ const driver = streamDriver;
280
309
  // The no-patch pi-tool round-trip store: the MCP bridge parks calls here;
281
310
  // the provider emits them as real pi toolUse turns and completes them from
282
311
  // the next call's toolResult.
283
- const roundTrips = new ToolRoundTrips(activeDriver, (s, d) => fileLog.log(s, d, s === "round-trip-fail" ? "warn" : "debug"));
312
+ const roundTrips = new ToolRoundTrips(
313
+ activeDriver,
314
+ (s, d, level) =>
315
+ fileLog.log(
316
+ s,
317
+ d,
318
+ level ?? (s === "round-trip-fail" ? "warn" : "debug"),
319
+ ),
320
+ );
284
321
  const replay = new WrapperReplay();
285
322
  // Native re-exec only emits for builtins actually active in the session;
286
323
  // anything else (or an unknown name) falls back to the wrapper card.
@@ -306,7 +343,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
306
343
  );
307
344
  roundTrips.failAll("antigravity turn ended with an unresolved pi tool call");
308
345
  };
309
- legacyDriver.onTurnEnd = onTurnEnd;
346
+ streamDriver.onTurnEnd = onTurnEnd;
310
347
  acpDriver.onTurnEnd = onTurnEnd;
311
348
  const streamSimple = createStreamSimple({
312
349
  entries,
@@ -350,6 +387,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
350
387
  acpLog,
351
388
  fileLog,
352
389
  authCapture: authCapture ?? null,
390
+ runAcpPickSetup,
353
391
  });
354
392
 
355
393
  // AskAntigravity tool: one-shot delegation to agy (ported from
@@ -382,12 +420,115 @@ export default async function (pi: ExtensionAPI): Promise<void> {
382
420
  },
383
421
  });
384
422
 
423
+ /** ACP pick follow-through (first-run wizard): the same self-service
424
+ * setup the /agy engine acp command runs, but immediately - the 1.5 GB
425
+ * download starts while the toast is still on screen, progress rides the
426
+ * footer status, and the Google sign-in opens when the install lands.
427
+ * Restart still applies the engine (drivers wire at load); this only
428
+ * removes the wait. Fire-and-forget: the caller already toasted the
429
+ * promise, failures land in the daily log + a warning toast. */
430
+ // eslint-disable-next-line @typescript-eslint/no-inner-declarations -- hoisted: registerAgyCommand below injects it
431
+ async function runAcpPickSetup(ctx: { ui: ExtensionUIContext }): Promise<void> {
432
+ acpSelfHealRan = true;
433
+ let lastPhase = "";
434
+ ctx.ui.setStatus("agy-acp", "downloading ACP server…");
435
+ try {
436
+ const status = await ensureAcpReady({
437
+ configBin: loadConfig().acp.bin,
438
+ onProgress: (m) => {
439
+ // Dual surface: the status bar carries the live percent (cleared
440
+ // on completion, zero footprint); the chat window gets phase
441
+ // milestones only (download start, unpacking, installed) - same
442
+ // line-in-chat feel as other extensions' notify() notices. The
443
+ // percent variant updates every chunk and would spam the chat.
444
+ ctx.ui.setStatus("agy-acp", m);
445
+ if (m !== lastPhase && !/\d+%/.test(m)) {
446
+ ctx.ui.notify(m, "info");
447
+ lastPhase = m;
448
+ }
449
+ },
450
+ });
451
+ ctx.ui.setStatus("agy-acp", undefined);
452
+ fileLog.log(
453
+ "acp-setup",
454
+ status.ok
455
+ ? { ok: true, binarySource: status.binarySource, needsLogin: status.needsLogin }
456
+ : { ok: false, error: status.error },
457
+ status.ok ? "info" : "warn",
458
+ );
459
+ if (!status.ok) {
460
+ ctx.ui.notify(`ACP auto-setup failed (${status.error}).\n${status.manual}`, "warning");
461
+ return;
462
+ }
463
+ // Spread, not a bare acp patch: a bare {bin} patch would drop
464
+ // sibling keys (usageEstimate) from the file.
465
+ saveConfig({ acp: { ...loadConfig().acp, bin: status.bin } });
466
+ if (!status.needsLogin) {
467
+ ctx.ui.notify(`ACP server ready (auth: ${status.auth}). Restart applies the engine.`, "info");
468
+ return;
469
+ }
470
+ ctx.ui.notify(
471
+ "ACP server ready. Signing in: the Google sign-in opens in your browser and completes when you finish it.",
472
+ "info",
473
+ );
474
+ const r = await runAcpAuth({
475
+ bin: status.bin,
476
+ ...(authCapture ? { extraEnv: authCapture.browserEnv, authUrlFile: authCapture.file } : {}),
477
+ log: acpLog,
478
+ });
479
+ fileLog.log("acp-auth", r.ok ? { ok: true } : { ok: false, error: r.error }, r.ok ? "info" : "warn");
480
+ if (r.ok) ctx.ui.notify("Signed in. The ACP engine is ready; restart applies it.", "info");
481
+ else ctx.ui.notify(`ACP sign-in failed (${r.error}).\nRun /agy auth to retry; /agy auth-manual has manual steps.`, "warning");
482
+ } catch (err) {
483
+ ctx.ui.setStatus("agy-acp", undefined);
484
+ fileLog.log("acp-setup", { error: String(err) }, "warn");
485
+ ctx.ui.notify(`ACP setup failed (${String(err)}). /agy auth retries; /agy doctor inspects.`, "warning");
486
+ }
487
+ }
488
+
385
489
  // MCP tool bridge: expose pi's tools to agy over localhost Streamable HTTP.
386
490
  // Calls park in the provider's round-trip store and complete through pi's
387
491
  // normal toolUse loop (native cards, permissions, hooks) - no patch, no
388
492
  // privileged API. Started on session_start, torn down on session_shutdown.
389
- pi.on("session_start", async (_event, ctx) => {
493
+ pi.on("session_start", async (event, ctx) => {
390
494
  if (ctx.hasUI) activeUi = ctx.ui;
495
+ // First-run engine picker: ask once, on the first interactive start,
496
+ // which turn engine to use. Skipped headless (ctx.mode !== "tui"),
497
+ // when AGY_ENGINE is set, or once any config file exists (any save -
498
+ // even of an unrelated knob - means the user has been here before).
499
+ // esc = decide later: nothing is written, the picker reappears next
500
+ // start. Like /agy engine, the choice applies on the next start
501
+ // (drivers wire at load). The await intentionally runs before the
502
+ // bridge startup below: on a genuine first run the modal blocks input
503
+ // anyway, so the delay is invisible.
504
+ if (event.reason === "startup" && ctx.mode === "tui" && shouldOfferEnginePicker(CONFIG_PATH)) {
505
+ // Best-effort, like the legacy-patch notice below: a picker failure
506
+ // (mid-prompt TUI teardown, resize races) must never take down the
507
+ // rest of session_start - the MCP bridge startup included. The
508
+ // default engine keeps working untouched.
509
+ try {
510
+ const picked = await showEnginePicker(ctx.ui);
511
+ if (picked) {
512
+ saveConfig({ engine: picked });
513
+ ctx.ui.notify(savedEngineMessage(picked), "info");
514
+ if (picked === "acp") void runAcpPickSetup(ctx);
515
+ }
516
+ } catch (err) {
517
+ fileLog.log("engine-picker", { error: String(err) }, "warn");
518
+ console.error(`[antigravity-bridge] engine picker failed: ${String(err)}`);
519
+ }
520
+ }
521
+ // agy presence check (stream-json engine): the CLI is the whole engine,
522
+ // so a missing binary means every Antigravity turn would fail. Warn on
523
+ // every process start until it is installed (per-process flag so /new,
524
+ // /resume and /reload re-fires do not nag mid-session). Runs after the
525
+ // picker above, so a first-run stream-json pick warns immediately.
526
+ if (engine === "stream-json" && !agyMissingWarned && !isAgyInstalled(binary)) {
527
+ agyMissingWarned = true;
528
+ const msg = agyMissingMessage();
529
+ if (ctx.hasUI) ctx.ui.notify(msg, "warning");
530
+ else console.error(`[antigravity-bridge] ${msg}`);
531
+ }
391
532
  // Legacy cleanup: users who ran the old consent-gated patcher still
392
533
  // carry pi.invokeTool in their installed pi. Inert, but tell them once
393
534
  // and offer /agy patch-cleanup. Never auto-edits the install.
@@ -421,7 +562,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
421
562
  );
422
563
  if (status.ok) {
423
564
  if (status.binarySource === "installed" || status.binarySource === "existing") {
424
- saveConfig({ acp: { bin: status.bin, permissions: loadConfig().acp.permissions } });
565
+ saveConfig({ acp: { ...loadConfig().acp, bin: status.bin } });
425
566
  }
426
567
  if (status.needsLogin) {
427
568
  const msg = acpLoginPending();
@@ -557,9 +698,113 @@ export default async function (pi: ExtensionAPI): Promise<void> {
557
698
  isError: !skill,
558
699
  });
559
700
  };
560
- const r = await startMcpServer({ listTools, onToolCall: bridgeOnToolCall }, { log: mcpLog });
701
+ const r = await startMcpServer(
702
+ {
703
+ listTools,
704
+ onToolCall: bridgeOnToolCall,
705
+ onApproval: (ticket, payload) => roundTrips.onApproval(ticket, payload),
706
+ },
707
+ { log: mcpLog },
708
+ );
561
709
  if (r.ok && r.handle) {
562
710
  mcpHandle = r.handle;
711
+ // Stream-json engine registration: the agy CLI discovers MCP servers
712
+ // from ~/.gemini/config/mcp_config.json (ACP uses session/new
713
+ // mcpServers instead; verified live 2026-09-07). Per-pid entry,
714
+ // removed at session_shutdown; stale entries swept at start.
715
+ sweepStaleBridgeServers();
716
+ registerBridgeServer({
717
+ pid: process.pid,
718
+ port: r.handle.port,
719
+ token: r.handle.token,
720
+ tokenHeader: TOKEN_HEADER,
721
+ });
722
+ // --- Approval gate (docs/TODO.md 2.5) ------------------------------
723
+ // agy native tool calls pass through a pi-side approval: a PreToolUse
724
+ // hook parks in the bridge, the provider emits a shadow toolUse, and
725
+ // pi's permission extensions gate it like any native call. Off until
726
+ // enabled (auto = on only when a third-party gate extension exists).
727
+ {
728
+ const cfg = loadConfig();
729
+ const mode = resolveGateMode(cfg.approvals.gateMode, detectPermissionGateExtensions());
730
+ if (mode === "dedicated") {
731
+ // The explicit antigravity_approve variant is planned; until it
732
+ // ships, dedicated stages the same shadow tools. Say so, so the
733
+ // config value never lies silently.
734
+ fileLog.log("approval-dedicated-as-shadow", {}, "warn");
735
+ }
736
+ if (mode === "off") {
737
+ // Gate off: remove ONLY this session's group. Other sessions'
738
+ // groups in a shared workspace are never touched - a gate-off
739
+ // session must not strip a gate-on session's matchers.
740
+ const unstaged = removeGateHooks(process.cwd());
741
+ if (unstaged.wrote) fileLog.log("approval-unstaged", unstaged, "info");
742
+ } else {
743
+ // Script: per-pid file; 0600 because the bridge token is
744
+ // embedded (peer review 2026-09-07).
745
+ const scriptPath = path.join(logsDir(), `approval-hook-${process.pid}.js`);
746
+ fs.mkdirSync(path.dirname(scriptPath), { recursive: true, mode: 0o700 });
747
+ fs.writeFileSync(
748
+ scriptPath,
749
+ hookScriptSource({
750
+ port: r.handle.port,
751
+ token: r.handle.token,
752
+ deadlineMs: APPROVAL_PARK_MS,
753
+ }),
754
+ { mode: 0o600 },
755
+ );
756
+ gateScriptPath = scriptPath;
757
+ const staged = stageGateHooks(process.cwd(), {
758
+ port: r.handle.port,
759
+ token: r.handle.token,
760
+ scriptPath,
761
+ parkBudgetMs: APPROVAL_PARK_MS,
762
+ });
763
+ fileLog.log("approval-staged", { mode, script: scriptPath, ...staged }, staged.wrote ? "info" : "debug");
764
+
765
+ // Shadow bases: factory twins of pi's own builtins (public API).
766
+ // pi.getAllTools() is unusable here: it returns ToolInfo, which
767
+ // strips execute. Marker calls never execute; non-marker calls
768
+ // delegate to the twins, so behavior matches the standard
769
+ // builtins (session-level bash-operations overrides are not
770
+ // inherited; documented in README).
771
+ const gateCwd = process.cwd();
772
+ const bases: Record<string, AnyToolDefinition> = {
773
+ bash: createBashToolDefinition(gateCwd) as unknown as AnyToolDefinition,
774
+ write: createWriteToolDefinition(gateCwd) as unknown as AnyToolDefinition,
775
+ edit: createEditToolDefinition(gateCwd) as unknown as AnyToolDefinition,
776
+ };
777
+ const askMode = cfg.approvals.mode;
778
+ const policy: GatePolicy = async ({ tool, params, ctx }) => {
779
+ if (askMode === "allow") return { allow: true };
780
+ if (askMode === "deny") {
781
+ return { allow: false, reason: `blocked by approval gate (mode: deny): ${tool}` };
782
+ }
783
+ const extCtx = ctx as { hasUI?: boolean; ui?: Pick<ExtensionUIContext, "confirm"> } | undefined;
784
+ if (!extCtx?.hasUI || typeof extCtx.ui?.confirm !== "function") {
785
+ return { allow: false, reason: `approval gate: no UI to approve ${tool} (headless)` };
786
+ }
787
+ const what =
788
+ typeof params.command === "string"
789
+ ? params.command
790
+ : typeof params.path === "string"
791
+ ? params.path
792
+ : JSON.stringify(stripMarkerFields(params)).slice(0, 200);
793
+ const ok = await extCtx.ui.confirm(`agy ${tool}?`, what, { timeout: APPROVAL_PARK_MS });
794
+ return ok ? { allow: true } : { allow: false, reason: `declined in pi (agy ${tool})` };
795
+ };
796
+ const handle = r.handle;
797
+ for (const [name, base] of Object.entries(bases)) {
798
+ pi.registerTool(
799
+ createShadowTool(base, policy, { verifyTicket: (t) => handle.approvals.has(t) }),
800
+ );
801
+ }
802
+ // The park is wired ONLY after the shadows are registered: an
803
+ // approval toolUse must never dispatch to the REAL builtin and
804
+ // execute locally.
805
+ roundTrips.approvalPark = handle.approvals;
806
+ }
807
+ }
563
808
  } else {
564
809
  console.error(`[antigravity-bridge] MCP tool bridge disabled: ${r.reason}`);
565
810
  }
@@ -572,8 +817,29 @@ export default async function (pi: ExtensionAPI): Promise<void> {
572
817
  mcpHandle = null;
573
818
  await h?.close();
574
819
  roundTrips.failAll("antigravity session shut down");
575
- await legacyDriver.close("shutdown");
576
- await acpDriver.close("shutdown");
820
+ // "recycle", NOT "shutdown": pi fires session_shutdown on /new, /resume
821
+ // and /fork (docs/extensions.md session lifecycle), not only on process
822
+ // exit. The drivers are process-lifetime singletons; closing them with
823
+ // "shutdown" latched them permanently and every later turn failed with
824
+ // "ACP driver is shut down." (regression 2026-09-07). Recycle kills the
825
+ // connection now; the next turn respawns it. event.reason is
826
+ // deliberately ignored: recycle is correct even on real process exit
827
+ // ("quit") - the connection kill is identical and nothing runs after.
828
+ await streamDriver.close("recycle", "session shutdown");
829
+ await acpDriver.close("recycle", "session shutdown");
830
+ unregisterBridgeServer(process.pid);
831
+ // Approval gate: unstage hooks and remove the per-pid script. Pending
832
+ // approvals already failed closed via handle close (bridge shutdown deny).
833
+ const unstaged = removeGateHooks(process.cwd());
834
+ if (unstaged.wrote) fileLog.log("approval-unstaged", unstaged, "info");
835
+ if (gateScriptPath) {
836
+ try {
837
+ fs.rmSync(gateScriptPath, { force: true });
838
+ } catch {
839
+ /* best effort */
840
+ }
841
+ gateScriptPath = null;
842
+ }
577
843
  });
578
844
  }
579
845
 
@@ -600,6 +866,9 @@ interface AgyCommandCtx {
600
866
  acpLog: (msg: string, data?: unknown) => void;
601
867
  /** Daily file logger (src/daily-log.ts); command + doctor surfacing. */
602
868
  fileLog: DailyLogger;
869
+ /** Wizard-pick follow-through (download now + chained sign-in); reused
870
+ * by /agy engine's no-args modal so both entry points behave alike. */
871
+ runAcpPickSetup: (cmdCtx: { ui: ExtensionUIContext }) => Promise<void>;
603
872
  /** BROWSER-capture handles; null when unavailable (Windows, unwritable
604
873
  * data dir). /agy auth passes them to the sign-in process. */
605
874
  authCapture: { browserEnv: Record<string, string>; file: string } | null;
@@ -711,7 +980,7 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
711
980
  ui?.notify(`ACP auto-setup failed (${status.error}).\n${status.manual}`, "warning");
712
981
  return;
713
982
  }
714
- saveConfig({ acp: { bin: status.bin, permissions: loadConfig().acp.permissions } });
983
+ saveConfig({ acp: { ...loadConfig().acp, bin: status.bin } });
715
984
  if (status.needsLogin) {
716
985
  ui?.notify(
717
986
  `ACP engine set. ${acpLoginPending()}`,
@@ -720,6 +989,28 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
720
989
  } else {
721
990
  ui?.notify(`ACP engine ready (auth: ${status.auth}). Takes effect on the next pi start (or /reload).`, "info");
722
991
  }
992
+ } else if (!val && mode === "tui" && ui) {
993
+ // Same modal as the first-run wizard: switching engines deserves
994
+ // the explanations, not a bare usage line. Semantics match the
995
+ // direct path above: plan blocks acp, an acp pick chains setup
996
+ // + sign-in immediately, restart applies the switch.
997
+ const current = loadConfig().engine;
998
+ const picked = await showEnginePicker(ui);
999
+ if (picked === null) {
1000
+ ui.notify(`engine unchanged: ${current}.`, "info");
1001
+ return;
1002
+ }
1003
+ if (picked === current) {
1004
+ ui.notify(`engine is already ${current}. Restart applies it if set this session.`, "info");
1005
+ return;
1006
+ }
1007
+ if (picked === "acp" && loadConfig().mode === "plan") {
1008
+ ui.notify("mode is plan; the ACP engine has no plan mode. /agy mode accept-edits first.", "warning");
1009
+ return;
1010
+ }
1011
+ saveConfig({ engine: picked });
1012
+ ui.notify(savedEngineMessage(picked), "info");
1013
+ if (picked === "acp") void ctx.runAcpPickSetup({ ui });
723
1014
  } else {
724
1015
  ui?.notify(`current engine: ${loadConfig().engine}\nusage: /agy engine stream-json|acp`, "info");
725
1016
  }
@@ -746,7 +1037,7 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
746
1037
  ui?.notify(`ACP auto-setup failed (${status.error}).\n${status.manual}`, "warning");
747
1038
  return;
748
1039
  }
749
- saveConfig({ acp: { bin: status.bin, permissions: loadConfig().acp.permissions } });
1040
+ saveConfig({ acp: { ...loadConfig().acp, bin: status.bin } });
750
1041
  if (!status.needsLogin) {
751
1042
  ui?.notify(`Already signed in (auth: ${status.auth}). Nothing to do.`, "info");
752
1043
  return;
@@ -774,7 +1065,9 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
774
1065
  if (rest.length > 0) {
775
1066
  // Only the keyword compares case-insensitively; the path keeps its case.
776
1067
  const bin = rest.toLowerCase() === "auto" ? "" : rest.replace(/^~(?=\/|$)/, os.homedir());
777
- saveConfig({ acp: { bin, permissions: loadConfig().acp.permissions } });
1068
+ // Spread, not a bare acp patch: a bare {bin, permissions} object
1069
+ // would drop sibling keys (usageEstimate) from the file.
1070
+ saveConfig({ acp: { ...loadConfig().acp, bin } });
778
1071
  ui?.notify(
779
1072
  bin
780
1073
  ? `acp.bin set to ${bin}. The next ACP turn (re)connects with it.`
@@ -844,6 +1137,8 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
844
1137
  // line the day it starts (then real usage mapping is worth wiring).
845
1138
  if (snap.acp.usageSeen) {
846
1139
  lines.push(" acp tokens: AVAILABLE in server payloads (wire real usage mapping next)");
1140
+ } else if (config.acp.usageEstimate !== "off") {
1141
+ lines.push(` acp tokens: ESTIMATED client-side (mode: ${config.acp.usageEstimate}; auto-off once the server sends real usage)`);
847
1142
  }
848
1143
  }
849
1144
  if (snap.lifecycle.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estebanforge/pi-antigravity-bridge",
3
- "version": "1.4.9",
3
+ "version": "1.5.0",
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",