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