@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.
- package/CHANGELOG.md +125 -0
- package/LICENSE +21 -0
- package/README.md +153 -0
- package/docs/ARCHITECTURE.md +48 -0
- package/docs/DEVELOPMENT.md +64 -0
- package/docs/PI-BRIDGE-GAPS.md +186 -0
- package/docs/PI-INVOKETOOL-PATCH.md +227 -0
- package/extensions/index.ts +474 -0
- package/package.json +69 -0
- package/src/ask-tool.ts +579 -0
- package/src/config.ts +119 -0
- package/src/diff-render.ts +190 -0
- package/src/discovery.ts +199 -0
- package/src/mcp-server.ts +443 -0
- package/src/models.ts +261 -0
- package/src/patcher.ts +571 -0
- package/src/poller.ts +202 -0
- package/src/protobuf.ts +184 -0
- package/src/provider.ts +502 -0
- package/src/runner.ts +386 -0
- package/src/sessions.ts +159 -0
|
@@ -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
|
+
}
|