@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,443 @@
1
+ // Capability-gated MCP server: exposes pi's tools to agy over Streamable HTTP.
2
+ //
3
+ // agy reads .agents/mcp_config.json from its --add-dir directories (verified),
4
+ // NOT from cwd. So we write our config into a bridge-controlled dir and the
5
+ // provider passes that dir as an EXTRA --add-dir. AskAntigravity omits it, so
6
+ // its agy starts plain. The user's global agy config is never touched.
7
+ //
8
+ // Hardening:
9
+ // - The whole request handler is wrapped so a client error (ECONNRESET on a
10
+ // killed-mid-call agy) can never crash the pi process.
11
+ // - Per-process config dir (agy-mcp-<pid>): concurrent pi sessions each own
12
+ // their file; no shared-file race, no cross-session routing.
13
+ // - Shared-secret header: agy sends it from the config; a browser cannot set a
14
+ // custom header on a simple cross-origin POST, so this blocks CSRF against
15
+ // the loopback server. Combined with 127.0.0.1 binding.
16
+ // - Request body size cap.
17
+ //
18
+ // CAPABILITY GATE: pi.invokeTool is NOT upstream pi (local patch, see
19
+ // docs/PI-INVOKETOOL-PATCH.md). If absent, startMcpServer returns { ok:false }
20
+ // and the bridge runs unchanged.
21
+
22
+ import http from "node:http";
23
+ import fs from "node:fs";
24
+ import os from "node:os";
25
+ import path from "node:path";
26
+ import crypto from "node:crypto";
27
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
28
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
29
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
30
+ import {
31
+ CallToolRequestSchema,
32
+ ListToolsRequestSchema,
33
+ LATEST_PROTOCOL_VERSION,
34
+ SUPPORTED_PROTOCOL_VERSIONS,
35
+ } from "@modelcontextprotocol/sdk/types.js";
36
+
37
+ /** Tools we do NOT expose to agy: it would just error (the provider is already
38
+ * antigravity, so the tool's own guard refuses; advertising it is noise). */
39
+ const SKIP_CIRCULAR = new Set(["AskAntigravity"]);
40
+
41
+ const BRIDGE_MCP_KEY = "pi-antigravity-bridge";
42
+ const TOKEN_HEADER = "x-bridge-token";
43
+ const MAX_BODY_BYTES = 1_000_000;
44
+
45
+ export interface McpServerHandle {
46
+ port: number;
47
+ close: () => Promise<void>;
48
+ }
49
+
50
+ export interface McpStartResult {
51
+ ok: boolean;
52
+ port?: number;
53
+ handle?: McpServerHandle;
54
+ reason?: string;
55
+ }
56
+
57
+ /** True only if the running pi exposes the local invokeTool patch. */
58
+ export function hasInvokeTool(pi: ExtensionAPI): boolean {
59
+ return typeof (pi as unknown as { invokeTool?: unknown }).invokeTool === "function";
60
+ }
61
+
62
+ /** Clamp an unsupported MCP-Protocol-Version header down to the SDK's LATEST.
63
+ *
64
+ * agy negotiates a protocol version newer than this SDK ships (e.g. 2026-07-28
65
+ * vs LATEST 2025-11-25). initialize is exempt from the transport's header
66
+ * check, and the SDK's initialize handler already downgrades the body version
67
+ * itself, but EVERY follow-up (tools/list, tools/call,
68
+ * notifications/initialized) is validated against the header -> 400 +
69
+ * transport-error. This server is stateless (a fresh transport per request),
70
+ * so it cannot track the negotiated version across requests; rewriting any
71
+ * unsupported value to LATEST is the correct, spec-friendly downgrade. The
72
+ * Node->Web conversion (Hono getRequestListener) builds the Web Request from
73
+ * req.rawHeaders, NOT the parsed req.headers object, so the value must be
74
+ * rewritten in the raw array (kept in sync with req.headers for any other
75
+ * reader). */
76
+ function clampProtocolVersionHeader(req: http.IncomingMessage): void {
77
+ const sent = req.headers["mcp-protocol-version"];
78
+ if (typeof sent !== "string" || SUPPORTED_PROTOCOL_VERSIONS.includes(sent)) return;
79
+ req.headers["mcp-protocol-version"] = LATEST_PROTOCOL_VERSION;
80
+ const raw = req.rawHeaders;
81
+ for (let i = 0; i < raw.length - 1; i += 2) {
82
+ if (raw[i].toLowerCase() === "mcp-protocol-version") raw[i + 1] = LATEST_PROTOCOL_VERSION;
83
+ }
84
+ }
85
+
86
+ interface PiToolMeta {
87
+ name: string;
88
+ description?: string;
89
+ parameters?: object;
90
+ sourceInfo?: { source?: string };
91
+ }
92
+
93
+ interface InvokeResult {
94
+ content?: Array<{ type: string; text?: string }>;
95
+ isError?: boolean;
96
+ }
97
+
98
+ const BRIDGE_BASE = path.join(os.homedir(), ".pi", "agent", "antigravity-bridge");
99
+
100
+ /** Per-process config dir. Each pi session owns its own file, so concurrent
101
+ * sessions never race on or cross-route through one shared config. */
102
+ export function bridgeMcpConfigDir(): string {
103
+ return path.join(BRIDGE_BASE, `agy-mcp-${process.pid}`);
104
+ }
105
+
106
+ function bridgeMcpConfigPath(): string {
107
+ return path.join(bridgeMcpConfigDir(), ".agents", "mcp_config.json");
108
+ }
109
+
110
+ /** True if this process's bridge config exists (server is running). The
111
+ * provider uses this to decide whether to add the extra --add-dir. */
112
+ export function bridgeMcpConfigExists(): boolean {
113
+ return fs.existsSync(bridgeMcpConfigPath());
114
+ }
115
+
116
+ function isPidAlive(pid: number): boolean {
117
+ try {
118
+ process.kill(pid, 0);
119
+ return true;
120
+ } catch (e) {
121
+ // EPERM: alive but not ours to signal. ESRCH: no such process.
122
+ return (e as NodeJS.ErrnoException).code === "EPERM";
123
+ }
124
+ }
125
+
126
+ /** Best-effort cleanup of stale per-pid dirs left by crashed sessions. */
127
+ function sweepStaleBridgeDirs(): void {
128
+ let entries: string[];
129
+ try {
130
+ entries = fs.readdirSync(BRIDGE_BASE);
131
+ } catch {
132
+ return;
133
+ }
134
+ for (const name of entries) {
135
+ if (!name.startsWith("agy-mcp-")) continue;
136
+ const pid = Number(name.slice("agy-mcp-".length));
137
+ if (!Number.isInteger(pid) || pid === process.pid) continue;
138
+ if (isPidAlive(pid)) continue;
139
+ try {
140
+ fs.rmSync(path.join(BRIDGE_BASE, name), { recursive: true, force: true });
141
+ } catch {
142
+ /* best effort */
143
+ }
144
+ }
145
+ }
146
+
147
+ /** Write our .agents/mcp_config.json (serverUrl + shared-secret header) so a
148
+ * provider agy that adds this dir via --add-dir discovers us. Atomic write. */
149
+ function writeBridgeMcpConfig(port: number, token: string): void {
150
+ const cfgPath = bridgeMcpConfigPath();
151
+ fs.mkdirSync(path.dirname(cfgPath), { recursive: true, mode: 0o700 });
152
+ const cfg = {
153
+ mcpServers: {
154
+ [BRIDGE_MCP_KEY]: {
155
+ serverUrl: `http://127.0.0.1:${port}/mcp`,
156
+ headers: { [TOKEN_HEADER]: token },
157
+ },
158
+ },
159
+ };
160
+ const tmp = `${cfgPath}.${process.pid}.tmp`;
161
+ try {
162
+ fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 });
163
+ fs.renameSync(tmp, cfgPath);
164
+ } catch (err) {
165
+ try {
166
+ fs.unlinkSync(tmp);
167
+ } catch {
168
+ /* nothing */
169
+ }
170
+ throw err;
171
+ }
172
+ }
173
+
174
+ /** Remove this process's config dir. Safe to delete unconditionally: only this
175
+ * pid owns it. */
176
+ function removeBridgeMcpConfig(): void {
177
+ try {
178
+ fs.rmSync(bridgeMcpConfigDir(), { recursive: true, force: true });
179
+ } catch {
180
+ /* best effort */
181
+ }
182
+ }
183
+
184
+ /** Options for {@link registerExitCleanup}. */
185
+ export interface ExitCleanupOptions {
186
+ /** Signals to catch for abrupt-termination cleanup. Default SIGINT/SIGTERM. */
187
+ signals?: NodeJS.Signals[];
188
+ /** Override the host-ownership check (tests). Default: process.listenerCount(sig) > 0. */
189
+ hasHostListener?: (sig: NodeJS.Signals) => boolean;
190
+ }
191
+
192
+ /** Register best-effort cleanup of this process's bridge config dir on process
193
+ * exit, returning a disposer that removes the handlers (call from close()).
194
+ *
195
+ * - 'exit' is always registered: synchronous, safe, catches process.exit() and
196
+ * event-loop drain. It does NOT fire on signal death.
197
+ * - For each signal in `signals` (default SIGINT/SIGTERM) we install a handler
198
+ * ONLY when the host process has no existing listener for it, so this
199
+ * extension never interferes with the host's own signal handling (e.g. a TUI
200
+ * cancel/quit flow). When we do install, we run cleanup then re-raise the
201
+ * signal so Node's default termination and exit code are preserved. Any
202
+ * abrupt termination that still bypasses these is swept on the next launch
203
+ * (sweepStaleBridgeDirs).
204
+ *
205
+ * `hasHostListener` is injectable so tests can exercise both branches without
206
+ * depending on which signals the test runtime happens to listen on. */
207
+ export function registerExitCleanup(
208
+ cleanup: () => void,
209
+ opts: ExitCleanupOptions = {},
210
+ ): () => void {
211
+ const signals = opts.signals ?? ["SIGINT", "SIGTERM"];
212
+ const hasHostListener = opts.hasHostListener ?? ((sig) => process.listenerCount(sig) > 0);
213
+ const onExit = (): void => cleanup();
214
+ process.once("exit", onExit);
215
+
216
+ const installed: Array<{ sig: NodeJS.Signals; handler: () => void }> = [];
217
+ for (const sig of signals) {
218
+ // Host owns this signal: defer. The exit handler plus next-launch sweep
219
+ // cover the abrupt-death gap without racing the host's handler.
220
+ if (hasHostListener(sig)) continue;
221
+ const handler = (): void => {
222
+ cleanup();
223
+ process.removeListener(sig, handler);
224
+ // Re-raise so default termination runs with the right exit code, BUT
225
+ // only if no host listener has appeared since install (ours is removed
226
+ // now, so listenerCount reflects the host). If the host registered
227
+ // later it already received this delivery alongside us; re-raising
228
+ // would double-deliver (e.g. triggering a "Ctrl-C twice to quit" path
229
+ // on the first keypress). When nobody owns it, re-raise safely.
230
+ if (process.listenerCount(sig) === 0) process.kill(process.pid, sig);
231
+ };
232
+ process.once(sig, handler);
233
+ installed.push({ sig, handler });
234
+ }
235
+
236
+ return (): void => {
237
+ process.removeListener("exit", onExit);
238
+ for (const { sig, handler } of installed) process.removeListener(sig, handler);
239
+ };
240
+ }
241
+
242
+ export async function startMcpServer(
243
+ pi: ExtensionAPI,
244
+ opts: { preferredPort?: number; log?: (s: string, d?: unknown) => void } = {},
245
+ ): Promise<McpStartResult> {
246
+ const log = opts.log ?? (() => {});
247
+
248
+ if (!hasInvokeTool(pi)) {
249
+ const reason =
250
+ "pi.invokeTool unavailable (needs the local pi patch). MCP tool bridge disabled; provider and AskAntigravity tool run unchanged.";
251
+ log("capability-missing", reason);
252
+ return { ok: false, reason };
253
+ }
254
+
255
+ const getAll = (pi as unknown as { getAllTools: () => PiToolMeta[] }).getAllTools.bind(pi);
256
+ const invoke =
257
+ (pi as unknown as { invokeTool: (n: string, a?: unknown, o?: { signal?: AbortSignal }) => Promise<InvokeResult> }).invokeTool.bind(pi);
258
+
259
+ const listHandler = async () => {
260
+ const all = getAll();
261
+ const tools = all
262
+ .filter((t) => t.sourceInfo?.source !== "builtin")
263
+ .filter((t) => !SKIP_CIRCULAR.has(t.name))
264
+ .map((t) => {
265
+ let inputSchema: object | undefined;
266
+ try {
267
+ inputSchema = t.parameters ? JSON.parse(JSON.stringify(t.parameters)) : undefined;
268
+ } catch {
269
+ inputSchema = { type: "object", properties: {}, additionalProperties: true };
270
+ }
271
+ return { name: t.name, description: t.description ?? t.name, inputSchema };
272
+ });
273
+ log("list-tools", { count: tools.length });
274
+ return { tools };
275
+ };
276
+
277
+ const callHandler = async (request: { params: { name: string; arguments?: unknown } }, signal?: AbortSignal) => {
278
+ const { name, arguments: args } = request.params;
279
+ log("call-tool", { name });
280
+ try {
281
+ const r = await invoke(name, args ?? {}, { signal });
282
+ const content =
283
+ r.content && r.content.length > 0 ? r.content : [{ type: "text", text: JSON.stringify(r) }];
284
+ log("call-tool-ok", { name });
285
+ return { content, isError: r.isError ?? false };
286
+ } catch (e) {
287
+ const msg = e instanceof Error ? e.message : String(e);
288
+ log("call-tool-fail", { name, msg });
289
+ return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true };
290
+ }
291
+ };
292
+
293
+ const makeServer = (signal: AbortSignal) => {
294
+ const s = new Server(
295
+ { name: "pi-antigravity-bridge", version: "1.0.0" },
296
+ { capabilities: { tools: {} } },
297
+ );
298
+ s.setRequestHandler(ListToolsRequestSchema, listHandler);
299
+ s.setRequestHandler(CallToolRequestSchema, (request) => callHandler(request, signal));
300
+ return s;
301
+ };
302
+
303
+ // Shared secret: agy sends it from the config headers. Browsers cannot set
304
+ // custom headers on a simple cross-origin POST, so this blocks web CSRF
305
+ // against the loopback server; local clients need the token too.
306
+ const token = crypto.randomUUID();
307
+ sweepStaleBridgeDirs();
308
+
309
+ return new Promise<McpStartResult>((resolve) => {
310
+ const httpServer = http.createServer(async (req, res) => {
311
+ // #1: a client-side stream error must never crash pi.
312
+ req.on("error", (e) => {
313
+ log("request-error", e instanceof Error ? e.message : String(e));
314
+ try {
315
+ if (!res.headersSent) res.writeHead(400).end();
316
+ else res.end();
317
+ } catch {
318
+ /* socket already gone */
319
+ }
320
+ });
321
+ try {
322
+ if (req.url?.includes("/.well-known/")) {
323
+ res.writeHead(404, { "content-type": "application/json" }).end('{"error":"not found"}');
324
+ return;
325
+ }
326
+ if (req.method !== "POST") {
327
+ res.writeHead(405).end();
328
+ return;
329
+ }
330
+ // #3: require the shared-secret header. Constant-time compare so a
331
+ // timing oracle can't recover the token byte-by-byte.
332
+ const received = req.headers[TOKEN_HEADER];
333
+ if (
334
+ typeof received !== "string" ||
335
+ received.length !== token.length ||
336
+ !crypto.timingSafeEqual(Buffer.from(received), Buffer.from(token))
337
+ ) {
338
+ log("unauthorized", { url: req.url });
339
+ res.writeHead(403, { "content-type": "application/json" }).end('{"error":"forbidden"}');
340
+ return;
341
+ }
342
+ // #4: cap request body size.
343
+ let body = "";
344
+ let bytes = 0;
345
+ let tooLarge = false;
346
+ for await (const chunk of req) {
347
+ body += chunk;
348
+ bytes += chunk.length;
349
+ if (bytes > MAX_BODY_BYTES) {
350
+ tooLarge = true;
351
+ break;
352
+ }
353
+ }
354
+ if (tooLarge) {
355
+ // We bailed before draining the oversize body; close the connection
356
+ // so the unread bytes can't desync the next request on this socket.
357
+ res.writeHead(413, { "content-type": "application/json", connection: "close" }).end('{"error":"payload too large"}');
358
+ return;
359
+ }
360
+ let parsed: { method?: string; params?: { protocolVersion?: string } };
361
+ try {
362
+ parsed = JSON.parse(body);
363
+ } catch {
364
+ res.writeHead(400).end("invalid json");
365
+ return;
366
+ }
367
+ // Protocol version: agy negotiates a version newer than this SDK ships
368
+ // (e.g. 2026-07-28 vs LATEST 2025-11-25). initialize is exempt from the
369
+ // transport's header check and the SDK downgrades its body version
370
+ // itself, but every follow-up (tools/list, tools/call,
371
+ // notifications/initialized) is header-checked -> 400 + transport-error.
372
+ // Clamp unsupported headers to LATEST. Stateless server (fresh transport
373
+ // per request) can't track the negotiated version across requests.
374
+ clampProtocolVersionHeader(req);
375
+ // #6: cancel the invoked tool if agy disconnects mid-call (e.g. killed
376
+ // by the runner timeout). req 'close' would fire on normal completion,
377
+ // so we only abort on client abort / response-closed-before-finished.
378
+ const ac = new AbortController();
379
+ req.on("aborted", () => ac.abort());
380
+ res.on("close", () => {
381
+ if (!res.writableEnded) ac.abort();
382
+ });
383
+ try {
384
+ // Stateless: a fresh transport+server per request.
385
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
386
+ transport.onerror = (e: Error) => log("transport-error", e.message);
387
+ const server = makeServer(ac.signal);
388
+ await server.connect(transport);
389
+ await transport.handleRequest(req, res, parsed as object);
390
+ } catch (e) {
391
+ log("handleRequest-error", e instanceof Error ? e.message : String(e));
392
+ if (!res.headersSent) res.writeHead(500).end();
393
+ }
394
+ } catch (e) {
395
+ // Catch-all (e.g. errors during body read/clamp) so pi never crashes.
396
+ log("request-handler-error", e instanceof Error ? e.message : String(e));
397
+ try {
398
+ if (!res.headersSent) res.writeHead(500).end();
399
+ } catch {
400
+ /* socket gone */
401
+ }
402
+ }
403
+ });
404
+
405
+ httpServer.on("error", (e) => {
406
+ log("http-error", e instanceof Error ? e.message : String(e));
407
+ resolve({ ok: false, reason: `http server error: ${e instanceof Error ? e.message : String(e)}` });
408
+ });
409
+
410
+ httpServer.listen(opts.preferredPort ?? 0, "127.0.0.1", () => {
411
+ const addr = httpServer.address();
412
+ const port = typeof addr === "object" && addr ? addr.port : 0;
413
+ if (!port) {
414
+ resolve({ ok: false, reason: "failed to bind" });
415
+ return;
416
+ }
417
+ try {
418
+ writeBridgeMcpConfig(port, token);
419
+ log("bridge-config-written", { port, path: bridgeMcpConfigPath() });
420
+ } catch (e) {
421
+ log("bridge-config-write-failed", e instanceof Error ? e.message : String(e));
422
+ }
423
+ // Clean up the config dir on abrupt termination (SIGINT/SIGTERM/crash)
424
+ // where session_shutdown -> close() does not run. Disposed in close().
425
+ const disposeExitCleanup = registerExitCleanup(removeBridgeMcpConfig);
426
+ log("listening", { port });
427
+ resolve({
428
+ ok: true,
429
+ port,
430
+ handle: {
431
+ port,
432
+ close: async () => {
433
+ await new Promise<void>((r) => httpServer.close(() => r()));
434
+ removeBridgeMcpConfig();
435
+ disposeExitCleanup();
436
+ log("bridge-config-removed", { port });
437
+ log("closed", { port });
438
+ },
439
+ },
440
+ });
441
+ });
442
+ });
443
+ }
package/src/models.ts ADDED
@@ -0,0 +1,261 @@
1
+ // Discover Gemini models from `agy models` and project them into pi's Model
2
+ // shape so they appear in the /model picker as antigravity/<slug>.
3
+ //
4
+ // agy prints one model per line, e.g.:
5
+ // Gemini 3.6 Flash (Medium)
6
+ // Gemini 3.1 Pro (High)
7
+ // Claude Sonnet 4.6 (Thinking)
8
+ // GPT-OSS 120B (Medium)
9
+ //
10
+ // We keep ONLY Gemini models here - Claude and GPT-OSS belong to other
11
+ // providers (pi-claude-bridge, etc.). Driving them through agy would double-
12
+ // bill and conflict with the user's other subscriptions.
13
+
14
+ import { spawn } from "node:child_process";
15
+ import fs from "node:fs";
16
+ import os from "node:os";
17
+ import path from "node:path";
18
+ import type { Api, Model } from "@earendil-works/pi-ai";
19
+
20
+ const DISCOVERY_TIMEOUT_MS = 8_000;
21
+
22
+ export interface AgyModelEntry {
23
+ /** Exact agy string, e.g. "Gemini 3.6 Flash (Medium)". */
24
+ full: string;
25
+ /** pi model id, e.g. "gemini-3-6-flash-medium". */
26
+ id: string;
27
+ }
28
+
29
+ /** Spawn `agy models` and return its raw stdout text. Returns "" on any
30
+ * failure (non-zero exit, spawn error, or watchdog timeout). Bounded by
31
+ * DISCOVERY_TIMEOUT_MS so a hung agy (auth prompt, network stall) can't
32
+ * block extension load. Shared by the provider and the tool catalog so the
33
+ * extension spawns `agy models` ONCE per load. */
34
+ export async function spawnAgyModelsRaw(binary: string): Promise<string> {
35
+ try {
36
+ return await new Promise<string>((resolve, reject) => {
37
+ const proc = spawn(binary, ["models"], {
38
+ stdio: ["ignore", "pipe", "ignore"],
39
+ shell: false,
40
+ });
41
+ proc.stdout?.setEncoding("utf8");
42
+ let out = "";
43
+ let done = false;
44
+ const finish = (v: string) => {
45
+ if (done) return;
46
+ done = true;
47
+ clearTimeout(watchdog);
48
+ resolve(v);
49
+ };
50
+ proc.stdout?.on("data", (d: string) => (out += d));
51
+ proc.on("error", (err) => {
52
+ clearTimeout(watchdog);
53
+ reject(err);
54
+ });
55
+ proc.on("close", (code) => finish(code === 0 ? out : ""));
56
+ const watchdog = setTimeout(() => {
57
+ try {
58
+ proc.kill("SIGKILL");
59
+ } catch {
60
+ /* already gone */
61
+ }
62
+ finish("");
63
+ }, DISCOVERY_TIMEOUT_MS);
64
+ });
65
+ } catch {
66
+ return "";
67
+ }
68
+ }
69
+
70
+ // --- catalog cache ----------------------------------------------------------
71
+ //
72
+ // `agy models` can take seconds (OAuth refresh, cold start) and its output
73
+ // rarely changes. We persist it to ~/.pi/agent/antigravity-bridge/models-
74
+ // cache.json with a short TTL so reloads serve instantly and only re-spawn in
75
+ // the background when stale. Only successful (non-empty) output is cached, so
76
+ // a broken agy is never sticky.
77
+
78
+ export const MODELS_CACHE_TTL_MS = 5 * 60_000;
79
+
80
+ const MODELS_CACHE_PATH = path.join(
81
+ os.homedir(),
82
+ ".pi",
83
+ "agent",
84
+ "antigravity-bridge",
85
+ "models-cache.json",
86
+ );
87
+
88
+ interface ModelsCache {
89
+ raw: string;
90
+ savedAt: number;
91
+ }
92
+
93
+ /** Read and validate the models cache. Returns null when missing or corrupt. */
94
+ function readModelsCache(cachePath: string = MODELS_CACHE_PATH): ModelsCache | null {
95
+ try {
96
+ const parsed = JSON.parse(fs.readFileSync(cachePath, "utf8")) as unknown;
97
+ if (
98
+ parsed &&
99
+ typeof parsed === "object" &&
100
+ !Array.isArray(parsed) &&
101
+ typeof (parsed as ModelsCache).raw === "string" &&
102
+ typeof (parsed as ModelsCache).savedAt === "number"
103
+ ) {
104
+ return parsed as ModelsCache;
105
+ }
106
+ } catch {
107
+ /* missing or corrupt: treat as no cache */
108
+ }
109
+ return null;
110
+ }
111
+
112
+ /** Atomically persist the raw catalog text (temp + rename, mode 0o600).
113
+ * Best-effort: a write failure just means the next load re-spawns. */
114
+ function writeModelsCache(raw: string, cachePath: string = MODELS_CACHE_PATH): void {
115
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
116
+ const tmp = `${cachePath}.${process.pid}.tmp`;
117
+ try {
118
+ fs.writeFileSync(tmp, JSON.stringify({ raw, savedAt: Date.now() }, null, 2) + "\n", {
119
+ mode: 0o600,
120
+ });
121
+ fs.renameSync(tmp, cachePath);
122
+ } catch {
123
+ try {
124
+ fs.unlinkSync(tmp);
125
+ } catch {
126
+ /* nothing to clean */
127
+ }
128
+ }
129
+ }
130
+
131
+ /** Fire-and-forget refresh of the models cache. Called on a stale-cache load so
132
+ * the next load sees fresh data without this one having to wait on agy. */
133
+ export function refreshModelsInBackground(
134
+ binary: string,
135
+ cachePath: string = MODELS_CACHE_PATH,
136
+ ): void {
137
+ void spawnAgyModelsRaw(binary)
138
+ .then((raw) => {
139
+ if (raw) writeModelsCache(raw, cachePath);
140
+ })
141
+ .catch(() => {
142
+ /* best effort: leave the stale cache in place */
143
+ });
144
+ }
145
+
146
+ /** Load the raw `agy models` text for catalog derivation, optimized for load
147
+ * time:
148
+ * - Fresh cache (< MODELS_CACHE_TTL_MS): return it, no spawn.
149
+ * - Stale cache (>= TTL): return it instantly, refresh in the background.
150
+ * - No cache: spawn once (blocks), persist. First-ever load only.
151
+ *
152
+ * pi registers providers with a static model list, so a background refresh only
153
+ * updates the cache for the NEXT load; the current session keeps whatever this
154
+ * returned. The provider falls back to FALLBACK_MODELS when the raw yields no
155
+ * Gemini entries (agy missing/auth-failed). */
156
+ export async function loadModelCatalogRaw(
157
+ binary: string,
158
+ cachePath: string = MODELS_CACHE_PATH,
159
+ ): Promise<string> {
160
+ const cache = readModelsCache(cachePath);
161
+ if (cache) {
162
+ if (Date.now() - cache.savedAt < MODELS_CACHE_TTL_MS) return cache.raw;
163
+ refreshModelsInBackground(binary, cachePath);
164
+ return cache.raw;
165
+ }
166
+ // No cache: populate it. Blocks once; later loads hit the cache above.
167
+ const raw = await spawnAgyModelsRaw(binary);
168
+ if (raw) writeModelsCache(raw, cachePath);
169
+ return raw;
170
+ }
171
+
172
+ /** Parse raw `agy models` text into the provider's slugified Gemini entries. */
173
+ export function entriesFromRaw(raw: string): AgyModelEntry[] {
174
+ return raw
175
+ .split("\n")
176
+ .map((line) => line.trim())
177
+ .filter((line) => line.length > 0)
178
+ .filter(isGeminiModel)
179
+ .map((full) => ({ full, id: slugify(full) }))
180
+ .filter((e): e is AgyModelEntry => e.id.length > 0);
181
+ }
182
+
183
+ /** Run `agy models`, return parsed Gemini entries. Returns [] on any failure
184
+ * (non-fatal - the provider falls back to a hardcoded set). */
185
+ export async function discoverAgyModels(binary: string): Promise<AgyModelEntry[]> {
186
+ return entriesFromRaw(await spawnAgyModelsRaw(binary));
187
+ }
188
+
189
+ /** Gemini models only. Case-insensitive: the name must contain "gemini" and
190
+ * NOT be a Claude/GPT-OSS entry (defensive - agy could rename lines). */
191
+ function isGeminiModel(line: string): boolean {
192
+ const l = line.toLowerCase();
193
+ if (!l.includes("gemini")) return false;
194
+ if (l.includes("claude")) return false;
195
+ if (l.includes("gpt")) return false;
196
+ return true;
197
+ }
198
+
199
+ /** "Gemini 3.6 Flash (Medium)" -> "gemini-3-6-flash-medium".
200
+ * Lowercase, non-alphanumerics -> "-", collapsed, trimmed. */
201
+ export function slugify(full: string): string {
202
+ return full
203
+ .toLowerCase()
204
+ .replace(/[^a-z0-9]+/g, "-")
205
+ .replace(/^-+|-+$/g, "");
206
+ }
207
+
208
+ /** Project an agy entry to pi's Model shape. */
209
+ export function toPiModel(entry: AgyModelEntry): Model<Api> {
210
+ const tier = /\(high\)/i.test(entry.full)
211
+ ? "high"
212
+ : /\(low\)/i.test(entry.full)
213
+ ? "low"
214
+ : "medium";
215
+ // "reasoning" gates pi's thinking-effort UI. Gemini reasons at every tier,
216
+ // but we only expose the toggle for High to avoid implying control we
217
+ // don't actually bridge to agy.
218
+ const reasoning = tier === "high";
219
+ return {
220
+ id: entry.id,
221
+ name: entry.full,
222
+ api: "agy-bridge" as Api,
223
+ provider: "antigravity",
224
+ // baseUrl/apiKey are not used (streamSimple intercepts everything), but
225
+ // pi requires non-empty values. The "agy-bridge" api string is a custom
226
+ // sentinel that no built-in provider claims, so it can never collide.
227
+ baseUrl: "agy-bridge://antigravity",
228
+ reasoning,
229
+ // agy's -p prompt is text-only. Advertising image input would let pi
230
+ // offer image attach, but extractUserPrompt silently drops image blocks,
231
+ // so the user would be misled. Keep input text-only until agy supports
232
+ // image passthrough in print mode.
233
+ input: ["text"],
234
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
235
+ // Gemini long context. agy doesn't expose the real per-model window in
236
+ // `agy models`; 1M is the documented Gemini ceiling.
237
+ contextWindow: 1_000_000,
238
+ maxTokens: 65_536,
239
+ };
240
+ }
241
+
242
+ /** Fallback catalog used when `agy models` fails at load (binary missing,
243
+ * auth not yet done, network stall). Keeps the picker populated so the user
244
+ * can still select a model and get a clear runtime error instead of an empty
245
+ * list. Update these when agy ships new Gemini versions. */
246
+ export const FALLBACK_MODELS: AgyModelEntry[] = [
247
+ { full: "Gemini 3.6 Flash (Medium)", id: "gemini-3-6-flash-medium" },
248
+ { full: "Gemini 3.6 Flash (High)", id: "gemini-3-6-flash-high" },
249
+ { full: "Gemini 3.1 Pro (High)", id: "gemini-3-1-pro-high" },
250
+ ];
251
+
252
+ /** Resolve a pi model id back to the exact agy string. O(n) over a small list
253
+ * - the provider calls this once per turn. Returns null on miss (caller
254
+ * falls back to passthrough, agy will likely reject). */
255
+ export function resolveAgyString(
256
+ piModelId: string,
257
+ entries: AgyModelEntry[],
258
+ ): string | null {
259
+ const found = entries.find((e) => e.id === piModelId);
260
+ return found ? found.full : null;
261
+ }