@estebanforge/pi-antigravity-bridge 1.4.0 → 1.4.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 +14 -0
- package/README.md +4 -4
- package/docs/ACP-ADOPTION-PLAN.md +33 -33
- package/docs/ACP-PROTOCOL-REFERENCE.md +1 -1
- package/extensions/index.ts +125 -36
- package/package.json +5 -4
- package/src/acp/connection.ts +22 -8
- package/src/acp/driver.ts +14 -10
- package/src/acp/setup.ts +355 -0
- package/src/driver.ts +20 -1
- package/src/provider.ts +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [1.4.1] - 2026-09-04
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Self-service ACP setup (`src/acp/setup.ts`): `/agy engine acp` now prepares the whole engine instead of printing instructions. It installs the official server binary from the antigravity-acp registry entry (`agentclientprotocol/registry`) into the pinned layout `~/.local/opt/agy-acp/<build>/` with a `current` symlink and the zip sha256 recorded (no upstream checksums exist; plan §12), points `acp.bin` at it, and prepares the login: `oauth-personal` by default, which is the user's own Antigravity subscription (the same Google account and plan as the `agy` CLI; the server opens the browser on the first ACP message, tokens persist). Credential values are never read or written (`acp_token.json` is only stat()ed); `gemini-api-key` stays a manual headless option, never the default. A `session_start` self-heal repeats the check silently when everything is ready and surfaces the manual steps only on failure; `AcpDriverOptions.bin` accepts a resolver so a mid-session install is picked up by the next turn without a restart; `/agy doctor` shows binary source and auth type. Tests inject the registry, archive, and unpacker, so the suite stays offline.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Stream-json frames split across pipe chunks are now reassembled instead of dropped: `AgyDriver` buffers the trailing partial stdout line (the scheme `JsonRpcSession.feed` already used on the ACP engine). Previously a large `tool` frame or the `result` frame split by a pipe boundary was lost whole, which could turn a successful turn into a bogus "agy exited with status 0" error. Regression test drives a fake agy whose reply is deliberately split mid-line.
|
|
14
|
+
- Both drivers attach an `'error'` listener on the child's stdin: an async pipe failure (EPIPE when agy/the ACP server dies mid-write) now fails the turn (ACP: tears the connection down) instead of escaping as an uncaught exception that kills the whole pi process.
|
|
15
|
+
- ACP permission answering is fail-closed: `session/request_permission` selects the first reject option unless the turn runs with `skipPermissions`. Previously every request was auto-approved regardless of the `permissions` setting shown by `/agy status`. The unsupported `engine=acp` + `mode=plan` combination is now refused at `/agy engine`, `/agy mode`, and the settings picker, and fails the turn with a visible error instead of silently running non-plan (ACP has no review-only mode in RC01).
|
|
16
|
+
- Startup-log fix hardening: four genuine ACP failure events (`session-load-failed-creating-fresh`, `connection-exited`, `cancel-failed`, `unsupported-server-request`) surface again; the dead MCP entries (`capability-missing`, `self-patch-error`) are gone; `call-tool-fail` no longer toasts for the routine fail-all on turn end / session shutdown.
|
|
17
|
+
- Startup log leaks: the ACP driver log sink now forwards only genuine failures (`start-failed`, `spawn-error`, `parse-error`, `write-failed`, `mode-apply-failed`, `timeout`, `stall`, `auth-required`) to stderr; routine lifecycle (`driver-created`, spawn, session new/load) stays in the `#lifecycle` ring buffer under `/agy doctor`. The MCP bridge logger no longer toasts (or headless-stderr-logs) normal startup/teardown events (`listening`, `bridge-config-written`, `bridge-config-removed`, `closed`); only failures surface, as warning toasts. The stream-json engine already had no terminal sink.
|
|
18
|
+
|
|
5
19
|
## [1.4.0] - 2026-09-04
|
|
6
20
|
|
|
7
21
|
### Added
|
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ Turns run through one of two engines behind the same provider surface (`config.e
|
|
|
25
25
|
|
|
26
26
|
Engine-dependent features: pi image attachments ride natively only on the ACP engine (the picker offers image attach automatically when `config.engine` is `acp`; the stream-json CLI prompt is text-only). With the optional G1 digest enabled, its delivery also differs: ACP ships it as a native `embeddedContext` resource block, stream-json prepends it to the prompt text.
|
|
27
27
|
|
|
28
|
-
Switch with `/agy engine acp|stream-json` (takes effect on restart).
|
|
28
|
+
Switch with `/agy engine acp|stream-json` (takes effect on restart). Setup is automatic: switching to `acp` installs Google's official ACP server binary from the [antigravity-acp registry entry](https://github.com/agentclientprotocol/registry) (`~/.local/opt/agy-acp/<build>/` + a `current` symlink, zip sha256 recorded; layout and pinning in [docs/ACP-ADOPTION-PLAN.md](docs/ACP-ADOPTION-PLAN.md)) and prepares the login. The login is your Antigravity subscription: on your first ACP message the server opens the Google login in your browser, and you sign in with the same account and plan you use for the Antigravity CLI (`agy`). It is no different from logging into the CLI; the server just keeps its own token file on your machine, like any Google tool, and this extension never sees your credentials. If you also export `GEMINI_API_KEY`, it is ignored: the server uses the auth type in settings.json, and setup always writes `oauth-personal`. A session start self-heals the same way, silently when everything is ready. Manual instructions (`/agy acp-auth`) surface only when a step fails. Sessions are engine-scoped, so switching engines never crosses conversations.
|
|
29
29
|
|
|
30
30
|
## What it cannot do
|
|
31
31
|
|
|
@@ -125,8 +125,8 @@ Env overrides: `AGY_BRIDGE_TOOLS`, `AGY_DIGEST`, `AGY_SYSTEM_PROMPT`. Env wins o
|
|
|
125
125
|
/agy thinking low|medium|high default thinking tier for the AskAntigravity tool
|
|
126
126
|
/agy digest on|off inject pi-side context into agy prompts (default off; see table above)
|
|
127
127
|
/agy system-prompt on|off send pi's system prompt + AGENTS.md to new agy conversations (default on)
|
|
128
|
-
/agy engine acp|stream-json switch the turn engine (restart to apply; default stream-json)
|
|
129
|
-
/agy acp-auth
|
|
128
|
+
/agy engine acp|stream-json switch the turn engine (restart to apply; default stream-json; acp runs self-service setup: binary install + auth bootstrap)
|
|
129
|
+
/agy acp-auth manual ACP credential setup (fallback; auto-setup normally covers this; default login = your Antigravity subscription, same account as the agy CLI)
|
|
130
130
|
/agy patch-cleanup restore the original pi files if an older version patched them
|
|
131
131
|
/agy clear drop all session bindings (force fresh conversations)
|
|
132
132
|
```
|
|
@@ -149,7 +149,7 @@ For isolation when running any agent that executes commands without a confirmati
|
|
|
149
149
|
| --- | --- |
|
|
150
150
|
| `AGY_BIN` | Path to the agy binary. Defaults to `agy` on PATH. |
|
|
151
151
|
| `AGY_ENGINE` | Turn engine: `stream-json` (default) or `acp`. Wins over the config file. |
|
|
152
|
-
| `AGY_ACP_BIN` | Path to the ACP server binary (`agy_acp_server.par`). Defaults to `agy_acp_server.par` on PATH. Wins over `config.acp.bin`. |
|
|
152
|
+
| `AGY_ACP_BIN` | Path to the ACP server binary (`agy_acp_server.par`). Defaults to `agy_acp_server.par` on PATH. Wins over `config.acp.bin`. When neither points at a binary, auto-setup installs one. |
|
|
153
153
|
| `AGY_EXTRA_ARGS` | Extra args appended to every invocation. Whitespace-split. |
|
|
154
154
|
| `AGY_CONVERSATIONS_DIR` | Override the conversations DB directory. |
|
|
155
155
|
| `AGY_MODE` | Override execution mode: `plan` (review-only) or `accept-edits` (default). Wins over the config file. |
|
|
@@ -6,7 +6,7 @@ PASS through the full stack. Gate F VERIFIED live (`scripts/smoke-acp-bridge.mjs
|
|
|
6
6
|
the real server lists bridge tools and completes a tool call through the
|
|
7
7
|
registered mcpServers entry. §6 parity run LIVE on both engines: 14/14
|
|
8
8
|
(`scripts/parity-live.mjs`). Phase-1 acceptance: ALL `[x]` (docs included).
|
|
9
|
-
Gates: A PASS, B ABSENT (managed —
|
|
9
|
+
Gates: A PASS, B ABSENT (managed — streaming engine retained
|
|
10
10
|
until upstream ships usage), C PASS, D FAIL on RC01 (kill+reload fallback
|
|
11
11
|
VERIFIED live, incl. the stale-exit race fix), E PASS, F PASS (verified
|
|
12
12
|
live 2026-09-03). Verdicts in section 8.1; full captured protocol data in
|
|
@@ -20,7 +20,7 @@ Peer review 2: Antigravity (agy), 2026-09-03. Verdict PROCEED WITH CHANGES.
|
|
|
20
20
|
7 findings integrated: Gate A made a blocking gate for the default flip (the
|
|
21
21
|
ACP binary takes no model flags, so the recycle fallback could not carry a
|
|
22
22
|
model), sessions re-keyed per engine (`@acp` suffix) after the engine-field
|
|
23
|
-
design was shown to erase
|
|
23
|
+
design was shown to erase streaming bindings on rollback, parked-timer pause
|
|
24
24
|
changed to remaining-budget semantics, acp-permission plumbing specified
|
|
25
25
|
through a TurnDriver interface, session/cancel + session/close pulled into
|
|
26
26
|
phase 1, AskAntigravity migration moved from phase 3 to phase 4.
|
|
@@ -47,7 +47,7 @@ Adopt the official Google ACP server (`agy_acp_server.par`, registry id
|
|
|
47
47
|
switch. The existing stream-json engine stays the default until the ACP
|
|
48
48
|
engine proves parity. Every change is an improvement or a one-to-one
|
|
49
49
|
replacement. No functionality is removed until a later phase deletes the
|
|
50
|
-
|
|
50
|
+
streaming engine on purpose.
|
|
51
51
|
|
|
52
52
|
Cross-references: [ARCHITECTURE.md](./ARCHITECTURE.md),
|
|
53
53
|
[PI-BRIDGE-GAPS.md](./PI-BRIDGE-GAPS.md), [DEVELOPMENT.md](./DEVELOPMENT.md).
|
|
@@ -165,8 +165,8 @@ is an unmaintained contract. ACP is the maintained one.
|
|
|
165
165
|
| Module | Role today | Disposition under ACP |
|
|
166
166
|
| --- | --- | --- |
|
|
167
167
|
| `src/provider.ts` | streamSimple: pi Context to agy turn to pi events; G9 round-trips; G1 digest; G10 system prompt | KEEP unchanged (additive thought-delta `if` only). Engine-agnostic by contract |
|
|
168
|
-
| `src/driver.ts` | persistent stream-json process, turn queue, recycle, timers | KEEP as
|
|
169
|
-
| `src/stream-events.ts` | NDJSON parser + usage mapping | KEEP as
|
|
168
|
+
| `src/driver.ts` | persistent stream-json process, turn queue, recycle, timers | KEEP as streaming engine. DELETE in phase 4 |
|
|
169
|
+
| `src/stream-events.ts` | NDJSON parser + usage mapping | KEEP as streaming. DELETE in phase 4 |
|
|
170
170
|
| `src/sessions.ts` | pi session to agy conversation store | KEEP. Add `engine` tag (section 9.4) |
|
|
171
171
|
| `src/config.ts` | runtime config | KEEP. Add engine + acp block (section 9.5) |
|
|
172
172
|
| `src/models.ts` | `agy models` to pi Model projection | KEEP. Catalog source stays the CLI until ACP exposes one (probe A.6) |
|
|
@@ -256,10 +256,10 @@ Every box must be `[x]` on the ACP engine before phase 4 flips the default.
|
|
|
256
256
|
Tested with the parity suite (section 11) and `scripts/parity-live.mjs`
|
|
257
257
|
(live, both engines: 14/14, 2026-09-03).
|
|
258
258
|
|
|
259
|
-
- [x] Text streams token-adjacent to
|
|
259
|
+
- [x] Text streams token-adjacent to streaming (no full-text re-sends) — live
|
|
260
260
|
both engines, delta stream + cumulative-resend guard held
|
|
261
261
|
- [x] Multi-turn conversation continuity via `session/load` or our store —
|
|
262
|
-
live: ACP `session/load` (history suppressed, clean reply),
|
|
262
|
+
live: ACP `session/load` (history suppressed, clean reply), streaming
|
|
263
263
|
`--conversation` resume; the provider's session store supplies the id
|
|
264
264
|
- [x] Turn serialization: two concurrent streamSimple calls never interleave
|
|
265
265
|
— live both engines (queue releases only on settle) + driver tests
|
|
@@ -276,23 +276,23 @@ Tested with the parity suite (section 11) and `scripts/parity-live.mjs`
|
|
|
276
276
|
- [x] Abort: pi user abort produces `aborted`, the server process survives,
|
|
277
277
|
the next turn on the session works — live both engines (aborted=true,
|
|
278
278
|
recovery turn OK). "Process survives" holds on NEITHER engine and is
|
|
279
|
-
the documented Gate D behavior:
|
|
279
|
+
the documented Gate D behavior: streaming kills the child; RC01 has no
|
|
280
280
|
cancel, ACP tears down after the -32601 probe and reloads next turn
|
|
281
281
|
- [x] pi session restart resumes the right conversation (engine-tagged) —
|
|
282
282
|
engine-scoped key tests + live `session/load`
|
|
283
283
|
- [x] Engine rollback preserves the other engine's conversation bindings
|
|
284
284
|
(engine-scoped keys, 9.4) — sessions tests (per-entry engine field
|
|
285
|
-
rejected precisely because set() would erase the
|
|
285
|
+
rejected precisely because set() would erase the streaming binding)
|
|
286
286
|
- [x] Model catalog: same `antigravity/*` model ids and effort tiers resolve
|
|
287
287
|
— shared provider catalog, engine-independent by construction
|
|
288
288
|
- [x] Model/effort switch takes effect (gate A mechanism) — live: ACP
|
|
289
|
-
`set_config_option` on a LOADED session;
|
|
289
|
+
`set_config_option` on a LOADED session; streaming recycle+resume
|
|
290
290
|
- [x] G1 digest and G10 system prompt reach the prompt identically — shared
|
|
291
291
|
prompt assembly in the provider; the driver receives the final prompt
|
|
292
292
|
- [x] Skills `activate_skill` answered by the bridge as today — bridge
|
|
293
293
|
server dep, same server serves both engines (ACP transport live)
|
|
294
294
|
- [x] Usage counters: present (mapped) or documented-absent (zero-usage),
|
|
295
|
-
per gate B — live:
|
|
295
|
+
per gate B — live: streaming mapped, ACP absent (Gate B row in the
|
|
296
296
|
parity matrix)
|
|
297
297
|
- [x] `/agy doctor` shows connection state, session id, prompt/session stats,
|
|
298
298
|
lifecycle log — implemented engine-aware (phase 1); re-verify in pi
|
|
@@ -303,7 +303,7 @@ Tested with the parity suite (section 11) and `scripts/parity-live.mjs`
|
|
|
303
303
|
as a visible `ACP session failed: Invalid params` turn error in the
|
|
304
304
|
first parity attempt, exactly as designed
|
|
305
305
|
- [x] `ask-tool` one-shot returns text, duration, abort/timeout states, and a
|
|
306
|
-
resumable session id — unchanged
|
|
306
|
+
resumable session id — unchanged `agy -p` path (phase-2 probe
|
|
307
307
|
decides whether it moves to ACP)
|
|
308
308
|
- [x] Overall-turn timer pauses while any round-trip is parked; a slow human
|
|
309
309
|
permission decision cannot kill the turn — fake tests (park/kickIdle
|
|
@@ -396,8 +396,8 @@ What to record for each gate:
|
|
|
396
396
|
| A | Model + effort switch per session? | `set_config_option` (or equivalent) switches both; next prompt uses them | NONE viable: the ACP binary takes no model flags, so respawn-per-switch cannot carry a model; a settings.json rewrite per switch is the only theoretical path and is rejected (unproven, racy). Gate A FAIL blocks the phase 4 default flip; ACP stays opt-in; the gap goes upstream |
|
|
397
397
|
| B | Usage anywhere? | Any update or `_meta` carries token counts | Zero-usage documented; README notes it |
|
|
398
398
|
| C | `tool_call` content richness | rawInput/output/locations enough to retire WrapperReplay + native-tools | Keep current replay machinery unchanged |
|
|
399
|
-
| D | Cancel latency + state | `stopReason: cancelled` within ~2s; session reusable after | Keep kill semantics on
|
|
400
|
-
| E | Startup cost | Cold start < 5s, steady RSS sane for a long-lived process | Spawn per turn-group only (like
|
|
399
|
+
| D | Cancel latency + state | `stopReason: cancelled` within ~2s; session reusable after | Keep kill semantics on streaming; on ACP keep kill as escalation path |
|
|
400
|
+
| E | Startup cost | Cold start < 5s, steady RSS sane for a long-lived process | Spawn per turn-group only (like the streaming recycle cadence) |
|
|
401
401
|
| F | MCP `mcpServers` registration | Bridge `tools/list` visible to the agent mid-turn | Keep bridge wired some other protocol-sanctioned way; if none, G9 parking breaks and phase 1 is BLOCKED |
|
|
402
402
|
| F.7 | Slash behavior | Commands not auto-expanded against our prompt text | Prefix-safe prompt assembly unchanged |
|
|
403
403
|
|
|
@@ -478,7 +478,7 @@ scripts/smoke-acp.mjs live smoke, gated by AGY_ACP_LIVE=1 (spends quota),
|
|
|
478
478
|
`AcpDriver` matches the existing `AgyDriver` public surface: `state`,
|
|
479
479
|
`activeHandle`, `run()`, `reentry()`, `kickIdle()`, `set onTurnEnd()`,
|
|
480
480
|
`snapshot()`, `close()`. `DriverActivity` gains one additive variant; the
|
|
481
|
-
|
|
481
|
+
streaming engine never emits it:
|
|
482
482
|
|
|
483
483
|
```ts
|
|
484
484
|
// stream-events/driver today: { type: "thought"; tokens: number }
|
|
@@ -487,7 +487,7 @@ legacy engine never emits it:
|
|
|
487
487
|
```
|
|
488
488
|
|
|
489
489
|
`provider.ts` change: when `delta` is present call `appendThinking`, else keep
|
|
490
|
-
the current token-count behavior. One `if`, additive,
|
|
490
|
+
the current token-count behavior. One `if`, additive, streaming untouched.
|
|
491
491
|
|
|
492
492
|
Interface extraction (review 2, finding 7): `provider.ts` imports the
|
|
493
493
|
concrete `AgyDriver` class today; deleting `driver.ts` in phase 4 would break
|
|
@@ -504,7 +504,7 @@ correlation-map entry (the pending `session/prompt` request, any unanswered
|
|
|
504
504
|
client-side requests), the driver settles the turn outcome as
|
|
505
505
|
`ERROR`/`aborted` (never left pending), and `onTurnEnd` fires so the
|
|
506
506
|
provider's `roundTrips.failAll()` fails parked round-trips exactly as the
|
|
507
|
-
|
|
507
|
+
streaming driver's `failTurn` path does. Nothing is ever written to a dead
|
|
508
508
|
socket; no promise survives the kill. Mirrors `ToolRoundTrips.failAll()`.
|
|
509
509
|
|
|
510
510
|
Event mapping (`src/acp/events.ts`):
|
|
@@ -524,7 +524,7 @@ Event mapping (`src/acp/events.ts`):
|
|
|
524
524
|
| `refusal` / `max_turn_requests` / `max_tokens` | TurnOutcome ERROR / OK+note | `error` / `stop` + `errorMessage` |
|
|
525
525
|
| `user_message_chunk` (during `session/load` only) | suppressed (history replay) | never emitted to pi as live text |
|
|
526
526
|
|
|
527
|
-
Defensive guard ported from the
|
|
527
|
+
Defensive guard ported from the streaming engine: if an `agent_message_chunk`
|
|
528
528
|
repeats the full accumulated text instead of a delta, detect and slice (the
|
|
529
529
|
`isCumulativeResend` lesson; same agy binary lineage, same quirk class).
|
|
530
530
|
Guard plus test ship in phase 1. Honest labeling: the ACP spec defines chunks
|
|
@@ -575,24 +575,24 @@ parked and, on settle, re-arms it with the REMAINING budget (`deadline -
|
|
|
575
575
|
now`), never a fresh cap: the overall timer is a turn DEADLINE, not an
|
|
576
576
|
inactivity guard. Every park carries its own timeout (`BRIDGE_TIMEOUT_MS`),
|
|
577
577
|
so a paused deadline cannot hang forever. Driver-local (AcpDriver only);
|
|
578
|
-
|
|
578
|
+
streaming behavior untouched; ships in phase 1.
|
|
579
579
|
|
|
580
580
|
### 9.4 Sessions schema (engine-scoped keys)
|
|
581
581
|
|
|
582
582
|
The first design (a per-entry `engine` field) was rejected in review 2:
|
|
583
583
|
`set()` overwrites the single `sid:<pi-session>` key, so one ACP turn ERASES
|
|
584
|
-
the
|
|
584
|
+
the streaming conversation binding, and a rollback loses continuity, breaking
|
|
585
585
|
the section 13 guarantee. Engines scope at the KEY level instead:
|
|
586
586
|
|
|
587
587
|
```jsonc
|
|
588
588
|
// ~/.pi/agent/antigravity-bridge/sessions.json
|
|
589
589
|
{
|
|
590
|
-
"sid:<pi-session>": { "conversationId": "<
|
|
590
|
+
"sid:<pi-session>": { "conversationId": "<streaming id>", "lastStepIdx": -1, "lastMessageCount": 42 },
|
|
591
591
|
"sid:<pi-session>@acp": { "conversationId": "<ACP sessionId>", "lastStepIdx": -1, "lastMessageCount": 42 }
|
|
592
592
|
}
|
|
593
593
|
```
|
|
594
594
|
|
|
595
|
-
- Un-suffixed keys belong to the
|
|
595
|
+
- Un-suffixed keys belong to the streaming engine, byte-compatible with every
|
|
596
596
|
persisted store; ACP keys carry an `@acp` suffix appended by
|
|
597
597
|
`sessionKey()` when `config.engine === "acp"`.
|
|
598
598
|
- Engines never touch each other's keys: rollback keeps both continuations,
|
|
@@ -747,11 +747,11 @@ ACP-PROTOCOL-REFERENCE.md "Phase-2 probe findings".
|
|
|
747
747
|
|
|
748
748
|
### Phase 3: consolidation (immediate priority, opt-in only)
|
|
749
749
|
|
|
750
|
-
Hard rule: Keep both engines in the extension. Default engine remains `stream-json`. All items below are non-breaking opt-in enhancements for the ACP engine that do NOT alter default behavior or touch
|
|
750
|
+
Hard rule: Keep both engines in the extension. Default engine remains `stream-json`. All items below are non-breaking opt-in enhancements for the ACP engine that do NOT alter default behavior or touch streaming paths.
|
|
751
751
|
|
|
752
752
|
Queue:
|
|
753
|
-
1. [x] Gate C evaluation: `native-tools.ts` + `WrapperReplay` retired on the ACP engine (retained for
|
|
754
|
-
2. [x] Retire `diff-render.ts` (git path) on the ACP engine — DONE 2026-09-04. The phase-2 probe proved ACP surfaces edit diffs natively (`tool_call` `content[]` `{type:"diff", path, oldText?, newText}`), which is richer AND cheaper than the git-sourced path: implemented as (a) `events.ts` extracts the first diff entry into the `tool_done` activity (`AcpEditDiff`); (b) `provider.ts` renders it on ACP turns via the new `formatInlineDiff` (in-memory `generateDiffString`, same line-numbered format, ZERO git subprocesses) instead of `TurnDiffContext.diffEdit`; (c) `diff-render.ts` git machinery remains solely for the `stream-json` engine. Tests: diff extraction (with/without `oldText`), ACP thinking-stream rendering (`+2 B` line-numbered), label-only fallback, no parking,
|
|
753
|
+
1. [x] Gate C evaluation: `native-tools.ts` + `WrapperReplay` retired on the ACP engine (retained for the streaming (`stream-json`) engine). ACP turns emit thinking labels for tool executions without parking turns for synthetic re-execution or wrapper cards; bridge MCP tools (`bridge_call`) continue parking natively. Verified: live parity 14/14 held.
|
|
754
|
+
2. [x] Retire `diff-render.ts` (git path) on the ACP engine — DONE 2026-09-04. The phase-2 probe proved ACP surfaces edit diffs natively (`tool_call` `content[]` `{type:"diff", path, oldText?, newText}`), which is richer AND cheaper than the git-sourced path: implemented as (a) `events.ts` extracts the first diff entry into the `tool_done` activity (`AcpEditDiff`); (b) `provider.ts` renders it on ACP turns via the new `formatInlineDiff` (in-memory `generateDiffString`, same line-numbered format, ZERO git subprocesses) instead of `TurnDiffContext.diffEdit`; (c) `diff-render.ts` git machinery remains solely for the `stream-json` engine. Tests: diff extraction (with/without `oldText`), ACP thinking-stream rendering (`+2 B` line-numbered), label-only fallback, no parking, streaming path unchanged. Verified: 165/165 vitest, tsc clean, live parity 14/14.
|
|
755
755
|
3. [x] `/agy doctor` diagnostics expansion — DONE 2026-09-04. The ACP
|
|
756
756
|
snapshot now carries `reconnects` (connections beyond the first =
|
|
757
757
|
Gate D kills + stale-exit replacements) and the handshake `agentInfo`
|
|
@@ -773,17 +773,17 @@ Status: 🚧 Phase 3 remaining items are COMPLETE (2026-09-04): 168/168 tests,
|
|
|
773
773
|
tsc clean, live parity 14/14, embeddedContext verified live (resource block
|
|
774
774
|
with a secret word answered correctly). Remaining phase-3 acceptance:
|
|
775
775
|
deleted-code census lands with phase 4's deletions (nothing deletes in
|
|
776
|
-
phase 3 — the
|
|
776
|
+
phase 3 — the streaming modules stay for the `stream-json` engine).
|
|
777
777
|
|
|
778
|
-
### Phase 4: default flip and
|
|
778
|
+
### Phase 4: default flip and streaming deletion (deferred, next month)
|
|
779
779
|
|
|
780
780
|
All items below are deferred until a full soak cycle of both engines has completed and upstream conditions are met:
|
|
781
781
|
|
|
782
|
-
1. AskAntigravity migration to ACP one-shot (migrating while `stream-json` is default breaks
|
|
782
|
+
1. AskAntigravity migration to ACP one-shot (migrating while `stream-json` is default breaks streaming-only users who haven't onboarded ACP auth; streaming conversation ids cannot resume under ACP). `mode: "plan"` delegations keep the `agy -p --mode plan` path (committed exception).
|
|
783
783
|
2. Delete `src/discovery.ts` (`/proc` fd-scan) once AskAntigravity migration completes.
|
|
784
784
|
3. Default flip: `config.engine` default becomes `"acp"` after one full release soak cycle with both engines shipping. Gate A PASS is verified.
|
|
785
785
|
4. Upstream Gate B resolution: Google ships usage counters in ACP payloads.
|
|
786
|
-
5.
|
|
786
|
+
5. Streaming engine deletion: `src/driver.ts`, `src/stream-events.ts`, and streaming tests deleted one release after the flip AND only once Gate B has lifted (so zero-usage is never forced).
|
|
787
787
|
|
|
788
788
|
Acceptance: default-flip release ships with the parity suite as acceptance evidence; deletion release has zero references to the removed modules.
|
|
789
789
|
|
|
@@ -801,7 +801,7 @@ Acceptance: default-flip release ships with the parity suite as acceptance evide
|
|
|
801
801
|
- **Provider**: the existing `provider-streaming` / `provider-digest` /
|
|
802
802
|
`provider-sysprompt` suites run against both engines via the shared driver
|
|
803
803
|
contract. New case: thought delta path.
|
|
804
|
-
- **Sessions**: engine-tag matching,
|
|
804
|
+
- **Sessions**: engine-tag matching, streaming-file compatibility (no tag),
|
|
805
805
|
cross-engine miss starts fresh.
|
|
806
806
|
- **Config**: narrowing of stale/garbage `engine` values, env precedence.
|
|
807
807
|
- **Parity suite**: the section 6 checklist encoded as an integration run
|
|
@@ -836,8 +836,8 @@ Install layout:
|
|
|
836
836
|
## 13. Rollback
|
|
837
837
|
|
|
838
838
|
At any phase: `AGY_ENGINE=stream-json` (or `/agy engine stream-json`) returns
|
|
839
|
-
to the
|
|
840
|
-
engine resumes its own conversations untouched. The
|
|
839
|
+
to the streaming engine. Sessions are keyed per engine (9.4), so the streaming
|
|
840
|
+
engine resumes its own conversations untouched. The streaming engine is only deleted in
|
|
841
841
|
phase 4, one release after the flip, so rollback stays possible throughout.
|
|
842
842
|
|
|
843
843
|
## 14. Documentation updates
|
|
@@ -856,7 +856,7 @@ phase 4, one release after the flip, so rollback stays possible throughout.
|
|
|
856
856
|
| # | Risk | L | I | Response |
|
|
857
857
|
| --- | --- | --- | --- | --- |
|
|
858
858
|
| R1 | Gate A fails (no model switch) | M | H | default flip blocked; ACP stays opt-in; escalate upstream. No ship-anyway: model switching is not optional |
|
|
859
|
-
| R2 | Gate F fails (bridge not registrable) | L | H | BLOCKS phase 1; escalate upstream, keep
|
|
859
|
+
| R2 | Gate F fails (bridge not registrable) | L | H | BLOCKS phase 1; escalate upstream, keep streaming |
|
|
860
860
|
| R3 | Usage absent (CONFIRMED, Gate B) | H | M | zero-usage documented on ACP; stream-json retained as secondary engine; phase-4 deletion conditioned on Gate B lift (review 4) |
|
|
861
861
|
| R4 | Binary churn (hourly registry, RC builds) | M | M | pin by build id, record sha256 |
|
|
862
862
|
| R5 | Server rejects second simultaneous session | L | M | one session per connection (today's model), N connections instead |
|
|
@@ -247,7 +247,7 @@ unreachable on RC01 — no cancel method).
|
|
|
247
247
|
|
|
248
248
|
- Chunks are pure DELTAS. Verified by content: numbers split mid-token across
|
|
249
249
|
chunk boundaries (`"...\n39\n40\n4"` then `"1\n42\n43..."`), and no chunk
|
|
250
|
-
ever contained a full prefix of the accumulated text. The
|
|
250
|
+
ever contained a full prefix of the accumulated text. The stream-json
|
|
251
251
|
cumulative-resend failure mode was NOT observed over ACP (defensive guard
|
|
252
252
|
stays cheap insurance).
|
|
253
253
|
- Throughput: 300 numbers (~600 tokens) completed within one 25 s poll;
|
package/extensions/index.ts
CHANGED
|
@@ -38,7 +38,7 @@ import { SessionStore } from "../src/sessions.js";
|
|
|
38
38
|
import { ToolRoundTrips, WrapperReplay, createStreamSimple } from "../src/provider.js";
|
|
39
39
|
import { AgyDriver } from "../src/driver.js";
|
|
40
40
|
import { AcpDriver } from "../src/acp/driver.js";
|
|
41
|
-
import {
|
|
41
|
+
import { ensureAcpReady, inspectAcpSetup } from "../src/acp/setup.js";
|
|
42
42
|
import type { TurnDriver } from "../src/driver-types.js";
|
|
43
43
|
import { CONFIG_PATH, loadConfig, saveConfig, type AgyMode, type BridgeTools, type Engine, type ThinkingTier } from "../src/config.js";
|
|
44
44
|
import { registerAskAntigravityTool, toolModelsFromRaw } from "../src/ask-tool.js";
|
|
@@ -97,14 +97,32 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
97
97
|
// MCP bridge handle, declared early: the ACP engine reads the bridge port
|
|
98
98
|
// at session/new / session/load time.
|
|
99
99
|
let mcpHandle: McpServerHandle | null = null;
|
|
100
|
+
// ACP self-heal runs once per process (session_start re-fires on /reload;
|
|
101
|
+
// a ready setup is two file stats, so re-running is harmless anyway).
|
|
102
|
+
let acpSelfHealRan = false;
|
|
100
103
|
// Two turn engines behind one contract (plan §9): stream-json (tested
|
|
101
104
|
// default) and the official ACP server (opt-in via config.engine, off by
|
|
102
105
|
// default). Neither spawns anything until its first turn.
|
|
106
|
+
// ACP log routing: only genuine failures reach stderr. Routine lifecycle
|
|
107
|
+
// (driver-created, spawn, session-new, ...) stays in the driver's
|
|
108
|
+
// #lifecycle ring buffer, visible via /agy doctor. An unfiltered sink fired
|
|
109
|
+
// console.error at extension load ("driver-created"), before any UI exists,
|
|
110
|
+
// and leaked raw driver lines into the terminal on every startup.
|
|
111
|
+
const acpFailures = new Set([
|
|
112
|
+
"start-failed", "spawn-error", "parse-error", "write-failed",
|
|
113
|
+
"mode-apply-failed", "timeout", "stall", "auth-required",
|
|
114
|
+
"session-load-failed-creating-fresh", "connection-exited", "cancel-failed",
|
|
115
|
+
"unsupported-server-request",
|
|
116
|
+
]);
|
|
103
117
|
const legacyDriver = new AgyDriver();
|
|
104
118
|
const acpDriver = new AcpDriver({
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
119
|
+
// Resolved per connection: the setup flow can install the binary and
|
|
120
|
+
// update acp.bin mid-session; the next turn picks it up (no restart).
|
|
121
|
+
bin: () => loadConfig().acp.bin,
|
|
122
|
+
log: (msg, data) => {
|
|
123
|
+
if (!acpFailures.has(msg)) return;
|
|
124
|
+
console.error(`[antigravity-bridge acp] ${msg}${data !== undefined ? " " + JSON.stringify(data) : ""}`);
|
|
125
|
+
},
|
|
108
126
|
mcpServers: () => {
|
|
109
127
|
const handle = mcpHandle;
|
|
110
128
|
if (!handle) return [];
|
|
@@ -233,29 +251,52 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
233
251
|
} catch {
|
|
234
252
|
/* detection is best-effort */
|
|
235
253
|
}
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
254
|
+
// ACP self-heal: engine=acp needs a server binary + auth. Silent when
|
|
255
|
+
// everything is ready; installs from the registry and bootstraps auth
|
|
256
|
+
// otherwise; manual instructions only on failure. Fire-and-forget: it
|
|
257
|
+
// must not delay session start (and nothing spawns until the first turn).
|
|
258
|
+
if (engine === "acp" && !acpSelfHealRan) {
|
|
259
|
+
acpSelfHealRan = true;
|
|
260
|
+
void ensureAcpReady({ configBin: loadConfig().acp.bin }).then((status) => {
|
|
261
|
+
if (status.ok) {
|
|
262
|
+
if (status.binarySource === "installed" || status.binarySource === "existing") {
|
|
263
|
+
saveConfig({ acp: { bin: status.bin, permissions: loadConfig().acp.permissions } });
|
|
264
|
+
}
|
|
265
|
+
if (status.needsLogin) {
|
|
266
|
+
const msg =
|
|
267
|
+
"ACP: one-time Google login pending. Your next antigravity message opens the browser; sign in with your Antigravity subscription account (the same login as the agy CLI). Tokens stay on your machine.";
|
|
268
|
+
if (ctx.hasUI) ctx.ui.notify(msg, "info");
|
|
269
|
+
else console.error(`[antigravity-bridge] ${msg}`);
|
|
270
|
+
}
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const msg = `ACP auto-setup failed (${status.error}).\n${status.manual}`;
|
|
274
|
+
if (ctx.hasUI) ctx.ui.notify(msg, "warning");
|
|
275
|
+
else console.error(`[antigravity-bridge] ${msg}`);
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
// Bridge failure logger. Routine lifecycle (listening,
|
|
279
|
+
// bridge-config-written/removed, closed) is normal startup/teardown
|
|
280
|
+
// traffic: toasting it every session, or pinning it via stderr in
|
|
281
|
+
// headless mode, was noise. Only genuine failures surface - as a
|
|
282
|
+
// warning toast (ctx.ui.notify, ephemeral) or stderr when headless.
|
|
283
|
+
// Per-turn success events (list-tools / call-tool) stay silent.
|
|
243
284
|
const mcpLog = (s: string, d?: unknown) => {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
"
|
|
285
|
+
// Routine abort traffic: failAll fires on turn end / session shutdown
|
|
286
|
+
// and the bridge answers every parked call with an error. Not a fault.
|
|
287
|
+
if (s === "call-tool-fail") {
|
|
288
|
+
const detail = (d as { msg?: string } | undefined)?.msg ?? "";
|
|
289
|
+
if (detail.includes("unresolved pi tool call") || detail.includes("session shut down")) return;
|
|
290
|
+
}
|
|
291
|
+
const failures = new Set([
|
|
292
|
+
"http-error", "bridge-config-write-failed", "call-tool-fail",
|
|
293
|
+
"transport-error", "handleRequest-error", "request-error",
|
|
294
|
+
"request-handler-error", "unauthorized",
|
|
249
295
|
]);
|
|
250
|
-
if (!
|
|
296
|
+
if (!failures.has(s)) return;
|
|
251
297
|
const msg = `[antigravity-bridge mcp] ${s}${d !== undefined ? " " + JSON.stringify(d) : ""}`;
|
|
252
|
-
if (ctx.hasUI)
|
|
253
|
-
|
|
254
|
-
|| s === "bridge-config-removed" || s === "closed";
|
|
255
|
-
ctx.ui.notify(msg, ok ? "info" : "warning");
|
|
256
|
-
} else {
|
|
257
|
-
console.error(msg);
|
|
258
|
-
}
|
|
298
|
+
if (ctx.hasUI) ctx.ui.notify(msg, "warning");
|
|
299
|
+
else console.error(msg);
|
|
259
300
|
};
|
|
260
301
|
// Start the bridge unless the user turned it off. No patch gate, no
|
|
261
302
|
// consent flow: calls route through pi's normal toolUse loop.
|
|
@@ -413,9 +454,32 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
|
|
|
413
454
|
}
|
|
414
455
|
if (sub === "engine") {
|
|
415
456
|
if (val === "acp" || val === "stream-json") {
|
|
457
|
+
if (val === "acp" && loadConfig().mode === "plan") {
|
|
458
|
+
ui?.notify("mode is plan; the ACP engine has no plan mode. /agy mode accept-edits first.", "warning");
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
416
461
|
const next = saveConfig({ engine: val });
|
|
462
|
+
if (next.engine !== "acp") {
|
|
463
|
+
ui?.notify("engine set to stream-json. Takes effect on the next pi start (or /reload).", "info");
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
// Self-service setup: install the server from the official
|
|
467
|
+
// registry and bootstrap auth now, so the restart just works.
|
|
468
|
+
// Manual instructions only when a step fails.
|
|
469
|
+
ui?.notify("engine set to acp. Preparing the server (binary + auth)…", "info");
|
|
470
|
+
const status = await ensureAcpReady({
|
|
471
|
+
configBin: loadConfig().acp.bin,
|
|
472
|
+
onProgress: (m) => ui?.notify(m, "info"),
|
|
473
|
+
});
|
|
474
|
+
if (!status.ok) {
|
|
475
|
+
ui?.notify(`ACP auto-setup failed (${status.error}).\n${status.manual}`, "warning");
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
saveConfig({ acp: { bin: status.bin, permissions: loadConfig().acp.permissions } });
|
|
417
479
|
ui?.notify(
|
|
418
|
-
|
|
480
|
+
status.needsLogin
|
|
481
|
+
? "ACP engine ready. Your first ACP message opens the Google login in your browser: sign in with the account of your Antigravity subscription (the same login as the Antigravity CLI, agy). One-time; tokens stay on your machine and this extension never sees them. Takes effect on the next pi start (or /reload)."
|
|
482
|
+
: `ACP engine ready (auth: ${status.auth}). Takes effect on the next pi start (or /reload).`,
|
|
419
483
|
"info",
|
|
420
484
|
);
|
|
421
485
|
} else {
|
|
@@ -426,17 +490,26 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
|
|
|
426
490
|
if (sub === "acp-auth") {
|
|
427
491
|
ui?.notify(
|
|
428
492
|
[
|
|
429
|
-
"ACP engine authentication (one-time
|
|
430
|
-
"
|
|
431
|
-
"
|
|
432
|
-
"
|
|
433
|
-
|
|
434
|
-
"
|
|
435
|
-
"
|
|
436
|
-
"
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
"
|
|
493
|
+
"ACP engine authentication (one-time; usually automatic -",
|
|
494
|
+
"/agy engine acp and session start set this up for you):",
|
|
495
|
+
"",
|
|
496
|
+
"The server is Google's official Antigravity ACP, installed from Google's",
|
|
497
|
+
"own registry. Logging in uses your Antigravity subscription: the same",
|
|
498
|
+
"Google account and plan as the Antigravity CLI (agy). It is no different",
|
|
499
|
+
"from logging into the CLI; the server just keeps its own token file on",
|
|
500
|
+
"your machine, like any Google tool. This extension never sees your",
|
|
501
|
+
"credentials.",
|
|
502
|
+
"",
|
|
503
|
+
"1. Server binary: auto-setup installs it. Manual: agy_acp_server.par from",
|
|
504
|
+
" the antigravity-acp registry; point acp.bin or AGY_ACP_BIN at it.",
|
|
505
|
+
'2. Default: put {"auth":{"type":"oauth-personal"}} in',
|
|
506
|
+
" ~/.gemini/antigravity-acp/settings.json, run one turn, and complete the",
|
|
507
|
+
" Google login that opens in your browser (headless: tunnel 127.0.0.1:<port>",
|
|
508
|
+
" over ssh, then open the URL on your machine).",
|
|
509
|
+
' Headless alternative: GEMINI_API_KEY + {"auth":{"type":"gemini-api-key"}}',
|
|
510
|
+
" (metered paid API - not your Antigravity plan). The key is used only",
|
|
511
|
+
" when that type is selected; with the default oauth-personal in place,",
|
|
512
|
+
" an exported key is ignored.",
|
|
440
513
|
"3. Run one turn; /agy doctor shows the server version when auth is OK.",
|
|
441
514
|
].join("\n"),
|
|
442
515
|
"info",
|
|
@@ -469,11 +542,22 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
|
|
|
469
542
|
lines.push(" lifecycle (last 5):");
|
|
470
543
|
for (const entry of snap.lifecycle.slice(-5)) lines.push(` ${entry}`);
|
|
471
544
|
}
|
|
545
|
+
if (engine === "acp") {
|
|
546
|
+
const setup = inspectAcpSetup({ configBin: config.acp.bin });
|
|
547
|
+
lines.push(
|
|
548
|
+
` acp binary: ${setup.bin ?? "not found (auto-setup offers install)"}${setup.source ? ` (${setup.source})` : ""}`,
|
|
549
|
+
` acp auth: ${setup.auth ?? "not configured (auto-setup bootstraps)"}`,
|
|
550
|
+
);
|
|
551
|
+
}
|
|
472
552
|
ui?.notify(lines.join("\n"), "info");
|
|
473
553
|
return;
|
|
474
554
|
}
|
|
475
555
|
if (sub === "mode") {
|
|
476
556
|
if (val === "plan" || val === "accept-edits") {
|
|
557
|
+
if (val === "plan" && ctx.engine === "acp") {
|
|
558
|
+
ui?.notify("the ACP engine has no plan mode (RC01). /agy engine stream-json first, or /agy mode accept-edits.", "warning");
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
477
561
|
const next = saveConfig({ mode: val as AgyMode });
|
|
478
562
|
ui?.notify(`mode set to ${next.mode}`, "info");
|
|
479
563
|
} else {
|
|
@@ -638,6 +722,11 @@ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promis
|
|
|
638
722
|
)
|
|
639
723
|
return;
|
|
640
724
|
|
|
725
|
+
if (pending.mode === "plan" && ctx.engine === "acp") {
|
|
726
|
+
ui.notify("the ACP engine has no plan mode (RC01). Switch /agy engine stream-json first.", "warning");
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
|
|
641
730
|
try {
|
|
642
731
|
const next = saveConfig(pending);
|
|
643
732
|
const changed = [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@estebanforge/pi-antigravity-bridge",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.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",
|
|
@@ -59,9 +59,10 @@
|
|
|
59
59
|
}
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
|
-
"@earendil-works/pi-ai": "^0.
|
|
63
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
64
|
-
"@earendil-works/pi-
|
|
62
|
+
"@earendil-works/pi-ai": "^0.85.0",
|
|
63
|
+
"@earendil-works/pi-coding-agent": "^0.85.0",
|
|
64
|
+
"@earendil-works/pi-server": "^0.85.0",
|
|
65
|
+
"@earendil-works/pi-tui": "^0.85.0",
|
|
65
66
|
"@types/node": "^22.0.0",
|
|
66
67
|
"tsx": "^4.19.0",
|
|
67
68
|
"typebox": "^1.1.38",
|
package/src/acp/connection.ts
CHANGED
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
// the effort tier baked in, e.g. gemini-3.8-flash-low)
|
|
12
12
|
// - session/cancel returns -32601 on RC01 (not implemented) — the driver
|
|
13
13
|
// treats that as "cancel unsupported" and falls back to teardown+kill
|
|
14
|
-
// - session/request_permission is answered in-connection:
|
|
15
|
-
//
|
|
14
|
+
// - session/request_permission is answered in-connection: policy per turn
|
|
15
|
+
// (skipPermissions on -> first allow option; off -> first reject option,
|
|
16
|
+
// fail-closed)
|
|
16
17
|
// - session/load replays history as notifications BEFORE its response; the
|
|
17
18
|
// driver suppresses updates while the load is in flight
|
|
18
19
|
// - mcpServers entries: {name, type:"http", url, headers:[]} — headers is a
|
|
@@ -58,6 +59,10 @@ export interface AcpConnectionOptions {
|
|
|
58
59
|
/** mcpServers entries for session/new AND session/load (run 6: load takes
|
|
59
60
|
* the same param). Evaluated lazily per call. */
|
|
60
61
|
mcpServers?: () => AcpMcpServer[];
|
|
62
|
+
/** Permission policy for session/request_permission, evaluated per request:
|
|
63
|
+
* "auto" selects the first allow option; "deny" fail-closes to the first
|
|
64
|
+
* reject option. Absent = deny (fail closed). */
|
|
65
|
+
permissions?: () => "auto" | "deny";
|
|
61
66
|
}
|
|
62
67
|
|
|
63
68
|
const INIT_TIMEOUT_MS = 30_000;
|
|
@@ -114,6 +119,13 @@ export class AcpConnection {
|
|
|
114
119
|
this.#child = child;
|
|
115
120
|
child.stdout?.setEncoding("utf8");
|
|
116
121
|
child.stderr?.setEncoding("utf8");
|
|
122
|
+
// Pipe failures arrive asynchronously as stream 'error' events; the sync
|
|
123
|
+
// try/catch in #write cannot see them. Without this listener an EPIPE
|
|
124
|
+
// (server died mid-handshake) is uncaught and kills pi.
|
|
125
|
+
child.stdin?.on("error", (err) => {
|
|
126
|
+
this.#opts.log("stdin-error", { message: err.message });
|
|
127
|
+
if (!this.#exited && !this.#killed) this.#finish(err.message);
|
|
128
|
+
});
|
|
117
129
|
|
|
118
130
|
const rpc = new JsonRpcSession({
|
|
119
131
|
send: (frame) => this.#write(frame),
|
|
@@ -253,9 +265,10 @@ export class AcpConnection {
|
|
|
253
265
|
await this.guarded("session/close", { sessionId }, SESSION_OP_TIMEOUT_MS);
|
|
254
266
|
}
|
|
255
267
|
|
|
256
|
-
/** Protocol-level
|
|
257
|
-
* first allow option
|
|
258
|
-
* the
|
|
268
|
+
/** Protocol-level permission answering: policy from the driver ("auto"
|
|
269
|
+
* selects the first allow option; "deny" fail-closes to the first reject
|
|
270
|
+
* option). Never hangs: options are data from the server and the answer
|
|
271
|
+
* is computed synchronously. */
|
|
259
272
|
#onServerRequest(method: string, params: unknown): Promise<unknown> {
|
|
260
273
|
if (method === "session/request_permission") {
|
|
261
274
|
const options = (
|
|
@@ -263,9 +276,10 @@ export class AcpConnection {
|
|
|
263
276
|
) as Array<{ optionId?: string; kind?: string }> | undefined;
|
|
264
277
|
const allow = options?.find((o) => typeof o.kind === "string" && o.kind.startsWith("allow"));
|
|
265
278
|
const deny = options?.find((o) => typeof o.kind === "string" && o.kind.startsWith("reject"));
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
279
|
+
const policy = this.#opts.permissions?.() ?? "deny";
|
|
280
|
+
const chosen = policy === "auto" ? (allow ?? deny ?? options?.[0]) : (deny ?? options?.[0]);
|
|
281
|
+
this.#opts.log("permission", { optionId: chosen?.optionId, policy });
|
|
282
|
+
return Promise.resolve({ outcome: { outcome: "selected", optionId: chosen?.optionId } });
|
|
269
283
|
}
|
|
270
284
|
// fs/* and terminal/* are declined: our client capabilities are off and
|
|
271
285
|
// agy keeps executing its own tools (plan §8 capability posture).
|
package/src/acp/driver.ts
CHANGED
|
@@ -34,8 +34,10 @@ import type {
|
|
|
34
34
|
const LIFECYCLE_LIMIT = 24;
|
|
35
35
|
|
|
36
36
|
export interface AcpDriverOptions {
|
|
37
|
-
/** Config acp.bin value (may be empty). Env AGY_ACP_BIN wins.
|
|
38
|
-
|
|
37
|
+
/** Config acp.bin value (may be empty). Env AGY_ACP_BIN wins. A function
|
|
38
|
+
* is resolved per connection: setup can install the binary and update
|
|
39
|
+
* config mid-session, and the next turn picks it up without a restart. */
|
|
40
|
+
bin: string | (() => string);
|
|
39
41
|
/** Extra argv for the binary (tests: node + fake-server script). */
|
|
40
42
|
binArgs?: string[];
|
|
41
43
|
extraEnv?: Record<string, string>;
|
|
@@ -88,11 +90,11 @@ export function acpModelSlug(model: string, effort?: string): string {
|
|
|
88
90
|
return `${model}-${effort}`;
|
|
89
91
|
}
|
|
90
92
|
|
|
91
|
-
/** Map our config knobs onto ACP session modes.
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
93
|
+
/** Map our config knobs onto ACP session modes. skipPermissions=false also
|
|
94
|
+
* fail-closes the in-connection permission handler (reject options), so the
|
|
95
|
+
* modes keep their server-side meaning. Known gap: the CLI's `--mode plan`
|
|
96
|
+
* has no ACP equivalent (review 4, finding 4) — plan + acp is refused at the
|
|
97
|
+
* command level and fails the turn visibly. */
|
|
96
98
|
export function acpMode(mode: string, skipPermissions: boolean): string {
|
|
97
99
|
if (skipPermissions) return "yolo";
|
|
98
100
|
return mode === "plan" ? "default" : "auto_edit";
|
|
@@ -122,7 +124,7 @@ export class AcpDriver implements TurnDriver {
|
|
|
122
124
|
|
|
123
125
|
constructor(opts: AcpDriverOptions) {
|
|
124
126
|
this.#opts = opts;
|
|
125
|
-
this.#log("driver-created", { bin: resolveAcpBinary(opts.bin) });
|
|
127
|
+
this.#log("driver-created", { bin: typeof opts.bin === "function" ? "(resolved per turn)" : resolveAcpBinary(opts.bin) });
|
|
126
128
|
}
|
|
127
129
|
|
|
128
130
|
get state(): DriverState {
|
|
@@ -417,17 +419,19 @@ export class AcpDriver implements TurnDriver {
|
|
|
417
419
|
this.#state = "starting";
|
|
418
420
|
this.#stats.spawns += 1;
|
|
419
421
|
const conn = new AcpConnection({
|
|
420
|
-
bin: resolveAcpBinary(this.#opts.bin),
|
|
422
|
+
bin: resolveAcpBinary(typeof this.#opts.bin === "function" ? this.#opts.bin() : this.#opts.bin),
|
|
421
423
|
binArgs: this.#opts.binArgs,
|
|
422
424
|
extraEnv: this.#opts.extraEnv,
|
|
423
425
|
cwd: request.cwd,
|
|
424
426
|
mcpServers: this.#opts.mcpServers,
|
|
425
427
|
log: (msg, data) => this.#log(msg, data),
|
|
428
|
+
// Fail-closed permissions: only turns with skipPermissions answer allow.
|
|
429
|
+
permissions: () => (this.#active?.request.skipPermissions ? "auto" : "deny"),
|
|
426
430
|
onUpdate: (sessionId, update) => this.#onConnectionUpdate(sessionId, update),
|
|
427
431
|
onExit: (info) => this.#onConnectionExit(conn, info),
|
|
428
432
|
});
|
|
429
433
|
this.#conn = conn;
|
|
430
|
-
this.#log("spawn", { bin: resolveAcpBinary(this.#opts.bin) });
|
|
434
|
+
this.#log("spawn", { bin: resolveAcpBinary(typeof this.#opts.bin === "function" ? this.#opts.bin() : this.#opts.bin) });
|
|
431
435
|
return conn
|
|
432
436
|
.start()
|
|
433
437
|
.then(() => {
|
package/src/acp/setup.ts
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
// ACP engine self-service setup: binary install from the official registry
|
|
2
|
+
// and auth bootstrap. Everything is automatic when possible; the caller
|
|
3
|
+
// surfaces `manual` only when a step fails. Instructions-as-first-resort is
|
|
4
|
+
// gone: /agy engine acp and session_start self-heal silently when ready.
|
|
5
|
+
//
|
|
6
|
+
// Sources: docs/ACP-ADOPTION-PLAN.md §2.1 (registry entry), §12 (pinned
|
|
7
|
+
// install layout: ~/.local/opt/agy-acp/<build>/ + current symlink, zip
|
|
8
|
+
// sha256 recorded at install time), docs/ACP-PROTOCOL-REFERENCE.md
|
|
9
|
+
// (settings.json auth shapes; the server opens the oauth browser itself).
|
|
10
|
+
// Credential rule: this module never reads or writes credential VALUES —
|
|
11
|
+
// acp_token.json is only stat()ed, gemini-api-key is read by the server
|
|
12
|
+
// from the environment.
|
|
13
|
+
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import os from "node:os";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
|
|
20
|
+
export const REGISTRY_URL =
|
|
21
|
+
"https://raw.githubusercontent.com/agentclientprotocol/registry/main/antigravity-acp/agent.json";
|
|
22
|
+
|
|
23
|
+
const INSTALL_TIMEOUT_MS = 30 * 60_000;
|
|
24
|
+
|
|
25
|
+
export interface SetupOptions {
|
|
26
|
+
/** Registry agent.json URL. Overridable for tests. */
|
|
27
|
+
registryUrl?: string;
|
|
28
|
+
/** Install root. Default ~/.local/opt/agy-acp. */
|
|
29
|
+
installRoot?: string;
|
|
30
|
+
/** Server settings dir. Default ~/.gemini/antigravity-acp. */
|
|
31
|
+
geminiDir?: string;
|
|
32
|
+
/** acp.bin from config (second resolution priority after AGY_ACP_BIN). */
|
|
33
|
+
configBin?: string;
|
|
34
|
+
/** Environment. Default process.env (test injection). */
|
|
35
|
+
env?: NodeJS.ProcessEnv;
|
|
36
|
+
/** Progress messages for the user (throttled by the caller's UI). */
|
|
37
|
+
onProgress?: (msg: string) => void;
|
|
38
|
+
fetchImpl?: typeof fetch;
|
|
39
|
+
/** Archive unpacker. Default: `unzip` subprocess (tests inject a copier). */
|
|
40
|
+
unpack?: (archive: string, dest: string) => Promise<void>;
|
|
41
|
+
/** Download when the binary is missing. Default true. */
|
|
42
|
+
install?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type AcpSetupStatus =
|
|
46
|
+
| {
|
|
47
|
+
ok: true;
|
|
48
|
+
bin: string;
|
|
49
|
+
binarySource: "env" | "config" | "installed" | "existing";
|
|
50
|
+
auth: string;
|
|
51
|
+
/** True when auth was just set to oauth-personal: the server opens
|
|
52
|
+
* Google login in the browser on the first ACP message. */
|
|
53
|
+
needsLogin: boolean;
|
|
54
|
+
/** Human-readable actions taken (empty when everything was ready). */
|
|
55
|
+
actions: string[];
|
|
56
|
+
}
|
|
57
|
+
| { ok: false; stage: "install" | "auth"; error: string; manual: string };
|
|
58
|
+
|
|
59
|
+
export const MANUAL_SETUP = [
|
|
60
|
+
"For some unknown reason, Google ships its ACP server as a separate",
|
|
61
|
+
"binary from the Agy CLI. So, as with any other part of its Antigravity",
|
|
62
|
+
"suite (Agy Desktop, Agy Editor, Agy CLI), you need to log in to",
|
|
63
|
+
"this one as well. Separately ¯\\_(ツ)_/¯",
|
|
64
|
+
"ACP manual setup:",
|
|
65
|
+
"1. Download the server zip for your platform from the antigravity-acp",
|
|
66
|
+
" registry entry (github.com/agentclientprotocol/registry, folder",
|
|
67
|
+
" antigravity-acp/agent.json) and unzip it, e.g. to",
|
|
68
|
+
" ~/.local/opt/agy-acp/<build>/; chmod +x agy_acp_server.par.",
|
|
69
|
+
"2. Point AGY_ACP_BIN or /agy config acp.bin at the binary",
|
|
70
|
+
" (~/.local/opt/agy-acp/current/agy_acp_server.par works; 'current' is a",
|
|
71
|
+
" symlink to the build dir).",
|
|
72
|
+
'3. Log in: put {"auth":{"type":"oauth-personal"}} in',
|
|
73
|
+
" ~/.gemini/antigravity-acp/settings.json and complete the Google login that",
|
|
74
|
+
" opens in your browser on your first ACP message. That login IS your",
|
|
75
|
+
" Antigravity subscription (Google AI Plus/Pro/Ultra).",
|
|
76
|
+
"Details: /agy acp-auth",
|
|
77
|
+
].join("\n");
|
|
78
|
+
|
|
79
|
+
export function platformKey(): string {
|
|
80
|
+
const goos = process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "windows" : "linux";
|
|
81
|
+
const goarch = process.arch === "arm64" ? "aarch64" : "x86_64";
|
|
82
|
+
return `${goos}-${goarch}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Build id from a registry archive URL:
|
|
86
|
+
* …/agy-acp-server-<build>-<platform>-<arch>.zip. */
|
|
87
|
+
export function buildIdFromArchive(url: string): string {
|
|
88
|
+
const base = path.posix.basename(url, ".zip");
|
|
89
|
+
const m = base.match(/^agy-acp-server-(.+)-(darwin|linux|windows)-(?:arm64|x86_64)$/);
|
|
90
|
+
if (!m) throw new Error(`unrecognized registry archive name: ${base}`);
|
|
91
|
+
return m[1];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function defaultInstallRoot(): string {
|
|
95
|
+
return path.join(os.homedir(), ".local", "opt", "agy-acp");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function defaultGeminiDir(opts: SetupOptions): string {
|
|
99
|
+
return opts.geminiDir ?? path.join(os.homedir(), ".gemini", "antigravity-acp");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Expand a leading ~ (spawn does not). Mirrors resolveAcpBinary. */
|
|
103
|
+
function expandTilde(p: string): string {
|
|
104
|
+
return p === "~" || p.startsWith("~/") ? path.join(os.homedir(), p.slice(1)) : p;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function executableFile(p: string): boolean {
|
|
108
|
+
try {
|
|
109
|
+
fs.accessSync(p, fs.constants.X_OK);
|
|
110
|
+
return fs.statSync(p).isFile();
|
|
111
|
+
} catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface RegistryBinary {
|
|
117
|
+
archive: string;
|
|
118
|
+
cmd?: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function fetchRegistryBinary(opts: SetupOptions, key: string): Promise<RegistryBinary> {
|
|
122
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
123
|
+
const res = await fetchImpl(opts.registryUrl ?? REGISTRY_URL, { signal: AbortSignal.timeout(15_000) });
|
|
124
|
+
if (!res.ok) throw new Error(`registry fetch failed: HTTP ${res.status}`);
|
|
125
|
+
const json = (await res.json()) as { distribution?: { binary?: Record<string, RegistryBinary> } };
|
|
126
|
+
const entry = json.distribution?.binary?.[key];
|
|
127
|
+
if (!entry?.archive) throw new Error(`registry has no ${key} binary`);
|
|
128
|
+
return entry;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function defaultUnpack(archive: string, dest: string): Promise<void> {
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
133
|
+
const child = spawn("unzip", ["-o", "-q", archive, "-d", dest], { stdio: "ignore" });
|
|
134
|
+
child.on("error", (err) => reject(new Error(`unzip is not available: ${err.message}`)));
|
|
135
|
+
child.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`unzip exited with ${code}`))));
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function downloadTo(
|
|
140
|
+
url: string,
|
|
141
|
+
dest: string,
|
|
142
|
+
fetchImpl: typeof fetch,
|
|
143
|
+
onProgress?: (msg: string) => void,
|
|
144
|
+
): Promise<string> {
|
|
145
|
+
const res = await fetchImpl(url, { signal: AbortSignal.timeout(INSTALL_TIMEOUT_MS) });
|
|
146
|
+
if (!res.ok || !res.body) throw new Error(`download failed: HTTP ${res.status}`);
|
|
147
|
+
const hash = createHash("sha256");
|
|
148
|
+
const total = Number(res.headers.get("content-length") ?? 0);
|
|
149
|
+
const out = fs.createWriteStream(dest);
|
|
150
|
+
let done = 0;
|
|
151
|
+
let lastPct = -1;
|
|
152
|
+
for await (const chunk of res.body) {
|
|
153
|
+
const buf = Buffer.from(chunk as unknown as Uint8Array);
|
|
154
|
+
hash.update(buf);
|
|
155
|
+
done += buf.length;
|
|
156
|
+
if (total > 0) {
|
|
157
|
+
const pct = Math.floor((done / total) * 100);
|
|
158
|
+
if (pct >= lastPct + 10) {
|
|
159
|
+
lastPct = pct;
|
|
160
|
+
onProgress?.(`downloading ACP server: ${pct}%`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (!out.write(buf)) await new Promise<void>((r) => out.once("drain", r));
|
|
164
|
+
}
|
|
165
|
+
await new Promise<void>((resolve, reject) => {
|
|
166
|
+
out.on("finish", () => resolve());
|
|
167
|
+
out.on("error", reject);
|
|
168
|
+
out.end();
|
|
169
|
+
});
|
|
170
|
+
return hash.digest("hex");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface InstallResult {
|
|
174
|
+
bin: string;
|
|
175
|
+
build: string;
|
|
176
|
+
sha256: string;
|
|
177
|
+
downloaded: boolean;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Fetch the pinned registry build for this platform and install it under
|
|
181
|
+
* <root>/<build>/ with a `current` symlink (plan §12). No-op when the
|
|
182
|
+
* build is already installed. Registry publishes no checksums; the zip
|
|
183
|
+
* sha256 is recorded at install time for drift detection. */
|
|
184
|
+
export async function installAcpBinary(opts: SetupOptions = {}): Promise<InstallResult> {
|
|
185
|
+
const root = opts.installRoot ?? defaultInstallRoot();
|
|
186
|
+
const entry = await fetchRegistryBinary(opts, platformKey());
|
|
187
|
+
const build = buildIdFromArchive(entry.archive);
|
|
188
|
+
const cmd = (entry.cmd ?? "./agy_acp_server.par").replace(/^\.\//, "");
|
|
189
|
+
const dir = path.join(root, build);
|
|
190
|
+
const bin = path.join(dir, cmd);
|
|
191
|
+
if (executableFile(bin)) {
|
|
192
|
+
return { bin, build, sha256: readRecordedSha(dir), downloaded: false };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
fs.mkdirSync(root, { recursive: true });
|
|
196
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
197
|
+
const tmpZip = path.join(root, `.download-${build}.zip`);
|
|
198
|
+
opts.onProgress?.(`downloading ACP server (build ${build})…`);
|
|
199
|
+
const sha = await downloadTo(entry.archive, tmpZip, opts.fetchImpl ?? fetch, opts.onProgress);
|
|
200
|
+
opts.onProgress?.("unpacking ACP server…");
|
|
201
|
+
const unpack = opts.unpack ?? defaultUnpack;
|
|
202
|
+
await unpack(tmpZip, dir);
|
|
203
|
+
fs.rmSync(tmpZip, { force: true });
|
|
204
|
+
if (!executableFile(bin)) throw new Error(`${cmd} not found in the archive after unpacking`);
|
|
205
|
+
fs.chmodSync(bin, 0o755);
|
|
206
|
+
fs.writeFileSync(path.join(dir, "zip.sha256"), `${sha} ${path.basename(entry.archive)}\n`);
|
|
207
|
+
|
|
208
|
+
// Re-point `current` at the new build. The link is ours (managed layout).
|
|
209
|
+
const link = path.join(root, "current");
|
|
210
|
+
fs.rmSync(link, { force: true });
|
|
211
|
+
fs.symlinkSync(build, link);
|
|
212
|
+
opts.onProgress?.("ACP server installed.");
|
|
213
|
+
return { bin, build, sha256: sha, downloaded: true };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function readRecordedSha(dir: string): string {
|
|
217
|
+
try {
|
|
218
|
+
return fs.readFileSync(path.join(dir, "zip.sha256"), "utf8").split(/\s+/)[0] ?? "";
|
|
219
|
+
} catch {
|
|
220
|
+
return "";
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export interface AuthState {
|
|
225
|
+
configured: boolean;
|
|
226
|
+
/** settings.json auth.type, "token" (acp_token.json present), or undefined. */
|
|
227
|
+
type?: string;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Auth state WITHOUT reading any credential value: settings.json carries
|
|
231
|
+
* only auth.type (+ non-secret gcp placement), acp_token.json is stat()ed. */
|
|
232
|
+
export function readAuthState(dir?: string): AuthState {
|
|
233
|
+
const target = dir ?? path.join(os.homedir(), ".gemini", "antigravity-acp");
|
|
234
|
+
try {
|
|
235
|
+
const raw = JSON.parse(fs.readFileSync(path.join(target, "settings.json"), "utf8")) as {
|
|
236
|
+
auth?: { type?: unknown };
|
|
237
|
+
};
|
|
238
|
+
const type = typeof raw.auth?.type === "string" ? raw.auth.type : undefined;
|
|
239
|
+
if (type) return { configured: true, type };
|
|
240
|
+
} catch {
|
|
241
|
+
/* absent or garbage = unconfigured */
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
fs.statSync(path.join(target, "acp_token.json"));
|
|
245
|
+
return { configured: true, type: "token" };
|
|
246
|
+
} catch {
|
|
247
|
+
return { configured: false };
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Write a minimal auth block into settings.json. The server reads this file
|
|
252
|
+
* at STARTUP, so this must happen before the first server spawn. */
|
|
253
|
+
export function writeAuthType(type: "gemini-api-key" | "oauth-personal", dir?: string): void {
|
|
254
|
+
const target = dir ?? path.join(os.homedir(), ".gemini", "antigravity-acp");
|
|
255
|
+
fs.mkdirSync(target, { recursive: true, mode: 0o700 });
|
|
256
|
+
const file = path.join(target, "settings.json");
|
|
257
|
+
let settings: Record<string, unknown> = {};
|
|
258
|
+
try {
|
|
259
|
+
settings = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
|
|
260
|
+
} catch {
|
|
261
|
+
/* fresh file */
|
|
262
|
+
}
|
|
263
|
+
const prevAuth = (typeof settings.auth === "object" && settings.auth !== null ? settings.auth : {}) as Record<string, unknown>;
|
|
264
|
+
fs.writeFileSync(file, `${JSON.stringify({ ...settings, auth: { ...prevAuth, type } }, null, 2)}\n`, { mode: 0o600 });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export interface BinaryResolution {
|
|
268
|
+
bin: string | null;
|
|
269
|
+
source: "env" | "config" | "existing" | "installed" | null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Read-only binary resolution: AGY_ACP_BIN > acp.bin > installed `current`
|
|
273
|
+
* layout. Does NOT install and does NOT fall back to the bare PATH name. */
|
|
274
|
+
export function resolveInstalledBinary(opts: SetupOptions = {}): BinaryResolution {
|
|
275
|
+
const env = opts.env ?? process.env;
|
|
276
|
+
const root = opts.installRoot ?? defaultInstallRoot();
|
|
277
|
+
const candidates: Array<[string, BinaryResolution["source"]]> = [
|
|
278
|
+
[env.AGY_ACP_BIN?.trim() ?? "", "env"],
|
|
279
|
+
[opts.configBin?.trim() ?? "", "config"],
|
|
280
|
+
[path.join(root, "current", "agy_acp_server.par"), "existing"],
|
|
281
|
+
];
|
|
282
|
+
for (const [raw, source] of candidates) {
|
|
283
|
+
if (!raw) continue;
|
|
284
|
+
const bin = expandTilde(raw);
|
|
285
|
+
if (executableFile(bin)) return { bin, source };
|
|
286
|
+
}
|
|
287
|
+
return { bin: null, source: null };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Read-only setup state (for /agy doctor): no installs, no writes. */
|
|
291
|
+
export function inspectAcpSetup(opts: SetupOptions = {}): { bin: string | null; source: string | null; auth: string | null } {
|
|
292
|
+
const binary = resolveInstalledBinary(opts);
|
|
293
|
+
const auth = readAuthState(defaultGeminiDir(opts));
|
|
294
|
+
return { bin: binary.bin, source: binary.source, auth: auth.configured ? (auth.type ?? "token") : null };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Orchestrate everything the ACP engine needs, in order: binary (install
|
|
298
|
+
* from the registry when missing), then auth (oauth-personal: the user's
|
|
299
|
+
* own Antigravity subscription - the same Google account and plan as the
|
|
300
|
+
* agy CLI login; the server opens the browser itself on the first message).
|
|
301
|
+
* Every step is skipped when already satisfied; failures return `manual`
|
|
302
|
+
* text for the caller to surface. */
|
|
303
|
+
export async function ensureAcpReady(opts: SetupOptions = {}): Promise<AcpSetupStatus> {
|
|
304
|
+
const actions: string[] = [];
|
|
305
|
+
|
|
306
|
+
// 1. Binary.
|
|
307
|
+
let binary = resolveInstalledBinary(opts);
|
|
308
|
+
if (!binary.bin) {
|
|
309
|
+
if (opts.install === false) {
|
|
310
|
+
return { ok: false, stage: "install", error: "no ACP server binary found", manual: MANUAL_SETUP };
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
const installed = await installAcpBinary(opts);
|
|
314
|
+
binary = { bin: installed.bin, source: "installed" };
|
|
315
|
+
actions.push(installed.downloaded ? `installed ACP server build ${installed.build}` : `found installed ACP server build ${installed.build}`);
|
|
316
|
+
} catch (err) {
|
|
317
|
+
return {
|
|
318
|
+
ok: false,
|
|
319
|
+
stage: "install",
|
|
320
|
+
error: err instanceof Error ? err.message : String(err),
|
|
321
|
+
manual: MANUAL_SETUP,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// 2. Auth. settings.json is read at server STARTUP; setup always runs
|
|
327
|
+
// before the first spawn, so a fresh write takes effect. oauth-personal is
|
|
328
|
+
// THE default: it is the Antigravity subscription (same Google account and
|
|
329
|
+
// plan as the agy CLI login). gemini-api-key (metered paid API) stays a
|
|
330
|
+
// manual option for headless boxes (/agy acp-auth); never the default.
|
|
331
|
+
const gdir = defaultGeminiDir(opts);
|
|
332
|
+
const auth = readAuthState(gdir);
|
|
333
|
+
if (!auth.configured) {
|
|
334
|
+
try {
|
|
335
|
+
writeAuthType("oauth-personal", gdir);
|
|
336
|
+
actions.push("configured oauth-personal auth (your Antigravity subscription login)");
|
|
337
|
+
return { ok: true, bin: binary.bin!, binarySource: binary.source!, auth: "oauth-personal", needsLogin: true, actions };
|
|
338
|
+
} catch (err) {
|
|
339
|
+
return {
|
|
340
|
+
ok: false,
|
|
341
|
+
stage: "auth",
|
|
342
|
+
error: err instanceof Error ? err.message : String(err),
|
|
343
|
+
manual: MANUAL_SETUP,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return {
|
|
348
|
+
ok: true,
|
|
349
|
+
bin: binary.bin!,
|
|
350
|
+
binarySource: binary.source!,
|
|
351
|
+
auth: auth.type ?? "token",
|
|
352
|
+
needsLogin: false,
|
|
353
|
+
actions,
|
|
354
|
+
};
|
|
355
|
+
}
|
package/src/driver.ts
CHANGED
|
@@ -132,6 +132,10 @@ export class AgyDriver implements TurnDriver {
|
|
|
132
132
|
#queueTail: Promise<void> = Promise.resolve();
|
|
133
133
|
#shutdown = false;
|
|
134
134
|
#stderrTail = "";
|
|
135
|
+
// Frames can split across pipe chunks; the trailing partial line lives here
|
|
136
|
+
// until its newline arrives (same scheme as JsonRpcSession.feed). Dropping
|
|
137
|
+
// it ate large tool frames and could hide the result frame of a turn.
|
|
138
|
+
#stdoutBuf = "";
|
|
135
139
|
#lifecycle: string[] = [];
|
|
136
140
|
#onTurnEnd: ((outcome: TurnOutcome) => void) | undefined;
|
|
137
141
|
#stats = {
|
|
@@ -332,7 +336,16 @@ export class AgyDriver implements TurnDriver {
|
|
|
332
336
|
windowsHide: true,
|
|
333
337
|
});
|
|
334
338
|
this.#child = child;
|
|
339
|
+
this.#stdoutBuf = "";
|
|
335
340
|
this.#stats.spawns += 1;
|
|
341
|
+
// Pipe write failures surface asynchronously as stream 'error' events;
|
|
342
|
+
// the sync try/catch around stdin.write cannot see them. Without this
|
|
343
|
+
// listener an EPIPE (agy died mid-write) is uncaught and kills pi.
|
|
344
|
+
child.stdin!.on("error", (err) => {
|
|
345
|
+
if (generation !== this.#generation) return;
|
|
346
|
+
const turn = this.#active;
|
|
347
|
+
if (turn && !turn.closed) this.#failTurn(turn, `agy stdin write failed: ${err.message}`);
|
|
348
|
+
});
|
|
336
349
|
this.#log(`spawn:${child.pid ?? "?"}:${request.conversationId ? "resume" : "fresh"}`);
|
|
337
350
|
this.#state = "ready";
|
|
338
351
|
|
|
@@ -379,10 +392,15 @@ export class AgyDriver implements TurnDriver {
|
|
|
379
392
|
}
|
|
380
393
|
|
|
381
394
|
#onStdout(chunk: string): void {
|
|
395
|
+
// Buffer the trailing partial line: a frame split across pipe chunks is
|
|
396
|
+
// reassembled when its newline arrives (mirrors JsonRpcSession.feed).
|
|
397
|
+
this.#stdoutBuf += chunk;
|
|
398
|
+
const lines = this.#stdoutBuf.split("\n");
|
|
399
|
+
this.#stdoutBuf = lines.pop() ?? "";
|
|
382
400
|
const turn = this.#active;
|
|
383
401
|
if (!turn || turn.closed) return;
|
|
384
402
|
if (turn.idleTimer) turn.idleTimer.refresh();
|
|
385
|
-
for (const line of
|
|
403
|
+
for (const line of lines) {
|
|
386
404
|
if (!line.trim()) continue;
|
|
387
405
|
this.#applyParsed(turn, parseAgyLine(line));
|
|
388
406
|
if (turn.closed) return;
|
|
@@ -585,6 +603,7 @@ export class AgyDriver implements TurnDriver {
|
|
|
585
603
|
if (!child) return;
|
|
586
604
|
this.#child = undefined;
|
|
587
605
|
this.#generation += 1;
|
|
606
|
+
this.#stdoutBuf = "";
|
|
588
607
|
try {
|
|
589
608
|
child.stdout?.removeAllListeners();
|
|
590
609
|
child.stderr?.removeAllListeners();
|
package/src/provider.ts
CHANGED
|
@@ -846,6 +846,14 @@ export function createStreamSimple(
|
|
|
846
846
|
const config = loadConfig();
|
|
847
847
|
const engine = deps.engine ?? config.engine;
|
|
848
848
|
const selected = engine === "acp" && deps.acpDriver ? deps.acpDriver : deps.driver;
|
|
849
|
+
// RC01: ACP modes are permission modes, there is no review-only plan.
|
|
850
|
+
// A plan turn on ACP would silently run non-plan; fail visibly instead.
|
|
851
|
+
if (selected === deps.acpDriver && config.mode === "plan") {
|
|
852
|
+
const partial = newAssistant(model);
|
|
853
|
+
const blocks: BlockState = { partial, textIdx: null, thinkingIdx: null, started: false };
|
|
854
|
+
finalize(stream, blocks, "error", "ACP engine has no plan mode. /agy mode accept-edits, or /agy engine stream-json.");
|
|
855
|
+
return stream;
|
|
856
|
+
}
|
|
849
857
|
if (selected && roundTrips) {
|
|
850
858
|
void runTurnDriver(stream, model, context, options, entries, store, {
|
|
851
859
|
driver: selected,
|