@estebanforge/pi-antigravity-bridge 1.6.4 → 1.7.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,11 +2,30 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.7.0] - 2026-09-23
6
+
7
+ ### Added
8
+
9
+ - **Opt-in web tools for any provider's model** (`agy_web_search`, `agy_read_url`). Pi ships no web tools and no MCP by default, so sessions driven by non-Antigravity models had no way to search or fetch. With `webTools: on` (`AGY_WEB_TOOLS`, `/agy web on`; off by default because Antigravity sessions already have native web tools on both engines), each call spawns a one-shot search-only `agy` agent (`--mode plan`, bridge MCP inheritance off, per-call agent dir under `~/.gemini/config/agents/pi-bridge-web-*` removed in `finally`), enforces a 120 s deadline and a 2 MiB output cap, tolerates agy's `OK`/`SUCCESS` result-status drift, requires an observed native `search_web`/`read_url_content` step before trusting the answer, and refuses the call if the agent touches any disallowed tool - the init frame advertises a broad tool list regardless of the agent's `tools:` restriction, so the response-side gate is the real anti-hallucination device. Stale agent dirs from crashed calls are swept by pid liveness at registration (marker-less dirs after a 24 h grace); dir names carry a random nonce so same-millisecond parallel calls cannot collide. URLs are scheme-allowlisted (http/https); queries cap at 2000 chars. Every call spends Antigravity subscription quota.
10
+ - **ACP native tool display cards.** The ACP engine had no card story: agy's own tool steps surfaced only as thinking-stream labels. agy's tool events now render as display-only pi entries (status icon plus the file path with colored diff lines, the first command line, or the captured output; persisted content bounded at 4000/12000/500 chars), streamed as steps start and complete. Events attributed to `mcpServer: "pi-bridge"` are filtered out, so bridge round-trips that already produce genuine pi tool cards never double-render. Stream-json keeps its stronger native re-exec cards.
11
+ - **Live ACP usage estimates.** The ACP engine showed zeros until a turn settled (Gate B). Estimates now stream live: the turn's prompt is tokenized once, each text/thought delta updates the running counts, and an `ACP ≈ X in / Y out` status line tracks the turn (suppressed when `acp.usageEstimate` is `off`). Estimates are superseded only by a real `PromptResponse.usage` the day the server sends one; diagnostic usage-shaped frames (context windows, occupancy) never disable them.
12
+ - **`/agy tools` session-only catalog control.** `hide <name>` / `show <name>` / `reset` filter the bridge catalog for the current session only (multi-word names OK; names validated against pi's registered tools; saved config never touched). Bare `/agy tools` lists exposed and hidden tools.
13
+
14
+ ### Changed
15
+
16
+ - **The bridge catalog is computed live and guarded at dispatch.** The exposed set used to be computed once at registration; `/mcp` or `pi.setActiveTools()` changes mid-session left stale tools callable. The catalog is now derived from pi's live active set on every `tools/list` AND re-checked immediately before every `tools/call` - a call for anything outside the fresh set is rejected (`a cached MCP catalog is not authorization`). The bridge's own internal tools (AskAntigravity, the display-only `antigravity` wrapper, `activate_skill`, `bridge_poll_result`, `agy_web_search`/`agy_read_url`) are excluded from the catalog, closing a gap where the wrapper tool was exposed. `/agy bridge` mode changes apply to a running server immediately; a stopped one picks them up on the next start.
17
+ - **Global MCP registration is stream-json-only; hygiene is universal.** Registering and unregistering the bridge's per-pid entry in `~/.gemini/config/mcp_config.json` now happens only for the stream-json engine (ACP supplies the bridge per-session and never touches the global config), while the stale-entry sweep and the stuck-suppression heal run for BOTH engines - an ACP-only session no longer leaves dead entries or a stuck suppression flag behind by a crashed stream-json session.
18
+
19
+ ### Fixed
20
+
21
+ - **Concurrent native tool results no longer cross-pair on ACP.** When several of agy's tools ran at once, a completed event for one could attach its diff/output to a different pending call. Adoption now happens only when exactly one call is pending, finished and errored ids leave the tracking map, and a known-acceptable residual is documented at the site: a done event for a genuinely unrelated call can still pair with the single pending entry.
22
+ - **Diagnostic usage frames can no longer kill ACP estimates.** The old suppression latch tripped on ANY usage-shaped frame and lived for the whole connection, so one context-window notice in turn one silenced the live estimates for every later turn, with no exact data ever replacing them. Suppression now keys on a real per-turn `PromptResponse.usage` only; a two-turn regression test pins that estimates survive the diagnostic frames.
23
+
5
24
  ## [1.6.3] - 2026-09-22
6
25
 
7
26
  ### Fixed
8
27
 
9
- - **The approval gate no longer leaks into standalone sessions, and its verdicts parse.** Thanks @explesy for the report (https://github.com/EstebanForge/pi-antigravity-bridge/issues/5). Two defects in the generated hook script and its staging. Every terminal path printed the bare decision scalar (`"deny"` / `"allow"`) instead of a JSON object, so any session that loaded a staged gate failed hook parsing outright (`protojson: syntax error`): the direct-POST and polling branches now print the full response object guarded by a `typeof json.decision === "string"` check, fall back to a fail-closed deny object on malformed bodies, and read ticket and poll status through `json?.`. The gate group itself was staged into the shared workspace `.agents/hooks.json`, so any standalone IDE or CLI session in that workspace loaded pi's gate: tool calls were denied with "no active antigravity turn" while pi sat idle, and mid-turn they parked into pi's approval queue. A live probe of the CLI settled the discovery rule (hooks load from `--add-dir` directories in stream-json and interactive sessions; print mode reads only the global config), and the gate now stages `.agents/hooks.json` into the session-private per-pid bridge dir beside `mcp_config.json`, which only this session's agy receives as an extra `--add-dir`. Staging into a pid-private file dropped the merge/backup/symlink surface to a plain atomic replace; the driver passes the dir when the gate is staged even without an MCP config; a legacy sweep at session start strips groups that dead sessions left in workspace files (live and foreign groups are never touched). The hook also gained per-fetch aborts (`AbortSignal.timeout`, POST capped at 10s, polls bounded by the remaining park budget) so a hung bridge request can never outlive the park deadline into an ungated soft-pass. Seven stub-server hook-execution regression tests pin the object contract; the staging suite covers the private-dir semantics and the sweep. 364 tests, tsc clean.
28
+ - **The approval gate no longer leaks into standalone sessions, and its verdicts parse.** Thanks @explesy for the report (issue #5). Two defects in the generated hook script and its staging. Every terminal path printed the bare decision scalar (`"deny"` / `"allow"`) instead of a JSON object, so any session that loaded a staged gate failed hook parsing outright (`protojson: syntax error`): the direct-POST and polling branches now print the full response object guarded by a `typeof json.decision === "string"` check, fall back to a fail-closed deny object on malformed bodies, and read ticket and poll status through `json?.`. The gate group itself was staged into the shared workspace `.agents/hooks.json`, so any standalone IDE or CLI session in that workspace loaded pi's gate: tool calls were denied with "no active antigravity turn" while pi sat idle, and mid-turn they parked into pi's approval queue. A live probe of the CLI settled the discovery rule (hooks load from `--add-dir` directories in stream-json and interactive sessions; print mode reads only the global config), and the gate now stages `.agents/hooks.json` into the session-private per-pid bridge dir beside `mcp_config.json`, which only this session's agy receives as an extra `--add-dir`. Staging into a pid-private file dropped the merge/backup/symlink surface to a plain atomic replace; the driver passes the dir when the gate is staged even without an MCP config; a legacy sweep at session start strips groups that dead sessions left in workspace files (live and foreign groups are never touched). The hook also gained per-fetch aborts (`AbortSignal.timeout`, POST capped at 10s, polls bounded by the remaining park budget) so a hung bridge request can never outlive the park deadline into an ungated soft-pass. Seven stub-server hook-execution regression tests pin the object contract; the staging suite covers the private-dir semantics and the sweep. 364 tests, tsc clean.
10
29
 
11
30
  ## [1.6.2] - 2026-09-19
12
31
 
package/README.md CHANGED
@@ -1,16 +1,6 @@
1
1
  # pi-antigravity-bridge
2
2
 
3
- A Gemini model provider **and** the `AskAntigravity` delegation tool for [pi](https://github.com/earendil-works/pi-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
-
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>
3
+ A Gemini model provider **and** the `AskAntigravity` delegation tool for [pi](https://github.com/earendil-works/pi), built on Google's official Antigravity binaries: the `agy` CLI by default, or **Google's official ACP server** (opt-in). It registers `antigravity/gemini-*` models in pi's `/model` picker (streaming), and provides the `AskAntigravity` tool for one-shot delegation - the same combined shape as `pi-claude-bridge`.
14
4
 
15
5
 
16
6
  If you also have [`@estebanforge/pi-ask-antigravity`](https://github.com/EstebanForge/pi-extensions/tree/main/packages/pi-ask-antigravity) installed, this bridge takes over: pi-ask-antigravity detects the bridge and registers nothing, so the `AskAntigravity` tool is never duplicated.
@@ -26,7 +16,7 @@ Multi-turn works. The provider binds a pi session to an agy conversation id (per
26
16
  Turns run through one of two engines behind the same provider surface (`config.engine`, default `stream-json`):
27
17
 
28
18
  - **stream-json** (default): the persistent `agy` CLI process. The tested default; live token usage; conversation resume via `--conversation`.
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).
19
+ - **acp** (beta): Google's official ACP server (`agy_acp_server.par`), JSON-RPC 2.0 over stdio. Beta: parity-verified live against the current build (RC01) - text streaming, multi-turn resume via `session/load`, bridge tools, effort switching, serialization, abort recovery (see `scripts/parity-live.mjs`). Two known RC01 gaps remain: no usage fields (token display runs on live client-side estimates via `acp.usageEstimate`, default `estimate`, streamed per delta and superseded automatically the day the server starts sending real per-turn usage) and no cancel (abort tears the server down and reloads it next turn).
30
20
 
31
21
  The choice of engine is left to the user, with the trade-offs explained in the tool: a first-run picker modal asks once on a fresh install (stream-json preselected; `esc` defers, and the modal reappears next start), and `/agy engine` with no arguments reopens it anytime. An `acp` pick downloads the ~1.5 GB server binary and starts the Google sign-in immediately; a restart applies the engine. With stream-json active and the `agy` binary missing, pi warns on every start until the binary is found.
32
22
 
@@ -48,7 +38,7 @@ Residual limits (with or without the bridge):
48
38
 
49
39
  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.
50
40
 
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.
41
+ The bridge starts a localhost MCP server inside pi's process. The exposed catalog is computed live from pi's active tools on every `tools/list` and re-checked immediately before every `tools/call`: the `bridgeTools` mode, the session's `/agy tools` hidden set, and the bridge's own internal tools (`AskAntigravity`, the display-only `antigravity` wrapper, `activate_skill`, `bridge_poll_result`, the `agy_web_search`/`agy_read_url` wrappers) are filtered each time, and a call for anything outside the fresh set is rejected - a cached MCP catalog is not authorization. A call that passes 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.
52
42
 
53
43
  **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.
54
44
 
@@ -56,7 +46,7 @@ The bridge starts a localhost MCP server inside pi's process. `tools/list` retur
56
46
 
57
47
  **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 for the whole delegated run the bridge hides its global per-pid entries: disabled before the spawn, released only on process close or error - agy watches the config and hot-reloads MCP servers on file changes, so a mid-run re-enable would poke the live delegation back into pi. A cross-process marker (`suppression.json` in the bridge's extensions-data dir) coordinates concurrent delegations: entries come back only when no live delegation remains, a session starting mid-delegation registers its own entry disabled, and the session-start heal re-enables only once the last delegator is gone (dead entries pruned by pid liveness with a 24h age bound). The inner agy therefore sees no bridge and cannot re-enter pi. Foreign servers in the global config are never touched.
58
48
 
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.
49
+ **Cost / fan-out.** Every registered pi tool except builtins and the bridge's own internal tools is exposed (respecting the `bridgeTools` mode and any `/agy tools` hides), 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.
60
50
 
61
51
  **Security.** The MCP server binds to `127.0.0.1` only and requires a per-session shared-secret header (`x-bridge-token`) that agy sends from the bridge config; browsers cannot set custom headers on a simple cross-origin POST, so this blocks web CSRF against the loopback server. Request bodies are size-capped. This is intended for single-user developer machines: any local process running as the same user can read the token from the per-pid config and call the exposed tools, so do not run it on a shared host where you do not trust other same-user processes.
62
52
 
@@ -75,6 +65,12 @@ your pi Agent Skills catalog; calling it returns the SKILL.md body. The bridge
75
65
  answers it directly, no pi round-trip. `/agy doctor` prints driver counters,
76
66
  bridge port, and the last lifecycle events without spending tokens.
77
67
 
68
+ On the ACP engine there is no re-exec: agy's own native tool steps render as
69
+ display-only cards in pi instead - a status icon plus the file path with
70
+ colored diff lines, the command line, or the captured output - streamed as the
71
+ steps start and complete. The stream-json engine keeps its native re-exec
72
+ cards for read-only steps.
73
+
78
74
  ## Approval gate (agy native tools)
79
75
 
80
76
  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.
@@ -120,13 +116,14 @@ If `agy models` fails at load (binary missing, auth not done, network stall), a
120
116
  | Key | Values | Default |
121
117
  | --- | --- | --- |
122
118
  | `askTool` | `on` (register the AskAntigravity delegation tool), `off` (no delegation tool; provider and models only) | `on` |
119
+ | `webTools` | `off` (no web tools), `on` (register `agy_web_search` + `agy_read_url` as Pi tools, usable by ANY provider's model). Each call spawns a one-shot search-only `agy` agent (plan mode, bridge MCP inheritance off), gates the answer on an observed native `search_web`/`read_url_content` step, and spends Antigravity quota. Off by default: Antigravity sessions already have native web tools on both engines; these serve other providers | `off` |
123
120
  | `bridgeTools` | `none` (bridge off), `all` (every non-builtin tool, incl. other `Ask*` delegations), `mcp` (pi-mcp-adapter tools + skills bridge only) | `all` |
124
121
  | `digest` | `off` (stable prompts; agy's prompt cache hits) or `on` (inject a delta of pi-side context - compaction summaries, other-provider turns - into each agy prompt; the delta changes every turn, so agy re-bills the full context). Enable for mixed-provider sessions where agy must see pi-side context | `off` |
125
122
  | `systemPrompt` | `on` (prepend pi's system prompt - operating instructions plus the global agent-dir `AGENTS.md` and ancestor `AGENTS.md`/`CLAUDE.md` - to the first prompt of each new agy conversation, plus a tool-priority note: Pi Bridge tools win over agy's native interactive ones, which never reach the user) or `off` (agy-native behavior) | `on` |
126
123
  | `turnTimeoutMin` | Overall cap on ONE agy turn, minutes, both engines. When it fires, the bridge kills the agy process mid-task ("ACP turn exceeded the 10m deadline" / "agy exceeded the 10m turn timeout") - it is a bridge cap, not a Google server limit. Default is TTY-aware: `0` (no cap) on an interactive pi - you are the backstop with Esc; `20` headless, where nobody can abort and one runaway turn blocks the serialized turn queue. Opt into a gate with 1-1440 (free type via `config.json` or `/agy timeout <1-1440|off>`, or the `/agy` picker's preset list); `0` disables explicitly; anything else falls back to the TTY-aware default. The 5m inactivity stall guard always still bounds a hung server | `0` TTY / `20` headless |
127
124
  | `inactivityTimeoutMin` | Silence cap, minutes, both engines: no stream-json stdout / no ACP session/update for this long fails the turn as a stall. `0` disables the guard | `5` |
128
125
 
129
- Env overrides: `AGY_BRIDGE_TOOLS`, `AGY_ASK_TOOL`, `AGY_DIGEST`, `AGY_SYSTEM_PROMPT`, `AGY_TURN_TIMEOUT_MIN`, `AGY_INACTIVITY_TIMEOUT_MIN`. Env wins over the file, so while `AGY_DIGEST` or `AGY_SYSTEM_PROMPT` is set, the matching `/agy digest` or `/agy system-prompt` toggle persists a value that never takes effect.
126
+ Env overrides: `AGY_BRIDGE_TOOLS`, `AGY_ASK_TOOL`, `AGY_WEB_TOOLS`, `AGY_DIGEST`, `AGY_SYSTEM_PROMPT`, `AGY_TURN_TIMEOUT_MIN`, `AGY_INACTIVITY_TIMEOUT_MIN`. Env wins over the file, so while `AGY_DIGEST` or `AGY_SYSTEM_PROMPT` is set, the matching `/agy digest` or `/agy system-prompt` toggle persists a value that never takes effect.
130
127
 
131
128
  The `activate_skill` catalog mirrors pi's directory-based skill discovery: the two global dirs plus project dirs, the latter only when pi has trusted the project (same gate pi itself applies). Pi's other skill sources - the `skills` settings array, `package.json` entries, and `--skill` CLI paths - are not mirrored and won't appear in the catalog.
132
129
 
@@ -147,7 +144,9 @@ The `activate_skill` catalog mirrors pi's directory-based skill discovery: the t
147
144
  /agy thinking low|medium|high fallback thinking tier for the AskAntigravity tool; callers may override per call
148
145
  /agy digest on|off inject pi-side context into agy prompts (default off; see table above)
149
146
  /agy system-prompt on|off send pi's system prompt + AGENTS.md + the Pi Bridge tool-priority note to new agy conversations (default on)
150
- /agy bridge all|mcp|none which pi tools the MCP bridge exposes to agy (default all; none = bridge off)
147
+ /agy bridge all|mcp|none which pi tools the MCP bridge exposes to agy (default all; none = bridge off; a running server picks up the new catalog immediately, a stopped one on the next start)
148
+ /agy tools [hide|show <name>|reset] session-only bridge-catalog hides (multi-word names OK; reset restores the full set; bare lists exposed + hidden; never touches saved config)
149
+ /agy web on|off register the agy_web_search + agy_read_url pi tools for ANY provider's model (default off; applies on the next pi start or /reload; every call spawns agy and spends Antigravity quota)
151
150
  /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)
152
151
  /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)
153
152
  /agy auth-manual manual ACP credential setup (fallback; auto-setup normally covers this; default login = your Antigravity subscription, same account as the agy CLI)
@@ -180,9 +179,12 @@ For isolation when running any agent that executes commands without a confirmati
180
179
  | `AGY_CONVERSATIONS_DIR` | Override the conversations DB directory. |
181
180
  | `AGY_MODE` | Override execution mode: `plan` (review-only) or `accept-edits` (default). Wins over the config file. |
182
181
  | `AGY_SKIP_PERMISSIONS` | `1`/`true` (default) to pass `--dangerously-skip-permissions` so commands don't hang on an unanswerable prompt in `-p` mode. `0`/`false` to prompt (hangs any `run_command` non-interactively). Wins over the config file. |
182
+ | `AGY_USAGE_ESTIMATE` | ACP token display: `estimate` (default; live client-side per-delta estimates), `direct` (pass through the server's own per-delta counts when it sends them), `off` (no usage display). Anything else falls back to `estimate`. Wins over the config file. |
183
183
  | `AGY_DEFAULT_MODEL` | Default model alias for the `AskAntigravity` tool (`flash`/`pro`/`gemini`, or a tier/version qualifier). Wins over the config file. |
184
184
  | `AGY_DEFAULT_THINKING` | Default thinking tier for the `AskAntigravity` tool: `low`/`medium`/`high`. Anything else falls back to `medium`. Wins over the config file. |
185
185
  | `AGY_DEBUG` | `1`/`true`/`on` writes the full trail to the daily log (driver lifecycle, raw bridge traffic). Default off: **only `error` records land on disk** - routine disk writes are zero for regular users, and warnings surface as UI toasts instead. |
186
+ | `AGY_APPROVALS` | Approval gate mode: `auto` (default; gate on only when a pi permission extension is detected), `shadow` (force the shadow-tool gate on), `dedicated`, `off`. Unknown values fall back to `auto`, so a typo can never force the gate on. Wins over `config.approvals.gateMode`. |
187
+ | `AGY_APPROVALS_MODE` | Fallback decision when no permission extension answers a gated call: `ask` (interactive prompt), `allow`, `deny` (headless default). Wins over `config.approvals.mode`. |
186
188
 
187
189
  ## Debug logs
188
190
 
@@ -50,6 +50,7 @@ import {
50
50
  createStreamSimple,
51
51
  formatEscalatedAck,
52
52
  formatPollAnswer,
53
+ type NativeDisplayEvent,
53
54
  } from "../src/provider.js";
54
55
  import {
55
56
  createShadowTool,
@@ -69,6 +70,7 @@ import { CONFIG_PATH, loadConfig, logsDir, MAX_TURN_CAP_MIN, parseCapMinutes, sa
69
70
  import { agyMissingMessage, isAgyInstalled, savedEngineMessage, showEnginePicker, shouldOfferEnginePicker } from "../src/engine-picker.js";
70
71
  import { createDailyLogger, type DailyLogger } from "../src/daily-log.js";
71
72
  import { registerAskAntigravityTool, toolModelsFromRaw } from "../src/ask-tool.js";
73
+ import { registerWebTools } from "../src/web-tools.js";
72
74
  import { bridgeMcpConfigDir, startMcpServer, TOKEN_HEADER, type McpServerHandle } from "../src/mcp-server.js";
73
75
  import {
74
76
  registerBridgeServer,
@@ -86,6 +88,7 @@ import {
86
88
  type SkillLite,
87
89
  } from "../src/skills.js";
88
90
  import { mapAgyToolToNative } from "../src/native-tools.js";
91
+ import { bridgedPiTools } from "../src/bridge-catalog.js";
89
92
  import { Type } from "typebox";
90
93
  import { patchStatus, restorePatch } from "../src/patch-cleanup.js";
91
94
  import { withDialogLock } from "../src/dialog-lock.js";
@@ -177,7 +180,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
177
180
  };
178
181
  fileLog.log(
179
182
  "extension-load",
180
- { engine, models: models.length, fallback: usingFallback, bridge: loadConfig().bridgeTools, askTool: loadConfig().askTool },
183
+ { engine, models: models.length, fallback: usingFallback, bridge: loadConfig().bridgeTools, askTool: loadConfig().askTool, webTools: loadConfig().webTools },
181
184
  "info",
182
185
  );
183
186
 
@@ -185,6 +188,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
185
188
  // MCP bridge handle, declared early: the ACP engine reads the bridge port
186
189
  // at session/new / session/load time.
187
190
  let mcpHandle: McpServerHandle | null = null;
191
+ // Session-only visibility overrides; never change either application's MCP config.
192
+ const hiddenBridgeTools = new Set<string>();
193
+ const getBridgeTools = () => bridgedPiTools(pi.getAllTools(), pi.getActiveTools(), loadConfig().bridgeTools, hiddenBridgeTools);
188
194
  // Approval-gate hook script (per-pid, token embedded). Written at session
189
195
  // start when the gate is active; removed at session_shutdown.
190
196
  let gateScriptPath: string | null = null;
@@ -328,12 +334,53 @@ export default async function (pi: ExtensionAPI): Promise<void> {
328
334
  ),
329
335
  );
330
336
  const replay = new WrapperReplay();
337
+ // ACP tool activity is display-only: durable Pi entries with native-looking
338
+ // titles and optional diffs. Unlike a synthetic Pi toolCall this does not
339
+ // re-execute edits, park the turn, or add tool results to model context.
340
+ if (engine === "acp") {
341
+ pi.registerEntryRenderer<NativeDisplayEvent>("agy-native-event", (entry, { expanded }, theme) => {
342
+ const event = entry.data;
343
+ const title = (event?.path ? path.basename(event.path) : event?.command?.split(/\r?\n/, 1)[0] ?? event?.name ?? "tool").slice(0, 160);
344
+ const status = event?.status === "failed" ? theme.fg("error", "✗") : theme.fg("success", "✓");
345
+ const lines = [`${status} ${theme.fg("toolTitle", event?.name ?? "Antigravity")} ${theme.fg("muted", title)}`];
346
+ if (expanded) {
347
+ if (event?.path) lines.push(theme.fg("dim", event.path));
348
+ if (event?.diff) {
349
+ for (const line of event.diff.split("\n")) {
350
+ const color = line.startsWith("+") ? "toolDiffAdded" : line.startsWith("-") ? "toolDiffRemoved" : "toolDiffContext";
351
+ lines.push(theme.fg(color, line));
352
+ }
353
+ }
354
+ if (event?.output && (!event.diff || event.status === "failed")) lines.push(theme.fg("toolOutput", event.output));
355
+ }
356
+ return new Text(lines.join("\n"), 0, 0);
357
+ });
358
+ }
359
+ let pendingNativeTools = 0;
360
+ const onNativeEvent = (event: NativeDisplayEvent): void => {
361
+ if (engine !== "acp" || event.mcpServer === "pi-bridge") return;
362
+ // MCP calls through our own pi-bridge already have genuine Pi tool cards;
363
+ // only native Antigravity / other MCP events need display-only entries.
364
+ if (event.status === "started") {
365
+ pendingNativeTools++;
366
+ activeUi?.setStatus("agy-native", `agy ${event.name}… (${pendingNativeTools})`);
367
+ return;
368
+ }
369
+ pendingNativeTools = Math.max(0, pendingNativeTools - 1);
370
+ if (pendingNativeTools === 0) activeUi?.setStatus("agy-native", undefined);
371
+ // Bound persisted session size; the ACP result and diff may be megabytes.
372
+ pi.appendEntry<NativeDisplayEvent>("agy-native-event", {
373
+ ...event,
374
+ output: event.output?.slice(0, 4000),
375
+ diff: event.diff?.slice(0, 12000),
376
+ command: event.command?.slice(0, 500),
377
+ });
378
+ };
331
379
  // Native re-exec only emits for builtins actually active in the session;
332
380
  // anything else (or an unknown name) falls back to the wrapper card.
333
381
  const nativeActive = (name: string): boolean => {
334
382
  try {
335
- const getAll = (pi as unknown as { getAllTools: () => Array<{ name: string }> }).getAllTools.bind(pi);
336
- return getAll().some((t) => t.name === name);
383
+ return pi.getActiveTools().includes(name);
337
384
  } catch {
338
385
  return false;
339
386
  }
@@ -354,6 +401,35 @@ export default async function (pi: ExtensionAPI): Promise<void> {
354
401
  };
355
402
  streamDriver.onTurnEnd = onTurnEnd;
356
403
  acpDriver.onTurnEnd = onTurnEnd;
404
+ // Pi's normal footer receives the incremental usage above. Mark the
405
+ // numbers as estimates in the status area too; ACP currently drops the
406
+ // SDK's exact usage metadata before it reaches the client.
407
+ if (engine === "acp") {
408
+ let lastUsageStatus = "";
409
+ pi.on("message_update", (event, ctx) => {
410
+ if (!ctx.hasUI || event.message.role !== "assistant" || event.message.provider !== "antigravity" || loadConfig().acp.usageEstimate === "off") return;
411
+ const { input, output } = event.message.usage;
412
+ const status = `ACP ≈ ${input} in / ${output} out`;
413
+ if (status !== lastUsageStatus) {
414
+ lastUsageStatus = status;
415
+ ctx.ui.setStatus("agy-usage", status);
416
+ }
417
+ });
418
+ const clearUsageStatus = (_event: unknown, ctx: { hasUI: boolean; ui: ExtensionUIContext }) => {
419
+ lastUsageStatus = "";
420
+ if (ctx.hasUI) ctx.ui.setStatus("agy-usage", undefined);
421
+ };
422
+ pi.on("agent_end", (event, ctx) => {
423
+ clearUsageStatus(event, ctx);
424
+ pendingNativeTools = 0;
425
+ if (ctx.hasUI) ctx.ui.setStatus("agy-native", undefined);
426
+ });
427
+ pi.on("session_shutdown", (event, ctx) => {
428
+ clearUsageStatus(event, ctx);
429
+ pendingNativeTools = 0;
430
+ if (ctx.hasUI) ctx.ui.setStatus("agy-native", undefined);
431
+ });
432
+ }
357
433
  const streamSimple = createStreamSimple({
358
434
  entries,
359
435
  store,
@@ -362,6 +438,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
362
438
  roundTrips,
363
439
  replay,
364
440
  nativeActive,
441
+ onNativeEvent,
365
442
  engine,
366
443
  log: fileLog.log.bind(fileLog),
367
444
  });
@@ -393,6 +470,8 @@ export default async function (pi: ExtensionAPI): Promise<void> {
393
470
  acpDriver,
394
471
  engine,
395
472
  getMcpPort: () => mcpHandle?.port ?? null,
473
+ bridgeTools: getBridgeTools,
474
+ hiddenBridgeTools,
396
475
  acpLog,
397
476
  fileLog,
398
477
  authCapture: authCapture ?? null,
@@ -409,6 +488,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
409
488
  // pi-ask-antigravity keeps deferring even then: off means NO delegation
410
489
  // tool from either package, not a fallback to pi-ask-antigravity.
411
490
  if (loadConfig().askTool) await registerAskAntigravityTool(pi, toolModels, fileLog.log.bind(fileLog));
491
+ // Web tools are opt-in (config.webTools, default off): Antigravity sessions
492
+ // already have native web tools; these serve NON-Antigravity providers.
493
+ if (loadConfig().webTools) registerWebTools(pi, { log: fileLog.log.bind(fileLog) });
412
494
 
413
495
  // Display-only wrapper tool: the provider emits mutating agy steps as
414
496
  // toolCalls against it (never re-executed - execute() replays the output
@@ -633,22 +715,13 @@ export default async function (pi: ExtensionAPI): Promise<void> {
633
715
  const bridgeMode: BridgeTools = loadConfig().bridgeTools;
634
716
  if (bridgeMode === "none") return; // user opted out
635
717
  if (mcpHandle) return; // already running (reload re-fires session_start)
636
- const SKIP = new Set(["AskAntigravity"]);
637
718
  // pi loads project skill locations only after the project is trusted;
638
719
  // mirror that gate. Global skill dirs are always scanned.
639
720
  const skills: SkillLite[] = scanSkills(ctx.isProjectTrusted() ? process.cwd() : undefined);
640
- const getAll = (pi as unknown as {
641
- getAllTools: () => Array<{ name: string; description?: string; parameters?: object; sourceInfo?: { source?: string } }>;
642
- }).getAllTools.bind(pi);
643
721
  const listTools = () => {
644
- const all = getAll();
645
- const filtered =
646
- bridgeMode === "mcp"
647
- ? all.filter((t) => /pi-mcp-adapter/.test(t.sourceInfo?.source ?? ""))
648
- : all.filter((t) => t.sourceInfo?.source !== "builtin");
649
- const tools = filtered
650
- .filter((t) => !SKIP.has(t.name))
651
- .map((t) => {
722
+ // /mcp and pi.setActiveTools() update the same live catalog. ACP gets
723
+ // this endpoint through session/new or session/load; no global config write.
724
+ const tools = getBridgeTools().map((t) => {
652
725
  let inputSchema: object = { type: "object", properties: {}, additionalProperties: true };
653
726
  try {
654
727
  if (t.parameters) inputSchema = JSON.parse(JSON.stringify(t.parameters)) as object;
@@ -657,7 +730,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
657
730
  }
658
731
  return { name: t.name, description: t.description ?? t.name, inputSchema };
659
732
  });
660
- if (skills.length > 0) {
733
+ if (skills.length > 0 && loadConfig().bridgeTools !== "none") {
661
734
  tools.push({
662
735
  name: ACTIVATE_SKILL_TOOL_NAME,
663
736
  description: `Activate a pi Agent Skill by name. Catalog:\n${catalogSummary(skills)}`,
@@ -695,6 +768,11 @@ export default async function (pi: ExtensionAPI): Promise<void> {
695
768
  return Promise.resolve(formatPollAnswer(wanted, roundTrips.poll(wanted)));
696
769
  }
697
770
  if (name !== ACTIVATE_SKILL_TOOL_NAME) {
771
+ // A cached MCP catalog is not authorization. An inactive/hidden tool
772
+ // must not get parked into Pi, even if agy retained its old schema.
773
+ if (!getBridgeTools().some((tool) => tool.name === name)) {
774
+ return Promise.resolve({ content: [{ type: "text", text: `Pi tool ${name} is not active or not exposed to Antigravity` }], isError: true });
775
+ }
698
776
  return roundTrips.onToolCall(callId, name, args, signal).then((r) => {
699
777
  // Early-ack: answer the HTTP request before agy's ~180s client
700
778
  // deadline with a poll handle; pi keeps executing meanwhile.
@@ -704,7 +782,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
704
782
  });
705
783
  }
706
784
  const wanted = typeof args.name === "string" ? args.name : "";
707
- const skill = findSkillByName(skills, wanted);
785
+ const skill = loadConfig().bridgeTools !== "none" ? findSkillByName(skills, wanted) : undefined;
708
786
  const body = skill ? readSkillBody(skill) : `unknown skill: ${wanted || "(none given)"}`;
709
787
  return Promise.resolve({
710
788
  content: [
@@ -730,14 +808,21 @@ export default async function (pi: ExtensionAPI): Promise<void> {
730
808
  // suppressed are healed here - but only when no live delegation is in
731
809
  // flight anywhere (marker-aware heal). A blind re-enable used to
732
810
  // un-hide the bridge during another session's active delegation.
811
+ // Global-config hygiene runs on BOTH engines: a crashed stream-json
812
+ // session must not leave dead-pid entries or stuck suppression flags
813
+ // for the next one.
733
814
  sweepStaleBridgeServers();
734
815
  healBridgeSuppression();
735
- registerBridgeServer({
736
- pid: process.pid,
737
- port: r.handle.port,
738
- token: r.handle.token,
739
- tokenHeader: TOKEN_HEADER,
740
- });
816
+ // ACP supplies the bridge per-session (session/new | session/load);
817
+ // only the stream-json CLI reads the global MCP config.
818
+ if (engine === "stream-json") {
819
+ registerBridgeServer({
820
+ pid: process.pid,
821
+ port: r.handle.port,
822
+ token: r.handle.token,
823
+ tokenHeader: TOKEN_HEADER,
824
+ });
825
+ }
741
826
  // --- Approval gate (docs/TODO.md 2.5) ------------------------------
742
827
  // agy native tool calls pass through a pi-side approval: a PreToolUse
743
828
  // hook parks in the bridge, the provider emits a shadow toolUse, and
@@ -862,7 +947,8 @@ export default async function (pi: ExtensionAPI): Promise<void> {
862
947
  // ("quit") - the connection kill is identical and nothing runs after.
863
948
  await streamDriver.close("recycle", "session shutdown");
864
949
  await acpDriver.close("recycle", "session shutdown");
865
- unregisterBridgeServer(process.pid);
950
+ if (engine === "stream-json") unregisterBridgeServer(process.pid);
951
+ hiddenBridgeTools.clear();
866
952
  // Approval gate: unstage hooks and remove the per-pid script. Pending
867
953
  // approvals already failed closed via handle close (bridge shutdown deny).
868
954
  const unstaged = removeGateHooks();
@@ -897,6 +983,8 @@ interface AgyCommandCtx {
897
983
  /** Engine latched at extension load (see the provider wiring note). */
898
984
  engine: Engine;
899
985
  getMcpPort: () => number | null;
986
+ bridgeTools: () => Array<{ name: string }>;
987
+ hiddenBridgeTools: Set<string>;
900
988
  /** Shared ACP log sink (login URL surfacing + failure events). */
901
989
  acpLog: (msg: string, data?: unknown) => void;
902
990
  /** Daily file logger (src/daily-log.ts); command + doctor surfacing. */
@@ -916,6 +1004,7 @@ interface PendingConfig {
916
1004
  defaultThinking?: ThinkingTier;
917
1005
  turnTimeoutMin?: number;
918
1006
  askTool?: boolean;
1007
+ webTools?: boolean;
919
1008
  bridgeTools?: BridgeTools;
920
1009
  digest?: boolean;
921
1010
  systemPrompt?: boolean;
@@ -935,6 +1024,7 @@ function statusText(ctx: AgyCommandCtx): string {
935
1024
  row("mode:", config.mode),
936
1025
  row("permissions:", perm),
937
1026
  row("AskAntigravity tool:", config.askTool ? "on" : "off"),
1027
+ row("Web tools:", config.webTools ? "on" : "off"),
938
1028
  row("AskAntigravity model:", config.defaultModel),
939
1029
  row("AskAntigravity thinking:", config.defaultThinking),
940
1030
  row("sessions:", `${ctx.store.size} bound`),
@@ -943,7 +1033,7 @@ function statusText(ctx: AgyCommandCtx): string {
943
1033
  row("digest:", config.digest ? "on" : "off"),
944
1034
  row("system prompt:", config.systemPrompt ? "on" : "off"),
945
1035
  "",
946
- "Subcommands: /agy auth, /agy auth-manual, /agy engine stream-json|acp, /agy mode plan|accept-edits, /agy permissions on|off, /agy ask on|off, /agy model <alias>, /agy thinking low|medium|high, /agy bridge all|mcp|none, /agy digest on|off, /agy system-prompt on|off, /agy acp-bin <path|auto>, /agy patch-cleanup, /agy clear",
1036
+ "Subcommands: /agy auth, /agy auth-manual, /agy engine stream-json|acp, /agy mode plan|accept-edits, /agy permissions on|off, /agy ask on|off, /agy model <alias>, /agy thinking low|medium|high, /agy bridge all|mcp|none, /agy tools [hide|show <name>|reset], /agy web on|off, /agy digest on|off, /agy system-prompt on|off, /agy acp-bin <path|auto>, /agy patch-cleanup, /agy clear",
947
1037
  ].join("\n");
948
1038
  }
949
1039
 
@@ -951,7 +1041,7 @@ function statusText(ctx: AgyCommandCtx): string {
951
1041
  function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
952
1042
  pi.registerCommand("agy", {
953
1043
  description:
954
- "Antigravity provider: status, doctor, settings picker, clear sessions. Usage: /agy [status|doctor|auth|auth-manual|engine stream-json|acp|mode plan|accept-edits|permissions on|off|ask on|off|model <alias>|thinking low|medium|high|bridge all|mcp|none|digest on|off|system-prompt on|off|timeout <1-1440|off>|acp-bin <path|auto>|patch-cleanup|clear]",
1044
+ "Antigravity provider: status, doctor, settings picker, clear sessions. Usage: /agy [status|doctor|auth|auth-manual|engine stream-json|acp|mode plan|accept-edits|permissions on|off|ask on|off|model <alias>|thinking low|medium|high|bridge all|mcp|none|tools [hide|show <name>|reset]|web on|off|digest on|off|system-prompt on|off|timeout <1-1440|off>|acp-bin <path|auto>|patch-cleanup|clear]",
955
1045
  handler: async (args, cmdCtx: ExtensionCommandContext) => {
956
1046
  const ui = cmdCtx.ui;
957
1047
  if (ui) activeUi = ui;
@@ -1245,13 +1335,31 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
1245
1335
  if (val === "all" || val === "mcp" || val === "none") {
1246
1336
  const next = saveConfig({ bridgeTools: val });
1247
1337
  ui?.notify(
1248
- next.bridgeTools === "none"
1249
- ? "bridge off. The MCP tool bridge will not start on the next pi start (or /reload)."
1250
- : `bridge tools set to ${next.bridgeTools}. The catalog rebuilds on the next pi start (or /reload).`,
1338
+ ctx.getMcpPort() === null
1339
+ ? `bridge ${next.bridgeTools}. The bridge server is not running; /reload to apply.`
1340
+ : `bridge ${next.bridgeTools}. The active catalog and call guard update now; ACP may retain cached schemas until the next session/load.`,
1251
1341
  "info",
1252
1342
  );
1253
1343
  } else {
1254
- ui?.notify(`bridge: ${loadConfig().bridgeTools}\nusage: /agy bridge all|mcp|none\n all: every non-builtin pi tool (default). mcp: pi-mcp-adapter tools + skills only. none: bridge off.`, "info");
1344
+ ui?.notify(`bridge: ${loadConfig().bridgeTools}\nusage: /agy bridge all|mcp|none\n all: active non-builtin pi tools. mcp: active pi-mcp-adapter tools + skills. none: bridge off.`, "info");
1345
+ }
1346
+ return;
1347
+ }
1348
+ if (sub === "tools") {
1349
+ const name = (args ?? "").trim().split(/\s+/).slice(2).join(" ");
1350
+ if (val === "reset") {
1351
+ ctx.hiddenBridgeTools.clear();
1352
+ ui?.notify("Antigravity tool visibility reset for this Pi session. Pi's active tools are unchanged.", "info");
1353
+ } else if ((val === "hide" || val === "show") && name) {
1354
+ if (!pi.getAllTools().some((tool) => tool.name === name)) {
1355
+ ui?.notify(`Unknown Pi tool: ${name}`, "error");
1356
+ return;
1357
+ }
1358
+ if (val === "hide") ctx.hiddenBridgeTools.add(name);
1359
+ else ctx.hiddenBridgeTools.delete(name);
1360
+ ui?.notify(`${name}: ${val === "hide" ? "hidden from Antigravity" : "visible if active in Pi"}. No MCP config changed.`, "info");
1361
+ } else {
1362
+ ui?.notify(`Exposed active Pi tools: ${ctx.bridgeTools().map((tool) => tool.name).join(", ") || "(none)"}\nHidden in this session: ${[...ctx.hiddenBridgeTools].join(", ") || "(none)"}\n/agy tools hide|show <exact Pi tool name> | reset\nUse Pi's /mcp or pi.setActiveTools() to change the underlying active tools.`, "info");
1255
1363
  }
1256
1364
  return;
1257
1365
  }
@@ -1278,6 +1386,20 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
1278
1386
  }
1279
1387
  return;
1280
1388
  }
1389
+ if (sub === "web") {
1390
+ if (val === "on" || val === "off") {
1391
+ const next = saveConfig({ webTools: val === "on" });
1392
+ ui?.notify(
1393
+ next.webTools
1394
+ ? "Web tools on: agy_web_search and agy_read_url register on the next pi start (or /reload). Each call spends Antigravity quota."
1395
+ : "Web tools off: agy_web_search and agy_read_url will not register on the next pi start (or /reload).",
1396
+ "info",
1397
+ );
1398
+ } else {
1399
+ ui?.notify(`Web tools: ${loadConfig().webTools ? "on" : "off"}\nusage: /agy web on|off\nExposes agy_web_search + agy_read_url as Pi tools for ANY provider (Antigravity sessions already have native web tools). Default off; each call spends Antigravity quota.`, "info");
1400
+ }
1401
+ return;
1402
+ }
1281
1403
  if (sub === "digest") {
1282
1404
  if (val === "on" || val === "off") {
1283
1405
  const next = saveConfig({ digest: val === "on" });
@@ -1367,6 +1489,14 @@ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promis
1367
1489
  currentValue: config.askTool ? "on" : "off",
1368
1490
  values: ["on", "off"],
1369
1491
  },
1492
+ {
1493
+ id: "web",
1494
+ label: "Web tools",
1495
+ description:
1496
+ "Register agy_web_search + agy_read_url as Pi tools for ANY provider. Default off: Antigravity sessions already have native web tools, and each call spends Antigravity quota. Takes effect on the next pi start (or /reload).",
1497
+ currentValue: config.webTools ? "on" : "off",
1498
+ values: ["on", "off"],
1499
+ },
1370
1500
  {
1371
1501
  id: "model",
1372
1502
  label: "AskAntigravity model",
@@ -1437,6 +1567,8 @@ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promis
1437
1567
  pending.defaultThinking = newValue as ThinkingTier;
1438
1568
  } else if (id === "ask") {
1439
1569
  pending.askTool = newValue === "on";
1570
+ } else if (id === "web") {
1571
+ pending.webTools = newValue === "on";
1440
1572
  } else if (id === "bridge") {
1441
1573
  pending.bridgeTools = newValue as BridgeTools;
1442
1574
  } else if (id === "turn-cap") {
@@ -1483,6 +1615,7 @@ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promis
1483
1615
  ? `permissions=${next.skipPermissions ? "auto-approved" : "prompt"}`
1484
1616
  : null,
1485
1617
  pending.askTool !== undefined ? `AskAntigravity tool=${next.askTool ? "on" : "off"}` : null,
1618
+ pending.webTools !== undefined ? `webTools=${next.webTools ? "on" : "off"}` : null,
1486
1619
  pending.defaultModel !== undefined ? `AskAntigravity model=${next.defaultModel}` : null,
1487
1620
  pending.defaultThinking !== undefined ? `AskAntigravity thinking=${next.defaultThinking}` : null,
1488
1621
  pending.bridgeTools !== undefined ? `bridge=${next.bridgeTools}` : null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estebanforge/pi-antigravity-bridge",
3
- "version": "1.6.4",
3
+ "version": "1.7.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",
@@ -25,6 +25,7 @@ import os from "node:os";
25
25
  import path from "node:path";
26
26
  import { parseAuthPort, readLastUrl } from "./browser-capture.js";
27
27
  import { frameCarriesUsage } from "./events.js";
28
+ import type { AgyUsage } from "../driver-types.js";
28
29
  import { JsonRpcResponseError, JsonRpcSession } from "./jsonrpc.js";
29
30
 
30
31
  export interface AcpMcpServer {
@@ -82,6 +83,10 @@ export class AcpConnection {
82
83
  #rpc: JsonRpcSession | undefined;
83
84
  #stderrTail = "";
84
85
  #usageSeen = false;
86
+ /** Set ONLY by a parsed PromptResponse.usage; this gates estimate
87
+ * suppression. #usageSeen stays diagnostic: any usage-shaped frame trips
88
+ * it, and the connection outlives turns. */
89
+ #exactUsageSeen = false;
85
90
  #exited = false;
86
91
  #killed = false;
87
92
  #suppressUpdates = false;
@@ -115,6 +120,11 @@ export class AcpConnection {
115
120
  return this.#usageSeen;
116
121
  }
117
122
 
123
+ /** True once the server sent real per-turn usage (PromptResponse.usage). */
124
+ get exactUsageSeen(): boolean {
125
+ return this.#exactUsageSeen;
126
+ }
127
+
118
128
  /** While true, session/update notifications are dropped: they are the
119
129
  * full-text history replay that precedes a session/load response, never
120
130
  * live generation (run 6). */
@@ -237,7 +247,7 @@ export class AcpConnection {
237
247
  text: string,
238
248
  images: Array<{ data: string; mimeType: string }> = [],
239
249
  contextBlock?: { uri: string; text: string },
240
- ): Promise<{ stopReason: string }> {
250
+ ): Promise<{ stopReason: string; usage?: AgyUsage }> {
241
251
  // Block order: images, then the embedded-context resource (pi-side
242
252
  // digest), then the text question last (it refers to everything before
243
253
  // it). Shapes per the ACP v1 content-block schema; embeddedContext is
@@ -264,7 +274,15 @@ export class AcpConnection {
264
274
  if (typeof stopReason !== "string") {
265
275
  throw new Error("ACP session/prompt returned no stopReason");
266
276
  }
267
- return { stopReason };
277
+ // Optional, unstable ACP PromptResponse.usage: absent on 1.1.1 but
278
+ // parsed for the day the server forwards its SDK usage_metadata. Never
279
+ // infer exact counts from a context-window usage_update ({used,size}).
280
+ const usage = parseAcpTurnUsage(result?.usage);
281
+ if (usage) {
282
+ this.#usageSeen = true;
283
+ this.#exactUsageSeen = true;
284
+ }
285
+ return { stopReason, ...(usage ? { usage } : {}) };
268
286
  }
269
287
 
270
288
  /** Probe-and-cancel. Returns supported:false when the server does not
@@ -430,6 +448,28 @@ export class AcpConnection {
430
448
  }
431
449
  }
432
450
 
451
+ /** ACP's optional (unstable) per-turn PromptResponse.usage, not the
452
+ * session/usage_update context-window occupancy notification. */
453
+ export function parseAcpTurnUsage(value: unknown): AgyUsage | undefined {
454
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
455
+ const u = value as Record<string, unknown>;
456
+ const count = (v: unknown): number | undefined =>
457
+ typeof v === "number" && Number.isSafeInteger(v) && v >= 0 ? v : undefined;
458
+ const input = count(u.inputTokens);
459
+ const output = count(u.outputTokens);
460
+ const total = count(u.totalTokens);
461
+ const thought = count(u.thoughtTokens);
462
+ const cacheRead = count(u.cachedReadTokens);
463
+ if (input === undefined || output === undefined || total === undefined) return undefined;
464
+ return {
465
+ input_tokens: input,
466
+ output_tokens: output,
467
+ total_tokens: total,
468
+ ...(thought === undefined ? {} : { thinking_tokens: thought }),
469
+ ...(cacheRead === undefined ? {} : { cache_read_tokens: cacheRead }),
470
+ };
471
+ }
472
+
433
473
  export function translateError(method: string, err: unknown): Error {
434
474
  if (err instanceof AcpAuthError) return err;
435
475
  if (err instanceof JsonRpcResponseError) {