@chatpanel/bridge 0.4.0 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/bridge",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "Local bridge that exposes the AI coding agents installed on your machine — Claude Code (CLI), Codex (CLI), and Gemini CLI — to the ChatPanel Chrome extension over a localhost SSE endpoint. Bring your own agent.",
6
6
  "keywords": [
@@ -1,12 +1,14 @@
1
- // Gemini engine — drives the Gemini CLI (`gemini -p`) using your local login.
1
+ // Antigravity engine — drives the Antigravity CLI (`agy -p`) using your local
2
+ // login. This replaces Gemini CLI as the default Google-model agent (the Gemini
3
+ // CLI is being deprecated for individual users).
2
4
  //
3
- // Like the Codex engine, this shells out to the installed `gemini` binary in
4
- // non-interactive mode: `gemini -p "<prompt>"` runs once, prints the answer to
5
- // stdout, and exits. We run in an empty scratch dir for general chat so Gemini
6
- // never crawls the bridge's own files; set a working dir on the agent to point
7
- // it at a real project.
5
+ // agy -p "<prompt>" → run one prompt non-interactively, print the answer, exit
6
+ // agy --model <id> pick the model agy models list models
8
7
  //
9
- // Install: `npm i -g @google/gemini-cli`, then run `gemini` once to sign in.
8
+ // Images: Antigravity has no image flag, but it READS image files referenced by
9
+ // path in the prompt (vision) — same approach as Claude Code. We write the image
10
+ // into the workspace (cwd), grant read access with --add-dir, and reference the
11
+ // path so the model opens it.
10
12
 
11
13
  import { spawn, spawnSync } from 'node:child_process';
12
14
  import { mkdirSync, writeFileSync, unlinkSync } from 'node:fs';
@@ -14,38 +16,50 @@ import os from 'node:os';
14
16
  import path from 'node:path';
15
17
  import { findAgentBin } from '../env.js';
16
18
 
17
- // Idle timeout: re-armed on every stdout/stderr chunk, so a long run that keeps
18
- // streaming never trips it — only true silence does. Override with
19
- // CHATPANEL_GEMINI_TIMEOUT_MS (ms).
20
- const IDLE_MS = Number(process.env.CHATPANEL_GEMINI_TIMEOUT_MS) || 180_000;
21
- const SCRATCH = path.join(os.tmpdir(), 'chatpanel-gemini-scratch');
19
+ const IDLE_MS = Number(process.env.CHATPANEL_AGY_TIMEOUT_MS) || 180_000;
20
+ const SCRATCH = path.join(os.tmpdir(), 'chatpanel-agy-scratch');
22
21
 
23
- // Gemini CLI has no "list models" command (--help only lists extensions/sessions)
24
- // and stores no model in settings.json, so offer the common current ids. Free
25
- // text still accepted.
22
+ // `agy models` lists available models. Parse ids best-effort; free text is still
23
+ // accepted by the picker, and [] just means "type a model or use the default".
26
24
  export async function listModels() {
27
- return ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.5-flash-lite'];
25
+ try {
26
+ const bin = findAgentBin('agy') || 'agy';
27
+ const r = spawnSync(bin, ['models'], { encoding: 'utf8', timeout: 15000 });
28
+ const ids = [];
29
+ const seen = new Set();
30
+ for (const line of String(r.stdout || '').split('\n')) {
31
+ const tok = line.trim().split(/\s+/)[0] || '';
32
+ if (!/^[A-Za-z0-9][\w./:-]{1,79}$/.test(tok)) continue;
33
+ if (/^(name|model|models|id|provider|available)$/i.test(tok)) continue;
34
+ if (seen.has(tok)) continue;
35
+ seen.add(tok);
36
+ ids.push(tok);
37
+ if (ids.length >= 100) break;
38
+ }
39
+ return ids;
40
+ } catch {
41
+ return [];
42
+ }
28
43
  }
29
44
 
30
45
  let installed = false;
31
46
  let lastProbe = 0;
32
47
  export async function available() {
33
- // Cache a positive result, but keep re-probing (throttled) while not found, so
34
- // it self-heals once gemini appears on PATH — never cache a negative forever.
48
+ // Cache a positive result; keep re-probing (throttled) while not found so it
49
+ // self-heals once agy appears on PATH — never cache a negative forever.
35
50
  if (!installed && Date.now() - lastProbe > 4000) {
36
51
  lastProbe = Date.now();
37
52
  try {
38
- installed = !!findAgentBin('gemini');
53
+ installed = !!findAgentBin('agy');
39
54
  } catch {
40
55
  installed = false;
41
56
  }
42
57
  }
43
58
  return installed
44
59
  ? { ok: true }
45
- : { ok: false, reason: 'gemini not found on PATH. Install @google/gemini-cli, then run `gemini` once to sign in.' };
60
+ : { ok: false, reason: 'agy not found on PATH. Install Antigravity, then run `agy` once to sign in.' };
46
61
  }
47
62
 
48
- // Write base64 data-URL images into `dir` so Gemini's `@<file>` can read them.
49
63
  function writeImages(images, dir) {
50
64
  const files = [];
51
65
  for (let i = 0; i < (images?.length || 0); i++) {
@@ -63,6 +77,7 @@ function writeImages(images, dir) {
63
77
  return files;
64
78
  }
65
79
 
80
+ // The bridge is stateless, so replay the conversation as a single prompt.
66
81
  function buildPrompt(messages, system) {
67
82
  let p = system ? `${system}\n\n` : '';
68
83
  const history = messages.slice(0, -1);
@@ -84,9 +99,9 @@ export async function chat({ messages, system, options, images }, emit) {
84
99
  }
85
100
  const cwd = options.workingDir ? path.resolve(options.workingDir) : SCRATCH;
86
101
 
87
- // Images: write them into the cwd so Gemini's `@file` reference (which reads
88
- // files including images as multimodal input) can resolve them. Cleaned up
89
- // after the run. Written into cwd (not tmp) because `@` resolves to the workspace.
102
+ // Images: write into the cwd (workspace) and reference with `@<file>` agy
103
+ // reads @-referenced files (incl. images) inline as multimodal input, so no
104
+ // read-tool approval is needed in headless `-p` mode. (Confirmed working.)
90
105
  const imageFiles = writeImages(images, cwd);
91
106
  const cleanup = () => imageFiles.forEach((f) => { try { unlinkSync(f); } catch { /* gone */ } });
92
107
  let prompt = buildPrompt(messages, system);
@@ -94,22 +109,20 @@ export async function chat({ messages, system, options, images }, emit) {
94
109
  prompt += `\n\nThe user attached image(s): ${imageFiles.map((f) => '@' + path.basename(f)).join(' ')}`;
95
110
  }
96
111
 
97
- // `-p` is non-interactive (no TTY prompts). `-m` picks the model. `-y` (yolo)
98
- // auto-approves tool calls when the user opted into bypassPermissions — without
99
- // it Gemini would block on an approval it can't show in a headless run.
112
+ // `-p` runs one prompt non-interactively. --model picks the model.
113
+ // --dangerously-skip-permissions auto-approves tool use (headless has no human
114
+ // approver) only when the user opted into bypassPermissions.
100
115
  const args = ['-p', prompt];
101
- if (options.model) args.push('-m', options.model);
102
- if (options.permissionMode === 'bypassPermissions') args.push('-y');
116
+ if (options.model) args.push('--model', options.model);
117
+ if (options.permissionMode === 'bypassPermissions') args.push('--dangerously-skip-permissions');
103
118
 
104
119
  await new Promise((resolve, reject) => {
105
120
  let child;
106
121
  try {
107
- // stdin ignored: the prompt is passed via -p, and no TTY means no
108
- // interactive "trust this folder?" dialog can block us.
109
- child = spawn('gemini', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env } });
122
+ child = spawn('agy', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env } });
110
123
  } catch (e) {
111
124
  cleanup();
112
- return reject(new Error(`Failed to start gemini: ${e.message}`));
125
+ return reject(new Error(`Failed to start agy: ${e.message}`));
113
126
  }
114
127
 
115
128
  let out = '';
@@ -121,7 +134,7 @@ export async function chat({ messages, system, options, images }, emit) {
121
134
  idleTimer = setTimeout(() => {
122
135
  child.kill('SIGKILL');
123
136
  cleanup();
124
- reject(new Error(`Gemini timed out — no output for ${Math.round(IDLE_MS / 1000)}s.`));
137
+ reject(new Error(`Antigravity timed out — no output for ${Math.round(IDLE_MS / 1000)}s.`));
125
138
  }, IDLE_MS);
126
139
  };
127
140
  armIdle();
@@ -137,7 +150,7 @@ export async function chat({ messages, system, options, images }, emit) {
137
150
  child.on('error', (e) => {
138
151
  clearTimeout(idleTimer);
139
152
  cleanup();
140
- reject(new Error(`Failed to start gemini: ${e.message}`));
153
+ reject(new Error(`Failed to start agy: ${e.message}`));
141
154
  });
142
155
  child.on('close', (code) => {
143
156
  clearTimeout(idleTimer);
@@ -147,7 +160,7 @@ export async function chat({ messages, system, options, images }, emit) {
147
160
  emit({ type: 'done', text: '' });
148
161
  resolve();
149
162
  } else {
150
- reject(new Error(`Gemini exited ${code}: ${err.trim() || out.trim() || 'failed'}`));
163
+ reject(new Error(`Antigravity exited ${code}: ${err.trim() || out.trim() || 'failed'}`));
151
164
  }
152
165
  });
153
166
  });
@@ -195,13 +195,32 @@ export async function chat({ messages, system, options, images }, emit) {
195
195
  // Explicit project dir, else null → CLI runs in home (or WSL home).
196
196
  const cwd = options.workingDir ? path.resolve(options.workingDir) : null;
197
197
 
198
+ const tag = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
198
199
  const args = ['--print', '--output-format', 'stream-json', '--include-partial-messages', '--verbose'];
199
200
 
201
+ // Browser-tools relay: ChatPanel hands this CLI the page-action tools over an
202
+ // HTTP MCP server the bridge hosts (which relays each call to the extension).
203
+ // options.mcp = { url, serverName, specs }. We pre-allow the tools so a headless
204
+ // run doesn't block on approval, and merge alongside the user's own MCP servers.
205
+ const mcpFiles = [];
206
+ const mcpAllow = [];
207
+ if (options.mcp?.url && Array.isArray(options.mcp.specs) && options.mcp.specs.length) {
208
+ const serverName = options.mcp.serverName || 'chatpanel_browser';
209
+ const cfgFile = path.join(os.tmpdir(), `chatpanel-mcp-${tag}.json`);
210
+ await writeFile(cfgFile, JSON.stringify({ mcpServers: { [serverName]: { type: 'http', url: options.mcp.url } } }));
211
+ mcpFiles.push(cfgFile);
212
+ args.push('--mcp-config', cfgFile);
213
+ for (const s of options.mcp.specs) mcpAllow.push(`mcp__${serverName}__${s.name}`);
214
+ }
215
+
200
216
  // Gate writes/shell behind the chosen mode; otherwise restrict to read-only
201
- // tools so headless runs never block on an approval prompt.
217
+ // tools so headless runs never block on an approval prompt. The relayed browser
218
+ // tools are always pre-allowed (the user explicitly armed them this turn).
202
219
  if (permissionMode === 'bypassPermissions') args.push('--permission-mode', 'bypassPermissions');
203
- else if (permissionMode === 'acceptEdits') args.push('--permission-mode', 'acceptEdits');
204
- else args.push('--allowedTools', ...READONLY_TOOLS);
220
+ else if (permissionMode === 'acceptEdits') {
221
+ args.push('--permission-mode', 'acceptEdits');
222
+ if (mcpAllow.length) args.push('--allowedTools', ...mcpAllow);
223
+ } else args.push('--allowedTools', ...READONLY_TOOLS, ...mcpAllow);
205
224
 
206
225
  // Native Claude Code behavior; append the user's own system prompt if they set
207
226
  // one (no ChatPanel persona injected).
@@ -213,9 +232,11 @@ export async function chat({ messages, system, options, images }, emit) {
213
232
 
214
233
  // Attach images by writing them to temp files and asking Claude Code to Read
215
234
  // them — its Read tool loads images as vision (no special flag needed).
216
- const tag = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
217
235
  const imageFiles = await writeImages(images, tag);
218
- const cleanup = () => imageFiles.forEach((f) => unlink(f).catch(() => {}));
236
+ const cleanup = () => {
237
+ imageFiles.forEach((f) => unlink(f).catch(() => {}));
238
+ mcpFiles.forEach((f) => unlink(f).catch(() => {}));
239
+ };
219
240
  let prompt = buildPrompt(messages);
220
241
  if (imageFiles.length) {
221
242
  prompt += `\n\nThe user attached ${imageFiles.length} image file(s). Use the Read tool to view ${
@@ -0,0 +1,77 @@
1
+ // Built-in CLI agents — pi, opencode, kiro — that reuse the shared custom-engine
2
+ // runner (runSpec) with a FIXED spec each. Unlike the Pro "custom" engine, these
3
+ // are NOT entitlement-gated: they ship as built-ins, and the extension bounds
4
+ // free users to a single usable agent (FREE_LIMITS.bridgeAgents). Bring-your-own
5
+ // arbitrary CLIs stay Pro via custom.js.
6
+ //
7
+ // Specs come from each CLI's actual flags:
8
+ // pi — pi -p "<prompt>" · --model · @{path} images · --list-models
9
+ // opencode — opencode run "<prompt>" · -m provider/model · -f {path} images · models
10
+ // kiro — kiro-cli chat --no-interactive "<prompt>" · --model · --list-models
11
+
12
+ import { runSpec, listSpecModels } from './custom.js';
13
+ import { findAgentBin } from '../env.js';
14
+
15
+ function makeCliAgent(command, spec, notFoundHint) {
16
+ let installed = false;
17
+ let lastProbe = 0;
18
+ return {
19
+ async available() {
20
+ // Cache a positive result; re-probe (throttled) while not found so it
21
+ // self-heals once the CLI appears on PATH.
22
+ if (!installed && Date.now() - lastProbe > 4000) {
23
+ lastProbe = Date.now();
24
+ try {
25
+ installed = !!findAgentBin(command);
26
+ } catch {
27
+ installed = false;
28
+ }
29
+ }
30
+ return installed ? { ok: true } : { ok: false, reason: notFoundHint };
31
+ },
32
+ listModels(options = {}) {
33
+ return listSpecModels(command, spec.listModelsArgs, options.workingDir);
34
+ },
35
+ chat(input, emit) {
36
+ return runSpec({ ...spec, command }, input, emit);
37
+ },
38
+ };
39
+ }
40
+
41
+ export const pi = makeCliAgent(
42
+ 'pi',
43
+ {
44
+ args: '-p',
45
+ promptVia: 'arg',
46
+ modelArg: '--model {model}',
47
+ imageArg: '@{path}',
48
+ listModelsArgs: '--list-models',
49
+ label: 'Pi',
50
+ },
51
+ 'pi not found on PATH. Install Pi, then run `pi` once to sign in.',
52
+ );
53
+
54
+ export const opencode = makeCliAgent(
55
+ 'opencode',
56
+ {
57
+ args: 'run',
58
+ promptVia: 'arg',
59
+ modelArg: '-m {model}',
60
+ imageArg: '-f {path}',
61
+ listModelsArgs: 'models',
62
+ label: 'OpenCode',
63
+ },
64
+ 'opencode not found on PATH. Install opencode, then sign in.',
65
+ );
66
+
67
+ export const kiro = makeCliAgent(
68
+ 'kiro-cli',
69
+ {
70
+ args: 'chat --no-interactive',
71
+ promptVia: 'arg',
72
+ modelArg: '--model {model}',
73
+ listModelsArgs: '--list-models',
74
+ label: 'Kiro',
75
+ },
76
+ 'kiro-cli not found on PATH. Install Kiro CLI, then sign in.',
77
+ );
@@ -59,6 +59,12 @@ function imageTokensFor(imageArg, files) {
59
59
  // CHATPANEL_CUSTOM_TIMEOUT_MS (ms).
60
60
  const IDLE_MS = Number(process.env.CHATPANEL_CUSTOM_TIMEOUT_MS) || 180_000;
61
61
 
62
+ // Many CLIs emit ANSI colour/escape codes even when piped (kiro-cli does), which
63
+ // leak into the answer as `\x1b[38;5;141m…`. Strip them from text output. (We
64
+ // also set NO_COLOR on the child env, but this is the robust backstop.)
65
+ const ANSI_RE = /\u001b\[[0-9;?]*[ -/]*[@-~]/g;
66
+ const stripAnsi = (s) => s.replace(ANSI_RE, '');
67
+
62
68
  export async function available() {
63
69
  // The engine ships in every bridge; individual custom agents are user-defined
64
70
  // (Pro) and validated per request and via /agent-check.
@@ -92,19 +98,26 @@ export async function listModels(options = {}) {
92
98
  throw new Error('Custom agents require ChatPanel Pro.');
93
99
  }
94
100
  const spec = options.custom || {};
95
- const listArgs = String(spec.listModelsArgs || '').trim();
96
- if (!spec.command || !listArgs) return [];
97
- const resolved = resolveCommand(spec.command);
98
- if (!resolved) throw new Error(`Couldn't find "${spec.command}".`);
99
- const cwd = options.workingDir ? path.resolve(options.workingDir) : null;
101
+ return listSpecModels(spec.command, spec.listModelsArgs, options.workingDir);
102
+ }
103
+
104
+ // Shared model listing (no Pro gate): run a CLI's list-models invocation and
105
+ // parse it. Used by the Pro custom engine (gated above) AND built-in CLI agents.
106
+ export async function listSpecModels(command, listModelsArgs, workingDir) {
107
+ const listArgs = String(listModelsArgs || '').trim();
108
+ if (!command || !listArgs) return [];
109
+ const resolved = resolveCommand(command);
110
+ if (!resolved) throw new Error(`Couldn't find "${command}".`);
111
+ const cwd = workingDir ? path.resolve(workingDir) : null;
100
112
  const [bin, argv, opts] = buildSpawnSpec(resolved, listArgs.split(/\s+/).filter(Boolean), cwd);
113
+ opts.env = { ...(opts.env || process.env), NO_COLOR: '1', CLICOLOR: '0' };
101
114
  const stdout = await new Promise((resolve, reject) => {
102
115
  let child;
103
- try { child = spawn(bin, argv, opts); } catch (e) { return reject(new Error(`Failed to start ${spec.command}: ${e.message}`)); }
116
+ try { child = spawn(bin, argv, opts); } catch (e) { return reject(new Error(`Failed to start ${command}: ${e.message}`)); }
104
117
  let out = '';
105
118
  const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('Listing models timed out.')); }, 20000);
106
119
  child.stdout.on('data', (d) => (out += d.toString()));
107
- child.on('error', (e) => { clearTimeout(timer); reject(new Error(`Failed to start ${spec.command}: ${e.message}`)); });
120
+ child.on('error', (e) => { clearTimeout(timer); reject(new Error(`Failed to start ${command}: ${e.message}`)); });
108
121
  child.on('close', () => { clearTimeout(timer); resolve(out); });
109
122
  try { child.stdin.end(); } catch { /* some CLIs don't read stdin */ }
110
123
  });
@@ -130,9 +143,14 @@ export async function chat({ messages, system, options, images }, emit) {
130
143
  if (!(await isProEntitled(options.entitlement))) {
131
144
  throw new Error('Custom agents require ChatPanel Pro. Upgrade in Settings to bring your own CLI agent.');
132
145
  }
146
+ return runSpec(options.custom || {}, { messages, system, options, images }, emit);
147
+ }
133
148
 
134
- const spec = options.custom || {};
135
- if (!spec.command) throw new Error('This custom agent has no command configured.');
149
+ // Run a CLI agent from a spec SHARED by the Pro custom engine (gated in chat()
150
+ // above) and the built-in CLI engines (pi/opencode/kiro). This never gates; the
151
+ // built-in agents are bounded instead by the extension's free 1-agent limit.
152
+ export async function runSpec(spec, { messages, system, options = {}, images }, emit) {
153
+ if (!spec.command) throw new Error('This agent has no command configured.');
136
154
 
137
155
  const resolved = resolveCommand(spec.command);
138
156
  if (!resolved) {
@@ -200,6 +218,8 @@ export async function chat({ messages, system, options, images }, emit) {
200
218
  }
201
219
 
202
220
  const [bin, argv, opts] = buildSpawnSpec(resolved, args, cwd);
221
+ // Discourage CLIs from colourizing output (kiro-cli etc.) when piped.
222
+ opts.env = { ...(opts.env || process.env), NO_COLOR: '1', FORCE_COLOR: '0', CLICOLOR: '0', TERM: 'dumb' };
203
223
 
204
224
  await new Promise((resolve, reject) => {
205
225
  let child;
@@ -248,7 +268,7 @@ export async function chat({ messages, system, options, images }, emit) {
248
268
  }
249
269
  } else {
250
270
  streamedAny = true;
251
- emit({ type: 'delta', text: s });
271
+ emit({ type: 'delta', text: stripAnsi(s) });
252
272
  }
253
273
  });
254
274
  child.stderr.on('data', (d) => { armIdle(); stderr += d.toString(); });
package/src/env.js CHANGED
@@ -1,4 +1,4 @@
1
- // Resolve a usable PATH for spawning the agent CLIs (codex, gemini).
1
+ // Resolve a usable PATH for spawning the agent CLIs (codex, agy).
2
2
  //
3
3
  // When the bridge runs as a login service (LaunchAgent / Scheduled Task) — or as
4
4
  // a double-clicked app — it inherits a MINIMAL PATH, not your interactive shell's.
@@ -14,7 +14,7 @@ let enriched = false;
14
14
 
15
15
  // The agent CLIs the bridge shells out to. Claude has its own richer resolution
16
16
  // (resolveClaude: native / cli.js / WSL / SDK) below.
17
- const AGENT_CLIS = ['codex', 'gemini', 'claude'];
17
+ const AGENT_CLIS = ['codex', 'claude', 'agy', 'pi', 'opencode', 'kiro-cli'];
18
18
 
19
19
  // Is `name` executable somewhere on the current PATH?
20
20
  function onPath(name) {
package/src/server.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // ChatPanel Bridge — a tiny localhost server that exposes the coding agents
3
- // running on this machine (Claude Code, Codex and Gemini, each via its CLI) to
3
+ // running on this machine (Claude Code, Codex and Antigravity, each via its CLI) to
4
4
  // the ChatPanel Chrome extension. Zero runtime dependencies.
5
5
  //
6
6
  // GET /health → { ok, version, agents: [...], update: {current,latest,…} }
@@ -16,9 +16,11 @@
16
16
 
17
17
  import { createServer } from 'node:http';
18
18
  import os from 'node:os';
19
+ import { randomUUID } from 'node:crypto';
19
20
  import * as claude from './engines/claude.js';
20
21
  import * as codex from './engines/codex.js';
21
- import * as gemini from './engines/gemini.js';
22
+ import * as antigravity from './engines/antigravity.js';
23
+ import { pi, opencode, kiro } from './engines/cli-agents.js';
22
24
  import * as custom from './engines/custom.js';
23
25
  import { installService, uninstallService, serviceStatus, restartService } from './service.js';
24
26
  import { enrichPath, findAgentBin, resolveCommand } from './env.js';
@@ -27,20 +29,76 @@ import { checkForUpdate, selfUpdate } from './update.js';
27
29
  // Hardcoded (not read from package.json) so it survives Bun's single-file
28
30
  // --compile, where package.json isn't on a readable FS. CI fails the publish if
29
31
  // this drifts from package.json, so the two can't silently diverge.
30
- const VERSION = '0.4.0';
32
+ const VERSION = '0.6.0';
31
33
  const HOST = process.env.CHATPANEL_BRIDGE_HOST || '127.0.0.1';
32
34
  const PORT = Number(process.env.CHATPANEL_BRIDGE_PORT) || 4319;
33
35
 
34
36
  const ENGINES = {
35
37
  claude: { engine: claude, label: 'Claude Code' },
36
38
  codex: { engine: codex, label: 'Codex' },
37
- gemini: { engine: gemini, label: 'Gemini CLI' },
39
+ antigravity: { engine: antigravity, label: 'Antigravity' },
40
+ pi: { engine: pi, label: 'Pi' },
41
+ opencode: { engine: opencode, label: 'OpenCode' },
42
+ kiro: { engine: kiro, label: 'Kiro' },
38
43
  // "Bring your own" — one engine drives any user-onboarded CLI (Pro). Hidden
39
44
  // from /health (it's not a single installable agent; the extension manages the
40
45
  // list and validates commands via /agent-check).
41
46
  custom: { engine: custom, label: 'Custom', hidden: true },
42
47
  };
43
48
 
49
+ // --------------------------------------------------------------------------
50
+ // Browser-tools relay. When the extension arms "Act on page" for a CLI agent, it
51
+ // sends the tool specs in /chat. We host an HTTP MCP server (/mcp/<session>) the
52
+ // CLI connects to; each tools/call is RELAYED to the extension over the chat SSE
53
+ // stream (a `tool_request` event), executed there (it owns the browser), and the
54
+ // result POSTed back to /tool-result. The bridge itself never touches the page.
55
+ // --------------------------------------------------------------------------
56
+ const sessions = new Map(); // sessionId -> { id, emit, specs, pending: Map, nextId }
57
+
58
+ function createSession(emit, specs) {
59
+ const id = randomUUID();
60
+ const s = { id, emit, specs, pending: new Map(), nextId: 0 };
61
+ sessions.set(id, s);
62
+ return s;
63
+ }
64
+
65
+ function deleteSession(id) {
66
+ const s = sessions.get(id);
67
+ if (!s) return;
68
+ for (const p of s.pending.values()) p.reject(new Error('chat ended'));
69
+ sessions.delete(id);
70
+ }
71
+
72
+ // Ask the extension to run a tool and await its result. Resolves to MCP content.
73
+ function relayToolCall(session, name, input) {
74
+ return new Promise((resolve, reject) => {
75
+ const id = `t${++session.nextId}`;
76
+ const timer = setTimeout(() => {
77
+ session.pending.delete(id);
78
+ reject(new Error('tool call timed out'));
79
+ }, 120_000);
80
+ session.pending.set(id, {
81
+ resolve: (result) => { clearTimeout(timer); resolve(toMcpContent(result)); },
82
+ reject: (e) => { clearTimeout(timer); reject(e); },
83
+ });
84
+ session.emit({ type: 'tool_request', session: session.id, id, name, input });
85
+ });
86
+ }
87
+
88
+ // The extension returns a string OR { text, image(dataURL) }; map to MCP content.
89
+ function toMcpContent(result) {
90
+ if (result == null) return { content: [{ type: 'text', text: 'ok' }] };
91
+ if (typeof result === 'string') return { content: [{ type: 'text', text: result }] };
92
+ const content = [];
93
+ if (result.text) content.push({ type: 'text', text: String(result.text) });
94
+ if (typeof result.image === 'string') {
95
+ const m = /^data:([^;]+);base64,(.+)$/s.exec(result.image);
96
+ if (m) content.push({ type: 'image', data: m[2], mimeType: m[1] });
97
+ }
98
+ if (!content.length) content.push({ type: 'text', text: 'ok' });
99
+ return { content };
100
+ }
101
+
44
102
  // --------------------------------------------------------------------------
45
103
  // CORS — allow the extension (chrome-extension://…) and localhost dev origins.
46
104
  // --------------------------------------------------------------------------
@@ -135,26 +193,103 @@ async function handleChat(req, res) {
135
193
  let closed = false;
136
194
  req.on('close', () => (closed = true));
137
195
 
196
+ const safeEmit = (obj) => { if (!closed) emit(obj); };
197
+
198
+ // Browser-tools relay: when the extension sends page-tool specs, host an MCP
199
+ // server for this turn and tell the engine to point the CLI at it.
200
+ const options = { ...(body.options || {}) };
201
+ let session = null;
202
+ if (body.pageTools?.specs?.length) {
203
+ session = createSession(safeEmit, body.pageTools.specs);
204
+ options.mcp = {
205
+ url: `http://${HOST}:${PORT}/mcp/${session.id}`,
206
+ serverName: 'chatpanel_browser',
207
+ specs: body.pageTools.specs,
208
+ };
209
+ }
210
+
138
211
  try {
139
212
  await target.engine.chat(
140
213
  {
141
214
  messages: Array.isArray(body.messages) ? body.messages : [],
142
215
  system: body.system || '',
143
- options: body.options || {},
216
+ options,
144
217
  images: Array.isArray(body.images) ? body.images : [],
145
218
  },
146
- (obj) => {
147
- if (!closed) emit(obj);
148
- },
219
+ safeEmit,
149
220
  );
150
221
  } catch (e) {
151
222
  log('error', `${body.agent} chat failed: ${e?.message || e}`);
152
223
  emit({ type: 'error', error: e?.message || String(e) });
153
224
  } finally {
225
+ if (session) deleteSession(session.id);
154
226
  if (!res.writableEnded) res.end();
155
227
  }
156
228
  }
157
229
 
230
+ // POST /mcp/<session> — the HTTP MCP server the CLI agent connects to. JSON-RPC
231
+ // over POST; tools/call relays to the extension and waits for /tool-result.
232
+ async function handleMcp(req, res, sessionId) {
233
+ let msg;
234
+ try {
235
+ msg = await readBody(req);
236
+ } catch {
237
+ return json(res, 200, { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } });
238
+ }
239
+ const session = sessions.get(sessionId);
240
+ const reply = (result) => {
241
+ if (sessionId) res.setHeader('Mcp-Session-Id', sessionId);
242
+ json(res, 200, { jsonrpc: '2.0', id: msg.id ?? null, result });
243
+ };
244
+ const fail = (code, message) => json(res, 200, { jsonrpc: '2.0', id: msg.id ?? null, error: { code, message } });
245
+
246
+ // Notifications (no id) — ack and ignore.
247
+ if (msg.id == null) { res.writeHead(202); return res.end(); }
248
+
249
+ if (msg.method === 'initialize') {
250
+ return reply({
251
+ protocolVersion: msg.params?.protocolVersion || '2025-06-18',
252
+ capabilities: { tools: { listChanged: false } },
253
+ serverInfo: { name: 'chatpanel-browser', version: VERSION },
254
+ });
255
+ }
256
+ if (!session) return fail(-32001, 'Session not found (chat already ended)');
257
+ if (msg.method === 'tools/list') {
258
+ return reply({
259
+ tools: session.specs.map((s) => ({
260
+ name: s.name,
261
+ description: s.description,
262
+ inputSchema: s.parameters || { type: 'object', properties: {} },
263
+ })),
264
+ });
265
+ }
266
+ if (msg.method === 'tools/call') {
267
+ try {
268
+ return reply(await relayToolCall(session, msg.params?.name, msg.params?.arguments || {}));
269
+ } catch (e) {
270
+ return reply({ content: [{ type: 'text', text: `error: ${e?.message || e}` }], isError: true });
271
+ }
272
+ }
273
+ return fail(-32601, `Method not found: ${msg.method}`);
274
+ }
275
+
276
+ // POST /tool-result — the extension returns a relayed tool's result.
277
+ async function handleToolResult(req, res) {
278
+ let body;
279
+ try {
280
+ body = await readBody(req);
281
+ } catch (e) {
282
+ return json(res, 400, { error: 'Bad JSON: ' + e.message });
283
+ }
284
+ const session = sessions.get(body.session);
285
+ if (!session) return json(res, 404, { error: 'no such session' });
286
+ const pending = session.pending.get(body.id);
287
+ if (!pending) return json(res, 404, { error: 'no such pending call' });
288
+ session.pending.delete(body.id);
289
+ pending.resolve(body.result);
290
+ return json(res, 200, { ok: true });
291
+ }
292
+
158
293
  // POST /complete → { agent, prompt, model? } → { text } — a fast, single-shot
159
294
  // completion for prompt autocomplete. Uses the engine's complete() if it has one
160
295
  // (Claude: Haiku, no tools), else a one-shot chat collected into text.
@@ -253,11 +388,18 @@ const server = createServer(async (req, res) => {
253
388
  version: VERSION,
254
389
  home: os.homedir(),
255
390
  codex: findAgentBin('codex') || null,
256
- gemini: findAgentBin('gemini') || null,
391
+ agy: findAgentBin('agy') || null,
257
392
  path: process.env.PATH,
258
393
  });
259
394
  }
260
395
  if (req.method === 'POST' && url.pathname === '/chat') return handleChat(req, res);
396
+ if (url.pathname.startsWith('/mcp/')) {
397
+ const sid = decodeURIComponent(url.pathname.slice(5));
398
+ if (req.method === 'POST') return handleMcp(req, res, sid);
399
+ if (req.method === 'GET') { res.writeHead(405); return res.end(); } // no server-initiated stream
400
+ if (req.method === 'DELETE') { deleteSession(sid); res.writeHead(204); return res.end(); }
401
+ }
402
+ if (req.method === 'POST' && url.pathname === '/tool-result') return handleToolResult(req, res);
261
403
  if (req.method === 'POST' && url.pathname === '/complete') return handleComplete(req, res);
262
404
  if (req.method === 'POST' && url.pathname === '/list-models') return handleListModels(req, res);
263
405
  if (req.method === 'POST' && url.pathname === '/agent-check') return handleAgentCheck(req, res);
@@ -282,7 +424,7 @@ function startServer() {
282
424
  const a = await engine.available().catch(() => ({ ok: false }));
283
425
  log('info', ` ${a.ok ? '✓' : '✕'} ${label}${a.ok ? '' : ' — ' + (a.reason || 'unavailable')}`);
284
426
  }
285
- log('info', 'Open the ChatPanel side panel; installed agents (Claude Code, Codex, Gemini CLI) appear automatically.');
427
+ log('info', 'Open the ChatPanel side panel; installed agents (Claude Code, Codex, Antigravity) appear automatically.');
286
428
  });
287
429
  }
288
430