agy-plugin-porter 0.1.0 → 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,250 @@
1
+ // One Antigravity conversation's copy of a ported Claude Code mod. Each conversation
2
+ // gets its own module instance, so module variables and $.state are per conversation,
3
+ // like Claude Code's per-session state (live, Phase 2.5A).
4
+ import { readFile } from 'node:fs/promises';
5
+
6
+ import { ELEMENTS, serialize } from './elements.mjs';
7
+ import { overwriteOf, toClaudeCall } from './tool-calls.mjs';
8
+
9
+ const matches = (matcher, e) => !matcher || Object.entries(matcher).every(([k, v]) => e[k] === v);
10
+ const quiet = (p) => { p.catch(() => {}); return p; };
11
+
12
+ // $ methods with no Antigravity equivalent that optional features call: they resolve
13
+ // to nothing so the mod keeps working (plan §5.5).
14
+ const NO_OPS = new Set(['ui.blit', 'ui.selection', 'session.messages']);
15
+ const NO_OP_NOUNS = new Set(['model', 'agent']);
16
+
17
+ export class Instance {
18
+ constructor(engine, conv, cwd) {
19
+ Object.assign(this, { engine, conv, cwd: cwd ?? '' });
20
+ this.hooks = [];
21
+ this.state = new Map();
22
+ this.panes = new Map();
23
+ this.commands = new Map();
24
+ this.tools = new Map();
25
+ this.callbacks = new Map();
26
+ this.pending = [];
27
+ this.inTurn = false;
28
+ this.status = undefined;
29
+ this.renderQueued = false;
30
+ this.$ = this.dollar();
31
+ }
32
+
33
+ async load() {
34
+ const mod = await import(`${this.engine.modUrl}?conv=${encodeURIComponent(this.conv || '_none')}`);
35
+ const register = mod.register ?? mod.default?.register ?? mod.default;
36
+ if (typeof register !== 'function') throw new Error('the mod exports no register function');
37
+ const on = (event, a, b) => {
38
+ const hook = b ? { event, matcher: a, fn: b } : { event, matcher: null, fn: a };
39
+ this.hooks.push(hook);
40
+ // on(...).catch(fn): fn($, e, next) runs when the handler throws.
41
+ const registration = { catch: (fn) => { hook.onError = fn; return registration; } };
42
+ return registration;
43
+ };
44
+ await register(on, this.engine.options);
45
+ await this.dispatch('session.start', { cwd: this.cwd, source: 'startup' }, () => ({}));
46
+ }
47
+
48
+ has(event) { return this.hooks.some((h) => h.event === event); }
49
+
50
+ // Claude's middleware chain: each handler gets next(), the last next() runs `core`.
51
+ // next() runs the rest once; a second call (from a .catch handler) gets the same result.
52
+ dispatch(event, e, core) {
53
+ const hooks = this.hooks.filter((h) => h.event === event && matches(h.matcher, e));
54
+ const run = (i, input) => {
55
+ if (i >= hooks.length) return Promise.resolve(core(input));
56
+ const hook = hooks[i];
57
+ let result;
58
+ const next = (changed = input) => {
59
+ if (!next.called) { next.called = true; result = run(i + 1, changed); }
60
+ return result;
61
+ };
62
+ next.called = false;
63
+ const frozen = Object.freeze({ ...input });
64
+ return Promise.resolve().then(() => hook.fn(this.$, frozen, next)).catch((err) => {
65
+ if (!hook.onError) throw err;
66
+ this.engine.log('handler-error', { conv: this.conv, event, message: err?.message ?? String(err) });
67
+ return hook.onError(this.$, frozen, next, err);
68
+ });
69
+ };
70
+ return run(0, e);
71
+ }
72
+
73
+ emit(msg) { this.engine.emit(this.conv, msg); }
74
+ // Only views the person can see count: Hangar keeps hidden copies of every view.
75
+ get isPlaced() { return this.engine.visibleViews(this.conv) > 0; }
76
+
77
+ impl() {
78
+ const inst = this;
79
+ const { store } = this.engine;
80
+ return {
81
+ ui: {
82
+ resolve: () => ELEMENTS,
83
+ invalidate: () => { inst.scheduleRender(); },
84
+ open: async ({ id, title }) => {
85
+ inst.panes.set(id, { id, title: title ?? id });
86
+ inst.scheduleRender();
87
+ return { id, isPlaced: inst.isPlaced };
88
+ },
89
+ close: async ({ id }) => { inst.panes.delete(id); inst.scheduleRender(); return {}; },
90
+ panes: async () => [...inst.panes.values()].map((p) => ({ id: p.id, title: p.title, isPlaced: inst.isPlaced, isShown: inst.isPlaced })),
91
+ status: async (text) => { inst.status = text ?? undefined; inst.emit({ type: 'status', text: text ?? null }); },
92
+ toast: async (text) => { inst.emit({ type: 'toast', text: String(text) }); },
93
+ copy: async ({ text }) => {
94
+ if (!inst.isPlaced) return { isCopied: false, reason: 'no view is open' };
95
+ inst.emit({ type: 'copy', text: String(text) });
96
+ return { isCopied: true };
97
+ },
98
+ },
99
+ // Never drafts or sends into agy's prompt box (it can't): copies, and says so.
100
+ prompt: {
101
+ fill: async ({ text }) => {
102
+ if (!inst.isPlaced) return { isFilled: false, reason: 'no view is open' };
103
+ inst.emit({ type: 'fill', text: String(text) });
104
+ return { isFilled: true };
105
+ },
106
+ },
107
+ state: {
108
+ get: async (key) => inst.state.get(key),
109
+ set: async (key, value) => { inst.state.set(key, value); inst.scheduleRender(); return {}; },
110
+ },
111
+ store: {
112
+ keys: async () => Object.keys(store.data),
113
+ get: async (key) => (Object.hasOwn(store.data, key) ? store.data[key] : undefined),
114
+ set: async (key, value) => { store.data[key] = value; store.save(); },
115
+ delete: async (key) => { delete store.data[key]; store.save(); },
116
+ },
117
+ clock: { now: async () => Date.now() },
118
+ fs: { read: async (path) => readFile(path, 'utf8') },
119
+ session: { cwd: async () => inst.cwd, id: async () => inst.conv },
120
+ command: { register: async (def) => { inst.commands.set(String(def.name), { ...def }); inst.scheduleRender(); return {}; } },
121
+ tool: { register: async (def) => { inst.tools.set(String(def.name), { ...def }); return {}; } },
122
+ };
123
+ }
124
+
125
+ // $ exposes only what the static scan found (mod.json `allow`); anything else throws.
126
+ dollar() {
127
+ const impl = this.impl();
128
+ const allow = new Set(this.engine.mod.allow ?? []);
129
+ const plugin = Object.freeze({ name: this.engine.mod.pluginName ?? this.engine.mod.plugin });
130
+ return new Proxy(Object.create(null), {
131
+ get: (_, noun) => {
132
+ if (typeof noun !== 'string' || noun === 'then') return undefined;
133
+ if (noun === 'plugin') {
134
+ if (!allow.has('plugin.name')) throw new Error("$.plugin wasn't in the mod's static scan");
135
+ return plugin;
136
+ }
137
+ return new Proxy(Object.create(null), {
138
+ get: (__, method) => {
139
+ if (typeof method !== 'string' || method === 'then') return undefined;
140
+ const name = `${noun}.${method}`;
141
+ if (!allow.has(name)) throw new Error(`$.${name} wasn't in the mod's static scan`);
142
+ if (NO_OPS.has(name) || NO_OP_NOUNS.has(noun)) return async () => undefined;
143
+ const fn = Object.hasOwn(impl, noun) && Object.hasOwn(impl[noun], method) ? impl[noun][method] : null;
144
+ return fn ?? (() => quiet(Promise.reject(new Error(`$.${name} is not available in the Antigravity port`))));
145
+ },
146
+ });
147
+ },
148
+ });
149
+ }
150
+
151
+ scheduleRender() {
152
+ if (this.renderQueued) return;
153
+ this.renderQueued = true;
154
+ setTimeout(() => {
155
+ this.renderQueued = false;
156
+ this.render().catch((err) => this.engine.log('render-error', { conv: this.conv, message: err.message }));
157
+ }, 0);
158
+ }
159
+
160
+ async renderOne(e, cb) {
161
+ try {
162
+ return { tree: serialize(await this.dispatch('ui.render', e, () => null), cb) };
163
+ } catch (err) {
164
+ return { tree: null, error: err.message };
165
+ }
166
+ }
167
+
168
+ async render() {
169
+ const callbacks = new Map();
170
+ const gen = (this.gen = (this.gen ?? 0) + 1);
171
+ let n = 0;
172
+ const cb = (fn) => { const id = `${gen}.${n++}`; callbacks.set(id, fn); return id; };
173
+ const props = { bodyColumns: 80, bodyRows: 40 };
174
+ const panes = [];
175
+ for (const p of this.panes.values()) {
176
+ const e = { component: 'Pane', requestId: p.id, surface: 'desktop', props };
177
+ panes.push({ id: p.id, title: p.title, ...(await this.renderOne(e, cb)) });
178
+ }
179
+ // AbovePrompt and PromptHint draw as a strip above the panes (plan §5.5).
180
+ const strips = [];
181
+ for (const component of ['AbovePrompt', 'PromptHint']) {
182
+ if (!this.hooks.some((h) => h.event === 'ui.render' && h.matcher?.component === component)) continue;
183
+ const r = await this.renderOne({ component, surface: 'desktop', props }, cb);
184
+ if (r.tree) strips.push({ component, tree: r.tree });
185
+ }
186
+ // Keep the previous render's callbacks too, for a click that crossed a redraw.
187
+ this.callbacks = new Map([...(this.lastCallbacks ?? []), ...callbacks]);
188
+ this.lastCallbacks = callbacks;
189
+ this.snapshot = { type: 'state', panes, strips, status: this.status ?? null, commands: [...this.commands.values()] };
190
+ this.emit(this.snapshot);
191
+ }
192
+
193
+ async act(id, args) {
194
+ const fn = this.callbacks.get(id);
195
+ if (!fn) throw new Error('that control is gone; the pane was redrawn');
196
+ await fn(...args);
197
+ this.scheduleRender();
198
+ }
199
+
200
+ // PreToolUse: run the tool.call chain until a handler returns or calls next().
201
+ // A handler waiting in next() resumes at PostToolUse (plan §5.2).
202
+ async preToolUse(payload) {
203
+ const { e, args } = toClaudeCall(payload.toolCall);
204
+ let reached;
205
+ const reachedNext = new Promise((r) => { reached = r; });
206
+ const core = (e2) => {
207
+ reached({ e2 });
208
+ return new Promise((resolve) => this.pending.push({ stepIdx: payload.stepIdx, tool: e.tool, at: Date.now(), resolve }));
209
+ };
210
+ const handled = this.dispatch('tool.call', e, core).then((returned) => ({ returned }), (error) => ({ error }));
211
+ let timer;
212
+ const timeout = new Promise((r) => { timer = setTimeout(() => r({ timeout: true }), this.engine.preToolWaitMs); });
213
+ const first = await Promise.race([reachedNext, handled, timeout]);
214
+ clearTimeout(timer);
215
+ if (first.e2) {
216
+ const overwrite = overwriteOf(args, e, first.e2);
217
+ return { decision: overwrite ? { decision: 'allow', overwrite } : null, path: overwrite ? 'next(changed)' : 'next' };
218
+ }
219
+ if (first.returned?.deny) return { decision: { decision: 'deny', reason: String(first.returned.deny) }, path: 'deny' };
220
+ if (first.error) this.engine.log('tool-call-error', { conv: this.conv, message: first.error?.message ?? String(first.error) });
221
+ return { decision: null, path: first.timeout ? 'timeout' : first.error ? 'error' : 'returned' };
222
+ }
223
+
224
+ // PostToolUse: resume the suspended handler with the same stepIdx (live: Post
225
+ // carries its Pre's stepIdx), else the oldest one for the same tool.
226
+ postToolUse(payload) {
227
+ this.dropStale();
228
+ const { e } = toClaudeCall(payload.toolCall);
229
+ let i = this.pending.findIndex((p) => p.stepIdx !== undefined && p.stepIdx === payload.stepIdx);
230
+ const by = i >= 0 ? 'stepIdx' : 'fifo';
231
+ if (i < 0) i = this.pending.findIndex((p) => p.tool === e.tool);
232
+ if (i < 0) return 'no suspended handler';
233
+ const [p] = this.pending.splice(i, 1);
234
+ const error = payload.error || '';
235
+ p.resolve(error ? { result: undefined, text: error, isError: true, error } : { result: undefined, text: '', isError: false });
236
+ return `resumed (${by})`;
237
+ }
238
+
239
+ // Handlers whose PostToolUse never came (a denied call, a lost event).
240
+ dropPending(reason) {
241
+ for (const p of this.pending.splice(0)) p.resolve({ result: undefined, text: reason, isError: true, error: reason });
242
+ }
243
+
244
+ dropStale(now = Date.now()) {
245
+ const stale = this.pending.filter((p) => now - p.at > this.engine.pendingMaxMs);
246
+ this.pending = this.pending.filter((p) => !stale.includes(p));
247
+ for (const p of stale) p.resolve({ result: undefined, text: 'no PostToolUse in time', isError: true, error: 'no PostToolUse in time' });
248
+ return stale.length;
249
+ }
250
+ }
@@ -0,0 +1,45 @@
1
+ // The sidecar's server_info.json and its exit. On Windows, quitting the app
2
+ // hard-kills the language server and orphans every sidecar (live,
3
+ // ui-mods-baseline.md), so the sidecar exits once ANTIGRAVITY_LS_ADDRESS stops
4
+ // answering. An orphan can notice that after its replacement started, so it
5
+ // removes server_info.json only while the file still holds its own pid (live fix).
6
+ import { readFileSync, rmSync, writeFileSync } from 'node:fs';
7
+ import { connect } from 'node:net';
8
+ import { join } from 'node:path';
9
+
10
+ export function writeServerInfo(dataDir, info) {
11
+ writeFileSync(join(dataDir, 'server_info.json'), JSON.stringify({ ...info, pid: process.pid }, null, 2));
12
+ }
13
+
14
+ export function removeServerInfoIfOurs(dataDir, pid = process.pid) {
15
+ const file = join(dataDir, 'server_info.json');
16
+ try {
17
+ if (JSON.parse(readFileSync(file, 'utf8')).pid !== pid) return false;
18
+ rmSync(file, { force: true });
19
+ return true;
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ // Calls onGone after two failed connects in a row, 5 s apart.
26
+ export function watchLanguageServer(address, onGone, { everyMs = 5000 } = {}) {
27
+ const m = /([^/:]+|\[[^\]]+\]):(\d+)/.exec(address ?? '');
28
+ if (!m) return null;
29
+ let failures = 0;
30
+ return setInterval(() => {
31
+ const socket = connect({ host: m[1].replace(/^\[|\]$/g, ''), port: Number(m[2]) });
32
+ const done = (alive) => {
33
+ socket.destroy();
34
+ failures = alive ? 0 : failures + 1;
35
+ if (failures >= 2) onGone('language server gone');
36
+ };
37
+ socket.setTimeout(2000, () => done(false));
38
+ socket.once('connect', () => done(true));
39
+ socket.once('error', () => done(false));
40
+ }, everyMs).unref();
41
+ }
42
+
43
+ export function stopOnSignals(stop) {
44
+ for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGBREAK']) process.on(sig, () => stop(sig));
45
+ }
@@ -0,0 +1,48 @@
1
+ // Shared by the sidecar, the MCP server and the hook forwarder. mod.json (next to
2
+ // this file, written by agy-plugin-porter) names the plugin, the sidecar and what the
3
+ // mod's static scan found. Finds the sidecar's data dir the way Google's science
4
+ // plugin does, and calls the sidecar through server_info.json.
5
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
6
+ import { homedir } from 'node:os';
7
+ import { join } from 'node:path';
8
+
9
+ export const MOD = JSON.parse(readFileSync(new URL('./mod.json', import.meta.url), 'utf8'));
10
+ export const LOOPBACK = '127.0.0.1';
11
+
12
+ // ANTIGRAVITY_EXECUTABLE_DATA_DIR (set for the sidecar only, live), else the first
13
+ // ~/.gemini/<product>/sidecar_data/<plugin>/<sidecar>/data that exists.
14
+ export function findDataDir() {
15
+ const env = process.env.ANTIGRAVITY_EXECUTABLE_DATA_DIR?.trim();
16
+ if (env) return env;
17
+ const gemini = join(homedir(), '.gemini');
18
+ let products = [];
19
+ try { products = readdirSync(gemini, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name); } catch {}
20
+ for (const product of products) {
21
+ const dir = join(gemini, product, 'sidecar_data', MOD.plugin, MOD.sidecar, 'data');
22
+ if (existsSync(dir)) return dir;
23
+ }
24
+ return null;
25
+ }
26
+
27
+ export function readServerInfo(dir = findDataDir()) {
28
+ if (!dir) return null;
29
+ try { return JSON.parse(readFileSync(join(dir, 'server_info.json'), 'utf8')); } catch { return null; }
30
+ }
31
+
32
+ // Loopback only, whatever server_info.json says. Re-reads the file on every call:
33
+ // a restart changes the port and token, and orphans leave stale files behind (live).
34
+ export async function callSidecar(path, body, timeoutMs = 2000) {
35
+ const info = readServerInfo();
36
+ if (!info) throw new Error('sidecar not running (no server_info.json)');
37
+ const port = Number(info.port);
38
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('bad port in server_info.json');
39
+ if (typeof info.token !== 'string' || !/^[0-9a-f]{16,128}$/.test(info.token)) throw new Error('bad token in server_info.json');
40
+ const res = await fetch(`http://${LOOPBACK}:${port}${path}`, {
41
+ method: body === undefined ? 'GET' : 'POST',
42
+ headers: { 'Content-Type': 'application/json', 'X-Sidecar-Token': info.token },
43
+ body: body === undefined ? undefined : JSON.stringify(body),
44
+ signal: AbortSignal.timeout(timeoutMs),
45
+ });
46
+ if (!res.ok) throw new Error(`HTTP ${res.status} from the sidecar`);
47
+ return res.json();
48
+ }
@@ -0,0 +1,55 @@
1
+ // Stdio MCP server for a ported Claude Code mod: the tools the mod registers with
2
+ // $.tool.register, plus one slash_<name> tool per $.command.register. Forwards to the
3
+ // sidecar. With the sidecar down it lists what the static scan found (mod.json) and
4
+ // fails calls with a clear error.
5
+ import { createInterface } from 'node:readline';
6
+
7
+ import { MOD, callSidecar } from './locate.mjs';
8
+
9
+ const CONVERSATION_META = 'antigravity.google/conversation_id';
10
+ const write = (msg) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', ...msg }) + '\n');
11
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
12
+ const OPEN_SCHEMA = { type: 'object', properties: {} };
13
+
14
+ const scannedTools = () => [
15
+ ...(MOD.tools ?? []).map((t) => ({ name: t.name, description: t.description ?? '', inputSchema: OPEN_SCHEMA })),
16
+ ...(MOD.commands ?? []).map((c) => ({
17
+ name: `slash_${c.name}`,
18
+ description: `Runs the /${c.name} command: ${c.description ?? ''}`.trim(),
19
+ inputSchema: { type: 'object', properties: { args: { type: 'string' } } },
20
+ })),
21
+ ];
22
+
23
+ // The sidecar may still be starting when agy asks for the list: retry for ~6 s.
24
+ async function listTools() {
25
+ for (let i = 0; i < 6; i++) {
26
+ try { return (await callSidecar('/api/mcp', { op: 'list' })).tools; } catch { await wait(500); }
27
+ }
28
+ return scannedTools();
29
+ }
30
+
31
+ createInterface({ input: process.stdin }).on('line', async (line) => {
32
+ let msg;
33
+ try { msg = JSON.parse(line); } catch { return; }
34
+ const { id, method, params = {} } = msg ?? {};
35
+ switch (method) {
36
+ case 'initialize':
37
+ return write({ id, result: { protocolVersion: params.protocolVersion ?? '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: MOD.mcpServer, version: MOD.version ?? '0.0.0' } } });
38
+ case 'ping':
39
+ return write({ id, result: {} });
40
+ case 'tools/list':
41
+ return write({ id, result: { tools: await listTools() } });
42
+ case 'tools/call': {
43
+ const conv = params._meta?.[CONVERSATION_META] ?? '';
44
+ try {
45
+ // A mod's own tool may do real work, so calls get longer than the 2 s default.
46
+ const r = await callSidecar('/api/mcp', { op: 'call', name: params.name, args: params.arguments ?? {}, conv }, 20_000);
47
+ return write({ id, result: { content: [{ type: 'text', text: r.text }], isError: Boolean(r.isError) } });
48
+ } catch (err) {
49
+ return write({ id, result: { content: [{ type: 'text', text: `${MOD.title} isn't running, so ${params.name} can't run (${err.message}).` }], isError: true } });
50
+ }
51
+ }
52
+ default:
53
+ if (id !== undefined) write({ id, error: { code: -32601, message: `method not found: ${method}` } });
54
+ }
55
+ });
@@ -0,0 +1,178 @@
1
+ // Sidecar for a Claude Code mod ported by agy-plugin-porter. Hosts the mod (engine.mjs)
2
+ // and serves its panes to the side-pane view, the hook forwarder (hook.mjs) and the
3
+ // MCP server (mcp-entry.mjs). Zero dependencies. The view's static files and the SDK's
4
+ // preload.js are public; every /api route needs this server's token (server_info.json)
5
+ // or the host's ANTIGRAVITY_SIDECAR_UI_TOKEN (the view's ?token=).
6
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
7
+ import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
8
+ import { createServer } from 'node:http';
9
+ import { homedir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { pathToFileURL } from 'node:url';
12
+
13
+ import { Engine } from './engine.mjs';
14
+ import { removeServerInfoIfOurs, stopOnSignals, watchLanguageServer, writeServerInfo } from './lifecycle.mjs';
15
+ import { LOOPBACK, MOD } from './locate.mjs';
16
+
17
+ const DATA_DIR = process.env.ANTIGRAVITY_EXECUTABLE_DATA_DIR?.trim()
18
+ || join(homedir(), '.gemini', 'antigravity', 'sidecar_data', MOD.plugin, MOD.sidecar, 'data');
19
+ mkdirSync(DATA_DIR, { recursive: true });
20
+ const tokens = [randomBytes(24).toString('hex'), process.env.ANTIGRAVITY_SIDECAR_UI_TOKEN].filter(Boolean);
21
+ const views = new Map(); // conversation id -> Map(view id -> {res, visible})
22
+ let nextView = 1;
23
+
24
+ function log(event, data = {}) {
25
+ try { appendFileSync(join(DATA_DIR, 'runtime.log'), JSON.stringify({ at: new Date().toISOString(), pid: process.pid, event, ...data }) + '\n'); } catch {}
26
+ }
27
+ process.on('unhandledRejection', (err) => log('unhandled-rejection', { message: err?.message ?? String(err) }));
28
+
29
+ const engine = new Engine({
30
+ mod: MOD,
31
+ modUrl: pathToFileURL(join(import.meta.dirname, MOD.entry)).href,
32
+ dataDir: DATA_DIR,
33
+ log,
34
+ visibleViews: (conv) => [...(views.get(conv)?.values() ?? [])].filter((v) => v.visible).length,
35
+ emit: (conv, msg) => {
36
+ const text = `data: ${JSON.stringify(msg)}\n\n`;
37
+ for (const v of views.get(conv)?.values() ?? []) v.res.write(text);
38
+ },
39
+ });
40
+
41
+ const pill = `[Open ${MOD.title}](sidecar://${MOD.plugin}/${MOD.sidecar}/ui.html)`;
42
+
43
+ function send(res, status, body, type = 'application/json; charset=utf-8') {
44
+ res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' });
45
+ res.end(typeof body === 'string' ? body : JSON.stringify(body));
46
+ }
47
+
48
+ function readJson(req) {
49
+ return new Promise((resolve) => {
50
+ let raw = '';
51
+ req.on('data', (c) => { if (raw.length < 4e6) raw += c; });
52
+ req.on('end', () => { try { const v = raw ? JSON.parse(raw) : {}; resolve(v && typeof v === 'object' ? v : {}); } catch { resolve({}); } });
53
+ });
54
+ }
55
+
56
+ // The SDK's preload.js gives the view window.sidecar (live: the host doesn't).
57
+ // Same lookup as science's server.cjs: ANTIGRAVITY_APP_DATA_DIR, else ~/.gemini/antigravity.
58
+ function preloadJs() {
59
+ const appDir = process.env.ANTIGRAVITY_APP_DATA_DIR?.trim() || join(homedir(), '.gemini', 'antigravity');
60
+ try { return readFileSync(join(appDir, 'lib', 'node_modules', 'sidecar_sdk', 'preload.js'), 'utf8'); } catch { return null; }
61
+ }
62
+
63
+ const STATIC = { '/ui.html': 'text/html', '/ui.js': 'application/javascript', '/ui-draw.js': 'application/javascript' };
64
+
65
+ // A view's conversation: a real id, or '' for a new-conversation view (no data shown).
66
+ const convOf = (v) => (typeof v === 'string' && v && v !== '_new' ? v : '');
67
+
68
+ const routes = {
69
+ 'GET /api/events': async (req, res, url) => {
70
+ const conv = convOf(url.searchParams.get('conv'));
71
+ const id = String(nextView++);
72
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive' });
73
+ res.write(`data: ${JSON.stringify({ type: 'hello', mod: MOD.title, conv, view: id })}\n\n`);
74
+ if (!conv) return;
75
+ if (!views.has(conv)) views.set(conv, new Map());
76
+ views.get(conv).set(id, { res, visible: false });
77
+ req.on('close', () => views.get(conv)?.delete(id));
78
+ log('view-open', { conv, view: id, views: views.get(conv).size });
79
+ const inst = await engine.get(conv);
80
+ await inst.render();
81
+ },
82
+ // The view reports its workspace, which is the conversation's cwd until a hook says otherwise.
83
+ 'POST /api/view': async (req, res, url, body) => {
84
+ const conv = convOf(body.conv);
85
+ if (conv) await engine.get(conv, typeof body.cwd === 'string' && body.cwd ? body.cwd : undefined);
86
+ send(res, 200, { ok: true });
87
+ },
88
+ // visibilityState + IntersectionObserver from the view: hidden copies don't count.
89
+ 'POST /api/visibility': async (req, res, url, body) => {
90
+ const v = views.get(convOf(body.conv))?.get(String(body.view));
91
+ if (v) v.visible = body.visible === true;
92
+ send(res, 200, { ok: Boolean(v) });
93
+ },
94
+ 'POST /api/action': async (req, res, url, body) => {
95
+ const inst = await engine.get(convOf(body.conv));
96
+ await inst.act(String(body.cb), Array.isArray(body.args) ? body.args : []);
97
+ send(res, 200, { ok: true });
98
+ },
99
+ 'POST /api/command': async (req, res, url, body) => {
100
+ const inst = await engine.get(convOf(body.conv));
101
+ const r = await engine.runCommand(inst, String(body.command), typeof body.args === 'string' ? body.args : '', 'view');
102
+ if (r.text) inst.emit({ type: 'toast', text: r.text });
103
+ send(res, 200, r);
104
+ },
105
+ 'POST /api/close': async (req, res, url, body) => {
106
+ const inst = await engine.get(convOf(body.conv));
107
+ inst.panes.delete(String(body.id));
108
+ await inst.dispatch('ui.close', { id: String(body.id), origin: { kind: 'person' } }, () => undefined);
109
+ await inst.render();
110
+ send(res, 200, { ok: true });
111
+ },
112
+ 'POST /api/hook': async (req, res, url, body) => {
113
+ const started = Date.now();
114
+ const payload = body.payload && typeof body.payload === 'object' ? body.payload : {};
115
+ const out = await engine.hook(String(body.event), payload);
116
+ log('hook', { hookEvent: body.event, ms: Date.now() - started, path: out.path, stdout: out.stdout,
117
+ conv: payload.conversationId, stepIdx: payload.stepIdx, tool: payload.toolCall?.name, error: payload.error });
118
+ send(res, 200, out);
119
+ },
120
+ 'POST /api/mcp': async (req, res, url, body) => {
121
+ if (body.op === 'list') return send(res, 200, { tools: await engine.listTools() });
122
+ const name = String(body.name);
123
+ const r = await engine.callTool(convOf(body.conv), name, body.args && typeof body.args === 'object' ? body.args : {});
124
+ log('mcp-call', { name, conv: body.conv, isError: r.isError });
125
+ // A server can't open a view (live), so command results say how to.
126
+ const link = name.startsWith('slash_') && MOD.hasPane ? `\n\nGive the user this link on its own line, exactly: ${pill}` : '';
127
+ send(res, 200, { isError: r.isError, text: `${r.text || '(done)'}${link}` });
128
+ },
129
+ 'GET /api/debug': (req, res) => send(res, 200, {
130
+ instances: [...engine.instances.values()].map(({ inst }) => ({
131
+ conv: inst.conv, cwd: inst.cwd, panes: [...inst.panes.keys()], pending: inst.pending.length,
132
+ views: views.get(inst.conv)?.size ?? 0, visibleViews: engine.visibleViews(inst.conv),
133
+ })),
134
+ }),
135
+ };
136
+
137
+ function tokenOk(t) {
138
+ if (typeof t !== 'string' || !t) return false;
139
+ const given = Buffer.from(t);
140
+ return tokens.some((k) => { const want = Buffer.from(k); return want.length === given.length && timingSafeEqual(want, given); });
141
+ }
142
+
143
+ const server = createServer(async (req, res) => {
144
+ const url = new URL(req.url, `http://${LOOPBACK}`);
145
+ if (req.method === 'GET' && url.pathname === '/preload.js') {
146
+ const js = preloadJs();
147
+ return js ? send(res, 200, js, 'application/javascript; charset=utf-8') : send(res, 404, { error: 'sidecar_sdk preload.js not found' });
148
+ }
149
+ if (req.method === 'GET' && Object.hasOwn(STATIC, url.pathname)) {
150
+ return send(res, 200, readFileSync(join(import.meta.dirname, url.pathname.slice(1)), 'utf8'), `${STATIC[url.pathname]}; charset=utf-8`);
151
+ }
152
+ if (!tokenOk(req.headers['x-sidecar-token'] || url.searchParams.get('token'))) return send(res, 401, { error: 'no valid token' });
153
+ const key = `${req.method} ${url.pathname}`;
154
+ if (!Object.hasOwn(routes, key)) return send(res, 404, { error: 'not found' });
155
+ try {
156
+ await routes[key](req, res, url, req.method === 'POST' ? await readJson(req) : null);
157
+ } catch (err) {
158
+ log('route-error', { path: url.pathname, message: err.message });
159
+ if (!res.headersSent) send(res, 500, { error: err.message });
160
+ }
161
+ });
162
+
163
+ function stop(reason) {
164
+ log('stop', { reason });
165
+ removeServerInfoIfOurs(DATA_DIR);
166
+ process.exit(0);
167
+ }
168
+
169
+ const webPort = Number.parseInt(process.env.ANTIGRAVITY_SIDECAR_WEB_PORT ?? '', 10);
170
+ server.on('error', (err) => { log('listen-error', { message: err.message }); process.exit(1); });
171
+ server.listen(Number.isNaN(webPort) ? 0 : webPort, LOOPBACK, () => {
172
+ const { port } = server.address();
173
+ writeServerInfo(DATA_DIR, { host: LOOPBACK, port, token: tokens[0] });
174
+ log('start', { port, node: process.version, dataDir: DATA_DIR, mod: MOD.entry, hostToken: tokens.length > 1 });
175
+ console.log(`[${MOD.plugin}/${MOD.sidecar}] listening on ${LOOPBACK}:${port}`);
176
+ watchLanguageServer(process.env.ANTIGRAVITY_LS_ADDRESS, stop);
177
+ });
178
+ stopOnSignals(stop);
@@ -0,0 +1,83 @@
1
+ // agy tool calls <-> Claude Code tool.call input. The names match lib/tool-map.mjs
2
+ // (test/mod-runtime.test.mjs checks it); this copy ships alone inside each sidecar.
3
+ // Arg names: the agy CLI's, which the desktop app uses too (live, ui-mods-baseline.md).
4
+ const AGY_TO_CLAUDE = {
5
+ run_command: 'Bash', view_file: 'Read', read_file: 'Read', write_to_file: 'Write', write_file: 'Write',
6
+ replace_file_content: 'Edit', edit_file: 'Edit', multi_replace_file_content: 'MultiEdit', grep_search: 'Grep',
7
+ find_by_name: 'Glob', list_dir: 'LS', read_url_content: 'WebFetch', search_web: 'WebSearch',
8
+ notebook_edit: 'NotebookEdit', invoke_subagent: 'Task',
9
+ };
10
+ const ARG_NAMES = {
11
+ CommandLine: 'command', Path: 'file_path', TargetFile: 'file_path', AbsolutePath: 'file_path',
12
+ Content: 'content', CodeContent: 'content', Target: 'old_string', Replacement: 'new_string',
13
+ };
14
+ // Tool args come from the model; never let one reach an object's prototype.
15
+ const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
16
+
17
+ export const claudeToolName = (agyName) => AGY_TO_CLAUDE[agyName] ?? agyName;
18
+
19
+ function safeJson(text) { try { return JSON.parse(text); } catch { return {}; } }
20
+
21
+ // Returns {e, args}: the Claude-shaped event and the raw agy args.
22
+ export function toClaudeCall(toolCall) {
23
+ const raw = typeof toolCall?.args === 'string' ? safeJson(toolCall.args) : toolCall?.args;
24
+ const args = raw && typeof raw === 'object' ? raw : {};
25
+ const toolName = toolCall?.name ?? '';
26
+ if ((toolName === 'call_mcp_tool' || toolName === 'mcp_tool') && (args.ServerName || args.server_name) && (args.ToolName || args.tool_name)) {
27
+ const server = args.ServerName ?? args.server_name;
28
+ const tool = args.ToolName ?? args.tool_name;
29
+ const argKey = 'Arguments' in args ? 'Arguments' : ('arguments' in args ? 'arguments' : 'Arguments');
30
+ const rawInner = args[argKey];
31
+ const inner = typeof rawInner === 'string' ? safeJson(rawInner) : (rawInner && typeof rawInner === 'object' ? rawInner : {});
32
+ const e = { tool: `mcp__${server}__${tool}` };
33
+ for (const [k, v] of Object.entries(inner)) if (!UNSAFE_KEYS.has(k)) e[k] = v;
34
+ const taggedArgs = Object.defineProperty({ ...args }, '_mcpEnvelope', {
35
+ value: { argKey, wasString: typeof rawInner === 'string', inner },
36
+ enumerable: false,
37
+ });
38
+ return { e, args: taggedArgs };
39
+ }
40
+ let mappedTool = claudeToolName(toolName);
41
+ if (mappedTool === toolName && toolName.startsWith('mcp_') && toolName !== 'mcp_tool') {
42
+ const rest = toolName.slice(4);
43
+ const idx = rest.indexOf('_');
44
+ if (idx > 0) mappedTool = `mcp__${rest.slice(0, idx)}__${rest.slice(idx + 1)}`;
45
+ }
46
+ const e = { tool: mappedTool };
47
+ for (const [k, v] of Object.entries(args)) if (!UNSAFE_KEYS.has(k)) e[ARG_NAMES[k] ?? k] = v;
48
+ const chunks = args.ReplacementChunks;
49
+ if (Array.isArray(chunks) && chunks.length) {
50
+ e.old_string = chunks.map((c) => c?.TargetContent ?? '').join('\n…\n');
51
+ e.new_string = chunks.map((c) => c?.ReplacementContent ?? '').join('\n…\n');
52
+ }
53
+ return { e, args };
54
+ }
55
+
56
+ // The args a handler's next({...changed}) changed, under their agy names: an agy
57
+ // `overwrite`. Returns null when nothing changed.
58
+ export function overwriteOf(args, before, after) {
59
+ if (args?._mcpEnvelope) {
60
+ const { argKey, wasString, inner } = args._mcpEnvelope;
61
+ const nextInner = { ...inner };
62
+ let changed = false;
63
+ for (const k of Object.keys({ ...inner, ...after })) {
64
+ if (k === 'tool' || UNSAFE_KEYS.has(k)) continue;
65
+ if (after[k] !== before[k]) {
66
+ nextInner[k] = after[k];
67
+ changed = true;
68
+ }
69
+ }
70
+ if (!changed) return null;
71
+ return {
72
+ ...args,
73
+ [argKey]: wasString ? JSON.stringify(nextInner) : nextInner,
74
+ };
75
+ }
76
+ const out = {};
77
+ for (const k of Object.keys(args)) {
78
+ if (UNSAFE_KEYS.has(k)) continue;
79
+ const name = ARG_NAMES[k] ?? k;
80
+ if (after[name] !== before[name]) out[k] = after[name];
81
+ }
82
+ return Object.keys(out).length ? out : null;
83
+ }