@estebanforge/pi-antigravity-bridge 1.4.10 → 1.5.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,6 +2,35 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.5.1] - 2026-09-15
6
+
7
+ ### Fixed
8
+
9
+ - Delegated agy no longer sees the pi tool bridge. `AskAntigravity` spawns `agy -p`, and any agy on the machine discovers MCP servers from the global `~/.gemini/config/mcp_config.json`, which carries a live `pi-bridge-*` entry for every running pi session. A delegation could therefore connect to a host bridge and call tools (observed live: `memory_search`), which the round-trip store denies fail-closed with "no active antigravity turn" the moment no provider turn is streaming - a dead end for the delegation and an error toast in the host session. The bridge entries are now suppressed while a delegated agy starts: disabled before the spawn, re-enabled on process close or after a 5s grace, whichever lands first. Suppression is reference-counted, so overlapping delegations cannot re-enable early; session start re-enables any entries a crashed delegation left disabled. Foreign MCP servers in the file are never touched. Residual race, documented in code: another session's provider agy respawning inside the window reads the entries disabled and that process lacks bridge tools until its next recycle.
10
+ - Real bridge tool-call failures now land on disk in default mode. The daily log records only errors, and `call-tool-fail` was logged at warn tier (toast only), so a failure like the one above left no durable trace. Genuine rejections now log at error tier; routine turn-end and shutdown aborts stay silent, with the reasons shared as constants between the emit sites and the log classifier so the two cannot drift.
11
+
12
+ ### Changed
13
+
14
+ - README corrected: the bridge mechanism section described only the dir-scoped discovery and claimed the user's global agy config is never touched. Global per-pid registration shipped earlier and is now documented, along with the delegation suppression and its window.
15
+
16
+ ## [1.5.0] - 2026-09-08
17
+
18
+ ### Added
19
+
20
+ - First-run engine picker. The choice of engine is left to the user: on a fresh install (no `config.json`, no `AGY_ENGINE`), the first interactive pi start opens a modal that explains both engines - stream-json needs the `agy` CLI installed and authenticated; ACP needs a second Google sign-in plus a ~1.5 GB server binary downloaded from Google. stream-json is preselected as the default; `esc` defers (nothing is written, the modal reappears next start). Headless sessions and existing installs are never asked.
21
+ - The ACP pick chains setup immediately instead of waiting for a restart: the ~1.5 GB server binary downloads right away (live percent in the status bar, phase milestones as chat lines), the Google sign-in opens when it lands, and a restart applies the engine. The next start's self-heal sees binary + auth settled and stays silent.
22
+ - `/agy engine` with no arguments (TUI) opens the same picker modal with identical semantics: plan mode blocks acp, an acp pick chains the download + sign-in, same-engine picks ack, esc acknowledges. Direct `/agy engine acp|stream-json` and the headless usage line are unchanged.
23
+ - Missing-CLI warning: while the stream-json engine is active and the `agy` binary cannot be found (PATH or `AGY_BIN`), every pi start warns with the official install URL (toast in the TUI, stderr headless) until the binary is detected. Auth state is not checked, presence only.
24
+
25
+ ### Fixed
26
+
27
+ - The picker's ACP line no longer claims "adds image input and native diffs": tool-result images ride both engines (probe-verified 2026-09-07) and stream-json has its own diff rendering. A negative test pin keeps the claim from returning.
28
+
29
+ ### Changed
30
+
31
+ - README streamlined: the engine capability table, switching, and setup/auth details moved to docs/ENGINES.md; the approval-gate mechanics, configuration, and sample extension moved to docs/APPROVAL-GATE.md. Both stay linked from the README, which now carries the quick surface and states that engine choice is the user's.
32
+ - Dependencies: fast-uri 3.1.5 → 3.1.7 clears four high-severity advisories (repeated hostname percent-decoding SSRF, malformed IPv6 normalization SSRF, percent-encoded scheme and IDN host confusion; in-range lockfile bump under ajv's `^3.0.1`), qs 6.15.3 → 6.16.0 clears a moderate pair (via express, dev surface). npm audit reports zero.
33
+
5
34
  ## [1.4.10] - 2026-09-07
6
35
 
7
36
  ### Added
package/README.md CHANGED
@@ -2,11 +2,16 @@
2
2
 
3
3
  A Gemini model provider **and** the `AskAntigravity` delegation tool for [pi](https://github.com/earendil-works/pi-coding-agent), 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`.
4
4
 
5
- <img width="2566" height="1723" alt="SCR-20260903-sefo" src="https://github.com/user-attachments/assets/20f0c04c-d622-4b11-962a-3478f64570c2" align="center"/>
5
+ <p>
6
+ <img width="1374" height="869" alt="image" src="https://github.com/user-attachments/assets/fdd6c1f0-5144-40ef-9371-77a94de50b76" align="center" />
7
+ </p>
8
+ <p>
9
+ <img width="1376" height="451" alt="SCR-20260908-mbox" src="https://github.com/user-attachments/assets/d105c8a7-1ace-42f7-8285-642150453761" align="center" />
10
+ </p>
11
+ <p>
12
+ <img width="1375" height="1342" alt="SCR-20260908-mbvc" src="https://github.com/user-attachments/assets/bb614c55-e0f8-4677-bb0e-6541071e91ef" align="center" />
13
+ </p>
6
14
 
7
- <br/><br/>
8
-
9
- <img width="1272" height="842" alt="SCR-20260903-sfcu" src="https://github.com/user-attachments/assets/c483613b-10ff-4d2f-9893-027d84130ac6" align="center" />
10
15
 
11
16
  If you also have [`@estebanforge/pi-ask-antigravity`](https://github.com/EstebanForge/pi-ask-antigravity) installed, this bridge takes over: pi-ask-antigravity detects the bridge and registers nothing, so the `AskAntigravity` tool is never duplicated.
12
17
 
@@ -21,35 +26,13 @@ Multi-turn works. The provider binds a pi session to an agy conversation id (per
21
26
  Turns run through one of two engines behind the same provider surface (`config.engine`, default `stream-json`):
22
27
 
23
28
  - **stream-json** (default): the persistent `agy` CLI process. The tested default; live token usage; conversation resume via `--conversation`.
24
- - **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 shows zero) and no cancel (abort tears the server down and reloads it next turn).
29
+ - **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 shows client-side ESTIMATES until Google ships usage; `acp.usageEstimate` off to keep zeros) and no cancel (abort tears the server down and reloads it next turn).
25
30
 
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. The `AskAntigravity` delegation tool is unaffected by `config.engine` and runs the `stream-json` CLI (`agy -p`) across both configurations.
31
+ 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.
27
32
 
28
- | Capability | `stream-json` (default) | `acp` (beta) |
29
- | --- | --- | --- |
30
- | Show thinking text | No (token count only, floor 64, no text body) | Yes (streams thought text via `agent_thought_chunk`; sparse on RC01 where reasoning often arrives in message text) |
31
- | Live token usage | Yes (live metrics from CLI step events) | No (absent in RC01, displays zero tokens) |
32
- | Image prompt input | No (CLI prompt is text-only; images dropped) | Yes (native image blocks forwarded to server) |
33
- | Image tool results | Yes (bridge tool results carry pixels; probe-verified 2026-09-07) | Yes (probe-verified 2026-09-05) |
34
- | Audio prompt input | No (dropped) | Protocol advertised (`promptCapabilities.audio: true`) |
35
- | Review-only plan mode | Yes (`--mode plan` review-only via `/agy mode plan`) | No (RC01 modes are permission levels; plan mode refused) |
36
- | Leading slash commands in prompt | Disabled via `--disable-slash-commands` (sent as plain text) | Server intercepts recognized commands (e.g. `/plan`) and executes them under the active policy |
37
- | Dynamic model / effort switch | Recycles process on model or effort change | Dynamic per-turn via `session/set_config_option` (no restart) |
38
- | Process lifecycle | 1 persistent `agy` process per provider; recycles on drift | 1 persistent server process hosting N sessions concurrently |
39
- | Session resume & persistence | Client-side map in `sessions.json` via `--conversation <id>` | Server-side session store via `session/load` and `session/new` |
40
- | Turn cancel / abort | Kills process group; in-flight turn terminates | Teardown, kill, and auto-reload on RC01 (-32601 fallback) |
41
- | MCP tool bridge routing | Injected filesystem config via `--add-dir` | Direct `mcpServers` param in `session/new` and `session/load` |
42
- | Tool execution & visibility | Native re-exec (read-only) + wrapper replay (mutating) | Server executes tools natively; events stream with content |
43
- | Inline file edit diffs | Sourced from git working tree in thinking block | Sourced from `tool_call content[]` or disk vs git HEAD |
44
- | Permission handling | `--dangerously-skip-permissions` (unattended CLI requirement) | Protocol-native `session/request_permission` (auto-approve when `skipPermissions` is on; auto-deny when off) |
45
- | Context digest delivery (G1) | Prepend plain text inline in prompt | Native `embeddedContext` resource block |
46
- | System prompt delivery (G10) | Prepend to first prompt of conversation | Prepend to first prompt of conversation |
47
- | Authentication methods | Inherits existing `agy` CLI OAuth state | 4 methods: `oauth-personal`, `oauth-business`, `gemini-api-key`, `agent-platform` |
48
- | Wire protocol | Undocumented CLI NDJSON stream format | Versioned JSON-RPC 2.0 over stdio (`protocolVersion: 1`) |
49
- | Diagnostics (`/agy doctor`) | Child PID, state, process spawns, recycles, queue stats | Server version, agentInfo, session counts, reconnect count, cancel support |
50
- | Integration channel | Spawns internal CLI stream-json dialect | Official Google first-party ACP server binary |
51
-
52
- 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: the same account and plan you use for the Antigravity CLI (`agy`). Sign in explicitly with `/agy auth` (engine `acp` selected): it opens the Google login in your browser and completes when you finish it. 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 auth-manual`) surface only when a step fails. Sessions are engine-scoped, so switching engines never crosses conversations.
33
+ Full capability comparison, switching, and setup/auth details: [docs/ENGINES.md](docs/ENGINES.md).
34
+
35
+ Switch and setup details live in [docs/ENGINES.md](docs/ENGINES.md): switching to `acp` self-installs Google's official server binary from the [antigravity-acp registry entry](https://github.com/agentclientprotocol/registry) and prepares the login (your Antigravity subscription, same account as the `agy` CLI; the extension never sees your credentials). `/agy auth` signs in explicitly, `/agy doctor` diagnoses, and a session start self-heals silently when everything is ready. Sessions are engine-scoped, so switching engines never crosses conversations.
53
36
 
54
37
  ## What it cannot do
55
38
 
@@ -58,20 +41,20 @@ agy runs its own closed tool loop (`read_file`, `write_file`, `edit_file`, `run_
58
41
  Residual limits (with or without the bridge):
59
42
 
60
43
  - agy's own edits still land directly on disk; pi's inline diff review does not engage for them.
61
- - agy commands run without per-action approval by default, same as every other tool in pi. The [Approval gate](#approval-gate-agy-native-tools) can put pi-side review in front of agy's mutating native tools (off by default; `auto` enables it only when a pi permission extension is installed). See also [Permissions](#permissions) below.
44
+ - agy commands run without per-action approval by default, same as every other tool in pi. The [Approval gate](docs/APPROVAL-GATE.md) can put pi-side review in front of agy's mutating native tools (off by default; `auto` enables it only when a pi permission extension is installed). See also [Permissions](#permissions) below.
62
45
  - No cost accounting: cost stays zero because agy runs on your subscription quota. Token usage is live.
63
46
 
64
47
  ## MCP tool bridge (agy uses pi's tools)
65
48
 
66
49
  While agy is the active model it normally cannot see pi's universe of extensions: agentmemory, codegraph, web search, slack/asana, the `Ask*` delegations, and any other installed pi tool. This extension optionally bridges that gap.
67
50
 
68
- The bridge starts a localhost MCP server inside pi's process. `tools/list` returns pi's registered tools (built-in file/shell tools and `AskAntigravity` are filtered out), and a `tools/call` routes into pi's own tool loop via the round-trip described below. agy discovers the server through a per-invocation config: the bridge writes `.agents/mcp_config.json` into a bridge-controlled dir (`~/.pi/agent/antigravity-bridge/agy-mcp-<pid>/`) and the provider passes that dir as an extra `--add-dir` when it spawns agy. The user's global agy config (`~/.gemini/config/mcp_config.json`) is never touched, so standalone agy outside pi is unaffected.
51
+ The bridge starts a localhost MCP server inside pi's process. `tools/list` returns pi's registered tools (built-in file/shell tools and `AskAntigravity` are filtered out), and a `tools/call` routes into pi's own tool loop via the round-trip described below. Discovery has two layers. The provider's agy gets a per-invocation config: the bridge writes `.agents/mcp_config.json` into a bridge-controlled dir (`~/.pi/agent/antigravity-bridge/agy-mcp-<pid>/`) and the driver passes that dir as an extra `--add-dir` when it spawns agy. A per-pid entry (`pi-bridge-<pid>`) is also registered in the user's global agy config (`~/.gemini/config/mcp_config.json`; foreign servers preserved, stale entries swept at start), so agy builds that read only the global config still find the bridge.
69
52
 
70
53
  **No patch required.** Bridge calls park in the provider's round-trip store; the provider ends the pi assistant message with a `toolUse` stop reason for the real pi tool, pi executes it in its own loop (native cards, permissions, hooks), and the toolResult completes the parked MCP response on the next stream call. This is the same mechanism tianzuo/pi-antigravity uses; upstream pi APIs only.
71
54
 
72
55
  **Long calls don't die.** agy's MCP client abandons a `tools/call` request at a flat ~180s, which used to kill any pi tool that ran longer (a long peer review, a build, a commit preview waiting for you). A call still running after ~20 seconds now settles its HTTP request with a `STILL RUNNING` answer carrying a `callId` while pi keeps executing; agy fetches the result through the bridge-local `bridge_poll_result` tool and polls until it lands. Escalated calls get their own 30-minute budget, so human-gated tools can take as long as the human takes. Fast calls stay fully synchronous and never see any of this. If a park does fail (abort, timeout, recycle), the late result is re-routed to agy as a follow-up prompt in the same conversation instead of being lost.
73
56
 
74
- **Recursion safety.** Only the provider's agy receives the extra `--add-dir`. The `AskAntigravity` tool spawns its own agy with just the workspace, so that inner agy starts plain (no pi tools) and cannot re-enter. `AskAntigravity` is also filtered from the exposed tool list. Standalone agy is unaffected because nothing is written to its global config.
57
+ **Recursion safety.** Only the provider's agy receives the extra `--add-dir`. `AskAntigravity` is also filtered from the exposed tool list. Its delegated `agy -p` spawns with just the workspace, and while it starts the bridge suppresses its global per-pid entries: disabled before the spawn, re-enabled on process close or after a 5s grace (whichever lands first), refcounted across overlapping delegations, and re-enabled at every session start. The inner agy therefore sees no bridge and cannot re-enter pi. A hand-run agy outside pi that starts inside the seconds-wide window briefly misses the bridge; foreign servers in the global config are never touched.
75
58
 
76
59
  **Cost / fan-out.** Every registered pi tool except builtins (and `AskAntigravity`) is exposed, including other delegation tools like `AskClaude`/`AskCodex`. agy can therefore chain into other models via the bridge, which is a new cost/time fan-out vector that did not exist before this feature.
77
60
 
@@ -94,35 +77,9 @@ bridge port, and the last lifecycle events without spending tokens.
94
77
 
95
78
  ## Approval gate (agy native tools)
96
79
 
97
- agy is not a plain model: it runs its own agent loop with its own native tools (`run_command`, `create_file`, `edit_file`, ...). Those calls execute inside agy with no pi involvement, so pi's permission extensions never saw them. The approval gate closes that gap: agy native tool calls pass through a pi-side approval in a form the existing permission-extension ecosystem gates with zero changes.
98
-
99
- Mechanics: the extension stages an `.agents/hooks.json` group in the workspace; the Antigravity server/CLI fires a `PreToolUse` hook before each mutating native tool runs. The hook script (generated, per-pid, mode 0600 because it embeds the bridge token) POSTs the call to the bridge and polls for a decision. The bridge parks it, the provider interrupts the pi-side view of the agy turn with a `toolUse` for a SHADOW tool named `bash`/`write`/`edit` (same schema as the real builtin plus internal `__agy*` marker fields), and pi's whole `tool_call` surface applies: any installed permission extension gates the call unchanged, and only if nothing blocks does the fallback policy run. The decision travels back to the hook and agy enforces it; on deny the reason text is what agy's model sees. Marker calls never execute locally (a ticket check denies forged ones); non-marker calls delegate to a factory twin of the real builtin, so normal pi bash/write/edit behavior is unchanged. Read-only agy tools stay ungated. Every decision lands in the daily log with tool names, source, and latency.
100
-
101
- Configuration (`/agy` config keys or environment):
80
+ agy runs its own agent loop with native tools (`run_command`, `create_file`, `edit_file`, ...), which pi's permission extensions never saw. The optional approval gate routes those calls through a pi-side approval: a staged PreToolUse hook parks the call, the provider surfaces it as a shadow `bash`/`write`/`edit` toolUse, and your permission extension (or the built-in ask/allow/deny fallback) decides before agy executes it. Off by default (`approvals.gateMode: auto` enables it only when a pi permission extension is detected); denials fail closed; read-only agy tools stay ungated.
102
81
 
103
- ```jsonc
104
- {
105
- "approvals": {
106
- "gateMode": "auto", // auto | shadow | dedicated | off
107
- "mode": "ask" // ask | allow | deny (fallback when no extension gates)
108
- }
109
- }
110
- // env: AGY_APPROVALS=shadow AGY_APPROVALS_MODE=ask
111
- ```
112
-
113
- `auto` (default) keeps the gate OFF until one of the known pi permission packages is detected (pi settings `packages` name-match or known config markers). `shadow` forces it on; `off` forces it off. `dedicated` currently stages the same shadow tools (the explicit `antigravity_approve` variant is planned; the config value is accepted today so the schema is stable). The fallback `mode` is consulted only when no extension blocked the call: `ask` shows a pi confirm dialog (headless runs deny, fail-closed), `allow` approves, `deny` blocks. Timeouts deny fail-closed: the staged hook timeout always exceeds the park budget, but a hook that outlives its timeout soft-passes upstream (verified against the ACP server), so the park always answers first.
114
-
115
- If you write your own gate extension, it sees a normal pi tool call:
116
-
117
- ```typescript
118
- export default function (pi) {
119
- pi.on("tool_call", async (event) => {
120
- if (event.input?.__agyGate && event.input.command?.startsWith("rm ")) {
121
- return { block: true, reason: "rm is not allowed through the agy gate" };
122
- }
123
- });
124
- }
125
- ```
82
+ Full mechanics, configuration, and a sample gate extension: [docs/APPROVAL-GATE.md](docs/APPROVAL-GATE.md).
126
83
 
127
84
  ## Install
128
85
 
@@ -134,7 +91,7 @@ Install with pi's package manager:
134
91
  pi install npm:@estebanforge/pi-antigravity-bridge
135
92
  ```
136
93
 
137
- Requires the **`agy` CLI** installed and authenticated. If you don't have it, follow Google's [official install guide](https://antigravity.google/docs/cli/install) for your platform, then run `agy` once to complete Google OAuth. The extension resolves `agy` on `$PATH`, or via the `AGY_BIN` environment variable.
94
+ Requires the **`agy` CLI** installed and authenticated. If you don't have it, follow Google's [official install guide](https://antigravity.google/docs/cli/install) for your platform, then run `agy` once to complete Google OAuth. The extension resolves `agy` on `$PATH`, or via the `AGY_BIN` environment variable. While the stream-json engine is active and the binary cannot be found, pi warns on every start (toast in the TUI, stderr headless) pointing at the install guide; the warning stops once the binary is detected.
138
95
 
139
96
  ## Usage
140
97
 
@@ -190,7 +147,7 @@ The `activate_skill` catalog mirrors pi's directory-based skill discovery: the t
190
147
  /agy system-prompt on|off send pi's system prompt + AGENTS.md + the Pi Bridge tool-priority note to new agy conversations (default on)
191
148
  /agy bridge all|mcp|none which pi tools the MCP bridge exposes to agy (default all; none = bridge off)
192
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)
193
- /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)
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)
194
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)
195
152
  /agy patch-cleanup restore the original pi files if an older version patched them
196
153
  /agy clear drop all session bindings (force fresh conversations)
@@ -204,7 +161,7 @@ Because agy runs non-interactively under this provider (nothing can answer a `y/
204
161
 
205
162
  If you want agy to execute nothing, use `/agy mode plan`. Do not combine `--sandbox` with skip-permissions ([#36](https://github.com/google-antigravity/antigravity-cli/issues/36)).
206
163
 
207
- For per-action review of agy's mutating native tools (`run_command`, `create_file`, `edit_file`, ...), see the [Approval gate](#approval-gate-agy-native-tools): with it on, the call must pass a pi-side approval (your permission extension, or the built-in ask/allow/deny fallback) before agy executes it.
164
+ For per-action review of agy's mutating native tools (`run_command`, `create_file`, `edit_file`, ...), see the [Approval gate](docs/APPROVAL-GATE.md): with it on, the call must pass a pi-side approval (your permission extension, or the built-in ask/allow/deny fallback) before agy executes it.
208
165
 
209
166
  ### Run pi inside a sandbox
210
167
 
@@ -0,0 +1,33 @@
1
+ # Approval gate (agy native tools)
2
+
3
+ agy is not a plain model: it runs its own agent loop with its own native tools (`run_command`, `create_file`, `edit_file`, ...). Those calls execute inside agy with no pi involvement, so pi's permission extensions never saw them. The approval gate closes that gap: agy native tool calls pass through a pi-side approval in a form the existing permission-extension ecosystem gates with zero changes.
4
+
5
+ Mechanics: the extension stages an `.agents/hooks.json` group in the workspace; the Antigravity server/CLI fires a `PreToolUse` hook before each mutating native tool runs. The hook script (generated, per-pid, mode 0600 because it embeds the bridge token) POSTs the call to the bridge and polls for a decision. The bridge parks it, the provider interrupts the pi-side view of the agy turn with a `toolUse` for a SHADOW tool named `bash`/`write`/`edit` (same schema as the real builtin plus internal `__agy*` marker fields), and pi's whole `tool_call` surface applies: any installed permission extension gates the call unchanged, and only if nothing blocks does the fallback policy run. The decision travels back to the hook and agy enforces it; on deny the reason text is what agy's model sees. Marker calls never execute locally (a ticket check denies forged ones); non-marker calls delegate to a factory twin of the real builtin, so normal pi bash/write/edit behavior is unchanged. Read-only agy tools stay ungated. Every decision lands in the daily log with tool names, source, and latency.
6
+
7
+ Configuration (`/agy` config keys or environment):
8
+
9
+ ```jsonc
10
+ {
11
+ "approvals": {
12
+ "gateMode": "auto", // auto | shadow | dedicated | off
13
+ "mode": "ask" // ask | allow | deny (fallback when no extension gates)
14
+ }
15
+ }
16
+ // env: AGY_APPROVALS=shadow AGY_APPROVALS_MODE=ask
17
+ ```
18
+
19
+ `auto` (default) keeps the gate OFF until one of the known pi permission packages is detected (pi settings `packages` name-match or known config markers). `shadow` forces it on; `off` forces it off. `dedicated` currently stages the same shadow tools (the explicit `antigravity_approve` variant is planned; the config value is accepted today so the schema is stable). The fallback `mode` is consulted only when no extension blocked the call: `ask` shows a pi confirm dialog (headless runs deny, fail-closed), `allow` approves, `deny` blocks. Timeouts deny fail-closed: the staged hook timeout always exceeds the park budget, but a hook that outlives its timeout soft-passes upstream (verified against the ACP server), so the park always answers first.
20
+
21
+ If you write your own gate extension, it sees a normal pi tool call:
22
+
23
+ ```typescript
24
+ export default function (pi) {
25
+ pi.on("tool_call", async (event) => {
26
+ if (event.input?.__agyGate && event.input.command?.startsWith("rm ")) {
27
+ return { block: true, reason: "rm is not allowed through the agy gate" };
28
+ }
29
+ });
30
+ }
31
+ ```
32
+
33
+ Live end-to-end verification (drive a real mutating agy turn through the gate) is still pending; see docs/TODO.md section 1.
@@ -27,11 +27,12 @@ src/config.ts persisted runtime config (engine + acp block, bridgeTools,
27
27
  src/daily-log.ts daily NDJSON support log (one file per day, 14-day retention, secret redaction, AGY_DEBUG verbose gate); fed by both drivers, the bridge, round-trips, /agy, and ask-tool
28
28
  src/ask-tool.ts the AskAntigravity one-shot delegation tool (model/thinking defaults)
29
29
  src/mcp-server.ts MCP tool bridge server: ferries tools/list + tools/call; calls park in the provider round-trip. Also the approval park: POST /approval (ticket early-ack) + GET /approval/<id>, fail-closed on timeout/unwired/close
30
- src/mcp-registration.ts registers/unregisters the bridge in ~/.gemini/config/mcp_config.json for the stream-json CLI (per-pid, atomic, stale sweep)
30
+ src/mcp-registration.ts registers/unregisters the bridge in ~/.gemini/config/mcp_config.json for the stream-json CLI (per-pid, atomic, stale sweep) and flips our entries off around AskAntigravity spawns (refcounted; session start re-enables)
31
31
  src/approval-gate.ts shadow tool factory (bash/write/edit): marker calls are ticket-verified approval round-trips, non-marker calls delegate; maps agy native tools onto the shadow surface
32
32
  src/approval-detect.ts third-party pi permission-extension detection; resolves approvals.gateMode auto (off until a gate extension exists)
33
33
  src/approval-hook.ts merge-safe .agents/hooks.json staging (PreToolUse) + generated 0600 early-ack/poll hook script; staged timeout exceeds the park budget (hook timeouts soft-pass)
34
34
  src/diff-render.ts stream-json: render agy's file edits as git diffs in pi's thinking stream; formatInlineDiff (no git) renders ACP's native diffs
35
+ src/engine-picker.ts first-run onboarding: engine picker overlay (SelectList + DynamicBorder), first-run gate (no config file + no AGY_ENGINE), agy binary detection (PATH / AGY_BIN), missing-CLI toast copy
35
36
  src/driver-types.ts TurnDriver contract shared by both engines (request/handle/snapshot types)
36
37
  src/acp/jsonrpc.ts NDJSON JSON-RPC 2.0 framing with line buffering and typed error results
37
38
  src/acp/connection.ts ACP server process + protocol (initialize, session/new+load, prompt with image/resource blocks, config options, cancel probing, auto permissions)
@@ -98,7 +98,8 @@ Most "stuck" reports trace to one of:
98
98
  - `tests/acp-driver.test.ts` - the ACP driver over the fake server (`tests/helpers/fake-acp-server.mjs`, scenario-selected): happy flow, load-replay, permission auto-answer, Gate D abort (cancel probe, teardown, `cancelSupported` memory), the stale-exit race (a killed connection's late exit must not fail its replacement - `ACP_FAKE_SLOW_DEATH_MS`), auth errors, park/kickIdle timer pause with remaining budget.
99
99
  - `tests/acp-config.test.ts` - engine selection narrowing (`AGY_ENGINE`/`config.engine`), acp block parsing.
100
100
  - `tests/daily-log.test.ts` - the support log: day rotation, retention cutoff boundary, secret redaction (incl. header blocks), the 4 KB record cap, never-throw on a broken dir, and the two-tier gate (debug records dropped unless `AGY_DEBUG`).
101
- - `tests/mcp-registration.test.ts` - the `~/.gemini/config/mcp_config.json` registration for the stream-json CLI: exact agy entry shape, foreign servers preserved, corrupt config refused, atomic writes, stale-entry sweep.
101
+ - `tests/mcp-registration.test.ts` - the `~/.gemini/config/mcp_config.json` registration for the stream-json CLI: exact agy entry shape, foreign servers preserved, corrupt config refused, atomic writes, stale-entry sweep, delegation suppression (`setBridgeEntriesDisabled` flip/restore/idempotence, refcounted `acquireBridgeSuppression`).
102
+ - `tests/engine-picker.test.ts` - first-run onboarding: picker gate (no config file + no AGY_ENGINE, fail-closed fs), option order (stream-json first), the ACP download/sign-in disclosure pins (intro + saved toast), agy binary detection (PATH scan, explicit path, fail-closed), missing-CLI toast copy.
102
103
  - `tests/provider-escalation.test.ts` - the early-ack + poll pipeline (escalation registry, poll views, late-delivery tombstones) and tool-result image forwarding on both engines.
103
104
  - `tests/approval-gate.test.ts` - the shadow tool factory: marker calls never execute, ticket verification denies forged/stale markers before the policy, denials throw, native-to-shadow mapping.
104
105
  - `tests/approval-park.test.ts` - the approval park end-to-end over a real (port 0) server: POST ticket early-ack, poll pending -> terminal, timeout deny, ungated/unwired deny, close fail-closed, provider round-trip (allow / block-deny / timeout), `__agy*` strip on G9 args.
@@ -0,0 +1,46 @@
1
+ # Engines
2
+
3
+ Turns run through one of two engines behind the same provider surface (`config.engine`, default `stream-json`). The choice of engine is left to the user: a first-run picker explains the trade-offs and asks once, and `/agy engine` (no arguments, TUI) reopens the same modal anytime. Direct switches work too: `/agy engine acp|stream-json`.
4
+
5
+ - **stream-json** (default): the persistent `agy` CLI process. The tested default; live token usage; conversation resume via `--conversation`.
6
+ - **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 shows client-side ESTIMATES until Google ships usage; `acp.usageEstimate` off to keep zeros) and no cancel (abort tears the server down and reloads it next turn).
7
+
8
+ ## First run
9
+
10
+ On a fresh install (no `config.json` yet), the first interactive pi start opens a picker modal that explains both engines - stream-json needs the `agy` CLI installed and authenticated; ACP needs a second Google sign-in plus a ~1.5 GB server binary downloaded from Google. stream-json is preselected (the default); `esc` decides later (nothing is written, the modal reappears on the next start).
11
+
12
+ Picking **acp** starts the server download immediately (progress in the status bar, milestones in the chat), then opens the Google sign-in; a restart applies the engine. Picking **stream-json** persists and toasts; while that engine is active and the `agy` binary is missing, pi warns on every start with the install link until the binary shows up.
13
+
14
+ ## Capabilities
15
+
16
+ | Capability | `stream-json` (default) | `acp` (beta) |
17
+ | --- | --- | --- |
18
+ | Show thinking text | No (token count only, floor 64, no text body) | Yes (streams thought text via `agent_thought_chunk`; sparse on RC01 where reasoning often arrives in message text) |
19
+ | Live token usage | Yes (live metrics from CLI step events) | Estimated client-side (absent in RC01; `acp.usageEstimate`, default on) |
20
+ | Image prompt input | No (CLI prompt is text-only; images dropped) | Yes (native image blocks forwarded to server) |
21
+ | Image tool results | Yes (bridge tool results carry pixels; probe-verified 2026-09-07) | Yes (probe-verified 2026-09-05) |
22
+ | Audio prompt input | No (dropped) | Protocol advertised (`promptCapabilities.audio: true`) |
23
+ | Review-only plan mode | Yes (`--mode plan` review-only via `/agy mode plan`) | No (RC01 modes are permission levels; plan mode refused) |
24
+ | Leading slash commands in prompt | Disabled via `--disable-slash-commands` (sent as plain text) | Server intercepts recognized commands (e.g. `/plan`) and executes them under the active policy |
25
+ | Dynamic model / effort switch | Recycles process on model or effort change | Dynamic per-turn via `session/set_config_option` (no restart) |
26
+ | Process lifecycle | 1 persistent `agy` process per provider; recycles on drift | 1 persistent server process hosting N sessions concurrently |
27
+ | Session resume & persistence | Client-side map in `sessions.json` via `--conversation <id>` | Server-side session store via `session/load` and `session/new` |
28
+ | Turn cancel / abort | Kills process group; in-flight turn terminates | Teardown, kill, and auto-reload on RC01 (-32601 fallback) |
29
+ | MCP tool bridge routing | Injected filesystem config via `--add-dir` | Direct `mcpServers` param in `session/new` and `session/load` |
30
+ | Tool execution & visibility | Native re-exec (read-only) + wrapper replay (mutating) | Server executes tools natively; events stream with content |
31
+ | Inline file edit diffs | Sourced from git working tree in thinking block | Sourced from `tool_call content[]` or disk vs git HEAD |
32
+ | Permission handling | `--dangerously-skip-permissions` (unattended CLI requirement) | Protocol-native `session/request_permission` (auto-approve when `skipPermissions` is on; auto-deny when off) |
33
+ | Context digest delivery (G1) | Prepend plain text inline in prompt | Native `embeddedContext` resource block |
34
+ | System prompt delivery (G10) | Prepend to first prompt of conversation | Prepend to first prompt of conversation |
35
+ | Authentication methods | Inherits existing `agy` CLI OAuth state | 4 methods: `oauth-personal`, `oauth-business`, `gemini-api-key`, `agent-platform` |
36
+ | Wire protocol | Undocumented CLI NDJSON stream format | Versioned JSON-RPC 2.0 over stdio (`protocolVersion: 1`) |
37
+ | Diagnostics (`/agy doctor`) | Child PID, state, process spawns, recycles, queue stats | Server version, agentInfo, session counts, reconnect count, cancel support |
38
+ | Integration channel | Spawns internal CLI stream-json dialect | Official Google first-party ACP server binary |
39
+
40
+ ## Engine-dependent features
41
+
42
+ 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. The `AskAntigravity` delegation tool is unaffected by `config.engine` and runs the `stream-json` CLI (`agy -p`) across both configurations.
43
+
44
+ ## Switching and setup
45
+
46
+ Switch with `/agy engine acp|stream-json` (takes effect on restart), or run `/agy engine` with no arguments for the same picker modal as first run (an `acp` pick there runs the same download + sign-in chain). 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](ACP-ADOPTION-PLAN.md)) and prepares the login. The login is your Antigravity subscription: the same account and plan you use for the Antigravity CLI (`agy`). Sign in explicitly with `/agy auth` (engine `acp` selected): it opens the Google login in your browser and completes when you finish it. 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 auth-manual`) surface only when a step fails. Sessions are engine-scoped, so switching engines never crosses conversations.
package/docs/TODO.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## 1. Approval gate: live end-to-end verification
4
4
 
5
- The gate is wired and unit-pinned (304 tests) but has never run against a live
5
+ The gate is wired and unit-pinned (333 tests) but has never run against a live
6
6
  agy turn. Enable it, drive an agy turn that mutates a file, and watch the
7
7
  round trip: PreToolUse hook -> POST /approval park -> shadow toolUse ->
8
8
  decision -> hook stdout -> agy enforces.
@@ -66,11 +66,13 @@ import { setupAuthUrlCapture } from "../src/acp/browser-capture.js";
66
66
  import { ensureAcpReady, inspectAcpSetup } from "../src/acp/setup.js";
67
67
  import type { TurnDriver, TurnOutcome } from "../src/driver-types.js";
68
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";
69
70
  import { createDailyLogger, type DailyLogger } from "../src/daily-log.js";
70
71
  import { registerAskAntigravityTool, toolModelsFromRaw } from "../src/ask-tool.js";
71
72
  import { startMcpServer, TOKEN_HEADER, type McpServerHandle } from "../src/mcp-server.js";
72
73
  import {
73
74
  registerBridgeServer,
75
+ setBridgeEntriesDisabled,
74
76
  sweepStaleBridgeServers,
75
77
  unregisterBridgeServer,
76
78
  } from "../src/mcp-registration.js";
@@ -130,6 +132,12 @@ export default async function (pi: ExtensionAPI): Promise<void> {
130
132
  const modelInput: Array<"text" | "image"> = engine === "acp" ? ["text", "image"] : ["text"];
131
133
  const models = entries.map((e) => toPiModel(e, modelInput));
132
134
 
135
+ // failAll reasons the bridge treats as routine (turn end / shutdown
136
+ // sweep), not faults. Shared by the emitters below and the mcpLog
137
+ // classifier so the substring match cannot drift from the text.
138
+ const FAIL_REASON_TURN_END = "antigravity turn ended with an unresolved pi tool call";
139
+ const FAIL_REASON_SHUTDOWN = "antigravity session shut down";
140
+
133
141
  // Daily file log: every sink below feeds ~/.pi/extensions-data/
134
142
  // estebanforge/pi-antigravity-bridge/logs/<YYYY-MM-DD>.ndjson (see
135
143
  // src/daily-log.ts). Fire-and-forget, secrets redacted, old days pruned.
@@ -139,14 +147,15 @@ export default async function (pi: ExtensionAPI): Promise<void> {
139
147
  // routine disk traffic), so a warn that never reaches the UI is lost.
140
148
  // Every warn toasts here instead; AGY_DEBUG=1 restores the full file
141
149
  // trail. Silent exceptions: deliberate aborts (pi already shows
142
- // "Operation aborted"), connection exits (the turn's own error block
143
- // carries real crashes), and call-tool-fail (same-instant duplicate of
144
- // round-trip-fail). No UI (headless): warn text falls back to stderr.
150
+ // "Operation aborted") and connection exits (the turn's own error block
151
+ // carries real crashes). call-tool-fail needs no exclusion here: its tier
152
+ // is debug or error, never warn. No UI (headless): warn text falls back
153
+ // to stderr.
145
154
  const rawFileLog = fileLog.log.bind(fileLog);
146
155
  fileLog.log = (event, data, level) => {
147
156
  rawFileLog(event, data, level);
148
157
  if (level !== "warn") return;
149
- if (event.startsWith("abort:") || event === "connection-exited" || event === "call-tool-fail") return;
158
+ if (event.startsWith("abort:") || event === "connection-exited") return;
150
159
  const d = (data ?? {}) as Record<string, unknown>;
151
160
  let text: string;
152
161
  if (event === "round-trip-fail") {
@@ -181,6 +190,8 @@ export default async function (pi: ExtensionAPI): Promise<void> {
181
190
  // ACP self-heal runs once per process (session_start re-fires on /reload;
182
191
  // a ready setup is two file stats, so re-running is harmless anyway).
183
192
  let acpSelfHealRan = false;
193
+ // Warn-once-per-process flag for the missing-agy-CLI toast (stream-json).
194
+ let agyMissingWarned = false;
184
195
  // OAuth URL capture: the server hands the login URL only to the
185
196
  // browser-open call (nothing on stdio), so a BROWSER wrapper records it
186
197
  // and the driver logs it as "auth-url". Local users keep the automatic
@@ -276,6 +287,10 @@ export default async function (pi: ExtensionAPI): Promise<void> {
276
287
  // Resolved per connection: the setup flow can install the binary and
277
288
  // update acp.bin mid-session; the next turn picks it up (no restart).
278
289
  bin: () => loadConfig().acp.bin,
290
+ // Resolved per turn: /agy and AGY_USAGE_ESTIMATE changes apply without
291
+ // a restart. Without this the driver defaults to "estimate" and the
292
+ // config knob (incl. "off") is dead.
293
+ usageEstimate: () => loadConfig().acp.usageEstimate,
279
294
  ...(authCapture ? { extraEnv: authCapture.browserEnv, authUrlFile: authCapture.file } : {}),
280
295
  log: acpLog,
281
296
  mcpServers: () => {
@@ -334,7 +349,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
334
349
  { status: outcome.status, error: outcome.error?.slice(0, 500), aborted: outcome.aborted },
335
350
  outcome.status === "OK" ? "info" : "warn",
336
351
  );
337
- roundTrips.failAll("antigravity turn ended with an unresolved pi tool call");
352
+ roundTrips.failAll(FAIL_REASON_TURN_END);
338
353
  };
339
354
  streamDriver.onTurnEnd = onTurnEnd;
340
355
  acpDriver.onTurnEnd = onTurnEnd;
@@ -380,6 +395,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
380
395
  acpLog,
381
396
  fileLog,
382
397
  authCapture: authCapture ?? null,
398
+ runAcpPickSetup,
383
399
  });
384
400
 
385
401
  // AskAntigravity tool: one-shot delegation to agy (ported from
@@ -412,12 +428,115 @@ export default async function (pi: ExtensionAPI): Promise<void> {
412
428
  },
413
429
  });
414
430
 
431
+ /** ACP pick follow-through (first-run wizard): the same self-service
432
+ * setup the /agy engine acp command runs, but immediately - the 1.5 GB
433
+ * download starts while the toast is still on screen, progress rides the
434
+ * footer status, and the Google sign-in opens when the install lands.
435
+ * Restart still applies the engine (drivers wire at load); this only
436
+ * removes the wait. Fire-and-forget: the caller already toasted the
437
+ * promise, failures land in the daily log + a warning toast. */
438
+ // eslint-disable-next-line @typescript-eslint/no-inner-declarations -- hoisted: registerAgyCommand below injects it
439
+ async function runAcpPickSetup(ctx: { ui: ExtensionUIContext }): Promise<void> {
440
+ acpSelfHealRan = true;
441
+ let lastPhase = "";
442
+ ctx.ui.setStatus("agy-acp", "downloading ACP server…");
443
+ try {
444
+ const status = await ensureAcpReady({
445
+ configBin: loadConfig().acp.bin,
446
+ onProgress: (m) => {
447
+ // Dual surface: the status bar carries the live percent (cleared
448
+ // on completion, zero footprint); the chat window gets phase
449
+ // milestones only (download start, unpacking, installed) - same
450
+ // line-in-chat feel as other extensions' notify() notices. The
451
+ // percent variant updates every chunk and would spam the chat.
452
+ ctx.ui.setStatus("agy-acp", m);
453
+ if (m !== lastPhase && !/\d+%/.test(m)) {
454
+ ctx.ui.notify(m, "info");
455
+ lastPhase = m;
456
+ }
457
+ },
458
+ });
459
+ ctx.ui.setStatus("agy-acp", undefined);
460
+ fileLog.log(
461
+ "acp-setup",
462
+ status.ok
463
+ ? { ok: true, binarySource: status.binarySource, needsLogin: status.needsLogin }
464
+ : { ok: false, error: status.error },
465
+ status.ok ? "info" : "warn",
466
+ );
467
+ if (!status.ok) {
468
+ ctx.ui.notify(`ACP auto-setup failed (${status.error}).\n${status.manual}`, "warning");
469
+ return;
470
+ }
471
+ // Spread, not a bare acp patch: a bare {bin} patch would drop
472
+ // sibling keys (usageEstimate) from the file.
473
+ saveConfig({ acp: { ...loadConfig().acp, bin: status.bin } });
474
+ if (!status.needsLogin) {
475
+ ctx.ui.notify(`ACP server ready (auth: ${status.auth}). Restart applies the engine.`, "info");
476
+ return;
477
+ }
478
+ ctx.ui.notify(
479
+ "ACP server ready. Signing in: the Google sign-in opens in your browser and completes when you finish it.",
480
+ "info",
481
+ );
482
+ const r = await runAcpAuth({
483
+ bin: status.bin,
484
+ ...(authCapture ? { extraEnv: authCapture.browserEnv, authUrlFile: authCapture.file } : {}),
485
+ log: acpLog,
486
+ });
487
+ fileLog.log("acp-auth", r.ok ? { ok: true } : { ok: false, error: r.error }, r.ok ? "info" : "warn");
488
+ if (r.ok) ctx.ui.notify("Signed in. The ACP engine is ready; restart applies it.", "info");
489
+ else ctx.ui.notify(`ACP sign-in failed (${r.error}).\nRun /agy auth to retry; /agy auth-manual has manual steps.`, "warning");
490
+ } catch (err) {
491
+ ctx.ui.setStatus("agy-acp", undefined);
492
+ fileLog.log("acp-setup", { error: String(err) }, "warn");
493
+ ctx.ui.notify(`ACP setup failed (${String(err)}). /agy auth retries; /agy doctor inspects.`, "warning");
494
+ }
495
+ }
496
+
415
497
  // MCP tool bridge: expose pi's tools to agy over localhost Streamable HTTP.
416
498
  // Calls park in the provider's round-trip store and complete through pi's
417
499
  // normal toolUse loop (native cards, permissions, hooks) - no patch, no
418
500
  // privileged API. Started on session_start, torn down on session_shutdown.
419
- pi.on("session_start", async (_event, ctx) => {
501
+ pi.on("session_start", async (event, ctx) => {
420
502
  if (ctx.hasUI) activeUi = ctx.ui;
503
+ // First-run engine picker: ask once, on the first interactive start,
504
+ // which turn engine to use. Skipped headless (ctx.mode !== "tui"),
505
+ // when AGY_ENGINE is set, or once any config file exists (any save -
506
+ // even of an unrelated knob - means the user has been here before).
507
+ // esc = decide later: nothing is written, the picker reappears next
508
+ // start. Like /agy engine, the choice applies on the next start
509
+ // (drivers wire at load). The await intentionally runs before the
510
+ // bridge startup below: on a genuine first run the modal blocks input
511
+ // anyway, so the delay is invisible.
512
+ if (event.reason === "startup" && ctx.mode === "tui" && shouldOfferEnginePicker(CONFIG_PATH)) {
513
+ // Best-effort, like the legacy-patch notice below: a picker failure
514
+ // (mid-prompt TUI teardown, resize races) must never take down the
515
+ // rest of session_start - the MCP bridge startup included. The
516
+ // default engine keeps working untouched.
517
+ try {
518
+ const picked = await showEnginePicker(ctx.ui);
519
+ if (picked) {
520
+ saveConfig({ engine: picked });
521
+ ctx.ui.notify(savedEngineMessage(picked), "info");
522
+ if (picked === "acp") void runAcpPickSetup(ctx);
523
+ }
524
+ } catch (err) {
525
+ fileLog.log("engine-picker", { error: String(err) }, "warn");
526
+ console.error(`[antigravity-bridge] engine picker failed: ${String(err)}`);
527
+ }
528
+ }
529
+ // agy presence check (stream-json engine): the CLI is the whole engine,
530
+ // so a missing binary means every Antigravity turn would fail. Warn on
531
+ // every process start until it is installed (per-process flag so /new,
532
+ // /resume and /reload re-fires do not nag mid-session). Runs after the
533
+ // picker above, so a first-run stream-json pick warns immediately.
534
+ if (engine === "stream-json" && !agyMissingWarned && !isAgyInstalled(binary)) {
535
+ agyMissingWarned = true;
536
+ const msg = agyMissingMessage();
537
+ if (ctx.hasUI) ctx.ui.notify(msg, "warning");
538
+ else console.error(`[antigravity-bridge] ${msg}`);
539
+ }
421
540
  // Legacy cleanup: users who ran the old consent-gated patcher still
422
541
  // carry pi.invokeTool in their installed pi. Inert, but tell them once
423
542
  // and offer /agy patch-cleanup. Never auto-edits the install.
@@ -451,7 +570,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
451
570
  );
452
571
  if (status.ok) {
453
572
  if (status.binarySource === "installed" || status.binarySource === "existing") {
454
- saveConfig({ acp: { bin: status.bin, permissions: loadConfig().acp.permissions } });
573
+ saveConfig({ acp: { ...loadConfig().acp, bin: status.bin } });
455
574
  }
456
575
  if (status.needsLogin) {
457
576
  const msg = acpLoginPending();
@@ -482,18 +601,27 @@ export default async function (pi: ExtensionAPI): Promise<void> {
482
601
  // filters below only decide what reaches the user. Bridge calls
483
602
  // start/end at info (one record per tool call, the fragile-path
484
603
  // skeleton); list-tools and startup chatter stay verbose.
485
- const level = failures.has(s)
486
- ? "warn"
487
- : s === "call-tool" || s === "call-tool-ok"
604
+ // Routine abort traffic (failAll on turn end / session shutdown
605
+ // answers every parked call with an error) is not a fault: debug
606
+ // only. Any other call-tool-fail is a real rejection (e.g. "no
607
+ // active antigravity turn" from a client that should not see the
608
+ // bridge) and lands at error tier - default mode records errors
609
+ // only, so this is the sole durable trace of the incident.
610
+ const detail = (d as { msg?: string } | undefined)?.msg ?? "";
611
+ const routineAbort =
612
+ s === "call-tool-fail" &&
613
+ (detail.includes(FAIL_REASON_TURN_END) || detail.includes(FAIL_REASON_SHUTDOWN));
614
+ const level = !failures.has(s)
615
+ ? s === "call-tool" || s === "call-tool-ok"
488
616
  ? "info"
489
- : "debug";
617
+ : "debug"
618
+ : routineAbort
619
+ ? "debug"
620
+ : s === "call-tool-fail"
621
+ ? "error"
622
+ : "warn";
490
623
  fileLog.log(s, d, level);
491
- // Routine abort traffic: failAll fires on turn end / session shutdown
492
- // and the bridge answers every parked call with an error. Not a fault.
493
- if (s === "call-tool-fail") {
494
- const detail = (d as { msg?: string } | undefined)?.msg ?? "";
495
- if (detail.includes("unresolved pi tool call") || detail.includes("session shut down")) return;
496
- }
624
+ if (routineAbort) return;
497
625
  if (!failures.has(s)) return;
498
626
  const msg = `[antigravity-bridge mcp] ${s}${d !== undefined ? " " + JSON.stringify(d) : ""}`;
499
627
  if (ctx.hasUI) ctx.ui.notify(msg, "warning");
@@ -597,11 +725,11 @@ export default async function (pi: ExtensionAPI): Promise<void> {
597
725
  );
598
726
  if (r.ok && r.handle) {
599
727
  mcpHandle = r.handle;
600
- // Stream-json engine registration: the agy CLI discovers MCP servers
601
- // from ~/.gemini/config/mcp_config.json (ACP uses session/new
602
- // mcpServers instead; verified live 2026-09-07). Per-pid entry,
603
- // removed at session_shutdown; stale entries swept at start.
728
+ // Stale entries swept at start; entries a crashed delegation left
729
+ // suppressed are healed here (live bridges start every session
730
+ // enabled).
604
731
  sweepStaleBridgeServers();
732
+ setBridgeEntriesDisabled(false);
605
733
  registerBridgeServer({
606
734
  pid: process.pid,
607
735
  port: r.handle.port,
@@ -705,7 +833,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
705
833
  const h = mcpHandle;
706
834
  mcpHandle = null;
707
835
  await h?.close();
708
- roundTrips.failAll("antigravity session shut down");
836
+ roundTrips.failAll(FAIL_REASON_SHUTDOWN);
709
837
  // "recycle", NOT "shutdown": pi fires session_shutdown on /new, /resume
710
838
  // and /fork (docs/extensions.md session lifecycle), not only on process
711
839
  // exit. The drivers are process-lifetime singletons; closing them with
@@ -755,6 +883,9 @@ interface AgyCommandCtx {
755
883
  acpLog: (msg: string, data?: unknown) => void;
756
884
  /** Daily file logger (src/daily-log.ts); command + doctor surfacing. */
757
885
  fileLog: DailyLogger;
886
+ /** Wizard-pick follow-through (download now + chained sign-in); reused
887
+ * by /agy engine's no-args modal so both entry points behave alike. */
888
+ runAcpPickSetup: (cmdCtx: { ui: ExtensionUIContext }) => Promise<void>;
758
889
  /** BROWSER-capture handles; null when unavailable (Windows, unwritable
759
890
  * data dir). /agy auth passes them to the sign-in process. */
760
891
  authCapture: { browserEnv: Record<string, string>; file: string } | null;
@@ -866,7 +997,7 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
866
997
  ui?.notify(`ACP auto-setup failed (${status.error}).\n${status.manual}`, "warning");
867
998
  return;
868
999
  }
869
- saveConfig({ acp: { bin: status.bin, permissions: loadConfig().acp.permissions } });
1000
+ saveConfig({ acp: { ...loadConfig().acp, bin: status.bin } });
870
1001
  if (status.needsLogin) {
871
1002
  ui?.notify(
872
1003
  `ACP engine set. ${acpLoginPending()}`,
@@ -875,6 +1006,28 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
875
1006
  } else {
876
1007
  ui?.notify(`ACP engine ready (auth: ${status.auth}). Takes effect on the next pi start (or /reload).`, "info");
877
1008
  }
1009
+ } else if (!val && mode === "tui" && ui) {
1010
+ // Same modal as the first-run wizard: switching engines deserves
1011
+ // the explanations, not a bare usage line. Semantics match the
1012
+ // direct path above: plan blocks acp, an acp pick chains setup
1013
+ // + sign-in immediately, restart applies the switch.
1014
+ const current = loadConfig().engine;
1015
+ const picked = await showEnginePicker(ui);
1016
+ if (picked === null) {
1017
+ ui.notify(`engine unchanged: ${current}.`, "info");
1018
+ return;
1019
+ }
1020
+ if (picked === current) {
1021
+ ui.notify(`engine is already ${current}. Restart applies it if set this session.`, "info");
1022
+ return;
1023
+ }
1024
+ if (picked === "acp" && loadConfig().mode === "plan") {
1025
+ ui.notify("mode is plan; the ACP engine has no plan mode. /agy mode accept-edits first.", "warning");
1026
+ return;
1027
+ }
1028
+ saveConfig({ engine: picked });
1029
+ ui.notify(savedEngineMessage(picked), "info");
1030
+ if (picked === "acp") void ctx.runAcpPickSetup({ ui });
878
1031
  } else {
879
1032
  ui?.notify(`current engine: ${loadConfig().engine}\nusage: /agy engine stream-json|acp`, "info");
880
1033
  }
@@ -901,7 +1054,7 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
901
1054
  ui?.notify(`ACP auto-setup failed (${status.error}).\n${status.manual}`, "warning");
902
1055
  return;
903
1056
  }
904
- saveConfig({ acp: { bin: status.bin, permissions: loadConfig().acp.permissions } });
1057
+ saveConfig({ acp: { ...loadConfig().acp, bin: status.bin } });
905
1058
  if (!status.needsLogin) {
906
1059
  ui?.notify(`Already signed in (auth: ${status.auth}). Nothing to do.`, "info");
907
1060
  return;
@@ -929,7 +1082,9 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
929
1082
  if (rest.length > 0) {
930
1083
  // Only the keyword compares case-insensitively; the path keeps its case.
931
1084
  const bin = rest.toLowerCase() === "auto" ? "" : rest.replace(/^~(?=\/|$)/, os.homedir());
932
- saveConfig({ acp: { bin, permissions: loadConfig().acp.permissions } });
1085
+ // Spread, not a bare acp patch: a bare {bin, permissions} object
1086
+ // would drop sibling keys (usageEstimate) from the file.
1087
+ saveConfig({ acp: { ...loadConfig().acp, bin } });
933
1088
  ui?.notify(
934
1089
  bin
935
1090
  ? `acp.bin set to ${bin}. The next ACP turn (re)connects with it.`
@@ -999,6 +1154,8 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
999
1154
  // line the day it starts (then real usage mapping is worth wiring).
1000
1155
  if (snap.acp.usageSeen) {
1001
1156
  lines.push(" acp tokens: AVAILABLE in server payloads (wire real usage mapping next)");
1157
+ } else if (config.acp.usageEstimate !== "off") {
1158
+ lines.push(` acp tokens: ESTIMATED client-side (mode: ${config.acp.usageEstimate}; auto-off once the server sends real usage)`);
1002
1159
  }
1003
1160
  }
1004
1161
  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.10",
3
+ "version": "1.5.1",
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",
package/src/acp/driver.ts CHANGED
@@ -19,9 +19,12 @@
19
19
  // in-connection (plan §9.3); no provider involvement
20
20
 
21
21
  import { randomUUID } from "node:crypto";
22
+ import type { UsageEstimate } from "../config.js";
22
23
  import { AcpConnection, resolveAcpBinary, type AcpMcpServer } from "./connection.js";
23
24
  import { mapStopReason, mapUpdate, TextAccumulator, type AcpEditDiff } from "./events.js";
25
+ import { estimateTokens, synthesizeUsage } from "./usage-estimate.js";
24
26
  import type {
27
+ AgyUsage,
25
28
  DriverActivity,
26
29
  DriverSnapshot,
27
30
  DriverState,
@@ -46,6 +49,10 @@ export interface AcpDriverOptions {
46
49
  authUrlFile?: string;
47
50
  /** Bridge registration for session/new AND session/load. */
48
51
  mcpServers?: () => AcpMcpServer[];
52
+ /** Gate B stopgap: how ACP turns synthesize usage while the server sends
53
+ * none. A function is resolved per turn (follows live config, like bin).
54
+ * Default "estimate". */
55
+ usageEstimate?: UsageEstimate | (() => UsageEstimate);
49
56
  log?: (msg: string, data?: unknown) => void;
50
57
  }
51
58
 
@@ -59,6 +66,13 @@ interface ActiveTurn {
59
66
  resolve: (o: TurnOutcome) => void;
60
67
  outcome: Promise<TurnOutcome>;
61
68
  response: TextAccumulator;
69
+ /** Usage-synthesis counters: per-delta token sums (estimate mode) and
70
+ * delta counts (direct mode). Sums are taken per delta, never over the
71
+ * joined text, so a word split across chunks stays two words. */
72
+ textTokens: number;
73
+ thoughtTokens: number;
74
+ textDeltas: number;
75
+ thoughtDeltas: number;
62
76
  sawResult: boolean;
63
77
  /** True once the prompt RPC was issued. Abort before this point has
64
78
  * nothing to cancel: probing would risk a success-as-noop answer from a
@@ -359,10 +373,16 @@ export class AcpDriver implements TurnDriver {
359
373
  switch (mapped.kind) {
360
374
  case "text": {
361
375
  const emit = turn.response.append(mapped.delta);
362
- if (emit) this.#emit(turn, { type: "text", delta: emit });
376
+ if (emit) {
377
+ turn.textDeltas += 1;
378
+ turn.textTokens += estimateTokens(emit);
379
+ this.#emit(turn, { type: "text", delta: emit });
380
+ }
363
381
  return;
364
382
  }
365
383
  case "thought": {
384
+ turn.thoughtDeltas += 1;
385
+ turn.thoughtTokens += estimateTokens(mapped.delta);
366
386
  this.#emit(turn, { type: "thought", delta: mapped.delta });
367
387
  return;
368
388
  }
@@ -543,6 +563,10 @@ export class AcpDriver implements TurnDriver {
543
563
  resolve,
544
564
  outcome,
545
565
  response: new TextAccumulator(),
566
+ textTokens: 0,
567
+ thoughtTokens: 0,
568
+ textDeltas: 0,
569
+ thoughtDeltas: 0,
546
570
  sawResult: false,
547
571
  promptStarted: false,
548
572
  aborted: false,
@@ -643,6 +667,16 @@ export class AcpDriver implements TurnDriver {
643
667
 
644
668
  #settle(turn: ActiveTurn, outcome: TurnOutcome): void {
645
669
  if (turn.closed) return;
670
+ // Gate B stopgap: synthesize usage on clean turns while the server sends
671
+ // none. Runs BEFORE close so the usage activity drains through the
672
+ // normal #nextActivity loop (provider maps it onto partial.usage).
673
+ if (outcome.status === "OK" && !outcome.aborted && outcome.usage === undefined) {
674
+ const usage = this.#syntheticUsage(turn);
675
+ if (usage) {
676
+ outcome.usage = usage;
677
+ this.#emit(turn, { type: "usage", usage });
678
+ }
679
+ }
646
680
  turn.closed = true;
647
681
  if (turn.overallTimer) clearTimeout(turn.overallTimer);
648
682
  if (turn.idleTimer) clearTimeout(turn.idleTimer);
@@ -671,6 +705,29 @@ export class AcpDriver implements TurnDriver {
671
705
  });
672
706
  }
673
707
 
708
+ /** Usage synthesis mode, resolved per turn (follows live config). */
709
+ #usageMode(): UsageEstimate {
710
+ const opt = this.#opts.usageEstimate;
711
+ return (typeof opt === "function" ? opt() : opt) ?? "estimate";
712
+ }
713
+
714
+ #syntheticUsage(turn: ActiveTurn): AgyUsage | undefined {
715
+ const mode = this.#usageMode();
716
+ if (mode === "off") return undefined;
717
+ // Gate B latch: any server frame with usage/token keys means real
718
+ // usage exists upstream; estimates must never shadow it.
719
+ if (this.#conn?.usageSeen) return undefined;
720
+ return synthesizeUsage({
721
+ mode,
722
+ prompt: turn.request.prompt,
723
+ contextText: turn.request.contextBlock?.text,
724
+ textTokens: turn.textTokens,
725
+ thoughtTokens: turn.thoughtTokens,
726
+ textDeltas: turn.textDeltas,
727
+ thoughtDeltas: turn.thoughtDeltas,
728
+ });
729
+ }
730
+
674
731
  #log(msg: string, data?: unknown): void {
675
732
  const line = `${new Date().toISOString().slice(11, 19)} ${msg}${data !== undefined ? ` ${JSON.stringify(data)}` : ""}`;
676
733
  this.#lifecycle.push(line);
@@ -0,0 +1,59 @@
1
+ // Gate B stopgap: synthesize AgyUsage for ACP turns until Google ships a
2
+ // real token-usage layer in agy_acp_server. Counting mechanism copied from
3
+ // the pi-token-speed extension (npm:pi-token-speed, src/engine.ts): a
4
+ // word/punctuation regex, 1 match = 1 token. The numbers are client-side
5
+ // ESTIMATES, never provider data: the connection's usageSeen latch (Gate B
6
+ // watch) keeps synthesis off the day real usage appears in any frame, so
7
+ // estimates can never shadow real numbers.
8
+
9
+ import type { UsageEstimate } from "../config.js";
10
+ import type { AgyUsage } from "../driver-types.js";
11
+
12
+ const TOKEN_REGEX = /\w+|[^\s\w]/g;
13
+
14
+ /** Word-boundary token estimate (pi-token-speed's estimateTokens). */
15
+ export function estimateTokens(text: string): number {
16
+ if (!text) return 0;
17
+ const matches = text.match(TOKEN_REGEX);
18
+ return matches ? matches.length : 0;
19
+ }
20
+
21
+ export interface UsageEstimateInput {
22
+ mode: UsageEstimate;
23
+ /** Full outgoing prompt text (input side; always regex-estimated). */
24
+ prompt: string;
25
+ /** ACP embeddedContext resource text (G1 digest): also reaches the model. */
26
+ contextText?: string;
27
+ /** Per-delta token sums (mode "estimate"). Summing per delta, like
28
+ * pi-token-speed's recordDelta, avoids word-merge artifacts when a
29
+ * chunk boundary splits a word. */
30
+ textTokens: number;
31
+ thoughtTokens: number;
32
+ /** Streamed delta counts (mode "direct": 1 token per delta). */
33
+ textDeltas: number;
34
+ thoughtDeltas: number;
35
+ }
36
+
37
+ /** Synthesize usage for a finished turn, or undefined when nothing is worth
38
+ * reporting (mode off, or a turn with no prompt and no output). */
39
+ export function synthesizeUsage(input: UsageEstimateInput): AgyUsage | undefined {
40
+ if (input.mode === "off") return undefined;
41
+ const inputTokens = estimateTokens(
42
+ input.contextText ? `${input.prompt}\n${input.contextText}` : input.prompt,
43
+ );
44
+ const thoughtTokens =
45
+ input.mode === "direct" ? input.thoughtDeltas : input.thoughtTokens;
46
+ const textTokens =
47
+ input.mode === "direct" ? input.textDeltas : input.textTokens;
48
+ // Thinking folds INTO output (OpenAI convention: reasoning tokens bill as
49
+ // output). toPiUsage drops thinking_tokens, and thinking time is inside
50
+ // elapsed wall time, so folding keeps the tokens/time ratio honest.
51
+ const outputTokens = textTokens + thoughtTokens;
52
+ if (inputTokens === 0 && outputTokens === 0) return undefined;
53
+ return {
54
+ input_tokens: inputTokens,
55
+ output_tokens: outputTokens,
56
+ thinking_tokens: thoughtTokens,
57
+ total_tokens: inputTokens + outputTokens,
58
+ };
59
+ }
package/src/ask-tool.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  snapshotConversations,
26
26
  } from "./discovery.js";
27
27
  import { loadConfig, type AgyMode, type ThinkingTier } from "./config.js";
28
+ import { acquireBridgeSuppression } from "./mcp-registration.js";
28
29
  import { spawnAgyModelsRaw } from "./models.js";
29
30
 
30
31
  // --- Constants -------------------------------------------------------------
@@ -46,6 +47,15 @@ const FAMILY_DEFAULT_TIER: Record<Family, ThinkingTier> = {
46
47
  pro: "high",
47
48
  other: "medium",
48
49
  };
50
+
51
+ /** Bridge-suppression grace for a delegated `agy -p`. The shared config stays
52
+ * disabled for min(process close, this): agy reads it once at startup, so
53
+ * this bound covers that read without staying disabled for the whole run.
54
+ * Residual race: a provider agy respawn in ANOTHER session landing inside
55
+ * the window reads the entries disabled and that process lacks bridge tools
56
+ * until its next recycle (session start heals the file). Fail-open past the
57
+ * window = status-quo behavior. */
58
+ const BRIDGE_SUPPRESS_MS = 5000;
49
59
  const TIER_RANK: Record<ThinkingTier, number> = { low: 0, medium: 1, high: 2 };
50
60
 
51
61
  // Static alias overlay for non-Gemini models agy may or may not surface.
@@ -510,6 +520,31 @@ export async function registerAskAntigravityTool(
510
520
  const binary = process.env.AGY_BIN || "agy";
511
521
  let out = "";
512
522
 
523
+ // Delegation isolation: any agy on this machine reads the global
524
+ // mcp_config.json, so this spawned `agy -p` would discover live
525
+ // pi-bridge-* entries and call tools the host bridge cannot serve
526
+ // outside a live provider turn ("no active antigravity turn").
527
+ // Delegation isolation: any agy on this machine reads the global
528
+ // mcp_config.json, so this spawned `agy -p` would discover live
529
+ // pi-bridge-* entries and call tools the host bridge cannot serve
530
+ // outside a live provider turn ("no active antigravity turn").
531
+ // Window = min(close, grace): the release fires on process close or
532
+ // after BRIDGE_SUPPRESS_MS, whichever lands first. Refcounted, so an
533
+ // overlapping delegation cannot re-enable early. A refused config
534
+ // fail-opens to the status quo.
535
+ const releaseBridge = acquireBridgeSuppression();
536
+ let suppressTimer: ReturnType<typeof setTimeout> | undefined = setTimeout(
537
+ releaseBridge,
538
+ BRIDGE_SUPPRESS_MS,
539
+ );
540
+ const restoreBridge = (): void => {
541
+ if (suppressTimer) {
542
+ clearTimeout(suppressTimer);
543
+ suppressTimer = undefined;
544
+ }
545
+ releaseBridge();
546
+ };
547
+
513
548
  const statusInterval = onUpdate
514
549
  ? setInterval(() => {
515
550
  const elapsed = Math.floor((Date.now() - start) / 1000);
@@ -592,6 +627,7 @@ export async function registerAskAntigravityTool(
592
627
  if (watchdog) clearTimeout(watchdog);
593
628
  if (sigkillTimer) clearTimeout(sigkillTimer);
594
629
  if (signal) signal.removeEventListener("abort", onAbort);
630
+ restoreBridge();
595
631
  };
596
632
  const onAbort = () => killTree();
597
633
 
@@ -698,6 +734,9 @@ export async function registerAskAntigravityTool(
698
734
  return { content: [{ type: "text", text: `failed to run agy: ${msg}` }], details };
699
735
  }
700
736
  finally {
737
+ // Belt and braces: cleanup() already restores on close/error; this
738
+ // covers paths that never reached the process (sync spawn throw).
739
+ restoreBridge();
701
740
  if (contextFile) {
702
741
  try {
703
742
  fs.unlinkSync(contextFile);
package/src/config.ts CHANGED
@@ -44,6 +44,12 @@ export type AgyMode = "accept-edits" | "plan";
44
44
  export type ThinkingTier = "low" | "medium" | "high";
45
45
  export type BridgeTools = "none" | "mcp" | "all";
46
46
 
47
+ /** How ACP turns report token usage while Gate B stands (agy sends none).
48
+ * "estimate" (default): word-boundary regex over prompt/response/thought
49
+ * text (pi-token-speed's mechanism). "direct": 1 token per streamed delta.
50
+ * "off": keep zero-usage. Real server usage (usageSeen latch) always wins. */
51
+ export type UsageEstimate = "estimate" | "direct" | "off";
52
+
47
53
  /** How the approval gate activates (docs/TODO.md section 2.5).
48
54
  *
49
55
  * "auto" (default): OFF until a third-party pi permission extension is
@@ -74,6 +80,10 @@ export interface AcpConfig {
74
80
  * (parity with skipPermissions). Kept as a key so future policies do not
75
81
  * change the config shape. */
76
82
  permissions: "auto";
83
+ /** Gate B stopgap: client-side token estimates for ACP turns so pi's
84
+ * usage surfaces show nonzero numbers. Estimates are labeled as such in
85
+ * /agy doctor and auto-disable when the server sends real usage. */
86
+ usageEstimate: UsageEstimate;
77
87
  }
78
88
 
79
89
  export interface AgyConfig {
@@ -149,7 +159,7 @@ const DEFAULTS: AgyConfig = {
149
159
  digest: false,
150
160
  systemPrompt: true,
151
161
  approvals: { gateMode: "auto", mode: "ask" },
152
- acp: { bin: "", permissions: "auto" },
162
+ acp: { bin: "", permissions: "auto", usageEstimate: "estimate" },
153
163
  };
154
164
 
155
165
  /** Load config merged over defaults. Env vars override the file when set. */
@@ -232,11 +242,19 @@ export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
232
242
  : "ask";
233
243
 
234
244
  const fileAcp = (typeof file.acp === "object" && file.acp !== null ? file.acp : {}) as Partial<AcpConfig>;
245
+ // Unknown values fall back to "estimate" (same narrow-parse pattern as
246
+ // gateMode: a typo must never silently change behavior).
247
+ const usageRaw = String(
248
+ process.env.AGY_USAGE_ESTIMATE ?? fileAcp.usageEstimate ?? DEFAULTS.acp.usageEstimate,
249
+ ).toLowerCase();
250
+ const usageEstimate: UsageEstimate =
251
+ usageRaw === "direct" || usageRaw === "off" ? usageRaw : "estimate";
235
252
  const acp: AcpConfig = {
236
253
  bin:
237
254
  process.env.AGY_ACP_BIN ??
238
255
  (typeof fileAcp.bin === "string" ? fileAcp.bin : DEFAULTS.acp.bin),
239
256
  permissions: "auto",
257
+ usageEstimate,
240
258
  };
241
259
 
242
260
  return {
@@ -0,0 +1,155 @@
1
+ // First-run onboarding: the engine picker plus the agy-presence warning.
2
+ //
3
+ // On the first interactive start (no config file yet, no AGY_ENGINE env) pi
4
+ // asks which turn engine to use: the stream-json `agy` CLI or Google's
5
+ // official ACP server. The choice persists via saveConfig({ engine }) and,
6
+ // like /agy engine, takes effect on the next pi start (drivers wire at load).
7
+ // Every start with the stream-json engine active also re-checks that the
8
+ // `agy` binary exists and warns until it does (re-auth is out of scope).
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).
12
+
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";
22
+ import fs from "node:fs";
23
+ import path from "node:path";
24
+ import type { Engine } from "./config.js";
25
+
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
+ },
33
+ {
34
+ value: "acp",
35
+ label: "ACP server (official Google)",
36
+ description: "second sign-in, ~1.5 GB binary download",
37
+ },
38
+ ];
39
+
40
+ /** Intro paragraph above the list. Text wraps, so explanations stay here and
41
+ * SelectItem descriptions stay one-liners (SelectList truncates long lines). */
42
+ export const ENGINE_PICKER_INTRO = [
43
+ "Pick the engine that runs your Antigravity turns. Switch anytime with /agy engine (restart applies it).",
44
+ "",
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).",
47
+ ].join("\n");
48
+
49
+ /** True only for a genuine first interactive run: no saved config yet (any
50
+ * existing file means the user has been here before) and no AGY_ENGINE env
51
+ * (env wins over the file, so the wizard would fight it). Fails closed to
52
+ * false - an fs error must never nag the user with a dialog. */
53
+ export function shouldOfferEnginePicker(
54
+ configPath: string,
55
+ env: NodeJS.ProcessEnv = process.env,
56
+ ): boolean {
57
+ if (env.AGY_ENGINE !== undefined) return false;
58
+ try {
59
+ return !fs.existsSync(configPath);
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ /** Narrow a picker value to an Engine. Items are our own constants, but an
66
+ * unknown value must never reach config as a cast string: it falls back to
67
+ * the tested default. */
68
+ export function toEngine(value: string): Engine {
69
+ return value === "acp" ? "acp" : "stream-json";
70
+ }
71
+
72
+ /** Toast copy shown after the choice is saved. ACP names the promise that
73
+ * matters: the binary download starts NOW (not on restart), sign-in follows
74
+ * when it lands, restart applies the engine. */
75
+ export function savedEngineMessage(engine: Engine): string {
76
+ return engine === "acp"
77
+ ? "Engine saved: acp. The ~1.5 GB server binary downloads now; the Google sign-in opens when it lands. Restart applies the engine."
78
+ : "Engine saved: stream-json. Restart pi to apply.";
79
+ }
80
+
81
+ /** True when the `agy` CLI binary can be found. A binRef with a path
82
+ * separator (AGY_BIN=/opt/agy/agy) must exist as a file; a bare name is
83
+ * searched on PATH. statSync cannot throw through the guards, but a race
84
+ * (file removed between listing and stat) fails closed to false. */
85
+ export function isAgyInstalled(binRef: string, env: NodeJS.ProcessEnv = process.env): boolean {
86
+ if (binRef.includes("/")) {
87
+ try {
88
+ return fs.statSync(binRef).isFile();
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+ return (env.PATH ?? "")
94
+ .split(path.delimiter)
95
+ .filter(Boolean)
96
+ .some((dir) => {
97
+ try {
98
+ return fs.statSync(path.join(dir, binRef)).isFile();
99
+ } catch {
100
+ return false;
101
+ }
102
+ });
103
+ }
104
+
105
+ /** Toast copy for the missing-CLI warning. Fires every pi start while the
106
+ * stream-json engine is active and the binary is absent. */
107
+ export function agyMissingMessage(): string {
108
+ 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
+ }
110
+
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)));
140
+
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
+ );
155
+ }
@@ -93,6 +93,61 @@ export function unregisterBridgeServer(pid: number, configPath: string = mcpConf
93
93
  return { wrote: true };
94
94
  }
95
95
 
96
+ /** Flip `disabled` on every pi-bridge-* entry (foreign servers untouched).
97
+ *
98
+ * (1) Delegation isolation: the global config is read by ANY agy on the
99
+ * machine, so an `agy -p` we spawn ourselves (AskAntigravity) would discover
100
+ * live bridge entries and call tools the round-trip store cannot serve
101
+ * outside a live provider turn (fail-closed "no active antigravity turn",
102
+ * observed live 2026-09-15). agy reads the config once at startup, so a
103
+ * short suppression window around the spawn hides the bridge from it.
104
+ *
105
+ * (2) Startup healing (disabled=false): clears entries a crashed delegation
106
+ * left suppressed. */
107
+ export function setBridgeEntriesDisabled(
108
+ disabled: boolean,
109
+ configPath: string = mcpConfigPath(),
110
+ ): { wrote: boolean; changed: number; reason?: string } {
111
+ const read = readConfig(configPath);
112
+ if (!read.ok) return { wrote: false, changed: 0, reason: read.reason };
113
+ let changed = 0;
114
+ for (const [name, entry] of Object.entries(read.config.mcpServers)) {
115
+ if (!/^pi-bridge-\d+$/.test(name)) continue;
116
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
117
+ const e = entry as { disabled?: unknown };
118
+ if ((e.disabled === true) === disabled) continue;
119
+ e.disabled = disabled;
120
+ changed++;
121
+ }
122
+ if (changed === 0) return { wrote: false, changed: 0 };
123
+ writeConfig(configPath, read.config);
124
+ return { wrote: true, changed };
125
+ }
126
+
127
+ const suppressionRefs = new Map<string, number>();
128
+
129
+ /** Reference-counted suppression for a self-spawned agy process (AskAntigravity
130
+ * delegation). First acquire disables every pi-bridge-* entry, last release
131
+ * re-enables; nested acquires are free, so overlapping delegations in one
132
+ * process cannot clobber each other's window. Same-process only: delegations
133
+ * from two pi sessions still race on the shared file - accepted, fail-open
134
+ * to the status-quo error. */
135
+ export function acquireBridgeSuppression(configPath: string = mcpConfigPath()): () => void {
136
+ const key = path.resolve(configPath);
137
+ const refs = (suppressionRefs.get(key) ?? 0) + 1;
138
+ suppressionRefs.set(key, refs);
139
+ if (refs === 1) setBridgeEntriesDisabled(true, configPath);
140
+ let released = false;
141
+ return () => {
142
+ if (released) return;
143
+ released = true;
144
+ const left = Math.max(0, (suppressionRefs.get(key) ?? 1) - 1);
145
+ if (left === 0) suppressionRefs.delete(key);
146
+ else suppressionRefs.set(key, left);
147
+ if (left === 0) setBridgeEntriesDisabled(false, configPath);
148
+ };
149
+ }
150
+
96
151
  /** Default liveness probe: can the signal be delivered? */
97
152
  function pidAlive(pid: number): boolean {
98
153
  try {