@estebanforge/pi-antigravity-bridge 1.4.10 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.5.0] - 2026-09-08
6
+
7
+ ### Added
8
+
9
+ - 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.
10
+ - 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.
11
+ - `/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.
12
+ - 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.
13
+
14
+ ### Fixed
15
+
16
+ - 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.
17
+
18
+ ### Changed
19
+
20
+ - 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.
21
+ - 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.
22
+
5
23
  ## [1.4.10] - 2026-09-07
6
24
 
7
25
  ### Added
package/README.md CHANGED
@@ -21,35 +21,13 @@ Multi-turn works. The provider binds a pi session to an agy conversation id (per
21
21
  Turns run through one of two engines behind the same provider surface (`config.engine`, default `stream-json`):
22
22
 
23
23
  - **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).
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 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
25
 
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.
26
+ 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
27
 
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.
28
+ Full capability comparison, switching, and setup/auth details: [docs/ENGINES.md](docs/ENGINES.md).
29
+
30
+ 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
31
 
54
32
  ## What it cannot do
55
33
 
@@ -58,7 +36,7 @@ agy runs its own closed tool loop (`read_file`, `write_file`, `edit_file`, `run_
58
36
  Residual limits (with or without the bridge):
59
37
 
60
38
  - 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.
39
+ - 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
40
  - No cost accounting: cost stays zero because agy runs on your subscription quota. Token usage is live.
63
41
 
64
42
  ## MCP tool bridge (agy uses pi's tools)
@@ -94,35 +72,9 @@ bridge port, and the last lifecycle events without spending tokens.
94
72
 
95
73
  ## Approval gate (agy native tools)
96
74
 
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.
75
+ 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.
100
76
 
101
- Configuration (`/agy` config keys or environment):
102
-
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
- ```
77
+ Full mechanics, configuration, and a sample gate extension: [docs/APPROVAL-GATE.md](docs/APPROVAL-GATE.md).
126
78
 
127
79
  ## Install
128
80
 
@@ -134,7 +86,7 @@ Install with pi's package manager:
134
86
  pi install npm:@estebanforge/pi-antigravity-bridge
135
87
  ```
136
88
 
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.
89
+ 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
90
 
139
91
  ## Usage
140
92
 
@@ -190,7 +142,7 @@ The `activate_skill` catalog mirrors pi's directory-based skill discovery: the t
190
142
  /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
143
  /agy bridge all|mcp|none which pi tools the MCP bridge exposes to agy (default all; none = bridge off)
192
144
  /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)
145
+ /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
146
  /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
147
  /agy patch-cleanup restore the original pi files if an older version patched them
196
148
  /agy clear drop all session bindings (force fresh conversations)
@@ -204,7 +156,7 @@ Because agy runs non-interactively under this provider (nothing can answer a `y/
204
156
 
205
157
  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
158
 
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.
159
+ 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
160
 
209
161
  ### Run pi inside a sandbox
210
162
 
@@ -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.
@@ -32,6 +32,7 @@ src/approval-gate.ts shadow tool factory (bash/write/edit): marker calls are ti
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)
@@ -99,6 +99,7 @@ Most "stuck" reports trace to one of:
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
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.
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,6 +66,7 @@ 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";
@@ -181,6 +182,8 @@ export default async function (pi: ExtensionAPI): Promise<void> {
181
182
  // ACP self-heal runs once per process (session_start re-fires on /reload;
182
183
  // a ready setup is two file stats, so re-running is harmless anyway).
183
184
  let acpSelfHealRan = false;
185
+ // Warn-once-per-process flag for the missing-agy-CLI toast (stream-json).
186
+ let agyMissingWarned = false;
184
187
  // OAuth URL capture: the server hands the login URL only to the
185
188
  // browser-open call (nothing on stdio), so a BROWSER wrapper records it
186
189
  // and the driver logs it as "auth-url". Local users keep the automatic
@@ -276,6 +279,10 @@ export default async function (pi: ExtensionAPI): Promise<void> {
276
279
  // Resolved per connection: the setup flow can install the binary and
277
280
  // update acp.bin mid-session; the next turn picks it up (no restart).
278
281
  bin: () => loadConfig().acp.bin,
282
+ // Resolved per turn: /agy and AGY_USAGE_ESTIMATE changes apply without
283
+ // a restart. Without this the driver defaults to "estimate" and the
284
+ // config knob (incl. "off") is dead.
285
+ usageEstimate: () => loadConfig().acp.usageEstimate,
279
286
  ...(authCapture ? { extraEnv: authCapture.browserEnv, authUrlFile: authCapture.file } : {}),
280
287
  log: acpLog,
281
288
  mcpServers: () => {
@@ -380,6 +387,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
380
387
  acpLog,
381
388
  fileLog,
382
389
  authCapture: authCapture ?? null,
390
+ runAcpPickSetup,
383
391
  });
384
392
 
385
393
  // AskAntigravity tool: one-shot delegation to agy (ported from
@@ -412,12 +420,115 @@ export default async function (pi: ExtensionAPI): Promise<void> {
412
420
  },
413
421
  });
414
422
 
423
+ /** ACP pick follow-through (first-run wizard): the same self-service
424
+ * setup the /agy engine acp command runs, but immediately - the 1.5 GB
425
+ * download starts while the toast is still on screen, progress rides the
426
+ * footer status, and the Google sign-in opens when the install lands.
427
+ * Restart still applies the engine (drivers wire at load); this only
428
+ * removes the wait. Fire-and-forget: the caller already toasted the
429
+ * promise, failures land in the daily log + a warning toast. */
430
+ // eslint-disable-next-line @typescript-eslint/no-inner-declarations -- hoisted: registerAgyCommand below injects it
431
+ async function runAcpPickSetup(ctx: { ui: ExtensionUIContext }): Promise<void> {
432
+ acpSelfHealRan = true;
433
+ let lastPhase = "";
434
+ ctx.ui.setStatus("agy-acp", "downloading ACP server…");
435
+ try {
436
+ const status = await ensureAcpReady({
437
+ configBin: loadConfig().acp.bin,
438
+ onProgress: (m) => {
439
+ // Dual surface: the status bar carries the live percent (cleared
440
+ // on completion, zero footprint); the chat window gets phase
441
+ // milestones only (download start, unpacking, installed) - same
442
+ // line-in-chat feel as other extensions' notify() notices. The
443
+ // percent variant updates every chunk and would spam the chat.
444
+ ctx.ui.setStatus("agy-acp", m);
445
+ if (m !== lastPhase && !/\d+%/.test(m)) {
446
+ ctx.ui.notify(m, "info");
447
+ lastPhase = m;
448
+ }
449
+ },
450
+ });
451
+ ctx.ui.setStatus("agy-acp", undefined);
452
+ fileLog.log(
453
+ "acp-setup",
454
+ status.ok
455
+ ? { ok: true, binarySource: status.binarySource, needsLogin: status.needsLogin }
456
+ : { ok: false, error: status.error },
457
+ status.ok ? "info" : "warn",
458
+ );
459
+ if (!status.ok) {
460
+ ctx.ui.notify(`ACP auto-setup failed (${status.error}).\n${status.manual}`, "warning");
461
+ return;
462
+ }
463
+ // Spread, not a bare acp patch: a bare {bin} patch would drop
464
+ // sibling keys (usageEstimate) from the file.
465
+ saveConfig({ acp: { ...loadConfig().acp, bin: status.bin } });
466
+ if (!status.needsLogin) {
467
+ ctx.ui.notify(`ACP server ready (auth: ${status.auth}). Restart applies the engine.`, "info");
468
+ return;
469
+ }
470
+ ctx.ui.notify(
471
+ "ACP server ready. Signing in: the Google sign-in opens in your browser and completes when you finish it.",
472
+ "info",
473
+ );
474
+ const r = await runAcpAuth({
475
+ bin: status.bin,
476
+ ...(authCapture ? { extraEnv: authCapture.browserEnv, authUrlFile: authCapture.file } : {}),
477
+ log: acpLog,
478
+ });
479
+ fileLog.log("acp-auth", r.ok ? { ok: true } : { ok: false, error: r.error }, r.ok ? "info" : "warn");
480
+ if (r.ok) ctx.ui.notify("Signed in. The ACP engine is ready; restart applies it.", "info");
481
+ else ctx.ui.notify(`ACP sign-in failed (${r.error}).\nRun /agy auth to retry; /agy auth-manual has manual steps.`, "warning");
482
+ } catch (err) {
483
+ ctx.ui.setStatus("agy-acp", undefined);
484
+ fileLog.log("acp-setup", { error: String(err) }, "warn");
485
+ ctx.ui.notify(`ACP setup failed (${String(err)}). /agy auth retries; /agy doctor inspects.`, "warning");
486
+ }
487
+ }
488
+
415
489
  // MCP tool bridge: expose pi's tools to agy over localhost Streamable HTTP.
416
490
  // Calls park in the provider's round-trip store and complete through pi's
417
491
  // normal toolUse loop (native cards, permissions, hooks) - no patch, no
418
492
  // privileged API. Started on session_start, torn down on session_shutdown.
419
- pi.on("session_start", async (_event, ctx) => {
493
+ pi.on("session_start", async (event, ctx) => {
420
494
  if (ctx.hasUI) activeUi = ctx.ui;
495
+ // First-run engine picker: ask once, on the first interactive start,
496
+ // which turn engine to use. Skipped headless (ctx.mode !== "tui"),
497
+ // when AGY_ENGINE is set, or once any config file exists (any save -
498
+ // even of an unrelated knob - means the user has been here before).
499
+ // esc = decide later: nothing is written, the picker reappears next
500
+ // start. Like /agy engine, the choice applies on the next start
501
+ // (drivers wire at load). The await intentionally runs before the
502
+ // bridge startup below: on a genuine first run the modal blocks input
503
+ // anyway, so the delay is invisible.
504
+ if (event.reason === "startup" && ctx.mode === "tui" && shouldOfferEnginePicker(CONFIG_PATH)) {
505
+ // Best-effort, like the legacy-patch notice below: a picker failure
506
+ // (mid-prompt TUI teardown, resize races) must never take down the
507
+ // rest of session_start - the MCP bridge startup included. The
508
+ // default engine keeps working untouched.
509
+ try {
510
+ const picked = await showEnginePicker(ctx.ui);
511
+ if (picked) {
512
+ saveConfig({ engine: picked });
513
+ ctx.ui.notify(savedEngineMessage(picked), "info");
514
+ if (picked === "acp") void runAcpPickSetup(ctx);
515
+ }
516
+ } catch (err) {
517
+ fileLog.log("engine-picker", { error: String(err) }, "warn");
518
+ console.error(`[antigravity-bridge] engine picker failed: ${String(err)}`);
519
+ }
520
+ }
521
+ // agy presence check (stream-json engine): the CLI is the whole engine,
522
+ // so a missing binary means every Antigravity turn would fail. Warn on
523
+ // every process start until it is installed (per-process flag so /new,
524
+ // /resume and /reload re-fires do not nag mid-session). Runs after the
525
+ // picker above, so a first-run stream-json pick warns immediately.
526
+ if (engine === "stream-json" && !agyMissingWarned && !isAgyInstalled(binary)) {
527
+ agyMissingWarned = true;
528
+ const msg = agyMissingMessage();
529
+ if (ctx.hasUI) ctx.ui.notify(msg, "warning");
530
+ else console.error(`[antigravity-bridge] ${msg}`);
531
+ }
421
532
  // Legacy cleanup: users who ran the old consent-gated patcher still
422
533
  // carry pi.invokeTool in their installed pi. Inert, but tell them once
423
534
  // and offer /agy patch-cleanup. Never auto-edits the install.
@@ -451,7 +562,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
451
562
  );
452
563
  if (status.ok) {
453
564
  if (status.binarySource === "installed" || status.binarySource === "existing") {
454
- saveConfig({ acp: { bin: status.bin, permissions: loadConfig().acp.permissions } });
565
+ saveConfig({ acp: { ...loadConfig().acp, bin: status.bin } });
455
566
  }
456
567
  if (status.needsLogin) {
457
568
  const msg = acpLoginPending();
@@ -755,6 +866,9 @@ interface AgyCommandCtx {
755
866
  acpLog: (msg: string, data?: unknown) => void;
756
867
  /** Daily file logger (src/daily-log.ts); command + doctor surfacing. */
757
868
  fileLog: DailyLogger;
869
+ /** Wizard-pick follow-through (download now + chained sign-in); reused
870
+ * by /agy engine's no-args modal so both entry points behave alike. */
871
+ runAcpPickSetup: (cmdCtx: { ui: ExtensionUIContext }) => Promise<void>;
758
872
  /** BROWSER-capture handles; null when unavailable (Windows, unwritable
759
873
  * data dir). /agy auth passes them to the sign-in process. */
760
874
  authCapture: { browserEnv: Record<string, string>; file: string } | null;
@@ -866,7 +980,7 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
866
980
  ui?.notify(`ACP auto-setup failed (${status.error}).\n${status.manual}`, "warning");
867
981
  return;
868
982
  }
869
- saveConfig({ acp: { bin: status.bin, permissions: loadConfig().acp.permissions } });
983
+ saveConfig({ acp: { ...loadConfig().acp, bin: status.bin } });
870
984
  if (status.needsLogin) {
871
985
  ui?.notify(
872
986
  `ACP engine set. ${acpLoginPending()}`,
@@ -875,6 +989,28 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
875
989
  } else {
876
990
  ui?.notify(`ACP engine ready (auth: ${status.auth}). Takes effect on the next pi start (or /reload).`, "info");
877
991
  }
992
+ } else if (!val && mode === "tui" && ui) {
993
+ // Same modal as the first-run wizard: switching engines deserves
994
+ // the explanations, not a bare usage line. Semantics match the
995
+ // direct path above: plan blocks acp, an acp pick chains setup
996
+ // + sign-in immediately, restart applies the switch.
997
+ const current = loadConfig().engine;
998
+ const picked = await showEnginePicker(ui);
999
+ if (picked === null) {
1000
+ ui.notify(`engine unchanged: ${current}.`, "info");
1001
+ return;
1002
+ }
1003
+ if (picked === current) {
1004
+ ui.notify(`engine is already ${current}. Restart applies it if set this session.`, "info");
1005
+ return;
1006
+ }
1007
+ if (picked === "acp" && loadConfig().mode === "plan") {
1008
+ ui.notify("mode is plan; the ACP engine has no plan mode. /agy mode accept-edits first.", "warning");
1009
+ return;
1010
+ }
1011
+ saveConfig({ engine: picked });
1012
+ ui.notify(savedEngineMessage(picked), "info");
1013
+ if (picked === "acp") void ctx.runAcpPickSetup({ ui });
878
1014
  } else {
879
1015
  ui?.notify(`current engine: ${loadConfig().engine}\nusage: /agy engine stream-json|acp`, "info");
880
1016
  }
@@ -901,7 +1037,7 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
901
1037
  ui?.notify(`ACP auto-setup failed (${status.error}).\n${status.manual}`, "warning");
902
1038
  return;
903
1039
  }
904
- saveConfig({ acp: { bin: status.bin, permissions: loadConfig().acp.permissions } });
1040
+ saveConfig({ acp: { ...loadConfig().acp, bin: status.bin } });
905
1041
  if (!status.needsLogin) {
906
1042
  ui?.notify(`Already signed in (auth: ${status.auth}). Nothing to do.`, "info");
907
1043
  return;
@@ -929,7 +1065,9 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
929
1065
  if (rest.length > 0) {
930
1066
  // Only the keyword compares case-insensitively; the path keeps its case.
931
1067
  const bin = rest.toLowerCase() === "auto" ? "" : rest.replace(/^~(?=\/|$)/, os.homedir());
932
- saveConfig({ acp: { bin, permissions: loadConfig().acp.permissions } });
1068
+ // Spread, not a bare acp patch: a bare {bin, permissions} object
1069
+ // would drop sibling keys (usageEstimate) from the file.
1070
+ saveConfig({ acp: { ...loadConfig().acp, bin } });
933
1071
  ui?.notify(
934
1072
  bin
935
1073
  ? `acp.bin set to ${bin}. The next ACP turn (re)connects with it.`
@@ -999,6 +1137,8 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
999
1137
  // line the day it starts (then real usage mapping is worth wiring).
1000
1138
  if (snap.acp.usageSeen) {
1001
1139
  lines.push(" acp tokens: AVAILABLE in server payloads (wire real usage mapping next)");
1140
+ } else if (config.acp.usageEstimate !== "off") {
1141
+ lines.push(` acp tokens: ESTIMATED client-side (mode: ${config.acp.usageEstimate}; auto-off once the server sends real usage)`);
1002
1142
  }
1003
1143
  }
1004
1144
  if (snap.lifecycle.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estebanforge/pi-antigravity-bridge",
3
- "version": "1.4.10",
3
+ "version": "1.5.0",
4
4
  "description": "Gemini provider for Pi on the Antigravity ACP server (official Google ACP) or the stream-json agy CLI. antigravity/* models in Pi's /model picker, no-patch MCP bridge: agy runs Pi's tools. ToS safe to use.",
5
5
  "keywords": [
6
6
  "pi-package",
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/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
+ }