@chatpanel/bridge 0.5.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.5.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": [
@@ -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 ${
package/src/server.js CHANGED
@@ -16,6 +16,7 @@
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
22
  import * as antigravity from './engines/antigravity.js';
@@ -28,7 +29,7 @@ import { checkForUpdate, selfUpdate } from './update.js';
28
29
  // Hardcoded (not read from package.json) so it survives Bun's single-file
29
30
  // --compile, where package.json isn't on a readable FS. CI fails the publish if
30
31
  // this drifts from package.json, so the two can't silently diverge.
31
- const VERSION = '0.5.0';
32
+ const VERSION = '0.6.0';
32
33
  const HOST = process.env.CHATPANEL_BRIDGE_HOST || '127.0.0.1';
33
34
  const PORT = Number(process.env.CHATPANEL_BRIDGE_PORT) || 4319;
34
35
 
@@ -45,6 +46,59 @@ const ENGINES = {
45
46
  custom: { engine: custom, label: 'Custom', hidden: true },
46
47
  };
47
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
+
48
102
  // --------------------------------------------------------------------------
49
103
  // CORS — allow the extension (chrome-extension://…) and localhost dev origins.
50
104
  // --------------------------------------------------------------------------
@@ -139,26 +193,103 @@ async function handleChat(req, res) {
139
193
  let closed = false;
140
194
  req.on('close', () => (closed = true));
141
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
+
142
211
  try {
143
212
  await target.engine.chat(
144
213
  {
145
214
  messages: Array.isArray(body.messages) ? body.messages : [],
146
215
  system: body.system || '',
147
- options: body.options || {},
216
+ options,
148
217
  images: Array.isArray(body.images) ? body.images : [],
149
218
  },
150
- (obj) => {
151
- if (!closed) emit(obj);
152
- },
219
+ safeEmit,
153
220
  );
154
221
  } catch (e) {
155
222
  log('error', `${body.agent} chat failed: ${e?.message || e}`);
156
223
  emit({ type: 'error', error: e?.message || String(e) });
157
224
  } finally {
225
+ if (session) deleteSession(session.id);
158
226
  if (!res.writableEnded) res.end();
159
227
  }
160
228
  }
161
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
+
162
293
  // POST /complete → { agent, prompt, model? } → { text } — a fast, single-shot
163
294
  // completion for prompt autocomplete. Uses the engine's complete() if it has one
164
295
  // (Claude: Haiku, no tools), else a one-shot chat collected into text.
@@ -262,6 +393,13 @@ const server = createServer(async (req, res) => {
262
393
  });
263
394
  }
264
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);
265
403
  if (req.method === 'POST' && url.pathname === '/complete') return handleComplete(req, res);
266
404
  if (req.method === 'POST' && url.pathname === '/list-models') return handleListModels(req, res);
267
405
  if (req.method === 'POST' && url.pathname === '/agent-check') return handleAgentCheck(req, res);