@estebanforge/pi-antigravity-bridge 1.2.3 → 1.2.5
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 +45 -0
- package/docs/ANTIGRAVITY-INTEGRATIONS.md +549 -0
- package/docs/PI-INVOKETOOL-PATCH.md +34 -7
- package/package.json +4 -4
- package/src/ask-tool.ts +188 -2
- package/src/patcher.ts +137 -17
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,51 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [1.2.5] - 2026-08-24
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **Patch updated for pi 0.84.3's bundled runtime.** Two upstream changes
|
|
10
|
+
broke the `pi.invokeTool` patch. First, the `core/extensions/loader.js`
|
|
11
|
+
facade was refactored (`runtime.assertActive()` became a local
|
|
12
|
+
`assertActive()` guard), so the sixth site no longer matched and the
|
|
13
|
+
two-phase apply aborted (fail-closed, no files written). Second, pi's `bin`
|
|
14
|
+
now launches `dist/bundle/cli.js`, a bundled runtime with its own embedded
|
|
15
|
+
core, so patching `dist/core/` could never affect a running pi. The patcher
|
|
16
|
+
now also replaces `dist/bundle/cli.js` with a shim that loads the modular
|
|
17
|
+
`dist/cli.js`, making the six sites live again; `findPiRoot` understands
|
|
18
|
+
the `dist/bundle` argv layout. Tradeoff: pi starts via the modular runtime
|
|
19
|
+
(the bundle's faster startup is forfeited while the patch is applied).
|
|
20
|
+
After upgrading, re-apply via `/agy patch apply` and fully restart pi.
|
|
21
|
+
- **Patcher hardening (peer-reviewed).** Atomic writes now preserve the
|
|
22
|
+
destination's permission bits. Without this, every apply stripped the
|
|
23
|
+
execute bit from `dist/bundle/cli.js` (pi's bin target) and broke the `pi`
|
|
24
|
+
command. The entry redirect is never written after any write error, so a
|
|
25
|
+
failed run can no longer silently switch pi to the modular runtime. Backups
|
|
26
|
+
copy forward from the previous same-version backup, so a repair run after a
|
|
27
|
+
partial patch keeps backups complete and restore still reverts the entry
|
|
28
|
+
redirect; an already-redirected entry with no surviving original now warns
|
|
29
|
+
loudly instead of failing silently later.
|
|
30
|
+
|
|
31
|
+
## [1.2.4] - 2026-08-13
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- **Model, thinking tier, and mode shown next to the tool name.** The
|
|
36
|
+
`AskAntigravity` tool now renders
|
|
37
|
+
`AskAntigravity [model=gemini-3.6-flash, thinking=high]` with a prompt
|
|
38
|
+
preview while a delegation runs, plus a tidy result row
|
|
39
|
+
(`✓ AskAntigravity 12.3s`) with an expandable body. Built on pi's
|
|
40
|
+
`renderCall`/`renderResult` hooks; the values shown are the resolved
|
|
41
|
+
config defaults (model alias + tier), not just the args the caller passed.
|
|
42
|
+
`AgyDetails` gained a `thinking` field.
|
|
43
|
+
- **Opt-in full-context delegation (`includeContext`).** New boolean param
|
|
44
|
+
(default `false`, isolated one-shot unchanged). When `true`, the current pi
|
|
45
|
+
conversation is exported as resolved markdown to
|
|
46
|
+
`~/.pi/extensions-data/estebanforge/pi-antigravity-bridge/` and the prompt
|
|
47
|
+
tells agy to read it first. The run passes `--add-dir` for that folder so
|
|
48
|
+
the sandbox can read it; the temp file is removed after the run.
|
|
49
|
+
|
|
5
50
|
## [1.2.3] - 2026-08-10
|
|
6
51
|
|
|
7
52
|
### Fixed
|
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
# Antigravity Editor Integrations: Reverse Engineered Internals
|
|
2
|
+
|
|
3
|
+
Date: 2026-08-21. Sources: the official VSIX `Google.google-antigravity_1.0.0` (marketplace.visualstudio.com/items?itemName=Google.google-antigravity, unpacked at `~/Downloads/Google.google-antigravity_1.0.0`), Zed's external-agents registry cache (`~/Library/Application Support/Zed/external_agents/registry/registry.json`), the ACP release zip from `dl.google.com`, and the binaries installed on this machine. I verified everything below by direct inspection or live execution unless marked otherwise. Section 8 has the reproduction commands.
|
|
4
|
+
|
|
5
|
+
## 0. Executive summary
|
|
6
|
+
|
|
7
|
+
Google ships two official mechanisms for driving Antigravity from an editor, plus one hidden one:
|
|
8
|
+
|
|
9
|
+
1. **Mechanism A, VS Code and JetBrains extension**: the extension spawns `agy --hub`, a local HTTP server that serves the complete Antigravity web UI. The extension embeds that UI in an iframe and the webapp drives the agent over an internal WebSocket protocol. The editor never talks to the agent core. It supplies IDE capabilities (open file, diffs, editor state) through a protobuf RPC bridge carried over postMessage.
|
|
10
|
+
2. **Mechanism B, Zed and any ACP client**: Google publishes `agy_acp_server`, a dedicated binary that speaks Agent Client Protocol (ACP) v1, JSON-RPC over stdio. A real programmatic integration: sessions, streaming updates, image and audio prompts, MCP support.
|
|
11
|
+
3. **Hidden, `agy agentapi`**: an undocumented subcommand that proxies conversation control into a running Antigravity IDE language server over HTTP via the `ANTIGRAVITY_LS_ADDRESS` environment variable.
|
|
12
|
+
|
|
13
|
+
There is still no published API for the agent core. The webapp WebSocket protocol behind `--hub` stays internal and undocumented. The ACP server is the closest thing to a sanctioned programmatic surface, and it is new: the current release is dated 2026-08-18, three days before this document.
|
|
14
|
+
|
|
15
|
+
## 1. Binary inventory on this machine
|
|
16
|
+
|
|
17
|
+
One assumption needed correcting before any of this analysis could stand: `~/.local/bin/agy` is not a construct wrapper. Codesign settles it in one command:
|
|
18
|
+
|
|
19
|
+
| Path | Identity | Notes |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| `~/.local/bin/agy` | Developer ID Application: Google LLC (EQHXZ8M8AV) | Real Antigravity CLI, Go binary, version 1.1.17, identifier `cli`, signed 2026-08-20 |
|
|
22
|
+
| `/Applications/Antigravity.app/Contents/Resources/bin/language_server` | Google LLC (EQHXZ8M8AV) | Agent platform backend, 137.8 MB Mach-O arm64, identifier `language_server` |
|
|
23
|
+
| `/Applications/Antigravity IDE.app` | (VS Code fork) | The IDE itself, Electron, standard `cli.js` launcher at `Contents/Resources/app/bin/antigravity-ide` |
|
|
24
|
+
| `~/Library/Application Support/Antigravity/bin/agy-node` | shell script | Runs the Antigravity Helper (Electron) as Node: `ELECTRON_RUN_AS_NODE=1 exec ".../Antigravity Helper" "$@"` |
|
|
25
|
+
| `~/.gemini/antigravity-cli/bin/agentapi` | shell script, 61 bytes | `exec "/Users/esteban/.local/bin/agy" agentapi "$@"` |
|
|
26
|
+
| `~/.gemini/antigravity-cli/bin/webm_encoder` | Mach-O arm64 | Screen recording encoder for screencasts |
|
|
27
|
+
|
|
28
|
+
The construct wrapper lives inside the sandbox (`/home/construct`), not on the host. On the host, the Google-signed binary sits on PATH directly.
|
|
29
|
+
|
|
30
|
+
Two distinct `agy` install locations matter:
|
|
31
|
+
|
|
32
|
+
- `~/.local/bin/agy`: the self-updating CLI install (what `agy install` configures, version 1.1.17 on this machine).
|
|
33
|
+
- `~/.gemini/bin/agy`: where the VS Code extension installs its own pinned copy via its auto-installer. Independent of the first. Not present on this machine yet because the VSIX was never activated here.
|
|
34
|
+
|
|
35
|
+
`--hub` never shows in `agy --help`, even with `AGY_ENABLE_HUB=1` set. The flags exist in the binary anyway: strings include `AGY_ENABLE_HUB`, `hub-port`, and the error text `Retry with a different port: --hub-port <port>.`. Go flag parsing accepts hidden flags, so the extension's spawn works regardless of help visibility.
|
|
36
|
+
|
|
37
|
+
## 2. Mechanism A: the VS Code extension
|
|
38
|
+
|
|
39
|
+
The bundle: `extension.js` (11.8 MB of Google Closure/tsickle output with the JSDoc annotations intact, which makes it very readable), `bridge.js` (1.7 MB, the webview-side RPC bridge), `loading_bridge.js` (33 KB, a fallback host-input page), plus an `extension_bin.cjs.map`. The internal Blaze package is `google3.cloud.developer_experience.antigravity_extensions.vscode`.
|
|
40
|
+
|
|
41
|
+
### 2.1 Install pipeline (binary_downloader)
|
|
42
|
+
|
|
43
|
+
The class `AntigravityServerManager` implements what its own doc comment calls the "Dynamic Auto-Installation strategy (`~/.gemini/bin/agy`)".
|
|
44
|
+
|
|
45
|
+
Constants:
|
|
46
|
+
|
|
47
|
+
- `DEFAULT_RELEASE_BASE_URL = 'https://antigravity-cli-auto-updater-974169037036.us-central1.run.app'` (production, a Cloud Run service).
|
|
48
|
+
- `DOGFOOD_RELEASE_BASE_URL = 'https://storage.googleapis.com/antigravity-public/antigravity-cli'` (used when the `antigravity.channel` config equals `dogfood`).
|
|
49
|
+
|
|
50
|
+
Manifest resolution:
|
|
51
|
+
|
|
52
|
+
- If the base URL ends in `.json`, it is fetched directly as the manifest.
|
|
53
|
+
- Otherwise the service endpoint form is used: `{base}/manifests/{goos}_{goarch}.json`.
|
|
54
|
+
- A valid manifest requires a non-empty string `version` and at least one of `url`, `binaries`, or `platforms`.
|
|
55
|
+
- Platform binary lookup: `binaries["{platform}-{arch}"]` first, then a normalized fallback used by public manifests: `win32 -> windows`, `arm64/aarch64 -> arm`, so keys look like `darwin-arm`, `linux-x86_64`, `windows-x86_64`.
|
|
56
|
+
- Each binary entry can carry `url`, `sha256`, `sha512`; the downloader verifies hashes when present.
|
|
57
|
+
- Download uses `fetch` with redirect following and streams to disk with progress reporting; partial files are deleted on failure.
|
|
58
|
+
- Installed target: `~/.gemini/bin/agy` (`.exe` suffix on Windows), via `getInstalledTargetPath()`.
|
|
59
|
+
|
|
60
|
+
Version gating:
|
|
61
|
+
|
|
62
|
+
- `verifyBinaryVersion` runs `agy --version` (5 s timeout) and requires semver `gte` against a minimum version; strings containing `dev` or `HEAD` bypass the check.
|
|
63
|
+
- `getBinaryVersionString` (3 s timeout) extracts `\d+\.\d+\.\d+[^ \t\n\r]*` from combined stdout+stderr; used for the launch log line.
|
|
64
|
+
|
|
65
|
+
### 2.2 Hub process lifecycle
|
|
66
|
+
|
|
67
|
+
Launch sequence in `AntigravityServerManager.start()`:
|
|
68
|
+
|
|
69
|
+
1. Acquire binary path (auto-install if needed, with progress UI).
|
|
70
|
+
2. Allocate an ephemeral port: `net.createServer(); server.listen(0, '127.0.0.1')`, read the assigned port, close the server, reuse the port number. Classic TOCTOU port allocation; the hub prints the retry hint if it loses the race.
|
|
71
|
+
3. Build args: `['--hub', '--hub-port=${port}', '--app_data_dir=antigravity']`, then `--add-dir=${folder.fsPath}` for every workspace folder, then any user-configured `antigravity.serverArgs` (see 2.7; this key is read but never declared in the manifest).
|
|
72
|
+
4. Spawn with `cwd` = first workspace folder (fallback: extension path) and env:
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
{...process.env,
|
|
76
|
+
HOME: os.homedir(),
|
|
77
|
+
USERPROFILE: os.homedir(),
|
|
78
|
+
AGY_ENABLE_HUB: '1',
|
|
79
|
+
ANTIGRAVITY_VSCODE_HOST: '1',
|
|
80
|
+
ANTIGRAVITY_AUTH_SUCCESS_APP: vscode.env.uriScheme || 'vscode',
|
|
81
|
+
}
|
|
82
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
5. Readiness poll: HTTP GET the root `http://127.0.0.1:${port}` every 250 ms for up to 15 s; any status code 200-499 counts as healthy (deliberately tolerant, so an auth-required 401 still means "server is up").
|
|
86
|
+
6. On exit: log `[LAUNCH ERROR] Server process exited unexpectedly with code ${code}, signal ${signal}`, clear `serverProcess`/`serverUrl` state.
|
|
87
|
+
7. `stop()` performs a graceful shutdown with a settle guard so double-stop is safe.
|
|
88
|
+
|
|
89
|
+
stdout is scanned line by line for the magic prefix `ANTIGRAVITY_OPEN_URL:`; the remainder of the line is parsed as a URI and opened with `vscode.env.openExternal`. That is how OAuth gets out of the hub process and into the browser (see 2.6). All stdout/stderr lines are echoed to the "Antigravity" output channel prefixed `[HUB STDOUT]` / `[HUB STDERR]`; lifecycle messages use `[LAUNCH]`, `[LAUNCH ERROR]`, `[INSTALL]`.
|
|
90
|
+
|
|
91
|
+
### 2.3 Webview embedding
|
|
92
|
+
|
|
93
|
+
The sidebar view `antigravity.panel` (activity bar container `antigravity-sidebar`) renders a minimal HTML shell. Key construction, from `renderIframe(webview, serverUrl, options)`:
|
|
94
|
+
|
|
95
|
+
- Base URL: `new URL(targetRoute || '', serverUrl)`; `targetRoute` allows deep-linking a specific app route.
|
|
96
|
+
- Query params set on it: `extensionView=true`, `extensionVariant=vs-code`, `useWebSocket=true`, `hostTheme=${theme}`, `enableMicrophone=false`, `workspaceUri=${first workspace folder uri}`, plus any caller-provided `extraParams`.
|
|
97
|
+
- The iframe element: `<iframe id="jetski-frame" src="${fullUrlString}" allow="clipboard-read; clipboard-write" sandbox="allow-scripts allow-same-origin allow-popups allow-forms allow-popups-to-escape-sandbox">` with an opacity transition reveal.
|
|
98
|
+
- CSP (meta tag): `default-src 'none'; frame-src ${serverUrl} https: http:; script-src ${webview.cspSource} 'unsafe-inline'; style-src ${webview.cspSource} 'unsafe-inline'; connect-src 'self' ${webview.cspSource} https: http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:*;`
|
|
99
|
+
- The shell also loads `bridge.js` as a webview URI (see 2.4) and applies VS Code editor font settings to the host document, forwarding later changes via `updateFontSettings` postMessages.
|
|
100
|
+
- Reveal loop: `fetch(fullUrlString)` retried up to 20 times at 1 s intervals; while retrying, the loader text reads `Connecting to Remote Antigravity tunnel (n)...` and after exhaustion `Could not connect to remote port. Please check VS Code Ports tab.` The tunnel wording implies the supported remote scenario is VS Code port forwarding of the hub port, not a second spawn on the remote host.
|
|
101
|
+
- A "compatibility modal" in the shell HTML supports an in-place "Update" action, used when the installed binary is older than the extension's minimum (ties into 2.1 version gating).
|
|
102
|
+
|
|
103
|
+
The critical design fact: `useWebSocket=true` tells the served webapp to talk to the hub over a WebSocket back to `ws://127.0.0.1:${port}` (hence the CSP `connect-src` entries). All agent control traffic, streaming, tool events, and conversation state ride that socket. The editor never sees that protocol.
|
|
104
|
+
|
|
105
|
+
### 2.4 The postMessage RPC bridge
|
|
106
|
+
|
|
107
|
+
`bridge.js` runs inside the webview shell (outside the iframe). Constants:
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
AGY_API_CHANNEL: "agy-ext-antigravity-api" // service: AntigravityApi, implemented by the webapp
|
|
111
|
+
EXTENSION_API_CHANNEL: "agy-ext-extension-api" // service: ExtensionApi, implemented by the editor
|
|
112
|
+
ANTIGRAVITY_IFRAME_SOURCE: "antigravity-iframe" // postMessage source tag from iframe
|
|
113
|
+
ANTIGRAVITY_EXTENSION_SOURCE: "antigravity-extension" // postMessage source tag from extension host
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Wiring (from `getExtensionApi` / `getAntigravity` / the V2 factory `Xe` in bridge.js):
|
|
117
|
+
|
|
118
|
+
- Inside the iframe, `getExtensionApi()` builds a client for `ExtensionApi` that serializes RPC frames and posts them with `window.parent.postMessage({...frame, source: "antigravity-iframe"}, "*")`, listening on `message` for replies.
|
|
119
|
+
- In the webview shell, the bridge receives iframe messages (validated by source tag and frame shape), forwards them to the extension host through the VS Code webview messaging API, and symmetrically delivers extension host messages down into the iframe. Interceptors can override any method on either service (used for the V2 event-emitter merge).
|
|
120
|
+
- RPC payloads are protobuf messages from `third_party/gemini_coder/proto/iframe_messages.proto` (proto namespace `gemini_coder.agent_ui_toolkit.iframe`), bundled as a protobuf-es generated module with an embedded `FileDescriptorProto`.
|
|
121
|
+
|
|
122
|
+
Event surface (the emitter names hard-coded in bridge.js): `onAntigravityReady`, `onDidChangeUrl`, `onDidSendChatMessage`, `onDidStartConversation`, `onDidChangeConversations`, `onKeyboardEvent`, `onMouseEvent`, `onWebviewFocused`.
|
|
123
|
+
|
|
124
|
+
### 2.5 iframe_messages.proto: complete service surface
|
|
125
|
+
|
|
126
|
+
I extracted the embedded FileDescriptorProto from bridge.js and decoded it with `protoc --decode=google.protobuf.FileDescriptorProto`. Services:
|
|
127
|
+
|
|
128
|
+
`AntigravityApi` (implemented by the Antigravity webapp; the editor calls these):
|
|
129
|
+
|
|
130
|
+
| Method | Request | Response |
|
|
131
|
+
|---|---|---|
|
|
132
|
+
| GetUrl | UrlQueryMessage | UrlResponseMessage |
|
|
133
|
+
| Navigate | UrlNavigateMessage | UrlNavigateResponse |
|
|
134
|
+
| SetEditorState | EditorStateMessage | EditorStateResponse |
|
|
135
|
+
| SetContextCategories | SetContextCategoriesRequest | SetContextCategoriesResponse |
|
|
136
|
+
| InsertSnippet | InsertSnippetRequest | InsertSnippetResponse |
|
|
137
|
+
| AddContext | AddContextRequest | AddContextResponse |
|
|
138
|
+
| SetTheme | ThemeChangeMessage | ThemeChangeResponse |
|
|
139
|
+
| AddSkills | AddSkillsMessage | AddSkillsResponse |
|
|
140
|
+
| SetComments | SetCommentsRequest | SetCommentsResponse |
|
|
141
|
+
| NewConversation | NewConversationRequest | NewConversationResponse |
|
|
142
|
+
| AddFileComment | AddFileCommentRequest | AddFileCommentResponse |
|
|
143
|
+
| DeleteFileComment | DeleteFileCommentRequest | DeleteFileCommentResponse |
|
|
144
|
+
| EditFileComment | EditFileCommentRequest | EditFileCommentResponse |
|
|
145
|
+
| TriggerSend | TriggerSendRequest | TriggerSendResponse |
|
|
146
|
+
| SendCommand | SendCommandRequest | SendCommandResponse |
|
|
147
|
+
| ListCommands | ListCommandsRequest | ListCommandsResponse |
|
|
148
|
+
| SetFileDiffs | FileDiffsChangedMessage | FileDiffsChangedResponse |
|
|
149
|
+
|
|
150
|
+
`ExtensionApi` (implemented by the editor; the webapp calls these):
|
|
151
|
+
|
|
152
|
+
| Method | Request | Response |
|
|
153
|
+
|---|---|---|
|
|
154
|
+
| OnDidChangeUrl | UrlChangeMessage | UrlChangeResponse |
|
|
155
|
+
| OnDidSendChatMessage | ChatMessageSentRequest | ChatMessageSentResponse |
|
|
156
|
+
| OnDidStartConversation | ConversationStartedRequest | ConversationStartedResponse |
|
|
157
|
+
| OnDidChangeConversations | ConversationsChangedMessage | ConversationsChangedResponse |
|
|
158
|
+
| OpenUrl | OpenUrlRequest | OpenUrlResponse |
|
|
159
|
+
| OpenFile | OpenFileRequest | OpenFileResponse |
|
|
160
|
+
| OpenArtifact | OpenArtifactRequest | OpenArtifactResponse |
|
|
161
|
+
| OpenSettings | OpenSettingsRequest | OpenSettingsResponse |
|
|
162
|
+
| OpenTerminal | OpenTerminalRequest | OpenTerminalResponse |
|
|
163
|
+
| OpenDiff | OpenDiffRequest | OpenDiffResponse |
|
|
164
|
+
| AddAgentEdit | AddAgentEditRequest | AddAgentEditResponse |
|
|
165
|
+
| CloseAllDiffZones | CloseAllDiffZonesRequest | CloseAllDiffZonesResponse |
|
|
166
|
+
| ResolveAllAgentEdits | ResolveAllAgentEditsRequest | ResolveAllAgentEditsResponse |
|
|
167
|
+
| RequestAgentEditsState | RequestAgentEditsStateRequest | RequestAgentEditsStateResponse |
|
|
168
|
+
| RequestDiffZonesState | RequestDiffZonesStateRequest | RequestDiffZonesStateResponse |
|
|
169
|
+
| StorageGetItems | StorageGetItemsRequest | StorageGetItemsResponse |
|
|
170
|
+
| StorageUpdateItems | StorageUpdateItemsRequest | StorageUpdateItemsResponse |
|
|
171
|
+
| ClipboardRead | ClipboardReadRequest | ClipboardReadResponse |
|
|
172
|
+
| OnKeyboardEvent | KeyboardEventRequest | KeyboardEventResponse |
|
|
173
|
+
| OnMouseEvent | MouseEventRequest | MouseEventResponse |
|
|
174
|
+
| OnWebviewFocused | WebviewFocusedRequest | WebviewFocusedResponse |
|
|
175
|
+
| GetBrowserNotificationPermissionState | NotificationPermissionQueryMessage | NotificationPermissionStateMessage |
|
|
176
|
+
| RequestBrowserNotificationPermission | NotificationPermissionRequestMessage | NotificationPermissionStateMessage |
|
|
177
|
+
| ShowBrowserNotification | NotificationRequestMessage | BrowserNotificationResponse |
|
|
178
|
+
| ShowNotification | ShowNotificationRequest | ShowNotificationResponse |
|
|
179
|
+
| ReportFeedbackMetadata | ReportFeedbackMetadataRequest | ReportFeedbackMetadataResponse |
|
|
180
|
+
| ProvideFeedback | ProvideFeedbackRequest | ProvideFeedbackResponse |
|
|
181
|
+
| ChangeWorkspace | ChangeWorkspaceRequest | ChangeWorkspaceResponse |
|
|
182
|
+
| ExecuteNotebookCells | ExecuteNotebookCellsRequest | ExecuteNotebookCellsResponse |
|
|
183
|
+
| GetEditorState | GetEditorStateRequest | GetEditorStateResponse |
|
|
184
|
+
| BroadcastComments | BroadcastCommentsRequest | BroadcastCommentsResponse |
|
|
185
|
+
| CanResolveConnection | CanResolveConnectionRequest | CanResolveConnectionResponse |
|
|
186
|
+
| ResolveConnection | ResolveConnectionRequest | ResolveConnectionResponse |
|
|
187
|
+
| OnAntigravityReady | OnAntigravityReadyRequest | OnAntigravityReadyResponse |
|
|
188
|
+
| LogTelemetry | LogTelemetryRequest | LogTelemetryResponse |
|
|
189
|
+
| GetContextCategories | GetContextCategoriesRequest | GetContextCategoriesResponse |
|
|
190
|
+
| QueryContextCategory | QueryContextCategoryRequest | QueryContextCategoryResponse |
|
|
191
|
+
|
|
192
|
+
Also present in the proto: a `ConnectionResolutionType` enum with at least `RESTART_LS` (ties `ResolveConnection` to restarting the language server).
|
|
193
|
+
|
|
194
|
+
Read the two tables together and the split is obvious: the editor is an IDE capability server (files, diffs, notifications, storage, clipboard, editor state) plus an event sink, and the webapp owns all agent state. `TriggerSend` is the closest thing to "programmatically send a chat message" from the editor side, and `SendCommand`/`ListCommands` expose a slash-command-like surface to the webapp.
|
|
195
|
+
|
|
196
|
+
### 2.6 Auth flow
|
|
197
|
+
|
|
198
|
+
1. Hub starts unauthenticated; when OAuth is needed it writes `ANTIGRAVITY_OPEN_URL:<url>` to stdout.
|
|
199
|
+
2. The extension opens that URL externally (`vscode.env.openExternal`).
|
|
200
|
+
3. `ANTIGRAVITY_AUTH_SUCCESS_APP` (set to the editor URI scheme, e.g. `vscode`) lets the OAuth landing page deep-link back into the editor after success.
|
|
201
|
+
4. Credentials land in `~/.gemini/oauth_creds.json` (mode 600 on this machine) alongside `google_accounts.json`; `~/.gemini/installation_id` and `state.json` hold install identity.
|
|
202
|
+
|
|
203
|
+
### 2.7 Extension manifest surface (package.json)
|
|
204
|
+
|
|
205
|
+
- Activation: `onStartupFinished` and `onCustomEditor:antigravity.artifactEditor`.
|
|
206
|
+
- Custom editors: `antigravity.artifactEditor` claims filename patterns `antigravity:/**/*.md` and `**/.gemini/*/brain/**/*.md` (agent artifacts and the agent "brain" directory get a rich viewer instead of plain markdown); `jetski.settingsEditor` claims `jetski-settings://**` (internal codename "jetski" = Antigravity).
|
|
207
|
+
- Views: single webview `antigravity.panel` in activity-bar container `antigravity-sidebar`.
|
|
208
|
+
- Commands: `showThirdPartyNotices`, `resetConversationState`, `insertSnippet` (also `insertTerminalSnippet`, bound not declared), `panel.focus`, `inlineDiff.acceptAll`, `inlineDiff.rejectAll`, `toggleInlineDiff`, `startNewConversation`, `toggleChatFocus`, `dynamic.acceptAgentStep`, `dynamic.rejectAgentStep`, `dynamic.interruptAgent`.
|
|
209
|
+
- Keybindings: `cmd+l` / `ctrl+l` context-dependent (editor selection to chat, terminal selection to chat, focus chat, close chat), `cmd+shift+l` new conversation, `alt+enter` accept agent step, `alt+shift+enter` reject, `escape` interrupt while agent running and panel focused.
|
|
210
|
+
- Declared configuration: `antigravity.enableTelemetry` (default true, telemetry goes to "Google Cloudmill"), `antigravity.enableInlineDiff` (default true, inline decorations vs side-by-side diff tab), `antigravity.channel` (default `production`, deprecationMessage "Internal channel setting").
|
|
211
|
+
- Undocumented configuration: `antigravity.serverArgs`, an array of extra CLI args appended to the hub spawn. Read by the server manager, never declared in `contributes.configuration`. Useful for enabling experimental hub flags without code changes.
|
|
212
|
+
- `buildInfo` in the manifest records the internal build system (SrcFS, depot path `//depot/...`).
|
|
213
|
+
|
|
214
|
+
## 3. Mechanism B: Agent Client Protocol (Zed and others)
|
|
215
|
+
|
|
216
|
+
### 3.1 Registry entry
|
|
217
|
+
|
|
218
|
+
Zed ships a local cache of the ACP agent registry at `~/Library/Application Support/Zed/external_agents/registry/registry.json` (upstream: `https://cdn.agentclientprotocol.com/registry/v1/latest/...`). The `antigravity-acp` entry, version 1.0.0, published with release artifacts dated 2026-08-18:
|
|
219
|
+
|
|
220
|
+
```json
|
|
221
|
+
{
|
|
222
|
+
"id": "antigravity-acp",
|
|
223
|
+
"name": "Google Antigravity",
|
|
224
|
+
"version": "1.0.0",
|
|
225
|
+
"description": "Google's AI coding agent",
|
|
226
|
+
"website": "https://antigravity.google/docs/ide/extensions",
|
|
227
|
+
"authors": ["Google LLC"],
|
|
228
|
+
"license": "proprietary",
|
|
229
|
+
"distribution": {
|
|
230
|
+
"binary": {
|
|
231
|
+
"darwin-aarch64": {
|
|
232
|
+
"archive": "https://dl.google.com/agy-extensions/releases/macos/agy-acp-server-agy_acp_server_20260818_01_RC01-darwin-arm64.zip",
|
|
233
|
+
"cmd": "./agy_acp_server.par"
|
|
234
|
+
},
|
|
235
|
+
"linux-x86_64": {
|
|
236
|
+
"archive": "https://dl.google.com/agy-extensions/releases/linux/agy-acp-server-agy_acp_server_20260818_01_RC01-linux-x86_64.zip",
|
|
237
|
+
"cmd": "./agy_acp_server.par",
|
|
238
|
+
"args": ["--uid="]
|
|
239
|
+
},
|
|
240
|
+
"linux-aarch64": {
|
|
241
|
+
"archive": "https://dl.google.com/agy-extensions/releases/linux/agy-acp-server-agy_acp_server_20260818_01_RC01-linux-arm64.zip",
|
|
242
|
+
"cmd": "./agy_acp_server.par",
|
|
243
|
+
"args": ["--uid="]
|
|
244
|
+
},
|
|
245
|
+
"windows-x86_64": {
|
|
246
|
+
"archive": "https://dl.google.com/agy-extensions/releases/windows/agy-acp-server-agy_acp_server_20260818_01_RC01-windows-x86_64.zip",
|
|
247
|
+
"cmd": "./agy_acp_server.exe"
|
|
248
|
+
},
|
|
249
|
+
"windows-aarch64": {
|
|
250
|
+
"archive": "https://dl.google.com/agy-extensions/releases/windows/agy-acp-server-agy_acp_server_20260818_01_RC01-windows-arm64.zip",
|
|
251
|
+
"cmd": "./agy_acp_server.exe"
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Note the Linux-only `--uid=` argument: Zed appends the real uid at spawn time, presumably because a sandboxed environment can't provide one.
|
|
259
|
+
|
|
260
|
+
### 3.2 Payload
|
|
261
|
+
|
|
262
|
+
The darwin-arm64 zip is 299.9 MB compressed and contains two files:
|
|
263
|
+
|
|
264
|
+
- `agy_acp_server.par`, 792,105,680 bytes uncompressed: a Mach-O arm64 executable despite the `.par` extension. It is a self-contained Google-style Python archive (log lines reference `main.py:80`, `settings.py:300`). 755 MB on disk.
|
|
265
|
+
- `localharness_external`, 101,551,680 bytes: Mach-O arm64. Name suggests a bundled local test harness; purpose not verified.
|
|
266
|
+
|
|
267
|
+
`agy_acp_server.par` is codesigned `Developer ID Application: Google LLC (EQHXZ8M8AV)`, identifier `agy_acp_server`.
|
|
268
|
+
|
|
269
|
+
### 3.3 Verified handshake (live test)
|
|
270
|
+
|
|
271
|
+
I spawned `./agy_acp_server.par` and sent one JSON-RPC line over stdin:
|
|
272
|
+
|
|
273
|
+
```
|
|
274
|
+
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{"fs":{"readTextFile":true,"writeTextFile":true}}}}
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Response on stdout:
|
|
278
|
+
|
|
279
|
+
```json
|
|
280
|
+
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":true,"audio":true,"embeddedContext":true},"mcpCapabilities":{"http":true,"sse":true},"sessionCapabilities":{"list":{},"resume":{}},"auth":{"logout":{}}},"authMethods":[{"description":"Log in with your Google account","id":"oauth-personal","name":"Log in with Google"},{"description":"Log in with your Gemini Enterprise account","id":"oauth-business","name":"Log in with Gemini Enterprise"},{"description":"Use an API key with Gemini Developer API","id":"gemini-api-key","name":"Gemini API key"},{"description":"Use Gemini Enterprise Agent Platform (formerly Vertex AI) with Application Default Credentials or an API key","id":"agent-platform","name":"Gemini Enterprise Agent Platform"}],"agentInfo":{"name":"antigravity-acp","title":"Google Antigravity","version":"agy_acp_server_20260818_01_RC01"}}}
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
Stderr during the run:
|
|
284
|
+
|
|
285
|
+
```
|
|
286
|
+
I0821 19:24:52.714127 ... main.py:80] Starting AGY ACP Server...
|
|
287
|
+
I0821 19:24:52.714230 ... main.py:81] Gemini home resolved to /Users/esteban/.gemini (default; $GEMINI_HOME is unset)
|
|
288
|
+
I0821 19:24:52.714300 ... settings.py:300] settings: path=/Users/esteban/.gemini/antigravity-acp/settings.json status=missing
|
|
289
|
+
I0821 19:24:52.748428 ... main.py:98] Shutting down AGY ACP Server...
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
Capability decoding:
|
|
293
|
+
|
|
294
|
+
- `loadSession: true`: clients can resume existing agent sessions.
|
|
295
|
+
- `promptCapabilities`: `image`, `audio`, `embeddedContext` all supported in prompts (multimodal input).
|
|
296
|
+
- `mcpCapabilities`: `http` and `sse` MCP servers supported.
|
|
297
|
+
- `sessionCapabilities`: `list` and `resume`, so session enumeration and resumption are first-class.
|
|
298
|
+
- `auth`: `logout` method exists; four auth methods: `oauth-personal` (Google account), `oauth-business` (Gemini Enterprise), `gemini-api-key` (Gemini Developer API key), `agent-platform` (Gemini Enterprise Agent Platform, formerly Vertex AI, via ADC or API key).
|
|
299
|
+
|
|
300
|
+
### 3.4 ACP method surface
|
|
301
|
+
|
|
302
|
+
Strings in the binary confirm the standard ACP v1 method set: `session/new` (54 hits), `session/prompt`, `session/load`, `session/update`, `session/set_mode`, `session/cancel`, plus `newSession` and `agentclientprotocol` (449 hits). This is the Zed ACP spec (agentclientprotocol.com), JSON-RPC 2.0 over stdio with `session/update` notifications for streaming agent progress.
|
|
303
|
+
|
|
304
|
+
What this means concretely: any client that implements ACP (Zed today, any custom harness tomorrow) gets a bidirectional structured channel to Antigravity. Create sessions, prompt with images, audio, embedded context, receive streaming updates, switch modes, cancel, list and resume sessions, manage MCP servers. No browser, no webapp, no SQLite scraping.
|
|
305
|
+
|
|
306
|
+
### 3.5 Runtime notes
|
|
307
|
+
|
|
308
|
+
- State lives under the shared Gemini home: `~/.gemini/antigravity-acp/settings.json`, conversations under `~/.gemini/antigravity-acp/conversations/` (created on first session).
|
|
309
|
+
- `$GEMINI_HOME` overrides the root, which is how you isolate a bridge instance from your desktop installs.
|
|
310
|
+
- The server is Python inside a frozen archive (pex-style; the .par carries readable source under `google3/`), so startup is fast enough for per-session spawns but not free; `sessionCapabilities.resume` plus a long-lived process is the sensible pattern for a bridge.
|
|
311
|
+
- The zip has no sha256 pins in the registry entry (unlike some other agents in the same registry; amp-acp pins sha256 per platform). Trust rests on dl.google.com transport plus the Apple code signature.
|
|
312
|
+
|
|
313
|
+
### 3.6 Additional live findings (second test round, 2026-08-21)
|
|
314
|
+
|
|
315
|
+
A second test round closed what the first round left open, and added some warnings.
|
|
316
|
+
|
|
317
|
+
- `session/new` requires authentication. Unauthenticated call returns a descriptive error: `Authentication required ... Either call the authenticate method (supports oauth-personal, gemini-api-key, agent-platform), or set auth.type in settings.json (~/.gemini/antigravity-acp/settings.json) to one of: oauth-personal, gemini-api-key (requires GEMINI_API_KEY env var), oauth-business (Gemini Enterprise; requires gcp.project/location), agent-platform (formerly 'vertex-ai', still accepted; requires GOOGLE_API_KEY, or a project and location from GOOGLE_CLOUD_PROJECT/...)`.
|
|
318
|
+
- Critical consequence: the ACP server ignores the desktop OAuth credentials at `~/.gemini/oauth_creds.json`. Its auth is separate, persisted under `~/.gemini/antigravity-acp/settings.json`.
|
|
319
|
+
- A stderr warning states: `Environment-based auth selection has been removed. AGY_ACP_ENABLE_OAUTH and a bare GEMINI_API_KEY no l[onger ...]`. Env-var auth was deliberately removed; settings.json or the `authenticate` method are the only paths.
|
|
320
|
+
- `session/list` works unauthenticated and returns `{"sessions":[]}` on a fresh install.
|
|
321
|
+
- Method naming: the wire method is `session/new` (snake); a `newSession` (camelCase) request yields `Method not found`. Internal handler is `new_session` in `google3/cloud/developer_experience/antigravity_extensions/acp_server/server.py` (~line 2727), auth gate `_assert_authenticated` (~line 2607), per traceback leaked on stderr.
|
|
322
|
+
- The server bundles the open-source ACP Python library from `google3/third_party/py/acp` (router/connection/task plumbing visible in tracebacks).
|
|
323
|
+
- `localharness_external` presence changes startup behavior; without it, stderr logs `Localharness not found.` (non-fatal). With it present, one run hung >30s at `initialize`.
|
|
324
|
+
- Startup reliability was nondeterministic across four runs: 2 of 4 hung at `initialize` indefinitely (with and without localharness), fixed only by killing and respawning. Treat this build (RC01) as experimental.
|
|
325
|
+
- Process shutdown: SIGTERM was ignored in tests; SIGKILL was required.
|
|
326
|
+
|
|
327
|
+
## 4. Hidden: `agy agentapi`
|
|
328
|
+
|
|
329
|
+
`agy agentapi --help` (works on 1.1.17) prints:
|
|
330
|
+
|
|
331
|
+
```
|
|
332
|
+
Usage: agentapi <command> [args]
|
|
333
|
+
|
|
334
|
+
Available Commands:
|
|
335
|
+
get-conversation-metadata <conversation_id>
|
|
336
|
+
new-conversation [--model=<flash_lite|flash|pro>] [--title=<title>] [--profile=<profile>] <prompt>
|
|
337
|
+
send-message [--title=<title>] <recipient_id> <content>
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
Behavior: `agentapi` is an HTTP client for a running Antigravity IDE instance. Without configuration it fails with `{"error": "ANTIGRAVITY_LS_ADDRESS is not set"}`. The `ANTIGRAVITY_LS_ADDRESS` environment variable must point at the live IDE language server (the `language_server` binary from `/Applications/Antigravity.app`). The `~/.gemini/antigravity-cli/bin/agentapi` shim exists so other tools (including agent-side scripts) can call it with a stable path.
|
|
341
|
+
|
|
342
|
+
This is the IDE-attach surface: create conversations in the running IDE, send messages into them, read metadata. Model selectors `flash_lite|flash|pro` confirm the current Antigravity model tiers. Recipient IDs for `send-message` presumably address agents or subagents inside a conversation (unverified beyond the help text).
|
|
343
|
+
|
|
344
|
+
## 5. Hub WebSocket protocol status
|
|
345
|
+
|
|
346
|
+
Known: the webapp served by `agy --hub` connects back over WebSocket (`useWebSocket=true`) and that socket carries the agent control plane. Unknown: the message schema, the auth handshake on the socket, and any compatibility guarantee. Nothing in the VSIX exposes it; the schema lives in the webapp bundle served by the hub and in the hub binary itself. Treat it as internal and unstable. Anyone needing programmatic control should use section 3 instead.
|
|
347
|
+
|
|
348
|
+
## 6. Security observations
|
|
349
|
+
|
|
350
|
+
- All hub and ACP traffic is loopback-only (`127.0.0.1`, ephemeral port, no TLS; the CSP allows plain `http://localhost:*` / `ws://localhost:*`).
|
|
351
|
+
- The webview CSP `connect-src ... https:` is broad: the embedded webapp may call any HTTPS origin from inside the editor.
|
|
352
|
+
- OAuth tokens sit in `~/.gemini/oauth_creds.json` with mode 600; multiple Google surfaces (CLI, IDE, extension, ACP server) share the Gemini home, so one compromise exposes all.
|
|
353
|
+
- The postMessage bridge uses `targetOrigin "*"` from inside the iframe; risk is contained by the sandbox attributes on the iframe (`allow-scripts allow-same-origin allow-popups allow-forms allow-popups-to-escape-sandbox`) but the pattern is permissive by design.
|
|
354
|
+
- Binaries carry Google LLC signatures (EQHXZ8M8AV) with the hardened runtime flag (0x10000).
|
|
355
|
+
- The extension auto-downloads and executes binaries from a Cloud Run URL gated only by that service's availability; channel `dogfood` switches to a public GCS bucket. No signature pinning observed in the downloader; hash verification exists only when the manifest supplies hashes.
|
|
356
|
+
|
|
357
|
+
## 7. Impact on this project (pi-antigravity-bridge)
|
|
358
|
+
|
|
359
|
+
This ranking predates the adversarial review; section 9 supersedes it where they disagree.
|
|
360
|
+
|
|
361
|
+
The bridge currently spawns `agy` print-mode and scrapes SQLite WAL step data with a hand-rolled protobuf decoder. The findings above give three surfaces, ranked:
|
|
362
|
+
|
|
363
|
+
1. **ACP server (recommended)**: `agy_acp_server` is the sanctioned programmatic entry point. JSON-RPC over stdio, streaming `session/update` notifications replace WAL polling, `loadSession`/`list`/`resume` replace the session watermark logic, multimodal prompts and MCP support come for free. Costs: 755 MB binary, download from `dl.google.com/agy-extensions/`, no sha256 pin in the registry. Auth flows through ACP `authenticate` with the four methods from 3.3. The existing provider architecture maps cleanly: pi request -> `session/new` + `session/prompt`, streaming events -> `session/update`, pi tool calls stay local to pi (the ACP agent runs its own tools, same model as today).
|
|
364
|
+
2. **`agy agentapi`**: for driving a live IDE Antigravity instance (workspace sessions the human can watch). Thin surface (three commands) but zero reverse engineering needed. Requires the IDE running and `ANTIGRAVITY_LS_ADDRESS` discovery.
|
|
365
|
+
3. **`agy --hub` + iframe protocol**: richest surface, but the WebSocket schema is internal and the editor bridge only makes sense inside a real webview host. Not worth it for a headless bridge. The one salvageable trick is `ANTIGRAVITY_OPEN_URL:` stdout handling as an auth pattern reference.
|
|
366
|
+
|
|
367
|
+
For session continuity work: ACP `sessionCapabilities.list/resume` removes the need for the bridge's own persisted session map on the agy side, though pi-session to ACP-session mapping would still be needed.
|
|
368
|
+
|
|
369
|
+
## 8. Reproducing these findings
|
|
370
|
+
|
|
371
|
+
```
|
|
372
|
+
# Extension bundle analysis (all offsets refer to extension.js unless noted)
|
|
373
|
+
rg -o 'ws://localhost[^"]{0,80}' extension.js # CSP entries
|
|
374
|
+
rg -o '.{150}http://127\.0\.0\.1.{150}' extension.js # backendUrl construction
|
|
375
|
+
python3 - <<'PY' # spawn spec + env around serverProcess
|
|
376
|
+
data=open('extension.js',encoding='utf-8',errors='replace').read()
|
|
377
|
+
i=data.find('this.serverProcess ='); print(data[i-3000:i+500])
|
|
378
|
+
PY
|
|
379
|
+
python3 - <<'PY' # extract embedded FileDescriptorProto from bridge.js
|
|
380
|
+
import re,base64
|
|
381
|
+
b=open('bridge.js',encoding='utf-8',errors='replace').read()
|
|
382
|
+
m=re.search(r'file_third_party_gemini_coder_proto_iframe_messages\s*=\s*[^;]*?([A-Za-z0-9+/=]{200,})',b)
|
|
383
|
+
open('/tmp/iframe_messages.fd','wb').write(base64.b64decode(m.group(1)))
|
|
384
|
+
PY
|
|
385
|
+
protoc --decode=google.protobuf.FileDescriptorProto google/protobuf/descriptor.proto < /tmp/iframe_messages.fd
|
|
386
|
+
|
|
387
|
+
# Binary identity
|
|
388
|
+
codesign -d -vv ~/.local/bin/agy
|
|
389
|
+
codesign -d -vv /Applications/Antigravity.app/Contents/Resources/bin/language_server
|
|
390
|
+
|
|
391
|
+
# ACP server live test
|
|
392
|
+
curl -sL -o agy-acp.zip 'https://dl.google.com/agy-extensions/releases/macos/agy-acp-server-agy_acp_server_20260818_01_RC01-darwin-arm64.zip'
|
|
393
|
+
unzip agy-acp.zip
|
|
394
|
+
( printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{"fs":{"readTextFile":true,"writeTextFile":true}}}}'; sleep 6 ) | ./agy_acp_server.par
|
|
395
|
+
|
|
396
|
+
# Hidden subcommands
|
|
397
|
+
agy agentapi --help
|
|
398
|
+
strings ~/.local/bin/agy | grep -E 'AGY_ENABLE_HUB|hub-port'
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
## Appendix: Gemini home layout observed on this machine
|
|
402
|
+
|
|
403
|
+
```
|
|
404
|
+
~/.gemini/
|
|
405
|
+
agy (not present yet on this host; VSIX installs here)
|
|
406
|
+
antigravity/ agent platform app data
|
|
407
|
+
antigravity-backup/
|
|
408
|
+
antigravity-cli/ bin/agentapi (shim), bin/webm_encoder, brain/, builtin/, cache/, log/, conversation_summaries.db
|
|
409
|
+
antigravity-ide/ IDE data
|
|
410
|
+
config/ includes memory.txtpb (agent memory)
|
|
411
|
+
history/ prompts/ skills/ tmp/
|
|
412
|
+
google_accounts.json oauth_creds.json projects.json settings.json state.json installation_id trustedFolders.json
|
|
413
|
+
GEMINI.md -> /Users/esteban/Dev/EstebanForge/AGENTS/AGENTS.md
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
## 9. Integration plan: ACP transport for this bridge
|
|
417
|
+
|
|
418
|
+
Status: proposed. Reviewed adversarially by an isolated reviewer with repo access on 2026-08-21; verdict was "proceed with changes". This section is the binding plan. Where it disagrees with section 7, this section wins.
|
|
419
|
+
|
|
420
|
+
### 9.1 Goal and framing
|
|
421
|
+
|
|
422
|
+
Replace the CLI print-mode transport (spawn `agy -p`, poll SQLite WAL, decode protobuf steps) with the official ACP server for pi turns, behind a flag, without weakening the working CLI path until Google ships a non-RC ACP build.
|
|
423
|
+
|
|
424
|
+
On framing: the obvious pitch, "replaces polling", is the wrong one. The poller is decent code (`PRAGMA data_version` gating, torn-read tolerance). The defects ACP actually fixes:
|
|
425
|
+
|
|
426
|
+
1. Conversation binding is unreliable on darwin: `procTreeOpenDbResolver` (src/discovery.ts) returns null on non-Linux, so a concurrent agy process can fail a turn with "could not be bound" (src/provider.ts).
|
|
427
|
+
2. Protobuf step field numbers are load-bearing and unversioned; every agy release can silently break the decoder (src/protobuf.ts, src/runner.ts).
|
|
428
|
+
3. No cancellation semantics beyond process kill.
|
|
429
|
+
4. No image input path today.
|
|
430
|
+
5. Blanket `--dangerously-skip-permissions` instead of per-action permission control.
|
|
431
|
+
|
|
432
|
+
### 9.2 Gating condition (Phase 0 exit criteria)
|
|
433
|
+
|
|
434
|
+
No bridge code gets written until Phase 0 answers all of these from primary sources. The .par contains readable Python source; read it, do not guess:
|
|
435
|
+
|
|
436
|
+
- [ ] Model selection: does `session/new` (or `session/set_mode`) accept a model parameter? Read `new_session` in `google3/cloud/developer_experience/antigravity_extensions/acp_server/server.py` (~line 2727) and the settings model.
|
|
437
|
+
- [ ] Model catalog: what models does the ACP server serve? Same set as `agy models` (incl. claude-*, gpt-oss-*) or a narrower Gemini-only set?
|
|
438
|
+
- [ ] Billing identity: does the Antigravity subscription cover ACP sessions under `oauth-personal`, or do some auth methods bill a different quota (Gemini API key, Vertex)? The four auth methods imply different billing paths.
|
|
439
|
+
- [ ] Streaming fidelity: capture real `session/update` frames from one authenticated prompt. Confirm tool_call/tool_call_update granularity, diff content blocks, usage/token counts, thinking blocks.
|
|
440
|
+
- [ ] Concurrency: do concurrent `session/prompt` calls on one connection serialize (head-of-line blocking)? Measure RSS of the server plus harness.
|
|
441
|
+
- [ ] Cancellation: does `session/cancel` actually cancel on the RC build? Measure initialize hang rate over 20 cold runs.
|
|
442
|
+
- [ ] Session resume + MCP: does `session/load` accept `mcpServers`? If not, resumed sessions would bind a dead bridge port (ports are ephemeral per process).
|
|
443
|
+
- [ ] MCP transport: is `mcpCapabilities.http` Streamable HTTP, and does it accept custom headers (the bridge's token header)?
|
|
444
|
+
|
|
445
|
+
Kill criteria: if model selection is impossible AND the catalog is narrower than `agy models`, ACP is a downgrade for this bridge regardless of protocol cleanliness. Stay on CLI and revisit when Google ships a non-RC build with session-scoped model selection.
|
|
446
|
+
|
|
447
|
+
### 9.3 Least-bad model-selection fallbacks (ranked, if no session param exists)
|
|
448
|
+
|
|
449
|
+
1. Per-session `settings.json` model override under a per-pi-process isolated `GEMINI_HOME`, written before `session/new`, serialized by a lock; model switch = new session. Acceptable only if the source shows the model is read per session, not at process start.
|
|
450
|
+
2. Honest degradation: keep CLI as default; under the flag collapse the catalog to a single entry (`antigravity/acp-default`) so the model picker never lies.
|
|
451
|
+
3. Per-model server processes: rejected (footprint multiplied by N, auth multiplied by N).
|
|
452
|
+
|
|
453
|
+
The default transport does not flip until model selection is session-scoped. Advertising `antigravity/*` slugs that ACP silently ignores is the one outcome worse than staying on the CLI.
|
|
454
|
+
|
|
455
|
+
### 9.4 Phase plan (revised after review)
|
|
456
|
+
|
|
457
|
+
**Phase 0, verify-before-build (no bridge code):**
|
|
458
|
+
|
|
459
|
+
- Unzip the `.par` (pex; readable source under `google3/`), read `new_session`, `set_mode`, settings, and auth code paths.
|
|
460
|
+
- Run one authenticated prompt (one-time `authenticate` with `oauth-personal`, browser roundtrip), capture `session/update` frames to a fixture file under `tests/fixtures/`.
|
|
461
|
+
- Run the kill-criteria checklist in 9.2. Record results in this doc.
|
|
462
|
+
- Decide the AskAntigravity question (see Phase 3) now, not later.
|
|
463
|
+
|
|
464
|
+
**Phase 1, transport behind a flag:**
|
|
465
|
+
|
|
466
|
+
- New `src/acp/client.ts`: process lifecycle, JSON-RPC stdio framing, initialize/authenticate handshake, first-turn initialize timeout with kill+retry (RC hang observed 2 of 4 runs).
|
|
467
|
+
- New `src/acp/acquire.ts`: download and cache the zip (~950 MB unpacked with `localharness_external`). Requirements: explicit user consent before first download, sha256 pinned by this repo (the registry and dl.google.com provide none), version pin with an update check, install under a bridge-owned directory. Never execute an unpinned download.
|
|
468
|
+
- Rewrite `src/runner.ts` internals to emit the same event stream from `session/update`; keep the public options/result shape.
|
|
469
|
+
- Widen the seam in the same phase, not later: extend the event union with `tool_update` (status, locations), `diff` (native old/new content; skip git reconstruction for these), `usage` (token counts, retire `zeroUsage()` fallback when present), and `mode`. Keeping only today's four event kinds would force Phase 4 rework.
|
|
470
|
+
- `src/sessions.ts`: store ACP session id; drop `lastStepIdx` (native resume); keep `lastMessageCount` (digest watermark, independent of steps); add a transport tag per record so a post-flip `get()` never feeds an agy conversation UUID to `session/load` (silent-wrong-resume risk). `narrowStoreMap` must validate the tag.
|
|
471
|
+
- `src/provider.ts`: unchanged apart from event-union expansion and digest watermark continuation.
|
|
472
|
+
- Cancellation: per-prompt deadline that (a) finalizes pi's stream, (b) marks the ACP session dead in the store, (c) kills and respawns the server (a watchdog kill nukes every concurrent session on that process; document this trade-off). `session/cancel` first, kill as backstop, not the reverse.
|
|
473
|
+
- Flag: `PI_AGY_TRANSPORT=acp` (env), CLI default unchanged.
|
|
474
|
+
|
|
475
|
+
**Phase 2, MCP discovery hop only (shrunk after review):**
|
|
476
|
+
|
|
477
|
+
- The pi dist patch (src/patcher.ts, docs/PI-INVOKETOOL-PATCH.md) does not retire. It supplies `pi.invokeTool` inside pi; `startMcpServer` hard-gates on it. ACP `mcpServers` replaces only the discovery hop: the `--add-dir` bridge config dir and `mcp_config.json` wiring. Delete that plumbing only.
|
|
478
|
+
- Register the existing bridge MCP server through `newSession mcpServers` (transport per the 9.2 checklist; fall back to keeping agy-side config if http-with-headers is unsupported).
|
|
479
|
+
- Permission handling: auto-approve only. Interactive forwarding of `session/request_permission` to pi's `ask_user_question` routes through `pi.invokeTool`, the very seam Phase 2 cannot remove; do not promise interactive permissions until pi exposes a provider UI channel.
|
|
480
|
+
|
|
481
|
+
**Phase 3, default flip and deletions (blocked on two decisions):**
|
|
482
|
+
|
|
483
|
+
- Blocked by: (a) AskAntigravity (src/ask-tool.ts) spawns `agy -p` directly and imports conversation-dir helpers from src/discovery.ts; it must either migrate to its own ACP session (second concurrent session on the shared server, see 9.2 concurrency) or the CLI path stays and deletions shrink accordingly; (b) model selection session-scoped (9.3).
|
|
484
|
+
- After unblocking: flip default to ACP, delete `src/poller.ts` and `src/protobuf.ts`, and delete `src/discovery.ts` only if ask-tool no longer imports it. Own zero or one legacy transport accordingly.
|
|
485
|
+
|
|
486
|
+
**Auth home (cross-phase requirement):**
|
|
487
|
+
|
|
488
|
+
- Add an explicit `/agy auth` command (extension entry, where `ctx.ui` exists) that runs the one-time `authenticate` flow before the first turn. `streamSimple` has no UI channel; the provider must fail with a clear "run /agy auth" error when unauthenticated.
|
|
489
|
+
- Headless/CI pi: only via `auth.type: gemini-api-key` in settings, which changes billing. Document this, do not hide it.
|
|
490
|
+
- Long-lived process: map auth-expiry errors to a reauth instruction; verify token refresh behavior in Phase 0.
|
|
491
|
+
- Isolate: run the ACP server under a bridge-owned `GEMINI_HOME` so bridge state never collides with desktop installs.
|
|
492
|
+
|
|
493
|
+
### 9.5 What does not change
|
|
494
|
+
|
|
495
|
+
- `src/models.ts` catalog discovery stays on `agy models` until 9.2 answers the catalog question.
|
|
496
|
+
- Turn digest (delta of pi-side context agy was not spawned for) stays; ACP sessions hold agy-side context exactly like conversations today.
|
|
497
|
+
- `src/diff-render.ts` stays (still needed for edit tools that do not carry native diff blocks).
|
|
498
|
+
- CLI print-mode transport stays as default and fallback until the Phase 3 conditions hold. This is time-boxed risk management on an RC build, not permanent compatibility cruft.
|
|
499
|
+
|
|
500
|
+
### 9.6 Known risks accepted
|
|
501
|
+
|
|
502
|
+
- RC build quality: initialize hangs (2 of 4 runs), SIGTERM ignored (SIGKILL needed), env auth removed mid-release cycle. Every watchdog and consent gate in this plan exists because of observed behavior, not speculation.
|
|
503
|
+
- 755 MB server + 100 MB harness footprint per pi process; measured, not assumed, in Phase 0.
|
|
504
|
+
- Zed's registry JSON is a local cache, not a contract; pin downloads to exact dl.google.com URLs with repo-computed hashes.
|
|
505
|
+
- No official stability guarantees on any of this until Google documents the ACP server beyond the Zed registry entry.
|
|
506
|
+
|
|
507
|
+
## 10. Trade-off analysis: ACP transport vs current CLI transport
|
|
508
|
+
|
|
509
|
+
Comparison of what the bridge gains and pays by moving pi turns from the CLI print-mode path (spawn `agy -p`, poll SQLite WAL, decode protobuf steps) to the ACP server. Every row is grounded in repo code or a live test; nothing speculative. The flag-and-fallback strategy in section 9 keeps this trade reversible at every phase.
|
|
510
|
+
|
|
511
|
+
### 10.1 What we win
|
|
512
|
+
|
|
513
|
+
| # | Gain under ACP | Cost today (CLI path) |
|
|
514
|
+
|---|---|---|
|
|
515
|
+
| 1 | Darwin conversation binding fixed: the session id comes from the protocol itself | `procTreeOpenDbResolver` (src/discovery.ts) returns null on non-Linux, so a concurrent agy process can fail a turn with "could not be bound" (src/provider.ts) |
|
|
516
|
+
| 2 | No dependence on unversioned protobuf internals: typed `session/update` frames under a versioned protocol | Decoder relies on magic step_type numbers (14/15/23 plus 9 tool types) and reverse-engineered field numbers; any agy release can silently break decoding (src/protobuf.ts, src/runner.ts) |
|
|
517
|
+
| 3 | Native diffs and usage: `tool_call_update` carries status, locations, old/new text, and likely token counts | Diffs are reconstructed from git after the fact (src/diff-render.ts); usage always falls back to `zeroUsage()` |
|
|
518
|
+
| 4 | Image and audio input (`promptCapabilities.image/audio/embeddedContext`) | Text-only prompts |
|
|
519
|
+
| 5 | Native session list and resume (`sessionCapabilities.list/resume`) | Bridge owns conversationId + step watermark + re-poll skip logic (src/sessions.ts) |
|
|
520
|
+
| 6 | Per-action permission requests (auto-approve mode at minimum) | Blanket `--dangerously-skip-permissions` |
|
|
521
|
+
| 7 | Streaming without the 250ms poll loop: no torn-read tolerance, no `data_version` coalescing | Modest cost only; the poller (src/poller.ts) is decent code |
|
|
522
|
+
| 8 | MCP registration through `newSession mcpServers`, no config-dir hack | `--add-dir` + per-conversation `mcp_config.json` wiring |
|
|
523
|
+
| 9 | Official protocol trajectory: public spec (agentclientprotocol.com), consumed by Zed, maintained by Google | The DB schema reverse engineering has no maintainer but this project |
|
|
524
|
+
|
|
525
|
+
### 10.2 What we pay
|
|
526
|
+
|
|
527
|
+
1. Footprint: 950 MB (755 MB server + 100 MB harness) versus the ~30 MB CLI; possibly per pi process.
|
|
528
|
+
2. Separate auth: one-time browser flow; headless pi needs `gemini-api-key`, a different billing path. The CLI today transparently reuses the desktop login.
|
|
529
|
+
3. RC stability tax: hangs at `initialize` (2 of 4 observed runs), SIGTERM ignored, env-based auth removed mid-cycle. Every watchdog in section 9 exists because of observed behavior, not speculation.
|
|
530
|
+
4. Process model inversion: one shared long-lived server versus free per-turn isolation; a hang-kill nukes every concurrent session on that process.
|
|
531
|
+
5. Download and pinning burden: no hashes published, RC-pinned URLs, Zed's registry is a cache not a contract; this repo owns consent, sha256 pinning, and updates.
|
|
532
|
+
6. Two transports to maintain during the transition, until the Phase 3 conditions hold.
|
|
533
|
+
7. Cancellation becomes negotiable: today abort is a guaranteed kill of a throwaway process; ACP relies on `session/cancel` on a build that ignored SIGTERM, so the deadline + mark-dead + respawn ladder from 9.4 is mandatory.
|
|
534
|
+
|
|
535
|
+
### 10.3 Incompatibilities (sharp edges)
|
|
536
|
+
|
|
537
|
+
1. Model selection: the gating risk. The CLI takes `--model`/`--effort` per turn; ACP v1 may pin the model per session. If so, mid-conversation model switches break (new session, history drop), and the effort tiers in src/models.ts have no known ACP analogue. Phase 0 (9.2) gates on this.
|
|
538
|
+
2. Catalog identity unverified: if the ACP server serves Gemini-only, the `antigravity/claude-*` and `gpt-oss-*` slugs die under the flag, and billing may move off the Antigravity subscription.
|
|
539
|
+
3. sessions.json schema: agy conversation UUIDs fed to `session/load` would fail or silently resume the wrong session. Requires the transport tag from 9.4; `lastStepIdx` becomes meaningless while `lastMessageCount` (digest watermark) stays.
|
|
540
|
+
4. Event union mismatch: ACP's richer blocks (tool_update, diff, usage, mode) do not fit today's four-kind `AgyEvent`; the seam must widen in Phase 1 or Phase 4 pays for it.
|
|
541
|
+
5. Conversation data location: ACP sessions live under `~/.gemini/antigravity-acp/conversations` (and under an isolated `GEMINI_HOME` if the bridge isolates). The `decode-db` script, the summaries DB, and any tooling that reads `antigravity-cli/conversations/*.db` goes blind to ACP sessions.
|
|
542
|
+
6. Permissions UX: interactive forwarding of `session/request_permission` to pi's question UI is unreachable from the provider path; auto-approve only until pi exposes a provider UI channel.
|
|
543
|
+
7. AskAntigravity divergence (src/ask-tool.ts): it spawns `agy -p` itself; either it becomes a second concurrent ACP session or it anchors the CLI path alive (Phase 3 decision).
|
|
544
|
+
|
|
545
|
+
### 10.4 Net assessment
|
|
546
|
+
|
|
547
|
+
The trade in one paragraph: we exchange self-maintained reverse engineering (fragile against agy updates, broken binding on macOS, text-only) for an official protocol with real capabilities, and we pay in footprint, auth bootstrap, and RC babysitting until Google stabilizes the ACP server. The flag plus CLI fallback keeps the trade reversible at every phase.
|
|
548
|
+
|
|
549
|
+
References: extension marketplace page (https://marketplace.visualstudio.com/items?itemName=Google.google-antigravity), Antigravity extensions doc (https://antigravity.google/docs/ide/extensions), ACP spec and registry (https://agentclientprotocol.com, https://cdn.agentclientprotocol.com/registry/v1/latest/).
|
|
@@ -71,6 +71,10 @@ extension APIs reference. That indirection is why a single method must be wired
|
|
|
71
71
|
at **six** places: the implementation, the actions-bundle binding, the runner's
|
|
72
72
|
copy, the runner's delegating method, the facade method, and the type.
|
|
73
73
|
|
|
74
|
+
Since pi 0.84.3 there is a **seventh edit**, not part of the chain but the
|
|
75
|
+
switch that makes the chain reachable: the entry redirect in
|
|
76
|
+
`bundle/cli.js` (see Site 7).
|
|
77
|
+
|
|
74
78
|
## Where it goes (paths are relative to the pi package root)
|
|
75
79
|
|
|
76
80
|
pi ships **compiled** (`dist/`); there is no `src/` to edit. Find the package
|
|
@@ -81,7 +85,9 @@ node -e "console.log(require('path').dirname(require.resolve('@earendil-works/pi
|
|
|
81
85
|
# typical: ~/.npm-global/lib/node_modules/@earendil-works/pi-coding-agent
|
|
82
86
|
```
|
|
83
87
|
|
|
84
|
-
All paths below are under `<package>/dist/`. Developed against pi `^0.82.1
|
|
88
|
+
All paths below are under `<package>/dist/`. Developed against pi `^0.82.1`;
|
|
89
|
+
anchors verified against `0.84.3` (the `loader.js` facade switched to a local
|
|
90
|
+
`assertActive()` guard in 0.84.3).
|
|
85
91
|
|
|
86
92
|
### Site 1 — `core/agent-session.js`: the implementation on `AgentSession`
|
|
87
93
|
|
|
@@ -151,7 +157,7 @@ In the `api` object literal returned to extensions, immediately after the
|
|
|
151
157
|
|
|
152
158
|
```js
|
|
153
159
|
invokeTool(name, args, options) {
|
|
154
|
-
|
|
160
|
+
assertActive();
|
|
155
161
|
return runtime.invokeTool(name, args, options);
|
|
156
162
|
},
|
|
157
163
|
```
|
|
@@ -159,7 +165,6 @@ In the `api` object literal returned to extensions, immediately after the
|
|
|
159
165
|
### Site 6 — `core/extensions/types.d.ts`: the type declaration
|
|
160
166
|
|
|
161
167
|
On the `ExtensionAPI` interface, immediately after `getAllTools(): ToolInfo[];`:
|
|
162
|
-
|
|
163
168
|
```ts
|
|
164
169
|
/**
|
|
165
170
|
* LOCAL PATCH (pi-antigravity-bridge): invoke a registered tool by name
|
|
@@ -168,6 +173,27 @@ On the `ExtensionAPI` interface, immediately after `getAllTools(): ToolInfo[];`:
|
|
|
168
173
|
invokeTool(name: string, args?: Record<string, unknown>, options?: { toolCallId?: string; signal?: AbortSignal; onUpdate?: (update: unknown) => void }): Promise<{ content: unknown[]; details: unknown; isError?: boolean }>;
|
|
169
174
|
```
|
|
170
175
|
|
|
176
|
+
### Site 7 — `bundle/cli.js`: the entry redirect (pi 0.84.3+)
|
|
177
|
+
|
|
178
|
+
pi 0.84.3 points its `bin` at `dist/bundle/cli.js`, a bundled runtime with its
|
|
179
|
+
own embedded copy of the core. A process launched from the bundle never loads
|
|
180
|
+
`dist/core/*`, so the six sites above stay inert. The fix is a full-file
|
|
181
|
+
replacement of the tiny bundle entry with a shim that loads the still-shipped
|
|
182
|
+
modular `dist/cli.js`:
|
|
183
|
+
|
|
184
|
+
```js
|
|
185
|
+
#!/usr/bin/env node
|
|
186
|
+
// LOCAL PATCH (pi-antigravity-bridge): redirect pi's bundled entry to the
|
|
187
|
+
// modular runtime under dist/, where the invokeTool patch sites take effect.
|
|
188
|
+
// Restore via /agy patch restore. See docs/PI-INVOKETOOL-PATCH.md.
|
|
189
|
+
import "../cli.js";
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Tradeoff: pi starts through the modular runtime, giving up the bundle's faster
|
|
193
|
+
startup. The patcher validates before writing that the file really is the
|
|
194
|
+
bundled entry (it imports `chunks/`) and that `dist/cli.js` exists; on pre-bundle
|
|
195
|
+
pi the file is absent and this site is skipped entirely.
|
|
196
|
+
|
|
171
197
|
## How to apply
|
|
172
198
|
|
|
173
199
|
**You usually don't.** On first load, the extension asks you once whether to
|
|
@@ -175,10 +201,11 @@ apply the patch (`src/patcher.ts`); see the note at the top of this document.
|
|
|
175
201
|
The manual steps below are for reference, auditing, or recovering without the
|
|
176
202
|
extension loaded.
|
|
177
203
|
|
|
178
|
-
The patch is plain edits to the six compiled files above
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
204
|
+
The patch is plain edits to the six compiled files above (plus the entry
|
|
205
|
+
redirect on pi 0.84.3+). Because pi ships no `src/`, there is nothing to
|
|
206
|
+
recompile. (The old "clear jiti's cache" step is a
|
|
207
|
+
no-op on pi 0.82.1+ (including 0.84.3), which sets `moduleCache: false` in its
|
|
208
|
+
extension loader, so jiti never writes an fs cache.)
|
|
182
209
|
|
|
183
210
|
A `pi` reinstall or update overwrites `dist/` and **removes the patch**, but the
|
|
184
211
|
extension re-applies it on the next start (then prompts you to restart pi).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@estebanforge/pi-antigravity-bridge",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.5",
|
|
4
4
|
"description": "Streaming Gemini provider for pi, built on the agy CLI. Registers antigravity/* models in pi's /model picker via SQLite polling + protobuf decode of agy's conversation DBs.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -55,9 +55,9 @@
|
|
|
55
55
|
}
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
|
-
"@earendil-works/pi-ai": "^0.84.
|
|
59
|
-
"@earendil-works/pi-coding-agent": "^0.84.
|
|
60
|
-
"@earendil-works/pi-tui": "^0.84.
|
|
58
|
+
"@earendil-works/pi-ai": "^0.84.3",
|
|
59
|
+
"@earendil-works/pi-coding-agent": "^0.84.3",
|
|
60
|
+
"@earendil-works/pi-tui": "^0.84.3",
|
|
61
61
|
"@types/node": "^22.0.0",
|
|
62
62
|
"tsx": "^4.19.0",
|
|
63
63
|
"typebox": "^1.1.38",
|
package/src/ask-tool.ts
CHANGED
|
@@ -15,6 +15,9 @@ import { spawn } from "node:child_process";
|
|
|
15
15
|
import * as fs from "node:fs";
|
|
16
16
|
import * as path from "node:path";
|
|
17
17
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { buildSessionContext, getAgentDir, keyHint } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
20
|
+
import { contentText } from "@earendil-works/pi-ai";
|
|
18
21
|
import { Type } from "typebox";
|
|
19
22
|
import {
|
|
20
23
|
CONVERSATIONS_DIR,
|
|
@@ -30,6 +33,10 @@ const DEFAULT_TIMEOUT_MIN = 10;
|
|
|
30
33
|
const GRACE_AFTER_TIMEOUT_MS = 5000;
|
|
31
34
|
const STATUS_INTERVAL_MS = 1000;
|
|
32
35
|
const STATUS_TAIL_CHARS = 160;
|
|
36
|
+
|
|
37
|
+
// renderCall / renderResult preview limits (match pi-claude-bridge).
|
|
38
|
+
const PREVIEW_MAX_CHARS = 1000;
|
|
39
|
+
const PREVIEW_MAX_LINES = 6;
|
|
33
40
|
const DISCOVERY_POLL_ATTEMPTS = 5;
|
|
34
41
|
const DISCOVERY_POLL_MS = 100;
|
|
35
42
|
|
|
@@ -302,7 +309,78 @@ export async function registerAskAntigravityTool(
|
|
|
302
309
|
timeoutMinutes: Type.Optional(
|
|
303
310
|
Type.Number({ description: `Hard cap on the agy run in minutes. Default ${DEFAULT_TIMEOUT_MIN}.` }),
|
|
304
311
|
),
|
|
312
|
+
includeContext: Type.Optional(
|
|
313
|
+
Type.Boolean({
|
|
314
|
+
description:
|
|
315
|
+
"When true, export the current pi conversation (resolved, as markdown) to a temp file inside the workspace and tell agy to read it first. Default false (isolated one-shot). Opt in only when the user explicitly wants agy to see the full conversation; it costs agy tokens to read.",
|
|
316
|
+
}),
|
|
317
|
+
),
|
|
305
318
|
}),
|
|
319
|
+
renderCall(args, theme, _context) {
|
|
320
|
+
// Show RESOLVED model/thinking/mode (config defaults applied) so the
|
|
321
|
+
// row identifies what will actually run, not just explicit args.
|
|
322
|
+
const cfg = loadConfig();
|
|
323
|
+
const requestedModel = (args.model as string | undefined)?.trim() || cfg.defaultModel;
|
|
324
|
+
const resolved =
|
|
325
|
+
resolveModel(requestedModel, entries, cfg.defaultThinking) ?? { model: requestedModel };
|
|
326
|
+
const thinking: ThinkingTier = resolved.effort ?? cfg.defaultThinking;
|
|
327
|
+
const mode: AgyMode = (args.mode as AgyMode | undefined) ?? "accept-edits";
|
|
328
|
+
const useDigest = typeof args.digest === "boolean" ? args.digest : mode === "plan";
|
|
329
|
+
const isContinue =
|
|
330
|
+
typeof args.conversationId === "string" && CONV_ID_RE.test(args.conversationId);
|
|
331
|
+
|
|
332
|
+
const tags: string[] = [`model=${resolved.model}`, `thinking=${thinking}`];
|
|
333
|
+
if (mode !== "accept-edits") tags.push(`mode=${mode}`);
|
|
334
|
+
if (useDigest) tags.push("digest");
|
|
335
|
+
if (isContinue) tags.push("continue");
|
|
336
|
+
if (args.includeContext) tags.push("context=full");
|
|
337
|
+
|
|
338
|
+
let text = theme.fg("mdLink", theme.bold("AskAntigravity "));
|
|
339
|
+
text += `${theme.fg("accent", `[${tags.join(", ")}]`)} `;
|
|
340
|
+
|
|
341
|
+
const prompt = String(args.prompt ?? "");
|
|
342
|
+
const truncated = prompt.length > PREVIEW_MAX_CHARS ? prompt.slice(0, PREVIEW_MAX_CHARS) : prompt;
|
|
343
|
+
const lines = truncated.split("\n").slice(0, PREVIEW_MAX_LINES);
|
|
344
|
+
text += theme.fg("muted", `"${lines.join("\n")}"`);
|
|
345
|
+
if (prompt.length > PREVIEW_MAX_CHARS || prompt.split("\n").length > PREVIEW_MAX_LINES) {
|
|
346
|
+
text += theme.fg("dim", " …");
|
|
347
|
+
}
|
|
348
|
+
return new Text(text, 0, 0);
|
|
349
|
+
},
|
|
350
|
+
renderResult(result, { expanded, isPartial }, theme) {
|
|
351
|
+
const d = result.details as AgyDetails | undefined;
|
|
352
|
+
if (isPartial) {
|
|
353
|
+
const status = result.content[0]?.type === "text" ? result.content[0].text : "working...";
|
|
354
|
+
return new Text(theme.fg("mdLink", "◉ AskAntigravity ") + theme.fg("muted", status), 0, 0);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const body = result.content[0]?.type === "text" ? result.content[0].text : "";
|
|
358
|
+
const errored = d?.exitCode !== 0 || !!d?.aborted || !!d?.timedOut;
|
|
359
|
+
|
|
360
|
+
let text = errored
|
|
361
|
+
? theme.fg("error", "✗ AskAntigravity error")
|
|
362
|
+
: theme.fg("mdLink", "✓ AskAntigravity");
|
|
363
|
+
|
|
364
|
+
const rTags: string[] = [];
|
|
365
|
+
if (d?.resolvedModel || d?.model) rTags.push(`model=${d?.resolvedModel ?? d?.model}`);
|
|
366
|
+
if (d?.thinking) rTags.push(`thinking=${d.thinking}`);
|
|
367
|
+
if (d?.mode && d.mode !== "accept-edits") rTags.push(`mode=${d.mode}`);
|
|
368
|
+
if (d?.includeContext) rTags.push("context=full");
|
|
369
|
+
if (rTags.length) text += ` ${theme.fg("accent", `[${rTags.join(", ")}]`)}`;
|
|
370
|
+
if (d?.durationMs) text += ` ${theme.fg("dim", `${(d.durationMs / 1000).toFixed(1)}s`)}`;
|
|
371
|
+
|
|
372
|
+
if (expanded) {
|
|
373
|
+
if (body) text += `\n${theme.fg("toolOutput", body)}`;
|
|
374
|
+
} else {
|
|
375
|
+
const truncated = body.length > PREVIEW_MAX_CHARS ? body.slice(0, PREVIEW_MAX_CHARS) : body;
|
|
376
|
+
const lines = truncated.split("\n").slice(0, PREVIEW_MAX_LINES);
|
|
377
|
+
if (lines.length) text += `\n${theme.fg("toolOutput", lines.join("\n"))}`;
|
|
378
|
+
if (body.length > PREVIEW_MAX_CHARS || body.split("\n").length > PREVIEW_MAX_LINES) {
|
|
379
|
+
text += `\n${theme.fg("dim", `… (${keyHint("app.tools.expand", "to expand")})`)}`;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return new Text(text, 0, 0);
|
|
383
|
+
},
|
|
306
384
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
307
385
|
// Circular-delegation guard: refuse if already running through the
|
|
308
386
|
// antigravity provider.
|
|
@@ -367,6 +445,29 @@ export async function registerAskAntigravityTool(
|
|
|
367
445
|
? `(Use compact digests, not full file contents.)\n${params.prompt}`
|
|
368
446
|
: params.prompt;
|
|
369
447
|
|
|
448
|
+
// Opt-in full-context export (isolated stays the default).
|
|
449
|
+
let contextFile: string | null = null;
|
|
450
|
+
if (params.includeContext) {
|
|
451
|
+
try {
|
|
452
|
+
const { messages } = buildSessionContext(ctx.sessionManager.getBranch());
|
|
453
|
+
if (messages.length) {
|
|
454
|
+
const md = renderAgentMessagesMarkdown(messages);
|
|
455
|
+
const ctxDir = askContextDir();
|
|
456
|
+
fs.mkdirSync(ctxDir, { recursive: true });
|
|
457
|
+
contextFile = path.join(
|
|
458
|
+
ctxDir,
|
|
459
|
+
`.ask-context-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.md`,
|
|
460
|
+
);
|
|
461
|
+
fs.writeFileSync(contextFile, md, { mode: 0o600 });
|
|
462
|
+
}
|
|
463
|
+
} catch {
|
|
464
|
+
contextFile = null;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
const effectivePrompt = contextFile
|
|
468
|
+
? `The full pi conversation context (as markdown) is at: ${contextFile}\nRead that file first for context, then do the task below.\n\n---\n\n${finalPrompt}`
|
|
469
|
+
: finalPrompt;
|
|
470
|
+
|
|
370
471
|
const args: string[] = ["--add-dir", cwd];
|
|
371
472
|
const extra = extraArgs();
|
|
372
473
|
if (extra.length) args.push(...extra);
|
|
@@ -379,14 +480,17 @@ export async function registerAskAntigravityTool(
|
|
|
379
480
|
if (config.skipPermissions !== false) args.push("--dangerously-skip-permissions");
|
|
380
481
|
if (isContinuation) args.push("--conversation", rawConvId as string);
|
|
381
482
|
args.push("--print-timeout", `${timeoutMin}m`);
|
|
382
|
-
args.push("-
|
|
483
|
+
if (contextFile) args.push("--add-dir", askContextDir());
|
|
484
|
+
args.push("-p", effectivePrompt);
|
|
383
485
|
|
|
384
486
|
const details: AgyDetails = {
|
|
385
487
|
model: requestedModel,
|
|
386
488
|
resolvedModel: resolved.model,
|
|
489
|
+
thinking: resolved.effort ?? config.defaultThinking,
|
|
387
490
|
mode,
|
|
388
491
|
digest: useDigest,
|
|
389
492
|
conversationId: isContinuation ? (rawConvId as string) : null,
|
|
493
|
+
includeContext: contextFile !== null,
|
|
390
494
|
exitCode: 0,
|
|
391
495
|
aborted: false,
|
|
392
496
|
timedOut: false,
|
|
@@ -578,16 +682,91 @@ export async function registerAskAntigravityTool(
|
|
|
578
682
|
const msg = err instanceof Error ? err.message : String(err);
|
|
579
683
|
return { content: [{ type: "text", text: `failed to run agy: ${msg}` }], details };
|
|
580
684
|
}
|
|
685
|
+
finally {
|
|
686
|
+
if (contextFile) {
|
|
687
|
+
try {
|
|
688
|
+
fs.unlinkSync(contextFile);
|
|
689
|
+
} catch {}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
581
692
|
},
|
|
582
693
|
});
|
|
583
694
|
}
|
|
584
695
|
|
|
696
|
+
// --- Full-context export (opt-in includeContext) --------------------------
|
|
697
|
+
// NOTE: duplicated per pi-ask-* / bridge package (each is self-contained).
|
|
698
|
+
// Duck-typed over role/content to tolerate AgentMessage's union + custom types.
|
|
699
|
+
|
|
700
|
+
// Tool-call inputs and tool-result bodies are clamped so the exported
|
|
701
|
+
// transcript stays reviewable; the agent can re-read any source file by path.
|
|
702
|
+
// User/assistant prose is kept in full (that IS the conversation).
|
|
703
|
+
const CONTEXT_BLOCK_MAX_CHARS = 2000;
|
|
704
|
+
|
|
705
|
+
function clampBlock(text: unknown, limit = CONTEXT_BLOCK_MAX_CHARS): string {
|
|
706
|
+
const t = String(text ?? "");
|
|
707
|
+
return t.length > limit ? `${t.slice(0, limit)}\n…[truncated, ${t.length - limit} more chars]` : t;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/** Render resolved pi AgentMessages to a readable markdown transcript.
|
|
711
|
+
* Pure: no IO. Caller writes the returned string to a temp file. */
|
|
712
|
+
function renderAgentMessagesMarkdown(messages: readonly unknown[]): string {
|
|
713
|
+
const lines: string[] = [
|
|
714
|
+
"# Pi conversation context",
|
|
715
|
+
"",
|
|
716
|
+
`_Exported for full-context delegation. ${messages.length} message(s)._`,
|
|
717
|
+
"",
|
|
718
|
+
];
|
|
719
|
+
for (const raw of messages) {
|
|
720
|
+
const m = raw as { role?: string; content?: unknown };
|
|
721
|
+
const role = m.role ?? "message";
|
|
722
|
+
const content = m.content;
|
|
723
|
+
if (role === "assistant") {
|
|
724
|
+
const blocks = (Array.isArray(content) ? content : []) as ReadonlyArray<{
|
|
725
|
+
type: string;
|
|
726
|
+
text?: string;
|
|
727
|
+
name?: string;
|
|
728
|
+
input?: unknown;
|
|
729
|
+
}>;
|
|
730
|
+
const text = blocks
|
|
731
|
+
.filter((b) => b.type === "text")
|
|
732
|
+
.map((b) => b.text ?? "")
|
|
733
|
+
.join("\n");
|
|
734
|
+
if (text.trim()) lines.push("## Assistant", "", text, "");
|
|
735
|
+
for (const b of blocks) {
|
|
736
|
+
if (b.type === "toolCall" || b.type === "tool_use") {
|
|
737
|
+
const input = clampBlock(
|
|
738
|
+
typeof b.input === "string" ? b.input : JSON.stringify(b.input ?? ""),
|
|
739
|
+
500,
|
|
740
|
+
);
|
|
741
|
+
lines.push(`> tool call: ${b.name ?? "(unknown)"}(${input})`, "");
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
} else if (role === "toolResult" || role === "tool_result" || role === "tool") {
|
|
745
|
+
const text = contentText(content as any);
|
|
746
|
+
if (text.trim()) lines.push("## Tool result", "", clampBlock(text), "");
|
|
747
|
+
} else {
|
|
748
|
+
const text = contentText(content as any);
|
|
749
|
+
if (text.trim()) lines.push(`## ${role}`, "", clampBlock(text), "");
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
return lines.join("\n");
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/** Centralized scratch dir for full-context exports, following the
|
|
756
|
+
* ~/.pi/extensions-data/<author>/<extension>/ convention (see pi-token-cost-ledger).
|
|
757
|
+
* Derived from getAgentDir() so rebranded distros resolve correctly. */
|
|
758
|
+
function askContextDir(): string {
|
|
759
|
+
return path.join(path.dirname(getAgentDir()), "extensions-data", "estebanforge", "pi-antigravity-bridge");
|
|
760
|
+
}
|
|
761
|
+
|
|
585
762
|
interface AgyDetails {
|
|
586
763
|
model: string | null;
|
|
587
764
|
resolvedModel: string | null;
|
|
765
|
+
thinking: ThinkingTier | null;
|
|
588
766
|
mode: AgyMode;
|
|
589
767
|
digest: boolean;
|
|
590
768
|
conversationId: string | null;
|
|
769
|
+
includeContext: boolean;
|
|
591
770
|
exitCode: number;
|
|
592
771
|
aborted: boolean;
|
|
593
772
|
timedOut: boolean;
|
|
@@ -595,12 +774,19 @@ interface AgyDetails {
|
|
|
595
774
|
stderr: string;
|
|
596
775
|
}
|
|
597
776
|
|
|
598
|
-
function emptyDetails(
|
|
777
|
+
function emptyDetails(
|
|
778
|
+
model: string | null = null,
|
|
779
|
+
resolvedModel: string | null = null,
|
|
780
|
+
thinking: ThinkingTier | null = null,
|
|
781
|
+
includeContext: boolean = false,
|
|
782
|
+
): AgyDetails {
|
|
599
783
|
return {
|
|
600
784
|
model,
|
|
601
785
|
resolvedModel,
|
|
786
|
+
thinking,
|
|
602
787
|
mode: "accept-edits",
|
|
603
788
|
digest: false,
|
|
789
|
+
includeContext,
|
|
604
790
|
conversationId: null,
|
|
605
791
|
exitCode: 0,
|
|
606
792
|
aborted: false,
|
package/src/patcher.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
// Self-applier for the pi.invokeTool local patch (docs/PI-INVOKETOOL-PATCH.md).
|
|
2
2
|
//
|
|
3
|
-
// Adds pi.invokeTool() at 6 sites in 4 compiled files under pi's dist
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// Adds pi.invokeTool() at 6 sites in 4 compiled files under pi's dist/, plus
|
|
4
|
+
// (pi 0.84.3+) an entry redirect that swaps the bundled dist/bundle/cli.js
|
|
5
|
+
// for a shim loading the modular dist/cli.js. pi ships compiled (no src/);
|
|
6
|
+
// these are plain, sentinel-detectable text edits, so a runtime patcher can
|
|
7
|
+
// apply them durably and idempotently. This lets the bridge self-heal after a
|
|
8
|
+
// pi reinstall/update without manual re-patching.
|
|
7
9
|
//
|
|
8
10
|
// Activation: pi's core is native ESM, cached per process; /reload does NOT pick
|
|
9
11
|
// up these edits. A patched dist only takes effect on a FULL pi restart. The
|
|
@@ -126,9 +128,9 @@ const PATCH_FILES: Array<{ file: string; sites: PatchSite[] }> = [
|
|
|
126
128
|
{
|
|
127
129
|
file: "core/extensions/loader.js",
|
|
128
130
|
anchor:
|
|
129
|
-
" getAllTools() {\n
|
|
131
|
+
" getAllTools() {\n assertActive();\n return runtime.getAllTools();\n },\n",
|
|
130
132
|
insertion: ` invokeTool(name, args, options) {
|
|
131
|
-
|
|
133
|
+
assertActive();
|
|
132
134
|
return runtime.invokeTool(name, args, options);
|
|
133
135
|
},
|
|
134
136
|
`,
|
|
@@ -140,6 +142,30 @@ const PATCH_FILES: Array<{ file: string; sites: PatchSite[] }> = [
|
|
|
140
142
|
|
|
141
143
|
const ALL_SITES: PatchSite[] = PATCH_FILES.flatMap((f) => f.sites);
|
|
142
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Entry redirect (pi 0.84.3+). pi's bin now points at dist/bundle/cli.js, a
|
|
147
|
+
* bundled runtime with its own embedded core; text patches to dist/core/ never
|
|
148
|
+
* reach a process launched from the bundle. Fix: replace the tiny bundle entry
|
|
149
|
+
* with a shim that loads the still-shipped modular dist/cli.js, where the six
|
|
150
|
+
* insertion sites live. The file is absent on pre-bundle pi (bin already
|
|
151
|
+
* modular); apply, status, and restore skip it there.
|
|
152
|
+
*/
|
|
153
|
+
const ENTRY_REDIRECT = {
|
|
154
|
+
/** Path relative to pi's dist/. */
|
|
155
|
+
file: "bundle/cli.js",
|
|
156
|
+
/** Proves an unpatched file is the bundled entry (it imports hashed chunks). */
|
|
157
|
+
probe: "chunks/",
|
|
158
|
+
/** Proves the redirect is already applied. */
|
|
159
|
+
sentinel: 'import "../cli.js";',
|
|
160
|
+
/** Full replacement content. */
|
|
161
|
+
content: `#!/usr/bin/env node
|
|
162
|
+
// LOCAL PATCH (pi-antigravity-bridge): redirect pi's bundled entry to the
|
|
163
|
+
// modular runtime under dist/, where the invokeTool patch sites take effect.
|
|
164
|
+
// Restore via /agy patch restore. See docs/PI-INVOKETOOL-PATCH.md.
|
|
165
|
+
import "../cli.js";
|
|
166
|
+
`,
|
|
167
|
+
};
|
|
168
|
+
|
|
143
169
|
export interface PatchResult {
|
|
144
170
|
/** True when every required site is present (after this run). */
|
|
145
171
|
present: boolean;
|
|
@@ -219,7 +245,10 @@ function candidateFromArgv(): string | null {
|
|
|
219
245
|
const real = fs.realpathSync(launched);
|
|
220
246
|
const dir = path.dirname(real);
|
|
221
247
|
if (path.basename(dir) === "dist") return path.dirname(dir);
|
|
222
|
-
//
|
|
248
|
+
// pi 0.84.3+ ships the bin entry at dist/bundle/cli.js.
|
|
249
|
+
if (path.basename(dir) === "bundle" && path.basename(path.dirname(dir)) === "dist") {
|
|
250
|
+
return path.dirname(path.dirname(dir));
|
|
251
|
+
}
|
|
223
252
|
return null;
|
|
224
253
|
} catch {
|
|
225
254
|
return null;
|
|
@@ -297,10 +326,20 @@ function siteMissing(content: string, site: PatchSite): boolean {
|
|
|
297
326
|
return !content.includes(site.sentinel);
|
|
298
327
|
}
|
|
299
328
|
|
|
300
|
-
/** Atomic write: per-pid tmp in the SAME dir (same filesystem) + rename.
|
|
329
|
+
/** Atomic write: per-pid tmp in the SAME dir (same filesystem) + rename.
|
|
330
|
+
* Preserves the destination's existing permission bits: the bundle entry is
|
|
331
|
+
* pi's bin target and must keep its execute bit through patch and restore.
|
|
332
|
+
* A destination that does not exist yet is created 0o644. */
|
|
301
333
|
function atomicWrite(filePath: string, content: string): void {
|
|
334
|
+
let mode = 0o644;
|
|
335
|
+
try {
|
|
336
|
+
mode = fs.statSync(filePath).mode & 0o777;
|
|
337
|
+
} catch {
|
|
338
|
+
/* destination absent: new file, plain default */
|
|
339
|
+
}
|
|
302
340
|
const tmp = `${filePath}.${process.pid}.tmp`;
|
|
303
|
-
fs.writeFileSync(tmp, content);
|
|
341
|
+
fs.writeFileSync(tmp, content, { mode });
|
|
342
|
+
fs.chmodSync(tmp, mode);
|
|
304
343
|
fs.renameSync(tmp, filePath);
|
|
305
344
|
}
|
|
306
345
|
|
|
@@ -387,6 +426,13 @@ export function patchStatus(opts: { root?: string; backupBase?: string } = {}):
|
|
|
387
426
|
if (siteMissing(txt, site)) missing.push(`${f.file}:${site.sentinel.slice(0, 40)}`);
|
|
388
427
|
}
|
|
389
428
|
}
|
|
429
|
+
// Entry redirect: required when the bundled entry exists (pi 0.84.3+).
|
|
430
|
+
const entryPath = path.join(root.root, "dist", ENTRY_REDIRECT.file);
|
|
431
|
+
if (fs.existsSync(entryPath)) {
|
|
432
|
+
const txt = fs.readFileSync(entryPath, "utf8");
|
|
433
|
+
if (!txt.includes(ENTRY_REDIRECT.sentinel)) missing.push(ENTRY_REDIRECT.file);
|
|
434
|
+
}
|
|
435
|
+
// Absent file = pre-bundle pi: the bin already points at the modular entry.
|
|
390
436
|
const backup = findNewestBackup(backupBaseOf(opts));
|
|
391
437
|
return {
|
|
392
438
|
present: missing.length === 0,
|
|
@@ -470,7 +516,46 @@ export function applyInvokeToolPatch(opts: PatchOpts = {}): PatchResult {
|
|
|
470
516
|
plan.push({ file: f.file, original, next, changed });
|
|
471
517
|
}
|
|
472
518
|
|
|
473
|
-
|
|
519
|
+
// Phase 1b: entry redirect. An absent file means pre-bundle pi; skip.
|
|
520
|
+
// Compute the previous backup now: the copy-forward in phase 2 needs it,
|
|
521
|
+
// and the already-redirected warning below needs to know whether any
|
|
522
|
+
// backup still holds the original entry bytes.
|
|
523
|
+
const prevBackup = findNewestBackup(backupBaseOf(opts));
|
|
524
|
+
let entry: { original: string } | null = null;
|
|
525
|
+
const entryPath = path.join(root, "dist", ENTRY_REDIRECT.file);
|
|
526
|
+
if (fs.existsSync(entryPath)) {
|
|
527
|
+
const original = fs.readFileSync(entryPath, "utf8");
|
|
528
|
+
if (!original.includes(ENTRY_REDIRECT.sentinel)) {
|
|
529
|
+
if (!original.includes(ENTRY_REDIRECT.probe)) {
|
|
530
|
+
const msg =
|
|
531
|
+
`unexpected content in ${ENTRY_REDIRECT.file} (pi ${version}); it does not look like ` +
|
|
532
|
+
`pi's bundled entry. No files were written. See docs/PI-INVOKETOOL-PATCH.md.`;
|
|
533
|
+
errors.push(msg);
|
|
534
|
+
log("entry-unexpected", { file: ENTRY_REDIRECT.file, version });
|
|
535
|
+
return { present: false, patched: false, alreadyPresent: false, changedFiles, errors, root, version };
|
|
536
|
+
}
|
|
537
|
+
if (!fs.existsSync(path.join(root, "dist", "cli.js"))) {
|
|
538
|
+
const msg =
|
|
539
|
+
`modular dist/cli.js not found (pi ${version}); the bundled entry has nowhere to ` +
|
|
540
|
+
`redirect to. No files were written.`;
|
|
541
|
+
errors.push(msg);
|
|
542
|
+
log("entry-target-missing", { version });
|
|
543
|
+
return { present: false, patched: false, alreadyPresent: false, changedFiles, errors, root, version };
|
|
544
|
+
}
|
|
545
|
+
entry = { original };
|
|
546
|
+
} else if (!prevBackup || !fs.existsSync(path.join(prevBackup.dir, ENTRY_REDIRECT.file))) {
|
|
547
|
+
// Already redirected, but no backup holds the original entry. Repair
|
|
548
|
+
// still proceeds (core sites matter); only the entry becomes unrestorable.
|
|
549
|
+
const msg =
|
|
550
|
+
`entry redirect already applied but no backup holds the original ${ENTRY_REDIRECT.file}; ` +
|
|
551
|
+
`/agy patch restore cannot revert it. Reinstall pi to fully revert.`;
|
|
552
|
+
errors.push(msg);
|
|
553
|
+
log("entry-original-missing", { file: ENTRY_REDIRECT.file });
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
// Absent entry file: pre-bundle pi, the bin already points at the modular entry.
|
|
557
|
+
|
|
558
|
+
const anyChange = plan.some((p) => p.changed) || entry !== null;
|
|
474
559
|
if (!anyChange) {
|
|
475
560
|
log("already-present", { root, version });
|
|
476
561
|
return { present: true, patched: false, alreadyPresent: true, changedFiles, errors, root, version };
|
|
@@ -485,8 +570,25 @@ export function applyInvokeToolPatch(opts: PatchOpts = {}): PatchResult {
|
|
|
485
570
|
path.join(backupDir, "VERSION"),
|
|
486
571
|
`${JSON.stringify({ version, root, createdAt: new Date().toISOString() }, null, 2)}\n`,
|
|
487
572
|
);
|
|
488
|
-
//
|
|
489
|
-
|
|
573
|
+
// Copy-forward: seed the new backup with the previous SAME-version backup
|
|
574
|
+
// so it stays complete even when this run changes only some files (a
|
|
575
|
+
// repair run after a partial patch). Without this, a repair that does not
|
|
576
|
+
// touch the entry would create a backup lacking it, and a later restore
|
|
577
|
+
// would silently skip the entry redirect.
|
|
578
|
+
if (prevBackup && prevBackup.version === version) {
|
|
579
|
+
for (const rel of [...PATCH_FILES.map((f) => f.file), ENTRY_REDIRECT.file]) {
|
|
580
|
+
const src = path.join(prevBackup.dir, rel);
|
|
581
|
+
if (!fs.existsSync(src)) continue;
|
|
582
|
+
const dst = path.join(backupDir, rel);
|
|
583
|
+
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
|
584
|
+
fs.copyFileSync(src, dst);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
// Overlay the pre-change bytes ONLY for files this run is about to write,
|
|
588
|
+
// so already-patched files keep their pristine bytes from the older backup
|
|
589
|
+
// instead of capturing current (patched) bytes.
|
|
590
|
+
for (const p of plan) if (p.changed) backupOriginal(p.file, p.original, backupDir);
|
|
591
|
+
if (entry) backupOriginal(ENTRY_REDIRECT.file, entry.original, backupDir);
|
|
490
592
|
log("backup-written", { backupDir });
|
|
491
593
|
} catch (e) {
|
|
492
594
|
const msg = `backup failed (${e instanceof Error ? e.message : String(e)}); aborting before any write.`;
|
|
@@ -513,6 +615,23 @@ export function applyInvokeToolPatch(opts: PatchOpts = {}): PatchResult {
|
|
|
513
615
|
}
|
|
514
616
|
}
|
|
515
617
|
|
|
618
|
+
// The entry redirect goes LAST: it is the activator. Until it lands, the
|
|
619
|
+
// patched core stays inert (the bundle still runs), so a partial run fails
|
|
620
|
+
// closed exactly like a facade-only failure. Never written after any write
|
|
621
|
+
// error: activating the modular runtime is the accepted tradeoff of a
|
|
622
|
+
// SUCCESSFUL patch, not of a failed one.
|
|
623
|
+
if (entry && errors.length === 0) {
|
|
624
|
+
try {
|
|
625
|
+
atomicWrite(entryPath, ENTRY_REDIRECT.content);
|
|
626
|
+
changedFiles.push(ENTRY_REDIRECT.file);
|
|
627
|
+
log("entry-redirected", { file: ENTRY_REDIRECT.file });
|
|
628
|
+
} catch (e) {
|
|
629
|
+
const msg = describeWriteError(e, root);
|
|
630
|
+
errors.push(`failed writing ${ENTRY_REDIRECT.file}: ${msg}`);
|
|
631
|
+
log("write-failed", { file: ENTRY_REDIRECT.file, code: (e as NodeJS.ErrnoException)?.code });
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
516
635
|
const status = patchStatus({ root, backupBase: opts.backupBase });
|
|
517
636
|
return {
|
|
518
637
|
present: status.present,
|
|
@@ -548,17 +667,18 @@ export function restorePatch(opts: PatchOpts = {}): RestoreResult {
|
|
|
548
667
|
}
|
|
549
668
|
|
|
550
669
|
const restoredFiles: string[] = [];
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
const
|
|
670
|
+
// Entry redirect restores last, mirroring apply order.
|
|
671
|
+
for (const rel of [...PATCH_FILES.map((f) => f.file), ENTRY_REDIRECT.file]) {
|
|
672
|
+
const src = path.join(backup.dir, rel);
|
|
673
|
+
const dst = path.join(root, "dist", rel);
|
|
554
674
|
if (!fs.existsSync(src)) continue;
|
|
555
675
|
try {
|
|
556
676
|
const content = fs.readFileSync(src, "utf8");
|
|
557
677
|
atomicWrite(dst, content);
|
|
558
|
-
restoredFiles.push(
|
|
678
|
+
restoredFiles.push(rel);
|
|
559
679
|
} catch (e) {
|
|
560
680
|
const msg = describeWriteError(e, root);
|
|
561
|
-
return { ok: false, restoredFiles, backupDir: backup.dir, reason: `failed restoring ${
|
|
681
|
+
return { ok: false, restoredFiles, backupDir: backup.dir, reason: `failed restoring ${rel}: ${msg}` };
|
|
562
682
|
}
|
|
563
683
|
}
|
|
564
684
|
log("restore-done", { backupDir: backup.dir, count: restoredFiles.length });
|