@estebanforge/pi-antigravity-bridge 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,474 @@
1
+ // pi-antigravity-bridge - extension entry point.
2
+ //
3
+ // Registers Gemini (via the agy CLI) as a pi model provider so it shows up in
4
+ // the /model picker as antigravity/gemini-*. When selected, pi routes each turn
5
+ // through streamSimple, which spawns `agy -p`, polls the conversation SQLite DB
6
+ // agy writes, decodes the protobuf step payloads, and streams the agent text
7
+ // back into pi's TUI.
8
+ //
9
+ // Architectural wall (cannot be worked around - see PLAN.md):
10
+ // agy runs its OWN closed tool loop against --add-dir. pi's read/write/edit/
11
+ // bash tools never fire. Tool activity is surfaced as thinking events
12
+ // ("[agy tool: editing foo.ts]") for visibility, but the edits already landed
13
+ // on disk and pi's inline diff review does not engage.
14
+ //
15
+ // /agy command: status, mode picker (plan / accept-edits),
16
+ // and session clear. Config persists to ~/.pi/agent/antigravity-bridge/
17
+ // config.json so toggles survive restarts.
18
+
19
+ import {
20
+ type ExtensionAPI,
21
+ type ExtensionCommandContext,
22
+ type ExtensionUIContext,
23
+ getSettingsListTheme,
24
+ } from "@earendil-works/pi-coding-agent";
25
+ import {
26
+ Container,
27
+ SettingsList,
28
+ Text,
29
+ type SettingItem,
30
+ } from "@earendil-works/pi-tui";
31
+ import {
32
+ entriesFromRaw,
33
+ FALLBACK_MODELS,
34
+ loadModelCatalogRaw,
35
+ toPiModel,
36
+ type AgyModelEntry,
37
+ } from "../src/models.js";
38
+ import { SessionStore } from "../src/sessions.js";
39
+ import { createStreamSimple } from "../src/provider.js";
40
+ import { CONFIG_PATH, loadConfig, saveConfig, type AgyMode, type ThinkingTier } from "../src/config.js";
41
+ import { registerAskAntigravityTool, toolModelsFromRaw } from "../src/ask-tool.js";
42
+ import { hasInvokeTool, startMcpServer, type McpServerHandle } from "../src/mcp-server.js";
43
+ import { applyInvokeToolPatch, decidePatchAction, patchStatus, restorePatch } from "../src/patcher.js";
44
+
45
+ function resolveAgyBinary(): string {
46
+ return process.env.AGY_BIN || "agy";
47
+ }
48
+
49
+ export default async function (pi: ExtensionAPI): Promise<void> {
50
+ // Claim the AskAntigravity tool for this process. pi-ask-antigravity (if also
51
+ // installed) checks this in-process flag OR the bridge's package.json on disk
52
+ // and defers. See that extension's isBridgeInstalled().
53
+ (globalThis as Record<symbol, unknown>)[Symbol.for("pi-antigravity-bridge:active")] = true;
54
+
55
+ const binary = resolveAgyBinary();
56
+
57
+ // Discover once at load. Failure is non-fatal: FALLBACK_MODELS keeps the
58
+ // picker populated so the user gets a clear runtime error from agy rather
59
+ // than an empty model list. /reload re-runs this and refreshes after an
60
+ // `agy update`.
61
+ // loadModelCatalogRaw serves a short-TTL cache (~/.pi/agent/antigravity-bridge/
62
+ // models-cache.json) so reloads are instant and only re-spawn in the
63
+ // background when stale. Derive both catalogs from the same raw text
64
+ // (provider's slugified Gemini entries + the tool's family/version/tier
65
+ // entries).
66
+ const raw = await loadModelCatalogRaw(binary);
67
+ const discovered = entriesFromRaw(raw);
68
+ const toolModels = toolModelsFromRaw(raw);
69
+ const usingFallback = discovered.length === 0;
70
+ const entries: AgyModelEntry[] = usingFallback ? FALLBACK_MODELS : discovered;
71
+ const models = entries.map(toPiModel);
72
+
73
+ const store = new SessionStore();
74
+ const streamSimple = createStreamSimple({ entries, store });
75
+
76
+ pi.registerProvider("antigravity", {
77
+ name: "Antigravity (agy)",
78
+ baseUrl: "agy-bridge://antigravity",
79
+ apiKey: "not-used",
80
+ api: "agy-bridge",
81
+ models: models.map((m) => ({
82
+ id: m.id,
83
+ name: m.name,
84
+ api: m.api,
85
+ reasoning: m.reasoning,
86
+ input: m.input,
87
+ cost: m.cost,
88
+ contextWindow: m.contextWindow,
89
+ maxTokens: m.maxTokens,
90
+ })),
91
+ streamSimple,
92
+ });
93
+
94
+ registerAgyCommand(pi, { entries, store, usingFallback });
95
+
96
+ // AskAntigravity tool: one-shot delegation to agy (ported from
97
+ // pi-ask-antigravity). When both extensions are installed, the bridge wins
98
+ // and pi-ask-antigravity registers nothing (its load-time defer guard
99
+ // detects this package via import.meta.resolve).
100
+ await registerAskAntigravityTool(pi, toolModels);
101
+
102
+ // MCP tool bridge: expose pi's tools to agy over localhost Streamable HTTP,
103
+ // backed by pi.invokeTool (local patch). Capability-gated: if the running pi
104
+ // lacks invokeTool, startMcpServer returns { ok: false } and the bridge runs
105
+ // unchanged (other instances do not break). Started on session_start so it is
106
+ // live before the provider's first turn; torn down on session_shutdown.
107
+ let mcpHandle: McpServerHandle | null = null;
108
+ const mcpLog = (s: string, d?: unknown) => {
109
+ // Quiet by default: surface only lifecycle/error events to stderr.
110
+ // Lifecycle + error events only. Per-turn success events (list-tools /
111
+ // call-tool) are deliberately NOT surfaced: writing to stderr during an
112
+ // active turn corrupts the pi TUI / construct daemon rendering (spinner
113
+ // gets stuck, hint text leaks into the display). Use a status-bar API for
114
+ // in-turn visibility instead.
115
+ const surfaced = new Set([
116
+ "listening", "capability-missing", "http-error", "closed",
117
+ "bridge-config-written", "bridge-config-removed", "bridge-config-write-failed",
118
+ "call-tool-fail", "transport-error", "handleRequest-error",
119
+ "request-error", "request-handler-error", "unauthorized", "self-patch-error",
120
+ ]);
121
+ if (surfaced.has(s)) {
122
+ console.error(`[antigravity-bridge mcp] ${s}${d !== undefined ? " " + JSON.stringify(d) : ""}`);
123
+ }
124
+ };
125
+ pi.on("session_start", async (_event, ctx) => {
126
+ // Decide the pi.invokeTool patch + MCP tool bridge path. The patch is only
127
+ // needed for the bridge; the provider and AskAntigravity tool always work.
128
+ // The bridge can only start when the patch is LIVE in this process; a
129
+ // just-applied patch needs a full pi restart (not /reload) to go live.
130
+ const live = hasInvokeTool(pi);
131
+ const action = live
132
+ ? ({ kind: "proceed" } as const)
133
+ : decidePatchAction(live, patchStatus().present, !!loadConfig().invokeToolPatchDeclined, !!ctx.hasUI);
134
+
135
+ try {
136
+ switch (action.kind) {
137
+ case "proceed": {
138
+ if (loadConfig().invokeToolPatchDeclined) {
139
+ saveConfig({ invokeToolPatchDeclined: false });
140
+ }
141
+ break;
142
+ }
143
+ case "notify-restart": {
144
+ ctx.ui.notify(
145
+ "The pi.invokeTool patch is present on disk but not loaded in this pi session. Fully RESTART pi (quit + relaunch) to start the MCP tool bridge.",
146
+ "warning",
147
+ );
148
+ break;
149
+ }
150
+ case "silent": {
151
+ // Previously declined. Stay quiet; bridge stays off until the user
152
+ // runs /agy patch apply or the patch becomes live.
153
+ mcpLog("patch-declined");
154
+ break;
155
+ }
156
+ case "ask": {
157
+ const apply = await ctx.ui.confirm(
158
+ "Apply the pi.invokeTool patch?",
159
+ "Enables the MCP tool bridge. Edits one method into your installed @earendil-works/pi-coding-agent/dist/ (reversible via /agy patch restore), and takes effect after a full pi restart.",
160
+ // Bound the wait so a non-confirm-capable RPC client (hasUI is always
161
+ // true in RPC) can't hang session_start. Timeout resolves like "no"
162
+ // (declined persists); reversible via /agy patch apply.
163
+ { timeout: 60_000 },
164
+ );
165
+ if (apply) {
166
+ const res = applyInvokeToolPatch({ log: mcpLog });
167
+ if (res.patched) {
168
+ saveConfig({ invokeToolPatchDeclined: false });
169
+ ctx.ui.notify(
170
+ `Applied the pi.invokeTool patch to ${res.root} (pi ${res.version}). Fully RESTART pi (quit + relaunch) to start the MCP tool bridge.`,
171
+ "warning",
172
+ );
173
+ } else if (res.errors.length > 0) {
174
+ ctx.ui.notify(`pi.invokeTool patch failed: ${res.errors[0]}`, "error");
175
+ }
176
+ } else {
177
+ saveConfig({ invokeToolPatchDeclined: true });
178
+ ctx.ui.notify(
179
+ "Skipped. The MCP tool bridge stays off. To enable it later, run /agy patch apply. Then restart pi.",
180
+ "info",
181
+ );
182
+ }
183
+ break;
184
+ }
185
+ case "headless-skip": {
186
+ console.error(
187
+ "[antigravity-bridge] pi.invokeTool patch missing; MCP tool bridge off. Apply interactively (/agy patch apply) or re-run pi in a TUI.",
188
+ );
189
+ break;
190
+ }
191
+ }
192
+ } catch (e) {
193
+ mcpLog("self-patch-error", e instanceof Error ? e.message : String(e));
194
+ }
195
+
196
+ if (action.kind !== "proceed") return; // bridge can't start without a live patch
197
+ if (mcpHandle) return; // already running (reload re-fires session_start)
198
+ const r = await startMcpServer(pi, { log: mcpLog });
199
+ if (r.ok && r.handle) {
200
+ mcpHandle = r.handle;
201
+ } else {
202
+ console.error(`[antigravity-bridge] MCP tool bridge disabled: ${r.reason}`);
203
+ }
204
+ });
205
+ pi.on("session_shutdown", async () => {
206
+ const h = mcpHandle;
207
+ mcpHandle = null;
208
+ await h?.close();
209
+ });
210
+ }
211
+
212
+ // --- /agy command -----------------------------------------------------------
213
+
214
+ interface AgyCommandCtx {
215
+ entries: AgyModelEntry[];
216
+ store: SessionStore;
217
+ usingFallback: boolean;
218
+ }
219
+
220
+ interface PendingConfig {
221
+ mode?: AgyMode;
222
+ skipPermissions?: boolean;
223
+ defaultModel?: string;
224
+ defaultThinking?: ThinkingTier;
225
+ }
226
+
227
+ function statusText(ctx: AgyCommandCtx): string {
228
+ const config = loadConfig();
229
+ const source = ctx.usingFallback ? "fallback (agy models failed)" : "discovered";
230
+ const perm = config.skipPermissions ? "auto-approved (DANGEROUS)" : "prompt (hangs in -p)";
231
+ return [
232
+ "Antigravity bridge",
233
+ ` models: ${ctx.entries.length} ${source}`,
234
+ ` mode: ${config.mode}`,
235
+ ` permissions: ${perm}`,
236
+ ` tool model: ${config.defaultModel}`,
237
+ ` tool thinking: ${config.defaultThinking}`,
238
+ ` sessions: ${ctx.store.size} bound`,
239
+ ` config: ${CONFIG_PATH}`,
240
+ ` invokeTool: ${patchStateLabel()}`,
241
+ "",
242
+ "Subcommands: /agy mode plan|accept-edits, /agy permissions on|off, /agy model flash|pro|gemini, /agy thinking low|medium|high, /agy patch status|apply|restore, /agy clear",
243
+ ].join("\n");
244
+ }
245
+
246
+ function patchStateLabel(): string {
247
+ const s = patchStatus();
248
+ if (s.present) return "patched";
249
+ if (!s.root) return "MISSING (pi root not found)";
250
+ if (loadConfig().invokeToolPatchDeclined) return `declined (pi ${s.version}). Resume: /agy patch apply`;
251
+ return `MISSING (pi ${s.version}). Apply it: /agy patch apply`;
252
+ }
253
+
254
+ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
255
+ pi.registerCommand("agy", {
256
+ description:
257
+ "Antigravity provider: status, mode picker, patch, clear sessions. Usage: /agy [status|mode [plan|accept-edits]|patch [status|apply|restore]|clear]",
258
+ handler: async (args, cmdCtx: ExtensionCommandContext) => {
259
+ const ui = cmdCtx.ui;
260
+ const mode = cmdCtx.mode;
261
+ const sub = (args ?? "").trim().split(/\s+/)[0]?.toLowerCase();
262
+ const val = (args ?? "").trim().split(/\s+/)[1]?.toLowerCase();
263
+
264
+ // Direct subcommands work everywhere (headless + TUI).
265
+ if (sub === "clear") {
266
+ ctx.store.clear();
267
+ ui?.notify("Cleared all antigravity session bindings.", "info");
268
+ return;
269
+ }
270
+ if (sub === "mode") {
271
+ if (val === "plan" || val === "accept-edits") {
272
+ const next = saveConfig({ mode: val as AgyMode });
273
+ ui?.notify(`mode set to ${next.mode}`, "info");
274
+ } else {
275
+ ui?.notify(`current mode: ${loadConfig().mode}\nusage: /agy mode plan|accept-edits`, "info");
276
+ }
277
+ return;
278
+ }
279
+ if (sub === "permissions") {
280
+ if (val === "on" || val === "off") {
281
+ const next = saveConfig({ skipPermissions: val === "on" });
282
+ const warn = next.skipPermissions ? "\nWARNING: agy can now run arbitrary commands without review." : "";
283
+ ui?.notify(`permissions: ${next.skipPermissions ? "auto-approved (DANGEROUS)" : "prompt"}${warn}`, next.skipPermissions ? "warning" : "info");
284
+ } else {
285
+ ui?.notify(`permissions: ${loadConfig().skipPermissions ? "auto-approved (DANGEROUS)" : "prompt"}\nusage: /agy permissions on|off\n(off hangs any run_command in non-interactive mode)`, "info");
286
+ }
287
+ return;
288
+ }
289
+ if (sub === "model") {
290
+ if (val && val.length > 0) {
291
+ const next = saveConfig({ defaultModel: val });
292
+ ui?.notify(`tool default model set to ${next.defaultModel}`, "info");
293
+ } else {
294
+ ui?.notify(`tool model: ${loadConfig().defaultModel}\nusage: /agy model flash|pro|gemini|<exact>`, "info");
295
+ }
296
+ return;
297
+ }
298
+ if (sub === "thinking") {
299
+ if (val === "low" || val === "medium" || val === "high") {
300
+ const next = saveConfig({ defaultThinking: val as ThinkingTier });
301
+ ui?.notify(`tool default thinking set to ${next.defaultThinking}`, "info");
302
+ } else {
303
+ ui?.notify(`tool thinking: ${loadConfig().defaultThinking}\nusage: /agy thinking low|medium|high`, "info");
304
+ }
305
+ return;
306
+ }
307
+
308
+ if (sub === "patch") {
309
+ if (val === "restore") {
310
+ const r = restorePatch();
311
+ ui?.notify(
312
+ r.ok
313
+ ? `Restored ${r.restoredFiles.length} file(s) from ${r.backupDir}. Restart pi to take effect.`
314
+ : `restore failed: ${r.reason}`,
315
+ r.ok ? "info" : "error",
316
+ );
317
+ return;
318
+ }
319
+ if (val === "apply") {
320
+ const r = applyInvokeToolPatch();
321
+ if (r.patched || r.alreadyPresent) {
322
+ saveConfig({ invokeToolPatchDeclined: false });
323
+ }
324
+ const msg = r.patched
325
+ ? `Applied patch to ${r.changedFiles.length} file(s) in ${r.root} (pi ${r.version}). Restart pi to activate.`
326
+ : r.alreadyPresent
327
+ ? `Patch already present in ${r.root} (pi ${r.version}).`
328
+ : `apply failed: ${r.errors[0] ?? "unknown error"}`;
329
+ ui?.notify(msg, r.patched || r.alreadyPresent ? "info" : "error");
330
+ return;
331
+ }
332
+ // status (default)
333
+ const s = patchStatus();
334
+ if (!s.root) {
335
+ ui?.notify("patch status: could not locate the pi package root.", "warning");
336
+ } else {
337
+ ui?.notify(
338
+ [
339
+ `pi.invokeTool patch: ${s.present ? "PRESENT" : "MISSING"}`,
340
+ ` root: ${s.root}`,
341
+ ` version: ${s.version}`,
342
+ s.missing.length ? ` missing: ${s.missing.length} site(s)` : null,
343
+ loadConfig().invokeToolPatchDeclined ? " consent: declined. Resume: /agy patch apply" : null,
344
+ s.backupDir ? ` backup: ${s.backupDir} (v${s.backupVersion})` : " backup: none",
345
+ "",
346
+ "Usage: /agy patch [status|apply|restore]",
347
+ ]
348
+ .filter(Boolean)
349
+ .join("\n"),
350
+ "info",
351
+ );
352
+ }
353
+ return;
354
+ }
355
+
356
+ // No subcommand (or "status"): print status, or open the picker in TUI.
357
+ if (sub && sub !== "status") {
358
+ ui?.notify(`unknown subcommand: ${sub}\n${statusText(ctx)}`, "warning");
359
+ return;
360
+ }
361
+
362
+ if (mode !== "tui" || !ui) {
363
+ ui?.notify(statusText(ctx), "info");
364
+ return;
365
+ }
366
+
367
+ await openAgyPicker(ui, ctx);
368
+ },
369
+ });
370
+ }
371
+
372
+ /** Interactive settings picker (TUI only). Rows: mode + permissions + model + thinking. */
373
+ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promise<void> {
374
+ const config = loadConfig();
375
+ const pending: PendingConfig = {};
376
+
377
+ const items: SettingItem[] = [
378
+ {
379
+ id: "mode",
380
+ label: "Execution mode",
381
+ description:
382
+ "accept-edits: agy applies edits directly. plan: review-only, no writes. Takes effect next turn.",
383
+ currentValue: config.mode,
384
+ values: ["accept-edits", "plan"],
385
+ },
386
+ {
387
+ id: "permissions",
388
+ label: "Permissions",
389
+ description:
390
+ "auto-approved: --dangerously-skip-permissions (required so commands don't hang in -p mode). prompt: agy asks y/n (hangs non-interactively).",
391
+ currentValue: config.skipPermissions ? "auto-approved" : "prompt",
392
+ values: ["auto-approved", "prompt"],
393
+ },
394
+ {
395
+ id: "model",
396
+ label: "Tool default model",
397
+ description:
398
+ "Alias used when the AskAntigravity tool omits its model param. flash/pro/gemini, or an exact id.",
399
+ currentValue: config.defaultModel,
400
+ values: ["flash", "pro", "gemini"],
401
+ },
402
+ {
403
+ id: "thinking",
404
+ label: "Tool default thinking",
405
+ description:
406
+ "Thinking tier used when the model alias names none. Pro has no Medium; it falls back to nearest.",
407
+ currentValue: config.defaultThinking,
408
+ values: ["low", "medium", "high"],
409
+ },
410
+ ];
411
+
412
+ await ui.custom((tui, theme, _kb, done) => {
413
+ const container = new Container();
414
+ container.addChild(
415
+ new Text(theme.fg("accent", theme.bold("Antigravity provider")), 1, 1),
416
+ );
417
+ const settingsList = new SettingsList(
418
+ items,
419
+ Math.min(items.length + 4, 15),
420
+ getSettingsListTheme(),
421
+ (id, newValue) => {
422
+ if (id === "mode") {
423
+ pending.mode = newValue as AgyMode;
424
+ } else if (id === "permissions") {
425
+ pending.skipPermissions = newValue === "auto-approved";
426
+ } else if (id === "model") {
427
+ pending.defaultModel = newValue;
428
+ } else if (id === "thinking") {
429
+ pending.defaultThinking = newValue as ThinkingTier;
430
+ }
431
+ },
432
+ () => done(undefined),
433
+ );
434
+ container.addChild(settingsList);
435
+
436
+ return {
437
+ render: (w: number) => container.render(w),
438
+ invalidate: () => container.invalidate(),
439
+ handleInput: (data: string) => {
440
+ settingsList.handleInput?.(data);
441
+ tui.requestRender();
442
+ },
443
+ };
444
+ });
445
+
446
+ if (
447
+ pending.mode === undefined &&
448
+ pending.skipPermissions === undefined &&
449
+ pending.defaultModel === undefined &&
450
+ pending.defaultThinking === undefined
451
+ )
452
+ return;
453
+
454
+ try {
455
+ const next = saveConfig(pending);
456
+ const changed = [
457
+ pending.mode ? `mode=${next.mode}` : null,
458
+ pending.skipPermissions !== undefined
459
+ ? `permissions=${next.skipPermissions ? "auto-approved" : "prompt"}`
460
+ : null,
461
+ pending.defaultModel !== undefined ? `tool model=${next.defaultModel}` : null,
462
+ pending.defaultThinking !== undefined ? `tool thinking=${next.defaultThinking}` : null,
463
+ ]
464
+ .filter(Boolean)
465
+ .join(", ");
466
+ ui.notify(`Saved: ${changed}`, "info");
467
+ } catch (err) {
468
+ ui.notify(
469
+ `Failed to save config: ${err instanceof Error ? err.message : String(err)}`,
470
+ "error",
471
+ );
472
+ }
473
+ void ctx;
474
+ }
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@estebanforge/pi-antigravity-bridge",
3
+ "version": "1.0.0",
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
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "antigravity",
9
+ "agy",
10
+ "gemini",
11
+ "google",
12
+ "provider",
13
+ "streaming"
14
+ ],
15
+ "license": "MIT",
16
+ "author": {
17
+ "name": "EstebanForge",
18
+ "email": "esteban@attitude.cl",
19
+ "url": "https://actitud.xyz"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/EstebanForge/pi-antigravity-bridge.git"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "type": "module",
29
+ "scripts": {
30
+ "build": "tsc --noEmit",
31
+ "test": "tsx --test tests/*.test.ts",
32
+ "prepublishOnly": "npm run build && npm test",
33
+ "run-agy": "tsx scripts/run-agy.ts",
34
+ "decode-db": "tsx scripts/decode-db.ts",
35
+ "smoke:pi": "bash scripts/smoke-in-pi.sh"
36
+ },
37
+ "files": [
38
+ "extensions",
39
+ "src",
40
+ "docs",
41
+ "README.md",
42
+ "CHANGELOG.md"
43
+ ],
44
+ "pi": {
45
+ "extensions": [
46
+ "./extensions"
47
+ ]
48
+ },
49
+ "peerDependencies": {
50
+ "@earendil-works/pi-coding-agent": "*"
51
+ },
52
+ "peerDependenciesMeta": {
53
+ "@earendil-works/pi-coding-agent": {
54
+ "optional": true
55
+ }
56
+ },
57
+ "devDependencies": {
58
+ "@earendil-works/pi-ai": "^0.82.1",
59
+ "@earendil-works/pi-coding-agent": "^0.82.1",
60
+ "@earendil-works/pi-tui": "^0.82.1",
61
+ "@types/node": "^22.0.0",
62
+ "tsx": "^4.19.0",
63
+ "typebox": "^1.1.38",
64
+ "typescript": "^5.8.0"
65
+ },
66
+ "dependencies": {
67
+ "@modelcontextprotocol/sdk": "^1.29.0"
68
+ }
69
+ }