@simonepri/refined-antigravity-acp 0.1.2 → 0.2.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,157 @@
1
+ import { sanitizeAssistantText, stripSteeringPrefix, stripSystemContext } from "./sanitize.js";
2
+ import { existsSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { DatabaseSync } from "node:sqlite";
6
+ import type { ProviderTimelineItem } from "@getpaseo/plugin/server/provider";
7
+
8
+ export interface ReplayedTimelineEvent {
9
+ readonly item: ProviderTimelineItem;
10
+ readonly timestamp?: string;
11
+ }
12
+
13
+ interface StepRow {
14
+ readonly idx: number;
15
+ readonly step_type: number;
16
+ readonly step_payload: Uint8Array | null;
17
+ }
18
+
19
+ function decodeVarint(buffer: Uint8Array, start: number): [number, number] {
20
+ let res = 0;
21
+ let shift = 0;
22
+ let offset = start;
23
+ while (offset < buffer.length) {
24
+ const b = buffer[offset++];
25
+ res |= (b & 0x7f) << shift;
26
+ shift += 7;
27
+ if (!(b & 0x80)) break;
28
+ }
29
+ return [res, offset];
30
+ }
31
+
32
+ function getField(buffer: Uint8Array, targetField: number): Uint8Array | null {
33
+ let offset = 0;
34
+ while (offset < buffer.length) {
35
+ let tag = 0;
36
+ let nextOffset = 0;
37
+ try {
38
+ [tag, nextOffset] = decodeVarint(buffer, offset);
39
+ } catch {
40
+ break;
41
+ }
42
+ offset = nextOffset;
43
+ const fieldNum = tag >> 3;
44
+ const wireType = tag & 0x7;
45
+ if (wireType === 2) {
46
+ const [len, afterLen] = decodeVarint(buffer, offset);
47
+ offset = afterLen;
48
+ const slice = buffer.subarray(offset, offset + len);
49
+ offset += len;
50
+ if (fieldNum === targetField) return slice;
51
+ } else if (wireType === 0) {
52
+ const [, afterVal] = decodeVarint(buffer, offset);
53
+ offset = afterVal;
54
+ } else if (wireType === 1) {
55
+ offset += 8;
56
+ } else if (wireType === 5) {
57
+ offset += 4;
58
+ } else {
59
+ break;
60
+ }
61
+ }
62
+ return null;
63
+ }
64
+
65
+ function extractTimestamp(payload: Uint8Array): string | undefined {
66
+ const f5 = getField(payload, 5);
67
+ if (!f5) return undefined;
68
+ const f1 = getField(f5, 1);
69
+ if (!f1) return undefined;
70
+ let offset = 0;
71
+ let seconds = 0;
72
+ let nanos = 0;
73
+ while (offset < f1.length) {
74
+ const [tag, nextOffset] = decodeVarint(f1, offset);
75
+ offset = nextOffset;
76
+ const fieldNum = tag >> 3;
77
+ const [val, afterVal] = decodeVarint(f1, offset);
78
+ offset = afterVal;
79
+ if (fieldNum === 1) seconds = val;
80
+ else if (fieldNum === 2) nanos = val;
81
+ }
82
+ if (!seconds) return undefined;
83
+ return new Date(seconds * 1000 + Math.floor(nanos / 1_000_000)).toISOString();
84
+ }
85
+
86
+ export function extractUserMessage(payload: Uint8Array, idx: number): ProviderTimelineItem | null {
87
+ const f19 = getField(payload, 19);
88
+ if (!f19) return null;
89
+ const textBytes = getField(f19, 2);
90
+ if (!textBytes || textBytes.length === 0) return null;
91
+ // Steered turns are persisted with the mid-turn marker baked into the user message,
92
+ // so replay has to strip it back out before the text reaches the Paseo timeline.
93
+ const rawText = Buffer.from(textBytes).toString("utf-8");
94
+ const text = stripSystemContext(stripSteeringPrefix(rawText)).trim();
95
+ if (!text) return null;
96
+ return { id: `hist-user-${idx}`, type: "user_message", text };
97
+ }
98
+
99
+ function extractAssistantMessage(payload: Uint8Array, idx: number): ProviderTimelineItem | null {
100
+ const f20 = getField(payload, 20);
101
+ if (!f20) return null;
102
+ const textBytes = getField(f20, 1);
103
+ if (!textBytes || textBytes.length === 0) return null;
104
+ const rawText = Buffer.from(textBytes).toString("utf-8");
105
+ const text = sanitizeAssistantText(rawText);
106
+ if (!text) return null;
107
+ return { id: `hist-asst-${idx}`, type: "assistant_message", text };
108
+ }
109
+
110
+ function extractTimelineItem(
111
+ stepType: number,
112
+ payload: Uint8Array,
113
+ idx: number,
114
+ ): ProviderTimelineItem | null {
115
+ if (stepType === 14) return extractUserMessage(payload, idx);
116
+ if (stepType === 15) return extractAssistantMessage(payload, idx);
117
+ return null;
118
+ }
119
+
120
+ /**
121
+ * Reads and replays historic conversation events from Antigravity SQLite DB.
122
+ * Database is located at ~/.gemini/antigravity-acp/conversations/<sessionId>.db.
123
+ */
124
+ export function readConversationHistory(
125
+ sessionId: string,
126
+ customDbPath?: string,
127
+ ): ReplayedTimelineEvent[] {
128
+ const dbPath =
129
+ customDbPath ??
130
+ join(homedir(), ".gemini", "antigravity-acp", "conversations", `${sessionId}.db`);
131
+ if (!existsSync(dbPath)) return [];
132
+
133
+ let db: DatabaseSync | null = null;
134
+ try {
135
+ db = new DatabaseSync(dbPath, { readOnly: true });
136
+ const stmt = db.prepare(
137
+ "SELECT idx, step_type, step_payload FROM steps WHERE step_type IN (14, 15) ORDER BY idx ASC;",
138
+ );
139
+ const rows = stmt.all() as unknown as StepRow[];
140
+ const events: ReplayedTimelineEvent[] = [];
141
+
142
+ for (const row of rows) {
143
+ if (!row.step_payload) continue;
144
+ const payload = new Uint8Array(row.step_payload);
145
+ const item = extractTimelineItem(row.step_type, payload, row.idx);
146
+ if (!item) continue;
147
+ const timestamp = extractTimestamp(payload);
148
+ events.push({ item, timestamp });
149
+ }
150
+ return events;
151
+ } catch (err) {
152
+ console.error(`[paseo-antigravity] Failed to read conversation history for ${sessionId}:`, err);
153
+ return [];
154
+ } finally {
155
+ db?.close();
156
+ }
157
+ }
@@ -0,0 +1,375 @@
1
+ import fs from "node:fs";
2
+ import http from "node:http";
3
+ import https from "node:https";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+
7
+ /**
8
+ * Protocol versions the target MCP server understands. Paseo's daemon embeds
9
+ * `@modelcontextprotocol/sdk`, whose streamable-HTTP transport rejects any request
10
+ * carrying an `MCP-Protocol-Version` header outside this list with HTTP 400.
11
+ */
12
+ export const DEFAULT_SUPPORTED_PROTOCOL_VERSIONS = [
13
+ "2025-11-25",
14
+ "2025-06-18",
15
+ "2025-03-26",
16
+ "2024-11-05",
17
+ "2024-10-07",
18
+ ] as const;
19
+
20
+ /** Headers that are meaningful only for a single hop and must not be forwarded. */
21
+ const HOP_BY_HOP_HEADERS = [
22
+ "connection",
23
+ "keep-alive",
24
+ "proxy-authenticate",
25
+ "proxy-authorization",
26
+ "te",
27
+ "trailer",
28
+ "transfer-encoding",
29
+ "upgrade",
30
+ ];
31
+
32
+ export interface McpProxyOptions {
33
+ /** Versions the upstream accepts. Anything else is rewritten to `fallbackProtocolVersion`. */
34
+ supportedProtocolVersions?: readonly string[];
35
+ /** Version substituted when the client asks for one the upstream does not support. */
36
+ fallbackProtocolVersion?: string;
37
+ /**
38
+ * File used to remember the listening port across restarts. Antigravity persists the
39
+ * MCP server URL into its per-session SQLite database, so a session resumed after a
40
+ * daemon restart still points at whatever port we used when it was created.
41
+ */
42
+ portFile?: string | null;
43
+ }
44
+
45
+ function defaultPortFile(): string {
46
+ const base =
47
+ process.env.PASEO_PLUGIN_DATA_DIR ?? path.join(os.homedir(), ".paseo", "plugin-data");
48
+ return path.join(base, "antigravity", "mcp-proxy-ports.json");
49
+ }
50
+
51
+ function readPortFile(file: string): Record<string, number> {
52
+ try {
53
+ const parsed: unknown = JSON.parse(fs.readFileSync(file, "utf-8"));
54
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
55
+ return Object.fromEntries(
56
+ Object.entries(parsed as Record<string, unknown>).filter(
57
+ ([, v]) => typeof v === "number" && Number.isInteger(v) && v > 0 && v < 65536,
58
+ ) as Array<[string, number]>,
59
+ );
60
+ } catch {
61
+ return {};
62
+ }
63
+ }
64
+
65
+ function writePortFile(file: string, origin: string, port: number): void {
66
+ try {
67
+ fs.mkdirSync(path.dirname(file), { recursive: true });
68
+ fs.writeFileSync(file, JSON.stringify({ ...readPortFile(file), [origin]: port }, null, 2));
69
+ } catch {
70
+ // A missing port file only costs us port stability, never correctness.
71
+ }
72
+ }
73
+
74
+ function sendJsonRpcError(res: http.ServerResponse, statusCode: number, message: string): void {
75
+ if (res.headersSent) return;
76
+ const payload = JSON.stringify({
77
+ jsonrpc: "2.0",
78
+ error: {
79
+ code: -32603,
80
+ message,
81
+ },
82
+ id: null,
83
+ });
84
+ res.writeHead(statusCode, {
85
+ "Content-Type": "application/json",
86
+ "Content-Length": Buffer.byteLength(payload),
87
+ });
88
+ res.end(payload);
89
+ }
90
+
91
+ interface JsonRpcDescription {
92
+ isNotification: boolean;
93
+ method: string | null;
94
+ }
95
+
96
+ function describeJsonRpc(body: Buffer): JsonRpcDescription {
97
+ if (body.length === 0) return { isNotification: false, method: null };
98
+ try {
99
+ const parsed: unknown = JSON.parse(body.toString("utf-8"));
100
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
101
+ return { isNotification: false, method: null };
102
+ }
103
+ const { method, id } = parsed as Record<string, unknown>;
104
+ const methodName = typeof method === "string" ? method : null;
105
+ return {
106
+ isNotification: methodName !== null && id === undefined,
107
+ method: methodName,
108
+ };
109
+ } catch {
110
+ return { isNotification: false, method: null };
111
+ }
112
+ }
113
+
114
+ function formatNotificationAbsorbLog(statusCode: number, method: string | null): string {
115
+ const methodLabel = method ? '"' + method + '"' : "unknown";
116
+ const base =
117
+ "[paseo-antigravity][mcp-proxy] Absorbed upstream " +
118
+ statusCode +
119
+ " on notification " +
120
+ methodLabel +
121
+ "; replied 202 so go-sdk does not tear the session down";
122
+ if (method === "notifications/cancelled") {
123
+ return base;
124
+ }
125
+ return base + " (indicates proxy normalization bug; investigate)";
126
+ }
127
+
128
+ function normalizeAccept(rawAccept: string | string[] | undefined): string {
129
+ const accept = Array.isArray(rawAccept) ? rawAccept.join(", ") : rawAccept;
130
+ if (!accept || !accept.includes("application/json") || !accept.includes("text/event-stream")) {
131
+ return "application/json, text/event-stream";
132
+ }
133
+ return accept;
134
+ }
135
+
136
+ /**
137
+ * A loopback reverse proxy for a single upstream MCP origin.
138
+ *
139
+ * Google's `agy_acp_server` ships an MCP client that advertises protocol version
140
+ * `2026-07-28`, which Paseo's daemon rejects with HTTP 400 on every request after
141
+ * `initialize`. This proxy normalizes that header down to a version the upstream
142
+ * accepts, turning a hard transport failure into ordinary version negotiation.
143
+ *
144
+ * It is origin-bound and forwards the request path and query verbatim, so
145
+ * `?callerAgentId=...` survives and the client's OAuth discovery probes
146
+ * (`/.well-known/oauth-protected-resource/...`) reach the real upstream path
147
+ * instead of being collapsed onto the MCP endpoint.
148
+ */
149
+ export class McpProxy {
150
+ private server: http.Server | null = null;
151
+ private port: number | null = null;
152
+ private readonly origin: URL;
153
+ private readonly supportedProtocolVersions: ReadonlySet<string>;
154
+ private readonly fallbackProtocolVersion: string;
155
+ private readonly portFile: string | null;
156
+
157
+ constructor(origin: string, options: McpProxyOptions = {}) {
158
+ this.origin = new URL(origin);
159
+ this.supportedProtocolVersions = new Set(
160
+ options.supportedProtocolVersions ?? DEFAULT_SUPPORTED_PROTOCOL_VERSIONS,
161
+ );
162
+ this.fallbackProtocolVersion =
163
+ options.fallbackProtocolVersion ?? DEFAULT_SUPPORTED_PROTOCOL_VERSIONS[0];
164
+ this.portFile = options.portFile === undefined ? defaultPortFile() : options.portFile;
165
+ }
166
+
167
+ private buildForwardHeaders(
168
+ req: http.IncomingMessage,
169
+ bodyLength: number,
170
+ ): http.OutgoingHttpHeaders {
171
+ const headers: http.OutgoingHttpHeaders = { ...req.headers };
172
+ delete headers.host;
173
+ for (const name of HOP_BY_HOP_HEADERS) delete headers[name];
174
+
175
+ const version = req.headers["mcp-protocol-version"];
176
+ if (typeof version === "string" && !this.supportedProtocolVersions.has(version)) {
177
+ headers["mcp-protocol-version"] = this.fallbackProtocolVersion;
178
+ }
179
+
180
+ headers.accept = normalizeAccept(req.headers.accept);
181
+
182
+ if (bodyLength > 0) {
183
+ if (!headers["content-type"]) {
184
+ headers["content-type"] = "application/json";
185
+ }
186
+ headers["content-length"] = bodyLength;
187
+ }
188
+
189
+ return headers;
190
+ }
191
+
192
+ private handleProxyResponse(
193
+ res: http.ServerResponse,
194
+ proxyRes: http.IncomingMessage,
195
+ rpcDesc: JsonRpcDescription,
196
+ ): void {
197
+ const statusCode = proxyRes.statusCode ?? 502;
198
+ if (rpcDesc.isNotification && statusCode >= 400 && statusCode < 500) {
199
+ console.error(formatNotificationAbsorbLog(statusCode, rpcDesc.method));
200
+ proxyRes.resume();
201
+ res.writeHead(202, { "Content-Length": "0" });
202
+ res.end();
203
+ return;
204
+ }
205
+
206
+ res.writeHead(statusCode, proxyRes.headers);
207
+ // MCP responses are often `text/event-stream`; without an explicit flush the
208
+ // headers can sit in Node's buffer until the first chunk, stalling the client.
209
+ res.flushHeaders();
210
+ proxyRes.pipe(res);
211
+ }
212
+
213
+ private forwardRequest(req: http.IncomingMessage, res: http.ServerResponse, body: Buffer): void {
214
+ const isTls = this.origin.protocol === "https:";
215
+ const transport = isTls ? https : http;
216
+ const rpcDesc = describeJsonRpc(body);
217
+
218
+ const proxyReq = transport.request(
219
+ {
220
+ protocol: this.origin.protocol,
221
+ hostname: this.origin.hostname,
222
+ port: this.origin.port || (isTls ? 443 : 80),
223
+ method: req.method,
224
+ // Verbatim path + query. The upstream owns its own routing.
225
+ path: req.url,
226
+ headers: this.buildForwardHeaders(req, body.length),
227
+ },
228
+ (proxyRes) => this.handleProxyResponse(res, proxyRes, rpcDesc),
229
+ );
230
+
231
+ proxyReq.on("error", (err) => {
232
+ sendJsonRpcError(res, 502, `MCP proxy error: ${err.message}`);
233
+ });
234
+ // Long-lived SSE streams must not be reaped by the default socket timeout.
235
+ proxyReq.setTimeout(0);
236
+ res.on("close", () => proxyReq.destroy());
237
+
238
+ proxyReq.end(body);
239
+ }
240
+
241
+ /**
242
+ * Buffering the request body in memory is safe and necessary because MCP JSON-RPC
243
+ * messages are small (typically <1MB) and buffering allows us to inspect for
244
+ * notifications (missing `id`) before forwarding or deciding whether to absorb 4xx
245
+ * responses, while also enabling re-streaming the body to upstream.
246
+ */
247
+ private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
248
+ const chunks: Buffer[] = [];
249
+ req.on("data", (chunk: Buffer) => chunks.push(chunk));
250
+ req.on("error", (err) => {
251
+ sendJsonRpcError(res, 400, `MCP proxy request error: ${err.message}`);
252
+ });
253
+ req.on("end", () => {
254
+ this.forwardRequest(req, res, Buffer.concat(chunks));
255
+ });
256
+ }
257
+
258
+ private listen(port: number): Promise<number> {
259
+ return new Promise((resolve, reject) => {
260
+ const server = http.createServer((req, res) => this.handleRequest(req, res));
261
+ server.keepAliveTimeout = 0;
262
+ server.headersTimeout = 0;
263
+ server.requestTimeout = 0;
264
+ server.once("error", reject);
265
+ server.listen(port, "127.0.0.1", () => {
266
+ const addr = server.address();
267
+ if (addr && typeof addr === "object") {
268
+ server.removeListener("error", reject);
269
+ this.server = server;
270
+ this.port = addr.port;
271
+ resolve(addr.port);
272
+ } else {
273
+ reject(new Error("MCP proxy failed to resolve a listening port"));
274
+ }
275
+ });
276
+ });
277
+ }
278
+
279
+ /**
280
+ * Starts the proxy on 127.0.0.1, preferring the port used on the previous run so
281
+ * that URLs Antigravity persisted for existing sessions keep resolving.
282
+ */
283
+ async start(): Promise<number> {
284
+ if (this.port !== null) return this.port;
285
+
286
+ const remembered = this.portFile ? readPortFile(this.portFile)[this.origin.origin] : undefined;
287
+ if (remembered !== undefined) {
288
+ try {
289
+ return await this.listen(remembered);
290
+ } catch {
291
+ // Port taken by something else; fall through to an ephemeral one.
292
+ }
293
+ }
294
+
295
+ const port = await this.listen(0);
296
+ if (this.portFile) writePortFile(this.portFile, this.origin.origin, port);
297
+ return port;
298
+ }
299
+
300
+ getPort(): number | null {
301
+ return this.port;
302
+ }
303
+
304
+ /**
305
+ * Rewrites an upstream MCP URL to route through this proxy, preserving path and query.
306
+ *
307
+ * Throws when the proxy is not listening. Returning the original URL here would
308
+ * silently hand Antigravity a direct upstream connection with no header
309
+ * normalization, which is exactly the failure this class exists to prevent.
310
+ */
311
+ rewriteUrl(originalUrl: string): string {
312
+ if (this.port === null) {
313
+ throw new Error(
314
+ `MCP proxy for ${this.origin.origin} is not running; refusing to expose an un-normalized MCP URL`,
315
+ );
316
+ }
317
+ const target = new URL(originalUrl);
318
+ return `http://127.0.0.1:${this.port}${target.pathname}${target.search}`;
319
+ }
320
+
321
+ async stop(): Promise<void> {
322
+ if (!this.server) return;
323
+ const server = this.server;
324
+ this.server = null;
325
+ this.port = null;
326
+ await new Promise<void>((resolve) => server.close(() => resolve()));
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Process-wide pool of proxies keyed by upstream origin.
332
+ *
333
+ * Proxies deliberately outlive individual agent connections: Antigravity writes the
334
+ * rewritten URL into its session database, so tearing a proxy down when one stream
335
+ * ends would leave every resumed session pointing at a dead port.
336
+ */
337
+ export class McpProxyPool {
338
+ private readonly proxies = new Map<string, Promise<McpProxy>>();
339
+ private readonly options: McpProxyOptions;
340
+
341
+ constructor(options: McpProxyOptions = {}) {
342
+ this.options = options;
343
+ }
344
+
345
+ async rewriteUrl(originalUrl: string): Promise<string> {
346
+ const origin = new URL(originalUrl).origin;
347
+ let pending = this.proxies.get(origin);
348
+ if (!pending) {
349
+ pending = (async () => {
350
+ const proxy = new McpProxy(origin, this.options);
351
+ await proxy.start();
352
+ return proxy;
353
+ })();
354
+ this.proxies.set(origin, pending);
355
+ pending.catch(() => this.proxies.delete(origin));
356
+ }
357
+ return (await pending).rewriteUrl(originalUrl);
358
+ }
359
+
360
+ async stopAll(): Promise<void> {
361
+ const pending = Array.from(this.proxies.values());
362
+ this.proxies.clear();
363
+ await Promise.all(
364
+ pending.map((p) =>
365
+ p.then(
366
+ (proxy) => proxy.stop(),
367
+ () => undefined,
368
+ ),
369
+ ),
370
+ );
371
+ }
372
+ }
373
+
374
+ /** Shared pool. One proxy per upstream origin for the lifetime of the daemon process. */
375
+ export const sharedMcpProxyPool = new McpProxyPool();
@@ -0,0 +1,37 @@
1
+ import { runAcpProvider } from "@getpaseo/plugin/server/acp";
2
+ import type {
3
+ ProviderConnectRequest,
4
+ ProviderConnection,
5
+ ProviderRegistration,
6
+ } from "@getpaseo/plugin/server/provider";
7
+ import { createAntigravityConnector } from "./connector.js";
8
+ import { SteeringConnection } from "./steering.js";
9
+
10
+ /**
11
+ * Creates the Antigravity provider registration for Paseo.
12
+ *
13
+ * Uses `createAntigravityConnector` to launch and stream to the local `agy_acp_server`,
14
+ * filtering redundant mode controls, discovering and advertising skills, and expanding
15
+ * slash commands. Wraps the established connection with `SteeringConnection` to support
16
+ * mid-turn steering (`prompt.steer`).
17
+ */
18
+ export function createAntigravityProvider(): ProviderRegistration {
19
+ const baseProvider = runAcpProvider({
20
+ id: "refined-antigravity-acp",
21
+ label: "Antigravity",
22
+ description: "Connect Paseo to Google Antigravity via ACP",
23
+ icon: "assets/icon.svg",
24
+ connector: createAntigravityConnector(),
25
+ acpOptions: {
26
+ waitForInitialCommands: true,
27
+ },
28
+ });
29
+
30
+ return {
31
+ ...baseProvider,
32
+ async connect(request: ProviderConnectRequest): Promise<ProviderConnection> {
33
+ const connection = await baseProvider.connect(request);
34
+ return new SteeringConnection(connection, request);
35
+ },
36
+ };
37
+ }