@estebanforge/pi-antigravity-bridge 1.4.3 → 1.4.4

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,6 +2,22 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.4.4] - 2026-09-04
6
+
7
+ ### Added
8
+
9
+ - The Google sign-in URL now surfaces in pi. The ACP server hands the OAuth URL only to the browser-open call (nothing on stdout/stderr, headless or not), so a `BROWSER` wrapper script records the URL and forwards to the real opener; the connection watches the record file and logs it as an `auth-url` event. The extension toasts it at warning level with the ssh port-forward command for the redirect port, so logins work from SSH sessions on remote machines; local users keep the automatic browser open and get the URL as a fallback. An existing `BROWSER` setting is chained, not replaced.
10
+
11
+ ### Fixed
12
+
13
+ - ACP login pending was reported only at the moment setup wrote `settings.json`, so after the restart, with `settings.json` in place but the browser login never completed, every check went silent: no toast, no hint, while the first ACP message had no token to use. `needsLogin` now tracks the token file (stat only, never read): `oauth-personal` without `acp_token.json` is login-pending, so the switch-time toast, the picker, and the `session_start` self-heal keep saying so on every start until the login is done.
14
+
15
+ ### Changed
16
+
17
+ - Engine switching is command-only now: `/agy engine stream-json|acp`. The bare-`/agy` settings picker no longer carries an Engine row (nor the post-save setup run), so the switch surface cannot be hit accidentally; `/agy engine acp` keeps the self-service setup (binary + auth) and the login warning. The picker's plan+acp (RC01) guard stays in a narrower form: the mode row alone can still produce `plan` while the engine is `acp`.
18
+ - ACP login-pending messages rewritten for end users: what happens (the server opens the Google sign-in page in your browser on the first Antigravity message), when (after the restart or immediately, per moment), and which account (your Antigravity subscription, the same Google account as the `agy` CLI). No URL to open manually. All remaining moments (engine switch, session start) now toast at warning level so the pending action stands out.
19
+ - The "Turn engine" wording became "Engine" in the README env table, the config comment, and the architecture doc heading; the picker row itself is gone (engine switching is command-only, see above).
20
+
5
21
  ## [1.4.3] - 2026-09-04
6
22
 
7
23
  ### Added
package/README.md CHANGED
@@ -25,7 +25,7 @@ Turns run through one of two engines behind the same provider surface (`config.e
25
25
 
26
26
  Engine-dependent features: pi image attachments ride natively only on the ACP engine (the picker offers image attach automatically when `config.engine` is `acp`; the stream-json CLI prompt is text-only). With the optional G1 digest enabled, its delivery also differs: ACP ships it as a native `embeddedContext` resource block, stream-json prepends it to the prompt text.
27
27
 
28
- Switch with `/agy engine acp|stream-json` (takes effect on restart). Setup is automatic: switching to `acp` installs Google's official ACP server binary from the [antigravity-acp registry entry](https://github.com/agentclientprotocol/registry) (`~/.local/opt/agy-acp/<build>/` + a `current` symlink, zip sha256 recorded; layout and pinning in [docs/ACP-ADOPTION-PLAN.md](docs/ACP-ADOPTION-PLAN.md)) and prepares the login. The login is your Antigravity subscription: on your first ACP message the server opens the Google login in your browser, and you sign in with the same account and plan you use for the Antigravity CLI (`agy`). It is no different from logging into the CLI; the server just keeps its own token file on your machine, like any Google tool, and this extension never sees your credentials. If you also export `GEMINI_API_KEY`, it is ignored: the server uses the auth type in settings.json, and setup always writes `oauth-personal`. A session start self-heals the same way, silently when everything is ready. Manual instructions (`/agy acp-auth`) surface only when a step fails. Sessions are engine-scoped, so switching engines never crosses conversations.
28
+ Switch with `/agy engine acp|stream-json` (takes effect on restart). Setup is automatic: switching to `acp` installs Google's official ACP server binary from the [antigravity-acp registry entry](https://github.com/agentclientprotocol/registry) (`~/.local/opt/agy-acp/<build>/` + a `current` symlink, zip sha256 recorded; layout and pinning in [docs/ACP-ADOPTION-PLAN.md](docs/ACP-ADOPTION-PLAN.md)) and prepares the login. The login is your Antigravity subscription: on your first ACP message the server opens the Google login in your browser, and you sign in with the same account and plan you use for the Antigravity CLI (`agy`). If no browser is available (an SSH session on a remote machine), pi shows the sign-in URL to copy, plus the ssh port-forward command for the login redirect. It is no different from logging into the CLI; the server just keeps its own token file on your machine, like any Google tool, and this extension never sees your credentials. If you also export `GEMINI_API_KEY`, it is ignored: the server uses the auth type in settings.json, and setup always writes `oauth-personal`. A session start self-heals the same way, silently when everything is ready. Manual instructions (`/agy acp-auth`) surface only when a step fails. Sessions are engine-scoped, so switching engines never crosses conversations.
29
29
 
30
30
  ## What it cannot do
31
31
 
@@ -154,7 +154,7 @@ For isolation when running any agent that executes commands without a confirmati
154
154
  | Variable | Purpose |
155
155
  | --- | --- |
156
156
  | `AGY_BIN` | Path to the agy binary. Defaults to `agy` on PATH. |
157
- | `AGY_ENGINE` | Turn engine: `stream-json` (default) or `acp`. Wins over the config file. |
157
+ | `AGY_ENGINE` | Engine: `stream-json` (default) or `acp`. Wins over the config file. |
158
158
  | `AGY_ACP_BIN` | Path to the ACP server binary (`agy_acp_server.par`). Defaults to `agy_acp_server.par` on PATH. Wins over `config.acp.bin`. When neither points at a binary, auto-setup installs one. |
159
159
  | `AGY_EXTRA_ARGS` | Extra args appended to every invocation. Whitespace-split. |
160
160
  | `AGY_CONVERSATIONS_DIR` | Override the conversations DB directory. |
@@ -2,7 +2,7 @@
2
2
 
3
3
  How the provider works internally. For build/test/debug workflow see [DEVELOPMENT.md](./DEVELOPMENT.md).
4
4
 
5
- ## Turn engine
5
+ ## Engine
6
6
 
7
7
  The provider ships two turn engines behind one contract (`TurnDriver`,
8
8
  `src/driver-types.ts`): the default **stream-json engine** (below) and the
@@ -40,6 +40,7 @@ import { SessionStore } from "../src/sessions.js";
40
40
  import { ToolRoundTrips, WrapperReplay, createStreamSimple } from "../src/provider.js";
41
41
  import { AgyDriver } from "../src/driver.js";
42
42
  import { AcpDriver } from "../src/acp/driver.js";
43
+ import { setupAuthUrlCapture } from "../src/acp/browser-capture.js";
43
44
  import { ensureAcpReady, inspectAcpSetup } from "../src/acp/setup.js";
44
45
  import type { TurnDriver } from "../src/driver-types.js";
45
46
  import { CONFIG_PATH, loadConfig, saveConfig, type AgyMode, type BridgeTools, type Engine, type ThinkingTier } from "../src/config.js";
@@ -58,6 +59,12 @@ import { mapAgyToolToNative } from "../src/native-tools.js";
58
59
  import { Type } from "typebox";
59
60
  import { patchStatus, restorePatch } from "../src/patch-cleanup.js";
60
61
 
62
+ // Last UI seen (session_start / /agy commands). The ACP login URL arrives
63
+ // via the driver log sink, which has no command context; the stash lets that
64
+ // sink toast instead of only logging to stderr. Module scope: both the
65
+ // default export (session_start, log sink) and registerAgyCommand assign it.
66
+ let activeUi: ExtensionUIContext | null = null;
67
+
61
68
  function resolveAgyBinary(): string {
62
69
  return process.env.AGY_BIN || "agy";
63
70
  }
@@ -102,6 +109,17 @@ export default async function (pi: ExtensionAPI): Promise<void> {
102
109
  // ACP self-heal runs once per process (session_start re-fires on /reload;
103
110
  // a ready setup is two file stats, so re-running is harmless anyway).
104
111
  let acpSelfHealRan = false;
112
+ // OAuth URL capture: the server hands the login URL only to the
113
+ // browser-open call (nothing on stdio), so a BROWSER wrapper records it
114
+ // and the driver logs it as "auth-url". Local users keep the automatic
115
+ // browser open; over SSH the URL surfaces for copy-paste with the
116
+ // port-forward command.
117
+ const authCapture = setupAuthUrlCapture();
118
+ if (!authCapture && process.platform !== "win32") {
119
+ // Rare (unwritable data dir). Surfacing the diagnostic: without it,
120
+ // login URLs would silently stop appearing on headless boxes.
121
+ console.error("[antigravity-bridge] OAuth URL capture unavailable; login URL surfacing is off (setup failed).");
122
+ }
105
123
  // Two turn engines behind one contract (plan §9): stream-json (tested
106
124
  // default) and the official ACP server (opt-in via config.engine, off by
107
125
  // default). Neither spawns anything until its first turn.
@@ -121,7 +139,17 @@ export default async function (pi: ExtensionAPI): Promise<void> {
121
139
  // Resolved per connection: the setup flow can install the binary and
122
140
  // update acp.bin mid-session; the next turn picks it up (no restart).
123
141
  bin: () => loadConfig().acp.bin,
142
+ ...(authCapture ? { extraEnv: authCapture.browserEnv, authUrlFile: authCapture.file } : {}),
124
143
  log: (msg, data) => {
144
+ if (msg === "auth-url") {
145
+ const { url, port } = (data ?? {}) as { url?: string; port?: number | null };
146
+ if (!url) return;
147
+ const ssh = port ? `\nSSH session? Forward the port on your machine first:\n ssh -N -L ${port}:127.0.0.1:${port} <user@host>` : "";
148
+ const text = `Google sign-in URL for the ACP engine:\n${url}${ssh}`;
149
+ if (activeUi) activeUi.notify(text, "warning");
150
+ else console.error(`[antigravity-bridge acp] ${text}`);
151
+ return;
152
+ }
125
153
  if (!acpFailures.has(msg)) return;
126
154
  console.error(`[antigravity-bridge acp] ${msg}${data !== undefined ? " " + JSON.stringify(data) : ""}`);
127
155
  },
@@ -241,6 +269,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
241
269
  // normal toolUse loop (native cards, permissions, hooks) - no patch, no
242
270
  // privileged API. Started on session_start, torn down on session_shutdown.
243
271
  pi.on("session_start", async (_event, ctx) => {
272
+ if (ctx.hasUI) activeUi = ctx.ui;
244
273
  // Legacy cleanup: users who ran the old consent-gated patcher still
245
274
  // carry pi.invokeTool in their installed pi. Inert, but tell them once
246
275
  // and offer /agy patch-cleanup. Never auto-edits the install.
@@ -270,8 +299,8 @@ export default async function (pi: ExtensionAPI): Promise<void> {
270
299
  saveConfig({ acp: { bin: status.bin, permissions: loadConfig().acp.permissions } });
271
300
  }
272
301
  if (status.needsLogin) {
273
- const msg = acpLoginPending("Your next antigravity message opens the browser;");
274
- if (ctx.hasUI) ctx.ui.notify(msg, "info");
302
+ const msg = acpLoginPending("Your next Antigravity message opens the Google sign-in page in your browser.");
303
+ if (ctx.hasUI) ctx.ui.notify(msg, "warning");
275
304
  else console.error(`[antigravity-bridge] ${msg}`);
276
305
  }
277
306
  return;
@@ -372,6 +401,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
372
401
  }
373
402
  });
374
403
  pi.on("session_shutdown", async () => {
404
+ // The UI is going away; a later auth-url must fall back to stderr
405
+ // instead of toasting into a dead UI (where it would be lost).
406
+ activeUi = null;
375
407
  const h = mcpHandle;
376
408
  mcpHandle = null;
377
409
  await h?.close();
@@ -384,11 +416,11 @@ export default async function (pi: ExtensionAPI): Promise<void> {
384
416
  // --- /agy command -----------------------------------------------------------
385
417
 
386
418
  /** Shared body for every ACP-login-pending moment (session_start self-heal,
387
- * /agy engine acp, picker): names the component so users know WHAT they are
388
- * logging into, and that it is the same Google account as the agy CLI with
389
- * its own token file. `browserTrigger` says when the browser opens. */
419
+ * /agy engine acp, picker). End-user simple: what happens, when, which
420
+ * account. The server opens the browser itself, so there is no URL to
421
+ * open manually. `browserTrigger` says when that happens. */
390
422
  function acpLoginPending(browserTrigger: string): string {
391
- return `One-time Google login pending for the antigravity-acp server, Google's own ACP binary and a separate component of the Antigravity suite (agy desktop, agy editor, agy cli, agy acp). ${browserTrigger} Sign in with your Antigravity subscription account: the same Google account as your agy CLI login, but its own login and token file. One-time; tokens stay on your machine and this extension never sees them.`;
423
+ return `One-time sign-in needed to finish ACP setup. ${browserTrigger} Use the Google account of your Antigravity subscription (the same account as your agy CLI login). If no browser opens, pi shows the sign-in URL to copy. The token stays on your machine; this extension never sees it.`;
392
424
  }
393
425
 
394
426
  interface AgyCommandCtx {
@@ -403,7 +435,6 @@ interface AgyCommandCtx {
403
435
  }
404
436
 
405
437
  interface PendingConfig {
406
- engine?: Engine;
407
438
  mode?: AgyMode;
408
439
  skipPermissions?: boolean;
409
440
  defaultModel?: string;
@@ -447,6 +478,7 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
447
478
  "Antigravity provider: status, doctor, settings picker, clear sessions. Usage: /agy [status|doctor|engine stream-json|acp|mode plan|accept-edits|permissions on|off|ask on|off|model <alias>|thinking low|medium|high|bridge all|mcp|none|digest on|off|system-prompt on|off|acp-bin <path|auto>|acp-auth|patch-cleanup|clear]",
448
479
  handler: async (args, cmdCtx: ExtensionCommandContext) => {
449
480
  const ui = cmdCtx.ui;
481
+ if (ui) activeUi = ui;
450
482
  const mode = cmdCtx.mode;
451
483
  const sub = (args ?? "").trim().split(/\s+/)[0]?.toLowerCase();
452
484
  const val = (args ?? "").trim().split(/\s+/)[1]?.toLowerCase();
@@ -501,12 +533,14 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
501
533
  return;
502
534
  }
503
535
  saveConfig({ acp: { bin: status.bin, permissions: loadConfig().acp.permissions } });
504
- ui?.notify(
505
- status.needsLogin
506
- ? `ACP engine ready. ${acpLoginPending("Your first ACP message (after the next pi start or /reload) opens the browser;")}`
507
- : `ACP engine ready (auth: ${status.auth}). Takes effect on the next pi start (or /reload).`,
508
- "info",
509
- );
536
+ if (status.needsLogin) {
537
+ ui?.notify(
538
+ `ACP engine set. ${acpLoginPending("After the restart (pi restart or /reload), your first Antigravity message opens the Google sign-in page in your browser.")}`,
539
+ "warning",
540
+ );
541
+ } else {
542
+ ui?.notify(`ACP engine ready (auth: ${status.auth}). Takes effect on the next pi start (or /reload).`, "info");
543
+ }
510
544
  } else {
511
545
  ui?.notify(`current engine: ${loadConfig().engine}\nusage: /agy engine stream-json|acp`, "info");
512
546
  }
@@ -547,8 +581,10 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
547
581
  " the antigravity-acp registry; point acp.bin or AGY_ACP_BIN at it.",
548
582
  '2. Default: put {"auth":{"type":"oauth-personal"}} in',
549
583
  " ~/.gemini/antigravity-acp/settings.json, run one turn, and complete the",
550
- " Google login that opens in your browser (headless: tunnel 127.0.0.1:<port>",
551
- " over ssh, then open the URL on your machine).",
584
+ " Google login that opens in your browser. No browser (SSH session)?",
585
+ " pi shows the sign-in URL to copy; forward the redirect port over ssh",
586
+ " (ssh -N -L <port>:127.0.0.1:<port> <user@host>), then open the URL",
587
+ " on your machine.",
552
588
  ' Headless alternative: GEMINI_API_KEY + {"auth":{"type":"gemini-api-key"}}',
553
589
  " (metered paid API - not your Antigravity plan). The key is used only",
554
590
  " when that type is selected; with the default oauth-personal in place,",
@@ -711,23 +747,15 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
711
747
  });
712
748
  }
713
749
 
714
- /** Interactive settings picker (TUI only). Rows: the full runtime config
715
- * surface (engine, mode, permissions, model, thinking, bridge, digest,
716
- * system prompt). Picking acp runs the same self-service setup as
717
- * `/agy engine acp` (binary + auth) right after the save. */
750
+ /** Interactive settings picker (TUI only). Rows: the runtime config surface
751
+ * (mode, permissions, model, thinking, bridge, digest, system prompt).
752
+ * Engine switching is command-only: /agy engine stream-json|acp (the
753
+ * command also runs self-service binary + auth setup for acp). */
718
754
  async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promise<void> {
719
755
  const config = loadConfig();
720
756
  const pending: PendingConfig = {};
721
757
 
722
758
  const items: SettingItem[] = [
723
- {
724
- id: "engine",
725
- label: "Turn engine",
726
- description:
727
- "stream-json: the streaming engine (default, supported). acp: Google's official ACP server (experimental, opt-in). Switch takes effect on the next pi start (or /reload); acp also runs binary + auth setup on save.",
728
- currentValue: config.engine,
729
- values: ["stream-json", "acp"],
730
- },
731
759
  {
732
760
  id: "mode",
733
761
  label: "Execution mode",
@@ -804,9 +832,7 @@ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promis
804
832
  Math.min(items.length + 4, 15),
805
833
  getSettingsListTheme(),
806
834
  (id, newValue) => {
807
- if (id === "engine") {
808
- pending.engine = newValue as Engine;
809
- } else if (id === "mode") {
835
+ if (id === "mode") {
810
836
  pending.mode = newValue as AgyMode;
811
837
  } else if (id === "permissions") {
812
838
  pending.skipPermissions = newValue === "auto-approved";
@@ -840,16 +866,13 @@ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promis
840
866
 
841
867
  if (Object.keys(pending).length === 0) return;
842
868
 
843
- // The engine latches at load: a plan mode active while the effective
844
- // engine is (or will be) acp would silently run non-plan. ACP has no
845
- // review-only mode (RC01); refuse the combination. Both sides fall back
846
- // to the on-disk config so a fresh engine pick over a saved plan (and
847
- // vice versa) is caught too.
848
- const nextEngine = pending.engine ?? ctx.engine;
869
+ // The picker cannot switch engines (command-only: /agy engine), but the
870
+ // mode row can still produce plan while the latched engine is acp. ACP
871
+ // has no review-only mode (RC01); refuse the combination.
849
872
  const nextMode = pending.mode ?? config.mode;
850
- if (nextMode === "plan" && nextEngine === "acp") {
873
+ if (nextMode === "plan" && ctx.engine === "acp") {
851
874
  ui.notify(
852
- "plan + acp is not supported (RC01): the ACP engine has no review-only mode. Save mode accept-edits or pick the stream-json engine first.",
875
+ "plan + acp is not supported (RC01): the ACP engine has no review-only mode. /agy engine stream-json first, or /agy mode accept-edits.",
853
876
  "warning",
854
877
  );
855
878
  return;
@@ -858,7 +881,6 @@ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promis
858
881
  try {
859
882
  const next = saveConfig(pending);
860
883
  const changed = [
861
- pending.engine !== undefined ? `engine=${next.engine}` : null,
862
884
  pending.mode ? `mode=${next.mode}` : null,
863
885
  pending.skipPermissions !== undefined
864
886
  ? `permissions=${next.skipPermissions ? "auto-approved" : "prompt"}`
@@ -873,26 +895,6 @@ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promis
873
895
  .filter(Boolean)
874
896
  .join(", ");
875
897
  ui.notify(`Saved: ${changed}`, "info");
876
- if (pending.engine === "acp") {
877
- // Same flow as /agy engine acp: persist the switch, then prepare
878
- // the server so the restart just works.
879
- ui.notify("Preparing the ACP server (binary + auth)…", "info");
880
- const status = await ensureAcpReady({
881
- configBin: loadConfig().acp.bin,
882
- onProgress: (m) => ui.notify(m, "info"),
883
- });
884
- if (status.ok) {
885
- saveConfig({ acp: { bin: status.bin, permissions: loadConfig().acp.permissions } });
886
- ui.notify(
887
- status.needsLogin
888
- ? `ACP server ready. ${acpLoginPending("Your first ACP message (after the restart) opens the browser;")}`
889
- : `ACP server ready (auth: ${status.auth}).`,
890
- "info",
891
- );
892
- } else {
893
- ui.notify(`ACP auto-setup failed (${status.error}).\n${status.manual}`, "warning");
894
- }
895
- }
896
898
  } catch (err) {
897
899
  ui.notify(
898
900
  `Failed to save config: ${err instanceof Error ? err.message : String(err)}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estebanforge/pi-antigravity-bridge",
3
- "version": "1.4.3",
3
+ "version": "1.4.4",
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",
@@ -0,0 +1,104 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ // The ACP server hands the Google OAuth URL ONLY to the browser-open call:
6
+ // nothing on stdout or stderr, headless or not (verified per
7
+ // docs/ACP-PROTOCOL-REFERENCE.md). The capture makes the URL visible:
8
+ // BROWSER points at a wrapper that appends the URL to a record file and then
9
+ // execs the real opener, and the connection watches the record file to log
10
+ // "auth-url". Local users keep the automatic browser open; over SSH the user
11
+ // copies the URL and forwards the redirect port.
12
+
13
+ export interface AuthUrlCapture {
14
+ /** Spawn env for the ACP server (BROWSER -> wrapper script). */
15
+ browserEnv: Record<string, string>;
16
+ /** Record file the connection watches. */
17
+ file: string;
18
+ /** Most recent URL in the record file, or null. */
19
+ lastUrl(): string | null;
20
+ }
21
+
22
+ /** First existing executable named `name` on `env.PATH`, or null. */
23
+ export function findOnPath(name: string, env: NodeJS.ProcessEnv = process.env): string | null {
24
+ for (const dir of (env.PATH ?? "").split(path.delimiter)) {
25
+ if (!dir) continue;
26
+ const candidate = path.join(dir, name);
27
+ try {
28
+ const st = fs.statSync(candidate);
29
+ if (st.isFile() && (st.mode & 0o111) !== 0) return candidate;
30
+ } catch {
31
+ /* not here */
32
+ }
33
+ }
34
+ return null;
35
+ }
36
+
37
+ /** Last http(s) line of `file`, or null (absent/unreadable counts as empty). */
38
+ export function readLastUrl(file: string): string | null {
39
+ try {
40
+ const lines = fs.readFileSync(file, "utf8").split("\n").filter((l) => /^https?:\/\//.test(l));
41
+ return lines.at(-1) ?? null;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ /** Port of the OAuth loopback redirect (redirect_uri param), or null. */
48
+ export function parseAuthPort(url: string): number | null {
49
+ try {
50
+ const redirect = new URL(url).searchParams.get("redirect_uri");
51
+ if (!redirect) return null;
52
+ const port = new URL(redirect).port;
53
+ return port ? Number(port) : null;
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ const shQuote = (s: string): string => `'${s.replace(/'/g, "'\\''")}'`;
60
+
61
+ /** Create the capture (record file + wrapper script) and return the spawn
62
+ * env. Idempotent: the wrapper is rewritten only when content changes. The
63
+ * opener to forward to is resolved once, at write time: an existing BROWSER
64
+ * wins, then xdg-open, then macOS open; with none available the wrapper
65
+ * records the URL and exits (headless: capture without an opener). The
66
+ * forward word-splits the BROWSER value, so flag forms (`open -a Chrome`)
67
+ * work; a BROWSER path containing spaces does not (rare, and BROWSER lists
68
+ * are not honored either - the first entry is what chains). Windows browser
69
+ * resolution differs; no capture there. */
70
+ export function setupAuthUrlCapture(dataDir = path.join(os.homedir(), ".pi", "agent", "antigravity-bridge"), env: NodeJS.ProcessEnv = process.env): AuthUrlCapture | null {
71
+ if (process.platform === "win32") return null;
72
+ const file = path.join(dataDir, "acp-auth-urls.log");
73
+ const wrapper = path.join(dataDir, "acp-browser-wrapper.sh");
74
+ try {
75
+ fs.mkdirSync(dataDir, { recursive: true });
76
+ // Truncate: the file mirrors the current login attempt only. OAuth
77
+ // URLs carry state + PKCE challenges, so keep the record private.
78
+ fs.writeFileSync(file, "", { mode: 0o600 });
79
+ const opener = env.BROWSER?.trim() || findOnPath("xdg-open", env) || findOnPath("open", env) || "";
80
+ const body = [
81
+ "#!/usr/bin/env sh",
82
+ "# Antigravity bridge: records the Google OAuth URL the ACP server hands",
83
+ "# to the browser, then forwards to the real opener. The bridge watches",
84
+ "# the record file and shows the URL in pi (copy it when logging in over",
85
+ "# SSH; forward the redirect port first).",
86
+ `printf '%s\\n' "$@" >> ${shQuote(file)}`,
87
+ `REAL=${shQuote(opener)}`,
88
+ 'if [ -n "$REAL" ]; then set -f; exec $REAL "$@"; fi',
89
+ "",
90
+ ].join("\n");
91
+ const write = (p: string, content: string, mode: number): void => {
92
+ try {
93
+ if (fs.readFileSync(p, "utf8") === content) return;
94
+ } catch {
95
+ /* new file */
96
+ }
97
+ fs.writeFileSync(p, content, { mode });
98
+ };
99
+ write(wrapper, body, 0o755);
100
+ return { browserEnv: { BROWSER: wrapper }, file, lastUrl: () => readLastUrl(file) };
101
+ } catch {
102
+ return null; // capture is best effort; login still works without it
103
+ }
104
+ }
@@ -20,8 +20,10 @@
20
20
  // LIST; a dead URL is accepted at session time (lazy connect)
21
21
 
22
22
  import { spawn, type ChildProcess } from "node:child_process";
23
+ import fs from "node:fs";
23
24
  import os from "node:os";
24
25
  import path from "node:path";
26
+ import { parseAuthPort, readLastUrl } from "./browser-capture.js";
25
27
  import { JsonRpcResponseError, JsonRpcSession } from "./jsonrpc.js";
26
28
 
27
29
  export interface AcpMcpServer {
@@ -63,6 +65,11 @@ export interface AcpConnectionOptions {
63
65
  * "auto" selects the first allow option; "deny" fail-closes to the first
64
66
  * reject option. Absent = deny (fail closed). */
65
67
  permissions?: () => "auto" | "deny";
68
+ /** Record file for BROWSER-captured OAuth URLs (src/acp/browser-capture.ts).
69
+ * The server hands the login URL only to the browser-open call, so the
70
+ * wrapper records it there; the connection watches the file and logs
71
+ * "auth-url" {url, port} for every new URL. */
72
+ authUrlFile?: string;
66
73
  }
67
74
 
68
75
  const INIT_TIMEOUT_MS = 30_000;
@@ -80,6 +87,8 @@ export class AcpConnection {
80
87
  agentInfo: Record<string, unknown> | undefined;
81
88
  /** Last known mode/model config echo from set_config_option results. */
82
89
  lastConfigOptions: unknown = undefined;
90
+ #authUrlWatcher: fs.FSWatcher | undefined;
91
+ #lastAuthUrl: string | null = null;
83
92
 
84
93
  constructor(opts: AcpConnectionOptions) {
85
94
  this.#opts = opts;
@@ -117,6 +126,7 @@ export class AcpConnection {
117
126
  ...(this.#opts.extraEnv ? { env: { ...process.env, ...this.#opts.extraEnv } } : {}),
118
127
  });
119
128
  this.#child = child;
129
+ if (this.#opts.authUrlFile) this.#watchAuthUrl();
120
130
  child.stdout?.setEncoding("utf8");
121
131
  child.stderr?.setEncoding("utf8");
122
132
  // Pipe failures arrive asynchronously as stream 'error' events; the sync
@@ -373,9 +383,36 @@ export class AcpConnection {
373
383
  }
374
384
  }
375
385
 
386
+ /** Surface OAuth login URLs captured by the BROWSER wrapper: every new
387
+ * URL in the record file is logged once as "auth-url" {url, port}. Best
388
+ * effort only - a broken watch never affects turns. */
389
+ #watchAuthUrl(): void {
390
+ const file = this.#opts.authUrlFile!;
391
+ try {
392
+ const watcher = fs.watch(file, () => this.#emitAuthUrl());
393
+ // Without a listener, a watch 'error' event escapes as uncaught.
394
+ watcher.on("error", () => {
395
+ /* record file gone: login URL surfacing is off until respawn */
396
+ });
397
+ this.#authUrlWatcher = watcher;
398
+ } catch {
399
+ /* no watch; the one-shot check below still covers earlier writes */
400
+ }
401
+ this.#emitAuthUrl(); // URLs recorded before the watch started
402
+ }
403
+
404
+ #emitAuthUrl(): void {
405
+ const url = readLastUrl(this.#opts.authUrlFile!);
406
+ if (!url || url === this.#lastAuthUrl) return;
407
+ this.#lastAuthUrl = url;
408
+ this.#opts.log("auth-url", { url, port: parseAuthPort(url) });
409
+ }
410
+
376
411
  #finish(reason: string): void {
377
412
  if (this.#exited) return;
378
413
  this.#exited = true;
414
+ this.#authUrlWatcher?.close();
415
+ this.#authUrlWatcher = undefined;
379
416
  this.abortAll(`connection exited: ${reason || "process gone"}`);
380
417
  this.#opts.onExit({ code: null, signal: null, stderrTail: this.#stderrTail });
381
418
  }
package/src/acp/driver.ts CHANGED
@@ -41,6 +41,9 @@ export interface AcpDriverOptions {
41
41
  /** Extra argv for the binary (tests: node + fake-server script). */
42
42
  binArgs?: string[];
43
43
  extraEnv?: Record<string, string>;
44
+ /** Record file for BROWSER-captured OAuth URLs; passed to the connection,
45
+ * which logs "auth-url" when the server tries to open a login. */
46
+ authUrlFile?: string;
44
47
  /** Bridge registration for session/new AND session/load. */
45
48
  mcpServers?: () => AcpMcpServer[];
46
49
  log?: (msg: string, data?: unknown) => void;
@@ -422,6 +425,7 @@ export class AcpDriver implements TurnDriver {
422
425
  bin: resolveAcpBinary(typeof this.#opts.bin === "function" ? this.#opts.bin() : this.#opts.bin),
423
426
  binArgs: this.#opts.binArgs,
424
427
  extraEnv: this.#opts.extraEnv,
428
+ authUrlFile: this.#opts.authUrlFile,
425
429
  cwd: request.cwd,
426
430
  mcpServers: this.#opts.mcpServers,
427
431
  log: (msg, data) => this.#log(msg, data),
package/src/acp/setup.ts CHANGED
@@ -227,6 +227,18 @@ export interface AuthState {
227
227
  type?: string;
228
228
  }
229
229
 
230
+ /** Token presence WITHOUT reading any credential value: acp_token.json is
231
+ * stat()ed, never opened. The token only exists after the browser login
232
+ * round-trip, so this is the real proof of a completed login. */
233
+ function tokenPresent(target: string): boolean {
234
+ try {
235
+ fs.statSync(path.join(target, "acp_token.json"));
236
+ return true;
237
+ } catch {
238
+ return false;
239
+ }
240
+ }
241
+
230
242
  /** Auth state WITHOUT reading any credential value: settings.json carries
231
243
  * only auth.type (+ non-secret gcp placement), acp_token.json is stat()ed. */
232
244
  export function readAuthState(dir?: string): AuthState {
@@ -240,12 +252,7 @@ export function readAuthState(dir?: string): AuthState {
240
252
  } catch {
241
253
  /* absent or garbage = unconfigured */
242
254
  }
243
- try {
244
- fs.statSync(path.join(target, "acp_token.json"));
245
- return { configured: true, type: "token" };
246
- } catch {
247
- return { configured: false };
248
- }
255
+ return tokenPresent(target) ? { configured: true, type: "token" } : { configured: false };
249
256
  }
250
257
 
251
258
  /** Write a minimal auth block into settings.json. The server reads this file
@@ -344,12 +351,18 @@ export async function ensureAcpReady(opts: SetupOptions = {}): Promise<AcpSetupS
344
351
  };
345
352
  }
346
353
  }
354
+ // settings.json alone proves nothing: setup writes it before the login,
355
+ // the token file only appears after the browser round-trip. oauth-personal
356
+ // without a token = login still pending, and every caller (switch toast,
357
+ // session_start self-heal) must keep saying so until the token exists.
358
+ // gemini-api-key has no browser login; a present token file means done.
359
+ const needsLogin = auth.type !== "gemini-api-key" && !tokenPresent(gdir);
347
360
  return {
348
361
  ok: true,
349
362
  bin: binary.bin!,
350
363
  binarySource: binary.source!,
351
364
  auth: auth.type ?? "token",
352
- needsLogin: false,
365
+ needsLogin,
353
366
  actions,
354
367
  };
355
368
  }
package/src/config.ts CHANGED
@@ -39,7 +39,7 @@ export interface AcpConfig {
39
39
  }
40
40
 
41
41
  export interface AgyConfig {
42
- /** Turn engine. Switching requires a pi restart (drivers wire at load). */
42
+ /** Engine. Switching requires a pi restart (drivers wire at load). */
43
43
  engine: Engine;
44
44
  /** Official-server ACP engine options (used when engine = "acp"). */
45
45
  acp: AcpConfig;