@estebanforge/pi-antigravity-bridge 1.8.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,8 +2,32 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 1.8.1 — 2026-10-13
6
+
5
7
  ## [Unreleased]
6
8
 
9
+ ### Changed
10
+
11
+ - **The engine picker rides the shared dialog kit.** `showEnginePicker` now renders through `@estebanforge/pi-tui-dialogs`' new `dialogChoice` primitive (extracted from this picker) instead of a hand-rolled SelectList overlay: same title, intro, options, preselect, and esc-means-decide-later semantics, now drawn in the kit's framed style. Esc resolves `undefined` instead of `null`; both call sites are updated.
12
+
13
+ ### Fixed
14
+
15
+ - **An unrelated config save no longer promotes a legacy install to the acp default.** `saveConfig` merged `DEFAULTS` over the raw file, so any save (mode, digest, the patch-cleanup notice) on a config file predating the engine key wrote `engine: "acp"` to disk, defeating the legacy continuity rule on the next start. `saveConfig` now persists the resolved legacy engine (`stream-json`) for key-less files, honors explicit `patch.engine`, and never bakes `AGY_ENGINE` into the file.
16
+
17
+ - **The choice modal windows its option list.** `dialogChoice` computed its SelectList window at construction and handed the frame the full list, so on short terminals or with a long description the frame clipped trailing options while the selection could still move into the invisible region. The list viewport now follows the frame height per render (the settings menu's rule), with room for SelectList's `(n/m)` scroll-position line, and the tests pin a 10-option dialog on a 12-row terminal.
18
+
19
+ - **New installs default to the ACP engine; stream-json is the fallback.** `config.engine` unset (no file key, no `AGY_ENGINE`) now resolves to `acp` (`DEFAULT_ENGINE` in config.ts), and stale/garbage stored values fall back to that same default instead of stream-json. One carve-out for continuity: an existing config file that predates the engine key (no `engine` field at all) keeps `stream-json` — the acp default is for new installs, and an existing install must not silently start the ~1.5 GB binary download. The first-run picker preselects acp and explains the trade in that order; the existing startup self-heal covers the rest (binary install + sign-in chain on engine acp). Both engines stay fully supported and every existing config with a saved engine is untouched.
20
+
21
+ - **The `/agy` settings picker refuses plan against the on-disk engine too.** The plan-mode guard checked only the session-latched engine; since the Switch engine row can change the on-disk engine mid-session without a restart, it now blocks on either (latched or saved) so plan + ACP can never land on disk through the settings surface.
22
+
23
+ ### Added
24
+
25
+ - **The `/agy` settings picker has a Switch engine row again** (first row, showing the active engine as its value). It is deliberately not a cycle row: Enter on it closes settings and opens the same picker modal `/agy engine` uses — explanations, plan-blocks-acp guard, and the acp pick's self-service setup + sign-in chain all come from the one shared path. Esc in the picker changes nothing.
26
+
27
+ ### Fixed
28
+
29
+ - **ACP sign-in no longer fails on servers that authenticate without writing `acp_token.json`.** Server 1.1.1 can answer `authenticate` OK from legacy `~/.gemini` credential artifacts (observed on darwin/arm64: instant OK, no browser, no token file, working turns) — but `/agy auth` treated the absent token file as a distinct failure and the session-start self-heal nagged login-pending forever on a fully working setup. The token file is a server-internal detail, not a contract. `/agy auth` now verifies the token-absent branch with a live `session/new` probe on the same connection: probe success is a real sign-in (result carries a note, the success toast says so), probe failure is an honest "no usable credentials" error that covers the lazily started browser flow. Both verified paths record a bridge-side marker (`~/.pi/agent/antigravity-bridge/acp-auth-verified.json`, a boolean plus timestamp, never a credential); `ensureAcpReady` treats the marker as login-done, `/agy doctor` shows `oauth-personal (verified, no token file)`, and any server-side `-32000` clears the marker so a stale one never masks a real logout.
30
+
7
31
  ## [1.8.0] - 2026-10-12
8
32
 
9
33
  ### Changed
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-antigravity-bridge
2
2
 
3
- A Gemini model provider **and** the `AskAntigravity` delegation tool for [pi](https://github.com/earendil-works/pi), built on Google's official Antigravity binaries: the `agy` CLI by default, or **Google's official ACP server** (opt-in). It registers `antigravity/gemini-*` models in pi's `/model` picker (streaming), and provides the `AskAntigravity` tool for one-shot delegation - the same combined shape as `pi-claude-bridge`.
3
+ A Gemini model provider **and** the `AskAntigravity` delegation tool for [pi](https://github.com/earendil-works/pi), built on Google's official Antigravity binaries: **Google's official ACP server** by default (new installs), with the `agy` CLI engine kept as the fallback. It registers `antigravity/gemini-*` models in pi's `/model` picker (streaming), and provides the `AskAntigravity` tool for one-shot delegation - the same combined shape as `pi-claude-bridge`.
4
4
 
5
5
 
6
6
  If you also have [`@estebanforge/pi-ask-antigravity`](https://github.com/EstebanForge/pi-extensions/tree/main/packages/pi-ask-antigravity) installed, this bridge takes over: pi-ask-antigravity detects the bridge and registers nothing, so the `AskAntigravity` tool is never duplicated.
@@ -13,12 +13,12 @@ Multi-turn works. The provider binds a pi session to an agy conversation id (per
13
13
 
14
14
  ### Two engines
15
15
 
16
- Turns run through one of two engines behind the same provider surface (`config.engine`, default `stream-json`):
16
+ Turns run through one of two engines behind the same provider surface (`config.engine`, default `acp`):
17
17
 
18
- - **stream-json** (default): the persistent `agy` CLI process. The tested default; live token usage; conversation resume via `--conversation`.
19
- - **acp** (beta): Google's official ACP server (`agy_acp_server.par`), JSON-RPC 2.0 over stdio. Beta: parity-verified live against the current build (RC01) - text streaming, multi-turn resume via `session/load`, bridge tools, effort switching, serialization, abort recovery (see `scripts/parity-live.mjs`). Two known RC01 gaps remain: no usage fields (token display runs on live client-side estimates via `acp.usageEstimate`, default `estimate`, streamed per delta and superseded automatically the day the server starts sending real per-turn usage) and no cancel (abort tears the server down and reloads it next turn).
18
+ - **acp** (default): Google's official ACP server (`agy_acp_server.par`), JSON-RPC 2.0 over stdio. Parity-verified live against the current build (RC01) - text streaming, multi-turn resume via `session/load`, bridge tools, effort switching, serialization, abort recovery (see `scripts/parity-live.mjs`). Two known RC01 gaps remain: no usage fields (token display runs on live client-side estimates via `acp.usageEstimate`, default `estimate`, streamed per delta and superseded automatically the day the server starts sending real per-turn usage) and no cancel (abort tears the server down and reloads it next turn).
19
+ - **stream-json** (fallback): the persistent `agy` CLI process. Live token usage; conversation resume via `--conversation`.
20
20
 
21
- The choice of engine is left to the user, with the trade-offs explained in the tool: a first-run picker modal asks once on a fresh install (stream-json preselected; `esc` defers, and the modal reappears next start), and `/agy engine` with no arguments reopens it anytime. An `acp` pick downloads the ~1.5 GB server binary and starts the Google sign-in immediately; a restart applies the engine. With stream-json active and the `agy` binary missing, pi warns on every start until the binary is found.
21
+ The choice of engine is left to the user, with the trade-offs explained in the tool: a first-run picker modal asks once on a fresh install (acp preselected; `esc` defers, and the modal reappears next start), and `/agy engine` with no arguments reopens it anytime. An `acp` pick downloads the ~1.5 GB server binary and starts the Google sign-in immediately; a restart applies the engine. Fresh installs default to acp and the startup self-heal installs the binary + chains the sign-in; with stream-json active and the `agy` binary missing, pi warns on every start until the binary is found.
22
22
 
23
23
  Full capability comparison, switching, and setup/auth details: [docs/ENGINES.md](docs/ENGINES.md).
24
24
 
@@ -147,7 +147,7 @@ The `activate_skill` catalog mirrors pi's directory-based skill discovery: the t
147
147
  /agy tools [hide|show <name>|reset] session-only bridge-catalog hides (multi-word names OK; reset restores the full set; bare lists exposed + hidden; never touches saved config)
148
148
  /agy web on|off register the agy_web_search + agy_read_url pi tools for ANY provider's model (default off; applies on the next pi start or /reload; every call spawns agy and spends Antigravity quota)
149
149
  /agy acp-bin <path|auto> point the ACP engine at a specific server binary (auto = setup installs, or AGY_ACP_BIN; applies on the next ACP turn)
150
- /agy engine acp|stream-json switch the turn engine (restart to apply; default stream-json; acp is beta and runs self-service setup: binary install + auth bootstrap). No arguments opens the engine picker modal (TUI)
150
+ /agy engine acp|stream-json switch the turn engine (restart to apply; default acp, stream-json is the fallback; acp runs self-service setup: binary install + auth bootstrap). No arguments opens the engine picker modal (TUI)
151
151
  /agy auth-manual manual ACP credential setup (fallback; auto-setup normally covers this; default login = your Antigravity subscription, same account as the agy CLI)
152
152
  /agy clear drop all session bindings (force fresh conversations)
153
153
  ```
@@ -58,11 +58,12 @@ import { hookScriptSource, removeGateHooks, stageGateHooks, sweepWorkspaceGateGr
58
58
  import { StreamDriver } from "../src/driver.js";
59
59
  import { AcpDriver } from "../src/acp/driver.js";
60
60
  import { runAcpAuth } from "../src/acp/auth.js";
61
+ import { setAuthInvalidatedHook } from "../src/acp/connection.js";
61
62
  import { setupAuthUrlCapture } from "../src/acp/browser-capture.js";
62
- import { ensureAcpReady, inspectAcpSetup } from "../src/acp/setup.js";
63
+ import { clearAcpAuthVerified, ensureAcpReady, inspectAcpSetup } from "../src/acp/setup.js";
63
64
  import type { TurnDriver, TurnOutcome } from "../src/driver-types.js";
64
65
  import { CONFIG_PATH, loadConfig, logsDir, MAX_TURN_CAP_MIN, parseCapMinutes, saveConfig, type AgyMode, type BridgeTools, type Engine, type ThinkingTier } from "../src/config.js";
65
- import { agyMissingMessage, isAgyInstalled, savedEngineMessage, showEnginePicker, shouldOfferEnginePicker } from "../src/engine-picker.js";
66
+ import { agyMissingMessage, ENGINE_SETTINGS_ROW_ID, engineSettingsRow, isAgyInstalled, savedEngineMessage, showEnginePicker, shouldOfferEnginePicker } from "../src/engine-picker.js";
66
67
  import { checkAgyCliVersion, describeAgyVersionCheck, MIN_AGY_VERSION } from "../src/agy-version.js";
67
68
  import { isValidAgyAgentName, listAgyAgents } from "../src/agents.js";
68
69
  import { formatSubagentRoster, SubagentRoster } from "../src/subagent-roster.js";
@@ -98,6 +99,11 @@ import { Type } from "typebox";
98
99
  import { patchStatus, restorePatch } from "../src/patch-cleanup.js";
99
100
  import { withDialogLock } from "../src/dialog-lock.js";
100
101
 
102
+ // The server disowning our verified marker (-32000 anywhere: turn, session
103
+ // new/load, cancel) must drop it, or ensureAcpReady keeps reporting a login
104
+ // that no longer exists. Wire once at load; the hook is a no-op until set.
105
+ setAuthInvalidatedHook(() => clearAcpAuthVerified());
106
+
101
107
  // Last UI seen (session_start / /agy commands). The ACP login URL arrives
102
108
  // via the driver log sink, which has no command context; the stash lets that
103
109
  // sink toast instead of only logging to stderr. Module scope: both the
@@ -217,9 +223,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
217
223
  // login URLs would silently stop appearing on headless boxes.
218
224
  console.error("[antigravity-bridge] OAuth URL capture unavailable; login URL surfacing is off (setup failed).");
219
225
  }
220
- // Two turn engines behind one contract (plan §9): stream-json (tested
221
- // default) and the official ACP server (opt-in via config.engine, off by
222
- // default). Neither spawns anything until its first turn.
226
+ // Two turn engines behind one contract (plan §9): the official ACP
227
+ // server (default for new installs) and the stream-json `agy` CLI
228
+ // (fallback). Neither spawns anything until its first turn.
223
229
  // ACP log routing: only genuine failures reach stderr. Routine lifecycle
224
230
  // (driver-created, spawn, session-new, ...) stays in the driver's
225
231
  // #lifecycle ring buffer, visible via /agy doctor. An unfiltered sink fired
@@ -605,8 +611,12 @@ export default async function (pi: ExtensionAPI): Promise<void> {
605
611
  log: acpLog,
606
612
  });
607
613
  fileLog.log("acp-auth", r.ok ? { ok: true } : { ok: false, error: r.error }, r.ok ? "info" : "warn");
608
- if (r.ok) ctx.ui.notify("Signed in. The ACP engine is ready; restart applies it.", "info");
609
- else ctx.ui.notify(`ACP sign-in failed (${r.error}).\nRun /agy auth to retry; /agy auth-manual has manual steps.`, "warning");
614
+ if (r.ok) {
615
+ ctx.ui.notify(
616
+ r.note ? `Signed in (${r.note}). The ACP engine is ready; restart applies it.` : "Signed in. The ACP engine is ready; restart applies it.",
617
+ "info",
618
+ );
619
+ } else ctx.ui.notify(`ACP sign-in failed (${r.error}).\nRun /agy auth to retry; /agy auth-manual has manual steps.`, "warning");
610
620
  } catch (err) {
611
621
  ctx.ui.setStatus("agy-acp", undefined);
612
622
  fileLog.log("acp-setup", { error: String(err) }, "warn");
@@ -1102,7 +1112,7 @@ function statusText(ctx: AgyCommandCtx): string {
1102
1112
  const source = ctx.usingFallback ? "fallback (agy models failed)" : "discovered";
1103
1113
  return [
1104
1114
  "Antigravity bridge",
1105
- ` engine: ${config.engine}${config.engine === "acp" ? " (official server, opt-in)" : ""}`,
1115
+ ` engine: ${config.engine}${config.engine === "stream-json" ? " (agy CLI fallback)" : " (official server)"}`,
1106
1116
  ` models: ${ctx.entries.length} ${source}`,
1107
1117
  ...settingsRows(ctx),
1108
1118
  ` sessions: ${ctx.store.size} bound`,
@@ -1222,23 +1232,7 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
1222
1232
  // the explanations, not a bare usage line. Semantics match the
1223
1233
  // direct path above: plan blocks acp, an acp pick chains setup
1224
1234
  // + sign-in immediately, restart applies the switch.
1225
- const current = loadConfig().engine;
1226
- const picked = await showEnginePicker(ui);
1227
- if (picked === null) {
1228
- ui.notify(`engine unchanged: ${current}.`, "info");
1229
- return;
1230
- }
1231
- if (picked === current) {
1232
- ui.notify(`engine is already ${current}. Restart applies it if set this session.`, "info");
1233
- return;
1234
- }
1235
- if (picked === "acp" && loadConfig().mode === "plan") {
1236
- ui.notify("mode is plan; the ACP engine has no plan mode. /agy mode accept-edits first.", "warning");
1237
- return;
1238
- }
1239
- saveConfig({ engine: picked });
1240
- ui.notify(savedEngineMessage(picked), "info");
1241
- if (picked === "acp") void ctx.runAcpPickSetup({ ui });
1235
+ await runEngineModal(ui, ctx);
1242
1236
  } else {
1243
1237
  ui?.notify(`current engine: ${loadConfig().engine}\nusage: /agy engine stream-json|acp`, "info");
1244
1238
  }
@@ -1282,7 +1276,10 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
1282
1276
  });
1283
1277
  ctx.fileLog.log("acp-auth", r.ok ? { ok: true } : { ok: false, error: r.error }, r.ok ? "info" : "warn");
1284
1278
  if (r.ok) {
1285
- ui?.notify("Signed in. The ACP engine is ready; takes effect on the next pi start (or /reload).", "info");
1279
+ ui?.notify(
1280
+ r.note ? `Signed in (${r.note}). The ACP engine is ready; takes effect on the next pi start (or /reload).` : "Signed in. The ACP engine is ready; takes effect on the next pi start (or /reload).",
1281
+ "info",
1282
+ );
1286
1283
  } else {
1287
1284
  ui?.notify(`ACP sign-in failed (${r.error}).\nRun /agy auth to retry; /agy auth-manual has manual steps.`, "warning");
1288
1285
  }
@@ -1794,14 +1791,17 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
1794
1791
  }
1795
1792
 
1796
1793
  /** Interactive settings picker (TUI only). Rows: the runtime config surface
1797
- * (mode, permissions, model, thinking, bridge, digest, system prompt).
1798
- * Engine switching is command-only: /agy engine stream-json|acp (the
1799
- * command also runs self-service binary + auth setup for acp). */
1794
+ * (mode, permissions, model, thinking, bridge, digest, system prompt) plus
1795
+ * the engine row, which resolves the menu on Enter and opens the same
1796
+ * picker modal as /agy engine (self-service binary + auth setup for acp). */
1800
1797
  async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promise<void> {
1801
1798
  const config = loadConfig();
1802
1799
  const pending: PendingConfig = {};
1803
1800
 
1804
1801
  const rows: DialogRow[] = [
1802
+ // Value-less action row: DialogMenu resolves the menu with this id on
1803
+ // Enter (see runEngineModal below); every other row cycles in place.
1804
+ engineSettingsRow(config.engine),
1805
1805
  {
1806
1806
  id: "mode",
1807
1807
  label: "Execution mode",
@@ -1892,7 +1892,7 @@ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promis
1892
1892
  },
1893
1893
  ];
1894
1894
 
1895
- await ui.custom<void>(
1895
+ const resolved = await ui.custom<string | undefined>(
1896
1896
  (host, theme, _kb, done) => {
1897
1897
  const menu = new DialogMenu(
1898
1898
  host,
@@ -1928,7 +1928,7 @@ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promis
1928
1928
  }
1929
1929
  },
1930
1930
  },
1931
- () => done(),
1931
+ (rowId) => done(rowId),
1932
1932
  );
1933
1933
  return {
1934
1934
  render: (w: number) => menu.render(w),
@@ -1942,14 +1942,18 @@ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promis
1942
1942
  DIALOG_OPTIONS,
1943
1943
  );
1944
1944
 
1945
- if (Object.keys(pending).length === 0) return;
1945
+ if (Object.keys(pending).length === 0) {
1946
+ if (resolved === ENGINE_SETTINGS_ROW_ID) await runEngineModal(ui, ctx);
1947
+ return;
1948
+ }
1946
1949
 
1947
1950
  // The picker cannot switch engines (command-only: /agy engine), but the
1948
- // mode row can still produce plan while the latched engine is acp. Plan
1949
- // cannot be enforced on provider turns on either engine (#1181 + RC01);
1950
- // refuse the combination.
1951
+ // mode row can still produce plan while the engine is acp - either the
1952
+ // session-latched engine or the on-disk one, which the Switch engine row
1953
+ // can change mid-session without a restart. Plan cannot be enforced on
1954
+ // provider turns on either engine (#1181 + RC01); refuse the combination.
1951
1955
  const nextMode = pending.mode ?? config.mode;
1952
- if (nextMode === "plan" && ctx.engine === "acp") {
1956
+ if (nextMode === "plan" && (ctx.engine === "acp" || config.engine === "acp")) {
1953
1957
  ui.notify(
1954
1958
  "plan cannot be enforced on provider turns on either engine (google-antigravity/antigravity-cli#1181; ACP additionally has no plan mode, RC01). /agy mode accept-edits.",
1955
1959
  "warning",
@@ -1983,4 +1987,30 @@ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promis
1983
1987
  "error",
1984
1988
  );
1985
1989
  }
1990
+
1991
+ if (resolved === ENGINE_SETTINGS_ROW_ID) await runEngineModal(ui, ctx);
1992
+ }
1993
+
1994
+ /** The engine-pick modal shared by /agy engine (no args) and the settings
1995
+ * picker's engine row: the same explanations as the first-run wizard and the
1996
+ * same semantics — plan blocks acp, an acp pick chains setup + sign-in
1997
+ * immediately, restart applies the switch. */
1998
+ async function runEngineModal(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promise<void> {
1999
+ const current = loadConfig().engine;
2000
+ const picked = await showEnginePicker(ui);
2001
+ if (picked === undefined) {
2002
+ ui.notify(`engine unchanged: ${current}.`, "info");
2003
+ return;
2004
+ }
2005
+ if (picked === current) {
2006
+ ui.notify(`engine is already ${current}. Restart applies it if set this session.`, "info");
2007
+ return;
2008
+ }
2009
+ if (picked === "acp" && loadConfig().mode === "plan") {
2010
+ ui.notify("mode is plan; the ACP engine has no plan mode. /agy mode accept-edits first.", "warning");
2011
+ return;
2012
+ }
2013
+ saveConfig({ engine: picked });
2014
+ ui.notify(savedEngineMessage(picked), "info");
2015
+ if (picked === "acp") void ctx.runAcpPickSetup({ ui });
1986
2016
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estebanforge/pi-antigravity-bridge",
3
- "version": "1.8.0",
3
+ "version": "1.8.1",
4
4
  "description": "Gemini provider for Pi on the Antigravity ACP server (official Google ACP) or the stream-json agy CLI. Use antigravity/* models in Pi. Full /model picker support. Models runs Pi's tools. ToS safe to use.",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/acp/auth.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { AcpConnection } from "./connection.js";
2
- import { hasAcpToken } from "./setup.js";
2
+ import { hasAcpToken, writeAcpAuthVerified } from "./setup.js";
3
3
 
4
4
  // Explicit sign-in (/agy auth): spawn a short-lived server, send
5
5
  // `authenticate` (the RPC that fires the loopback listener + browser OAuth
@@ -9,6 +9,13 @@ import { hasAcpToken } from "./setup.js";
9
9
  // blocks until the browser round-trip completes; the observed window is
10
10
  // minutes-scale (one flow timed out at ~8.5 minutes), so the timeout is
11
11
  // generous.
12
+ //
13
+ // Token-absent after an OK reply is NOT always a failure: server 1.1.1 can
14
+ // authenticate from legacy ~/.gemini credential artifacts and never writes
15
+ // acp_token.json (observed on darwin/arm64 2026-10-13). A live session/new
16
+ // probe separates that from a hollow accept: probe success = signed in
17
+ // (recorded in the bridge-side verified marker), probe failure = the server
18
+ // holds no usable credentials.
12
19
 
13
20
  export const AUTH_TIMEOUT_MS = 10 * 60_000;
14
21
 
@@ -25,18 +32,41 @@ export interface AcpAuthOptions {
25
32
  * authenticate replies, but write-after-reply is unverified; after a
26
33
  * successful reply the run polls for the token up to this long before
27
34
  * SIGTERM, so a laggard write is never killed mid-flight. A token that
28
- * never appears is a distinct failure, not silent success. */
35
+ * never appears is verified through a live session probe instead of
36
+ * failing outright. */
29
37
  tokenGraceMs?: number;
38
+ /** Bridge-side verified-sign-in marker. Written on every verified
39
+ * success (token file or live session probe). Default is the real
40
+ * bridge path; tests point this at a fixture. */
41
+ authVerifiedFile?: string;
30
42
  timeoutMs?: number;
31
43
  log?: (msg: string, data?: unknown) => void;
32
44
  }
33
45
 
34
46
  export interface AcpAuthResult {
35
47
  ok: boolean;
48
+ /** Present when verified through the session probe: the server
49
+ * authenticated from existing credentials without writing a local
50
+ * token file. Absent on the normal token-file path and on failure. */
51
+ note?: string;
36
52
  /** Failure reason (spawn, timeout, -32000 family, concurrent run). Absent when ok. */
37
53
  error?: string;
38
54
  }
39
55
 
56
+ /** Live credential probe for the token-absent branch: a server with usable
57
+ * credentials answers session/new; a hollow accept starts (or wants) a
58
+ * browser flow and the request times out or errors. Closes the probe
59
+ * session; the caller kills the connection either way. */
60
+ async function probeUsableCredentials(conn: AcpConnection, cwd?: string): Promise<boolean> {
61
+ try {
62
+ const s = await conn.newSession(cwd ?? process.cwd());
63
+ await conn.closeSession(s.sessionId).catch(() => {});
64
+ return true;
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
40
70
  let authInFlight: Promise<AcpAuthResult> | null = null;
41
71
 
42
72
  export function runAcpAuth(opts: AcpAuthOptions): Promise<AcpAuthResult> {
@@ -59,11 +89,19 @@ export function runAcpAuth(opts: AcpAuthOptions): Promise<AcpAuthResult> {
59
89
  const deadline = Date.now() + grace;
60
90
  while (!hasAcpToken(opts.tokenDir) && Date.now() < deadline) await sleep(200);
61
91
  if (!hasAcpToken(opts.tokenDir)) {
62
- return {
63
- ok: false,
64
- error: `the server accepted the sign-in, but no token file appeared in ${opts.tokenDir ?? "~/.gemini/antigravity-acp"} within ${Math.round(grace / 1000)}s`,
65
- };
92
+ // authenticate replied but no token file: either the server
93
+ // authenticated from artifacts outside antigravity-acp, or the
94
+ // reply is hollow. A live session/new separates the two.
95
+ if (!(await probeUsableCredentials(conn, opts.cwd))) {
96
+ return {
97
+ ok: false,
98
+ error: `the server accepted the sign-in but holds no usable credentials (session probe failed; token file absent in ${opts.tokenDir ?? "~/.gemini/antigravity-acp"}). Finish the Google sign-in if a browser opened, then retry /agy auth.`,
99
+ };
100
+ }
101
+ writeAcpAuthVerified(opts.authVerifiedFile);
102
+ return { ok: true, note: "the server authenticated from existing credentials; no browser sign-in was needed" };
66
103
  }
104
+ writeAcpAuthVerified(opts.authVerifiedFile);
67
105
  return { ok: true };
68
106
  } catch (err) {
69
107
  return { ok: false, error: err instanceof Error ? err.message : String(err) };
@@ -50,6 +50,16 @@ export class AcpAuthError extends Error {
50
50
  }
51
51
  }
52
52
 
53
+ /** Fired exactly when the SERVER reports an auth failure (-32000 family).
54
+ * The extension entry wires this to clearAcpAuthVerified(): a verified
55
+ * marker must never outlive the server disagreeing with it. Kept as a hook
56
+ * so the low-level connection stays decoupled from bridge file paths. */
57
+ let authInvalidatedHook: (() => void) | null = null;
58
+
59
+ export function setAuthInvalidatedHook(fn: (() => void) | null): void {
60
+ authInvalidatedHook = fn;
61
+ }
62
+
53
63
  export interface AcpConnectionOptions {
54
64
  /** Process working directory for the spawned server. */
55
65
  cwd: string;
@@ -613,6 +623,7 @@ export function translateError(method: string, err: unknown): Error {
613
623
  if (err instanceof AcpAuthError) return err;
614
624
  if (err instanceof JsonRpcResponseError) {
615
625
  if (err.code === -32000) {
626
+ authInvalidatedHook?.();
616
627
  const detail = typeof err.data === "object" && err.data !== null ? (err.data as { message?: unknown }).message : undefined;
617
628
  const detailText = typeof detail === "string" ? `: ${detail}` : `: ${err.message}`;
618
629
  return new AcpAuthError(`ACP authentication required for ${method}${detailText}. Run /agy auth to sign in; /agy auth-manual has manual steps.`);
package/src/acp/setup.ts CHANGED
@@ -29,6 +29,10 @@ export interface SetupOptions {
29
29
  installRoot?: string;
30
30
  /** Server settings dir. Default ~/.gemini/antigravity-acp. */
31
31
  geminiDir?: string;
32
+ /** Bridge-side verified-sign-in marker file. Default
33
+ * ~/.pi/agent/antigravity-bridge/acp-auth-verified.json (tests point this
34
+ * at a fixture to stay hermetic). */
35
+ authVerifiedFile?: string;
32
36
  /** acp.bin from config (second resolution priority after AGY_ACP_BIN). */
33
37
  configBin?: string;
34
38
  /** Environment. Default process.env (test injection). */
@@ -244,6 +248,41 @@ export function hasAcpToken(dir?: string): boolean {
244
248
  return tokenPresent(dir ?? path.join(os.homedir(), ".gemini", "antigravity-acp"));
245
249
  }
246
250
 
251
+ /** Bridge-side record that the ACP server proved its own credentials in a
252
+ * verified sign-in (runAcpAuth). Needed because servers may authenticate
253
+ * from artifacts this module cannot see: 1.1.1 honors legacy ~/.gemini
254
+ * credential files (e.g. jetski-standalone-oauth-token) and never writes
255
+ * acp_token.json, so the file check alone would nag login-pending forever
256
+ * on a working setup. Cleared the moment the server reports an auth error
257
+ * (-32000), so a stale marker never masks a real logout. Never holds a
258
+ * credential value: a boolean and a timestamp. */
259
+ export function defaultAcpAuthVerifiedPath(): string {
260
+ return path.join(os.homedir(), ".pi", "agent", "antigravity-bridge", "acp-auth-verified.json");
261
+ }
262
+
263
+ export function readAcpAuthVerified(file: string = defaultAcpAuthVerifiedPath()): boolean {
264
+ try {
265
+ const raw = JSON.parse(fs.readFileSync(file, "utf8")) as { verified?: unknown };
266
+ return raw.verified === true;
267
+ } catch {
268
+ return false;
269
+ }
270
+ }
271
+
272
+ export function writeAcpAuthVerified(file: string = defaultAcpAuthVerifiedPath()): void {
273
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
274
+ fs.writeFileSync(file, `${JSON.stringify({ verified: true, at: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 });
275
+ }
276
+
277
+ export function clearAcpAuthVerified(file: string = defaultAcpAuthVerifiedPath()): void {
278
+ try {
279
+ fs.rmSync(file, { force: true });
280
+ } catch {
281
+ /* best effort: a leftover marker only over-reports readiness, and the
282
+ next auth error clears it again. */
283
+ }
284
+ }
285
+
247
286
  /** Auth state WITHOUT reading any credential value: settings.json carries
248
287
  * only auth.type (+ non-secret gcp placement), acp_token.json is stat()ed. */
249
288
  export function readAuthState(dir?: string): AuthState {
@@ -302,8 +341,14 @@ export function resolveInstalledBinary(opts: SetupOptions = {}): BinaryResolutio
302
341
  /** Read-only setup state (for /agy doctor): no installs, no writes. */
303
342
  export function inspectAcpSetup(opts: SetupOptions = {}): { bin: string | null; source: string | null; auth: string | null } {
304
343
  const binary = resolveInstalledBinary(opts);
305
- const auth = readAuthState(defaultGeminiDir(opts));
306
- return { bin: binary.bin, source: binary.source, auth: auth.configured ? (auth.type ?? "token") : null };
344
+ const gdir = defaultGeminiDir(opts);
345
+ const auth = readAuthState(gdir);
346
+ if (!auth.configured) return { bin: binary.bin, source: binary.source, auth: null };
347
+ const type = auth.type ?? "token";
348
+ // Surface the verified-marker state so a setup that authenticates without
349
+ // a token file (1.1.1 legacy artifacts) reads as healthy in doctor.
350
+ const verified = type !== "gemini-api-key" && !tokenPresent(gdir) && readAcpAuthVerified(opts.authVerifiedFile);
351
+ return { bin: binary.bin, source: binary.source, auth: verified ? `${type} (verified, no token file)` : type };
307
352
  }
308
353
 
309
354
  /** Orchestrate everything the ACP engine needs, in order: binary (install
@@ -358,10 +403,12 @@ export async function ensureAcpReady(opts: SetupOptions = {}): Promise<AcpSetupS
358
403
  }
359
404
  // settings.json alone proves nothing: setup writes it before the login,
360
405
  // the token file only appears after the browser round-trip. oauth-personal
361
- // without a token = login still pending, and every caller (switch toast,
362
- // session_start self-heal) must keep saying so until the token exists.
363
- // gemini-api-key has no browser login; a present token file means done.
364
- const needsLogin = auth.type !== "gemini-api-key" && !tokenPresent(gdir);
406
+ // without a token is login-pending UNLESS a verified sign-in recorded that
407
+ // this server authenticates from artifacts the bridge cannot see (1.1.1
408
+ // legacy ~/.gemini credentials; marker written by runAcpAuth, cleared on
409
+ // any -32000). gemini-api-key has no browser login; a present token file
410
+ // means done.
411
+ const needsLogin = auth.type !== "gemini-api-key" && !tokenPresent(gdir) && !readAcpAuthVerified(opts.authVerifiedFile);
365
412
  return {
366
413
  ok: true,
367
414
  bin: binary.bin!,
package/src/config.ts CHANGED
@@ -38,9 +38,16 @@ export function logsDir(): string {
38
38
  );
39
39
  }
40
40
 
41
- /** Which turn engine drives turns. "stream-json" is the tested default;
42
- * "acp" is the official-server engine, opt-in (plan §9.5). */
41
+ /** Which turn engine drives turns. "acp" (the official server) is the
42
+ * default for new installs; "stream-json" (the `agy` CLI) remains a fully
43
+ * supported fallback. */
43
44
  export type Engine = "stream-json" | "acp";
45
+
46
+ /** The engine a fresh install runs when nothing is configured (no file key,
47
+ * no AGY_ENGINE): the official ACP server. Unknown/stale stored values also
48
+ * fall back here; the first startup's acp self-heal installs the binary and
49
+ * chains the sign-in. */
50
+ export const DEFAULT_ENGINE: Engine = "acp";
44
51
  export type AgyMode = "accept-edits" | "plan";
45
52
  export type ThinkingTier = "low" | "medium" | "high";
46
53
  export type BridgeTools = "none" | "mcp" | "all";
@@ -187,7 +194,7 @@ export interface AgyConfig {
187
194
  }
188
195
 
189
196
  const DEFAULTS: AgyConfig = {
190
- engine: "stream-json",
197
+ engine: DEFAULT_ENGINE,
191
198
  mode: "accept-edits",
192
199
  readOnly: false,
193
200
  skipPermissions: true,
@@ -250,9 +257,16 @@ export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
250
257
  // explicit AGY_MODE=accept-edits when the file says plan, violating the
251
258
  // documented precedence. Check env first.
252
259
  // Engine: narrow to the known set; anything else (incl. the pre-1.3.2
253
- // "sqlite" value) falls back to the tested default.
254
- const engineRaw = String(process.env.AGY_ENGINE ?? file.engine ?? DEFAULTS.engine).toLowerCase();
255
- const engine: Engine = engineRaw === "acp" ? "acp" : "stream-json";
260
+ // "sqlite" value) falls back to the default engine, same as an absent key.
261
+ // One absence exception: a config file WITHOUT an engine key belongs to an
262
+ // existing install (saveConfig persists the resolved legacy engine), and
263
+ // the acp default is for new installs only - an existing install keeps
264
+ // the legacy stream-json engine instead of silently starting the ~1.5 GB
265
+ // acp download. existsSync is false on EACCES too, which reads as fresh
266
+ // and picks acp; an unreadable config dir is a broken install either way.
267
+ const freshInstall = !fs.existsSync(configPath);
268
+ const engineFallback = freshInstall || file.engine !== undefined ? DEFAULT_ENGINE : "stream-json";
269
+ const engine = narrowEngine(String(process.env.AGY_ENGINE ?? file.engine ?? engineFallback).toLowerCase());
256
270
 
257
271
  const mode: AgyMode =
258
272
  process.env.AGY_MODE !== undefined
@@ -377,6 +391,14 @@ export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
377
391
  };
378
392
  }
379
393
 
394
+ /** Narrow a raw engine string to the known set; anything unknown (incl. the
395
+ * pre-1.3.2 "sqlite" value) falls to the default engine. Shared by
396
+ * loadConfig's effective-engine resolution and saveConfig's persistence so
397
+ * the two can never drift. */
398
+ function narrowEngine(raw: string): Engine {
399
+ return raw === "acp" || raw === "stream-json" ? raw : DEFAULT_ENGINE;
400
+ }
401
+
380
402
  /** Atomically persist a config patch (temp + rename). Merges onto the RAW
381
403
  * FILE values, never loadConfig(): env overrides must not be baked into the
382
404
  * file (AGY_READONLY=1 in the env would otherwise persist readOnly: true to
@@ -390,6 +412,13 @@ export function saveConfig(patch: Partial<AgyConfig>, configPath: string = CONFI
390
412
  // triggers normalization (a null becomes the default shape, never null).
391
413
  const fileAcp: Partial<AcpConfig> = file.acp && typeof file.acp === "object" ? file.acp : {};
392
414
  const fileApprovals: Partial<GateConfig> = file.approvals && typeof file.approvals === "object" ? file.approvals : {};
415
+ // Engine persistence follows loadConfig's continuity rule minus env (env
416
+ // must never be baked into the file). A legacy file without an engine key
417
+ // persists "stream-json": the bare DEFAULTS spread below would otherwise
418
+ // write the acp default into an existing install's file on any unrelated
419
+ // save, arming the ~1.5 GB download on the next start.
420
+ const persistedEngine = patch.engine ?? file.engine
421
+ ?? (fs.existsSync(configPath) ? "stream-json" : DEFAULT_ENGINE);
393
422
  const next: AgyConfig = {
394
423
  ...DEFAULTS,
395
424
  ...file,
@@ -400,6 +429,7 @@ export function saveConfig(patch: Partial<AgyConfig>, configPath: string = CONFI
400
429
  ? { approvals: { gateMode: fileApprovals.gateMode ?? DEFAULTS.approvals.gateMode, mode: fileApprovals.mode ?? DEFAULTS.approvals.mode } }
401
430
  : {}),
402
431
  ...patch,
432
+ engine: narrowEngine(String(persistedEngine).toLowerCase()),
403
433
  };
404
434
  const dir = path.dirname(configPath);
405
435
  fs.mkdirSync(dir, { recursive: true });
@@ -7,43 +7,37 @@
7
7
  // Every start with the stream-json engine active also re-checks that the
8
8
  // `agy` binary exists and warns until it does (re-auth is out of scope).
9
9
  //
10
- // UI: tui.md "Pattern 1" - SelectList framed by DynamicBorder inside pi's
11
- // native overlay (the window feel pi-rtk builds its modal on).
10
+ // UI: the shared framed choice modal from @estebanforge/pi-tui-dialogs
11
+ // (dialogChoice): one decision statement, a closed option list, esc cancels.
12
12
 
13
13
  import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
14
- import { DynamicBorder } from "@earendil-works/pi-coding-agent";
15
- import {
16
- Container,
17
- type SelectItem,
18
- SelectList,
19
- Spacer,
20
- Text,
21
- } from "@earendil-works/pi-tui";
14
+ import { dialogChoice, type ChoiceOption, type DialogContext, type DialogRow } from "@estebanforge/pi-tui-dialogs";
22
15
  import fs from "node:fs";
23
16
  import path from "node:path";
24
- import type { Engine } from "./config.js";
17
+ import { DEFAULT_ENGINE, type Engine } from "./config.js";
25
18
 
26
- /** Picker order is the default answer order: stream-json first. */
27
- export const ENGINE_PICKER_ITEMS: SelectItem[] = [
28
- {
29
- value: "stream-json",
30
- label: "Stream-JSON CLI",
31
- description: "default, recommended",
32
- },
19
+ /** Picker order is the default answer order: acp (the default engine)
20
+ * preselected first, stream-json as the fallback. */
21
+ export const ENGINE_PICKER_ITEMS: ChoiceOption[] = [
33
22
  {
34
23
  value: "acp",
35
24
  label: "ACP server (official Google)",
36
- description: "second sign-in, ~1.5 GB binary download",
25
+ description: "default · official Google server",
26
+ },
27
+ {
28
+ value: "stream-json",
29
+ label: "Stream-JSON CLI",
30
+ description: "the `agy` CLI, fallback",
37
31
  },
38
32
  ];
39
33
 
40
34
  /** Intro paragraph above the list. Text wraps, so explanations stay here and
41
- * SelectItem descriptions stay one-liners (SelectList truncates long lines). */
35
+ * option descriptions stay one-liners (the choice list truncates long lines). */
42
36
  export const ENGINE_PICKER_INTRO = [
43
37
  "Pick the engine that runs your Antigravity turns. Switch anytime with /agy engine (restart applies it).",
44
38
  "",
45
- "stream-json: the `agy` CLI you already installed and authenticated. Persistent process, streamed output. Tested default.",
46
- "ACP: Google's official server (agy_acp_server.par). Needs a second Google sign-in and a ~1.5 GB server binary downloaded from Google (automatic, one-time, unavoidable: the server is not part of the agy CLI).",
39
+ "ACP (default): Google's official server (agy_acp_server.par). Needs a second Google sign-in and a ~1.5 GB server binary downloaded from Google (automatic, one-time, unavoidable: the server is not part of the agy CLI).",
40
+ "stream-json (fallback): the `agy` CLI you already installed and authenticated. Persistent process, streamed output.",
47
41
  ].join("\n");
48
42
 
49
43
  /** True only for a genuine first interactive run: no saved config yet (any
@@ -64,9 +58,9 @@ export function shouldOfferEnginePicker(
64
58
 
65
59
  /** Narrow a picker value to an Engine. Items are our own constants, but an
66
60
  * unknown value must never reach config as a cast string: it falls back to
67
- * the tested default. */
61
+ * the default engine (acp), same rule as loadConfig's narrowing. */
68
62
  export function toEngine(value: string): Engine {
69
- return value === "acp" ? "acp" : "stream-json";
63
+ return value === "acp" || value === "stream-json" ? value : DEFAULT_ENGINE;
70
64
  }
71
65
 
72
66
  /** Toast copy shown after the choice is saved. ACP names the promise that
@@ -78,6 +72,23 @@ export function savedEngineMessage(engine: Engine): string {
78
72
  : "Engine saved: stream-json. Restart pi to apply.";
79
73
  }
80
74
 
75
+ /** /agy settings row that opens the engine picker. Deliberately value-less:
76
+ * DialogMenu cycles rows that carry `values`, but resolves the menu with the
77
+ * row id on Enter for rows without — the caller then closes settings and
78
+ * opens the same modal as the first-run wizard. The active engine rides as
79
+ * the display value so the row reads like the rest of the surface. */
80
+ export const ENGINE_SETTINGS_ROW_ID = "engine";
81
+
82
+ export function engineSettingsRow(engine: Engine): DialogRow {
83
+ return {
84
+ id: ENGINE_SETTINGS_ROW_ID,
85
+ label: "Switch engine",
86
+ description:
87
+ "Open the engine picker (stream-json CLI vs the official ACP server). Enter opens it and closes settings; esc in the picker changes nothing. Restart applies a switch.",
88
+ currentValue: engine,
89
+ };
90
+ }
91
+
81
92
  /** True when the `agy` CLI binary can be found. A binRef with a path
82
93
  * separator (AGY_BIN=/opt/agy/agy) must exist as a file; a bare name is
83
94
  * searched on PATH. statSync cannot throw through the guards, but a race
@@ -108,48 +119,23 @@ export function agyMissingMessage(): string {
108
119
  return "The `agy` CLI is not installed. Install it from https://antigravity.google/product/antigravity-cli and log in on it to use Antigravity models.";
109
120
  }
110
121
 
111
- /** Render the picker overlay. Resolves with the chosen engine, or null when
112
- * the user pressed esc (decide later - nothing is persisted). */
113
- export async function showEnginePicker(ctx: ExtensionUIContext): Promise<Engine | null> {
114
- return ctx.custom<Engine | null>(
115
- (tui, theme, _keybindings, done) => {
116
- const container = new Container();
117
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
118
- container.addChild(
119
- new Text(theme.fg("accent", theme.bold("Antigravity Bridge: choose your engine")), 1, 0),
120
- );
121
- container.addChild(new Text(theme.fg("muted", ENGINE_PICKER_INTRO), 1, 0));
122
- container.addChild(new Spacer(1));
123
-
124
- const list = new SelectList(ENGINE_PICKER_ITEMS, ENGINE_PICKER_ITEMS.length, {
125
- selectedPrefix: (t: string) => theme.fg("accent", t),
126
- selectedText: (t: string) => theme.fg("accent", t),
127
- description: (t: string) => theme.fg("muted", t),
128
- scrollInfo: (t: string) => theme.fg("dim", t),
129
- noMatch: (t: string) => theme.fg("warning", t),
130
- });
131
- list.onSelect = (item) => done(toEngine(item.value));
132
- list.onCancel = () => done(null);
133
- container.addChild(list);
134
-
135
- container.addChild(new Spacer(1));
136
- container.addChild(
137
- new Text(theme.fg("dim", "↑↓ navigate · enter select · esc decide later"), 1, 0),
138
- );
139
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
122
+ /** The kit's DialogContext wants the whole extension context, but both picker
123
+ * call sites hold only the ui object and are TUI-gated (ctx.mode === "tui")
124
+ * before they run. Wrap instead of threading two different ctx shapes. The
125
+ * cast is the pi-custom-options vs unknown-options signature gap; custom and
126
+ * notify exist at runtime on ExtensionUIContext. */
127
+ function asDialogContext(ui: ExtensionUIContext): DialogContext {
128
+ return { hasUI: true, mode: "tui", ui: ui as unknown as DialogContext["ui"] };
129
+ }
140
130
 
141
- return {
142
- render: (width: number) => container.render(width),
143
- invalidate: () => container.invalidate(),
144
- handleInput: (data: string) => {
145
- list.handleInput(data);
146
- tui.requestRender();
147
- },
148
- };
149
- },
150
- {
151
- overlay: true,
152
- overlayOptions: { anchor: "center" as const, width: 80, maxHeight: "85%" as const, margin: 1 },
153
- },
154
- );
131
+ /** Render the choice modal via the shared dialog kit. Resolves with the
132
+ * chosen engine, or undefined when the user pressed esc (decide later -
133
+ * nothing is persisted). */
134
+ export async function showEnginePicker(ctx: ExtensionUIContext): Promise<Engine | undefined> {
135
+ const picked = await dialogChoice(asDialogContext(ctx), "Antigravity Bridge: choose your engine", {
136
+ description: ENGINE_PICKER_INTRO,
137
+ options: ENGINE_PICKER_ITEMS,
138
+ footer: "↑↓ navigate · enter select · esc decide later",
139
+ });
140
+ return picked === undefined ? undefined : toEngine(picked);
155
141
  }
package/src/provider.ts CHANGED
@@ -345,8 +345,9 @@ export interface StreamSimpleDeps {
345
345
  * driver and bridge calls park as toolUse round-trips. Required with
346
346
  * roundTrips. */
347
347
  driver?: TurnDriver;
348
- /** Official-server ACP engine. Opt-in via config.engine = "acp"; when
349
- * absent the config switch falls back to the stream driver. */
348
+ /** Official-server ACP engine (the default engine). Selected via
349
+ * config.engine = "acp"; when absent the config switch falls back to
350
+ * the stream driver. */
350
351
  acpDriver?: TurnDriver;
351
352
  roundTrips?: ToolRoundTrips;
352
353
  /** Replay store for the display-only antigravity wrapper tool. Required