@chatpanel/bridge 0.10.0 → 0.10.2

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.10.0",
3
+ "version": "0.10.2",
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": [
@@ -34,6 +34,7 @@
34
34
  "scripts": {
35
35
  "start": "node src/server.js",
36
36
  "dev": "node --watch src/server.js",
37
+ "test": "node --test tests/*.test.mjs",
37
38
  "build:bin": "bash scripts/build-binaries.sh"
38
39
  },
39
40
  "dependencies": {},
@@ -17,7 +17,7 @@ import { spawn } from 'node:child_process';
17
17
  import { writeFile, unlink } from 'node:fs/promises';
18
18
  import os from 'node:os';
19
19
  import path from 'node:path';
20
- import { resolveClaude, buildSpawnSpec, isCompiledBinary } from '../env.js';
20
+ import { resolveClaude, buildSpawnSpec, isCompiledBinary, selfMcpStdio } from '../env.js';
21
21
 
22
22
  // Write base64 data-URL images to temp files. Claude Code reads them with its
23
23
  // Read tool (which feeds images to the model as vision), so we just reference the
@@ -93,6 +93,18 @@ function buildPrompt(messages) {
93
93
  return prompt;
94
94
  }
95
95
 
96
+ export function claudeMcpConfig(mcp) {
97
+ if (!mcp?.url || !Array.isArray(mcp.specs) || !mcp.specs.length) return null;
98
+ const serverName = mcp.serverName || 'chatpanel_browser';
99
+ const { command, args } = selfMcpStdio(mcp.url);
100
+ const toolNames = [...new Set(mcp.specs.map((s) => s?.name).filter(Boolean))];
101
+ return {
102
+ serverName,
103
+ config: { mcpServers: { [serverName]: { command, args } } },
104
+ allowedTools: toolNames.map((name) => `mcp__${serverName}__${name}`),
105
+ };
106
+ }
107
+
96
108
  // Spawn claude (however it resolves) and stream its stream-json output via
97
109
  // `emit`. Resolves with { streamedAny, resultText } once it closes 0. Returns
98
110
  // null (no spawn) when claude can't be resolved, so the caller can fall back.
@@ -198,19 +210,20 @@ export async function chat({ messages, system, options, images }, emit) {
198
210
  const tag = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
199
211
  const args = ['--print', '--output-format', 'stream-json', '--include-partial-messages', '--verbose'];
200
212
 
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).
213
+ // Browser-tools relay: ChatPanel hands this CLI the page-action tools through
214
+ // the bridge's stdio MCP proxy, which forwards each call to the per-chat HTTP
215
+ // MCP session and then to the extension.
203
216
  // options.mcp = { url, serverName, specs }. We pre-allow the tools so a headless
204
217
  // run doesn't block on approval, and merge alongside the user's own MCP servers.
205
218
  const mcpFiles = [];
206
219
  const mcpAllow = [];
207
- if (options.mcp?.url && Array.isArray(options.mcp.specs) && options.mcp.specs.length) {
208
- const serverName = options.mcp.serverName || 'chatpanel_browser';
220
+ const mcpConfig = claudeMcpConfig(options.mcp);
221
+ if (mcpConfig) {
209
222
  const cfgFile = path.join(os.tmpdir(), `chatpanel-mcp-${tag}.json`);
210
- await writeFile(cfgFile, JSON.stringify({ mcpServers: { [serverName]: { type: 'http', url: options.mcp.url } } }));
223
+ await writeFile(cfgFile, JSON.stringify(mcpConfig.config));
211
224
  mcpFiles.push(cfgFile);
212
225
  args.push('--mcp-config', cfgFile);
213
- for (const s of options.mcp.specs) mcpAllow.push(`mcp__${serverName}__${s.name}`);
226
+ mcpAllow.push(...mcpConfig.allowedTools);
214
227
  }
215
228
 
216
229
  // Gate writes/shell behind the chosen mode; otherwise restrict to read-only
@@ -45,6 +45,7 @@ export const pi = makeCliAgent(
45
45
  promptVia: 'arg',
46
46
  modelArg: '--model {model}',
47
47
  imageArg: '@{path}',
48
+ toolAdapter: 'pi-extension',
48
49
  listModelsArgs: '--list-models',
49
50
  label: 'Pi',
50
51
  },
@@ -65,6 +66,7 @@ export const opencode = makeCliAgent(
65
66
  // Browser tools come via the bridge's STABLE /mcp endpoint, registered once
66
67
  // with `opencode mcp add chatpanel --url http://127.0.0.1:4319/mcp` (opencode
67
68
  // only loads MCP from its global config, not a per-run file).
69
+ requiresStableMcp: true,
68
70
  listModelsArgs: 'models',
69
71
  label: 'OpenCode',
70
72
  },
@@ -110,6 +110,29 @@ function buildPrompt(messages, system) {
110
110
  return p;
111
111
  }
112
112
 
113
+ export function codexMcpConfigArgs(mcp) {
114
+ if (!mcp?.url) return [];
115
+ const name = mcp.serverName || 'chatpanel_browser';
116
+ const { command, args: pargs } = selfMcpStdio(mcp.url);
117
+ const args = [
118
+ '-c',
119
+ `mcp_servers.${name}.command=${JSON.stringify(command)}`,
120
+ '-c',
121
+ `mcp_servers.${name}.args=${JSON.stringify(pargs)}`,
122
+ '-c',
123
+ `mcp_servers.${name}.default_tools_approval_mode="approve"`,
124
+ '-c',
125
+ `mcp_servers.${name}.startup_timeout_sec=30`,
126
+ '-c',
127
+ `mcp_servers.${name}.tool_timeout_sec=120`,
128
+ ];
129
+ const toolNames = [...new Set((mcp.specs || []).map((s) => s?.name).filter(Boolean))];
130
+ if (toolNames.length) {
131
+ args.push('-c', `mcp_servers.${name}.enabled_tools=${JSON.stringify(toolNames)}`);
132
+ }
133
+ return args;
134
+ }
135
+
113
136
  // Write base64 data-URL images to temp files so `codex exec -i <file>` can
114
137
  // attach them to the prompt as vision input. Returns the paths (caller cleans up).
115
138
  async function writeImages(images, tag) {
@@ -149,12 +172,7 @@ export async function chat({ messages, system, options, images }, emit) {
149
172
  // Browser tools: register the bridge's MCP server as a stdio MCP server (the
150
173
  // bridge binary in --mcp-stdio mode), so Codex can call our page-action tools.
151
174
  // `-c key=value` parses value as TOML; JSON.stringify yields valid TOML here.
152
- if (options.mcp?.url) {
153
- const name = options.mcp.serverName || 'chatpanel_browser';
154
- const { command, args: pargs } = selfMcpStdio(options.mcp.url);
155
- args.push('-c', `mcp_servers.${name}.command=${JSON.stringify(command)}`);
156
- args.push('-c', `mcp_servers.${name}.args=${JSON.stringify(pargs)}`);
157
- }
175
+ args.push(...codexMcpConfigArgs(options.mcp));
158
176
  if (options.model) args.push('-m', options.model);
159
177
  if (options.extraArgs) args.push(...String(options.extraArgs).split(/\s+/).filter(Boolean));
160
178
  for (const f of imageFiles) args.push('-i', f); // attach images to the initial prompt
@@ -16,7 +16,7 @@
16
16
  // speak it), reusing the Claude engine's parser.
17
17
 
18
18
  import { spawn } from 'node:child_process';
19
- import { writeFile, unlink } from 'node:fs/promises';
19
+ import { readFile, writeFile, unlink } from 'node:fs/promises';
20
20
  import os from 'node:os';
21
21
  import path from 'node:path';
22
22
  import { resolveCommand, buildSpawnSpec, selfMcpStdio } from '../env.js';
@@ -64,6 +64,7 @@ const IDLE_MS = Number(process.env.CHATPANEL_CUSTOM_TIMEOUT_MS) || 180_000;
64
64
  // also set NO_COLOR on the child env, but this is the robust backstop.)
65
65
  const ANSI_RE = /\u001b\[[0-9;?]*[ -/]*[@-~]/g;
66
66
  const stripAnsi = (s) => s.replace(ANSI_RE, '');
67
+ const OPENCODE_STABLE_MCP_URL = 'http://127.0.0.1:4319/mcp';
67
68
 
68
69
  export async function available() {
69
70
  // The engine ships in every bridge; individual custom agents are user-defined
@@ -138,6 +139,120 @@ function buildPrompt(messages, system) {
138
139
  return p;
139
140
  }
140
141
 
142
+ function mcpToolSpecs(mcp) {
143
+ return (mcp?.specs || []).filter((s) => s?.name);
144
+ }
145
+
146
+ function jsIdentifier(name, index) {
147
+ const id = String(name).replace(/[^A-Za-z0-9_$]/g, '_');
148
+ return /^[A-Za-z_$]/.test(id) ? id : `tool_${index}_${id}`;
149
+ }
150
+
151
+ export function piToolArgs(extensionFile, mcp) {
152
+ return ['--extension', extensionFile];
153
+ }
154
+
155
+ export function buildPiExtensionSource(mcp) {
156
+ const specs = mcpToolSpecs(mcp);
157
+ const declarations = specs.map((spec, index) => {
158
+ const id = jsIdentifier(spec.name, index);
159
+ const schema = spec.parameters || { type: 'object', properties: {} };
160
+ return `const ${id}Tool = {
161
+ name: ${JSON.stringify(spec.name)},
162
+ label: ${JSON.stringify(spec.name)},
163
+ description: ${JSON.stringify(spec.description || spec.name)},
164
+ parameters: ${JSON.stringify(schema)},
165
+ async execute(toolCallId, params, signal) {
166
+ return callMcpTool(${JSON.stringify(spec.name)}, toolCallId, params, signal);
167
+ },
168
+ };`;
169
+ }).join('\n\n');
170
+ const registrations = specs.map((spec, index) => {
171
+ const id = jsIdentifier(spec.name, index);
172
+ return ` pi.registerTool(${id}Tool);`;
173
+ }).join('\n');
174
+
175
+ return `import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
176
+
177
+ const MCP_URL = ${JSON.stringify(mcp.url)};
178
+
179
+ function contentFromDataUrl(dataUrl) {
180
+ const match = /^data:([^;]+);base64,(.+)$/s.exec(String(dataUrl || ""));
181
+ return match ? { type: "image", data: match[2], mimeType: match[1] } : null;
182
+ }
183
+
184
+ function normalizeContent(content) {
185
+ const out = [];
186
+ for (const item of Array.isArray(content) ? content : []) {
187
+ if (item?.type === "text") {
188
+ out.push({ type: "text", text: String(item.text ?? "") });
189
+ } else if (item?.type === "image" && item.data) {
190
+ out.push({ type: "image", data: String(item.data), mimeType: String(item.mimeType || "image/png") });
191
+ } else if (typeof item?.image === "string") {
192
+ const img = contentFromDataUrl(item.image);
193
+ if (img) out.push(img);
194
+ if (item.text) out.push({ type: "text", text: String(item.text) });
195
+ }
196
+ }
197
+ return out.length ? out : [{ type: "text", text: "ok" }];
198
+ }
199
+
200
+ async function callMcpTool(toolName, toolCallId, params, signal) {
201
+ const response = await fetch(MCP_URL, {
202
+ method: "POST",
203
+ headers: { "Content-Type": "application/json" },
204
+ body: JSON.stringify({
205
+ jsonrpc: "2.0",
206
+ id: toolCallId || String(Date.now()),
207
+ method: "tools/call",
208
+ params: { name: toolName, arguments: params || {} },
209
+ }),
210
+ signal,
211
+ });
212
+ const message = await response.json();
213
+ if (message.error) {
214
+ return {
215
+ content: [{ type: "text", text: \`error: \${message.error.message || JSON.stringify(message.error)}\` }],
216
+ details: { error: message.error },
217
+ };
218
+ }
219
+ return {
220
+ content: normalizeContent(message.result?.content),
221
+ details: message.result ?? {},
222
+ };
223
+ }
224
+
225
+ ${declarations}
226
+
227
+ export default function (pi: ExtensionAPI) {
228
+ ${registrations}
229
+ }
230
+ `;
231
+ }
232
+
233
+ async function writePiExtension(mcp, tag) {
234
+ const file = path.join(os.tmpdir(), `chatpanel-pi-tools-${tag}.ts`);
235
+ await writeFile(file, buildPiExtensionSource(mcp));
236
+ return file;
237
+ }
238
+
239
+ async function opencodeHasStableMcpConfig() {
240
+ const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
241
+ const files = [
242
+ path.join(configHome, 'opencode', 'opencode.jsonc'),
243
+ path.join(os.homedir(), 'Library', 'Application Support', 'opencode', 'opencode.jsonc'),
244
+ ];
245
+ for (const file of files) {
246
+ try {
247
+ const text = await readFile(file, 'utf8');
248
+ if (text.includes(OPENCODE_STABLE_MCP_URL)) return true;
249
+ } catch {
250
+ /* missing config is fine */
251
+ }
252
+ }
253
+ return false;
254
+ }
255
+
141
256
  export async function chat({ messages, system, options, images }, emit) {
142
257
  // Pro gate — verified, not just UI. No valid signed entitlement → no run.
143
258
  if (!(await isProEntitled(options.entitlement))) {
@@ -201,7 +316,11 @@ export async function runSpec(spec, { messages, system, options = {}, images },
201
316
  // config file (spec.mcpArg, e.g. "--mcp-config {file}"), write a standard
202
317
  // mcpServers JSON pointing at the bridge's stdio MCP proxy and inject the flag.
203
318
  // Covers any CLI that reads the de-facto {mcpServers:{name:{command,args}}} shape.
204
- if (options.mcp?.url && spec.mcpArg) {
319
+ if (options.mcp?.url && spec.toolAdapter === 'pi-extension') {
320
+ const extensionFile = await writePiExtension(options.mcp, tag);
321
+ mcpFiles.push(extensionFile);
322
+ args = [...piToolArgs(extensionFile, options.mcp), ...args];
323
+ } else if (options.mcp?.url && spec.mcpArg) {
205
324
  const name = options.mcp.serverName || 'chatpanel_browser';
206
325
  const { command, args: pargs } = selfMcpStdio(options.mcp.url);
207
326
  const cfgFile = path.join(os.tmpdir(), `chatpanel-mcp-${tag}.json`);
@@ -217,6 +336,12 @@ export async function runSpec(spec, { messages, system, options = {}, images },
217
336
  // never a per-run/project file — so we can't inject it here. opencode reaches
218
337
  // the browser tools via the bridge's STABLE /mcp endpoint, registered once with
219
338
  // `opencode mcp add chatpanel --url http://127.0.0.1:4319/mcp`.
339
+ if (options.mcp?.url && spec.requiresStableMcp && !(await opencodeHasStableMcpConfig())) {
340
+ emit({
341
+ type: 'status',
342
+ text: `OpenCode needs one-time browser-tool setup: opencode mcp add chatpanel --url ${OPENCODE_STABLE_MCP_URL}`,
343
+ });
344
+ }
220
345
 
221
346
  const imageTokens = imageTokensFor(spec.imageArg, imageFiles);
222
347
  let placedImages = false;
package/src/server.js CHANGED
@@ -30,7 +30,7 @@ import { callLocalMcp } from './mcp-local.js';
30
30
  // Hardcoded (not read from package.json) so it survives Bun's single-file
31
31
  // --compile, where package.json isn't on a readable FS. CI fails the publish if
32
32
  // this drifts from package.json, so the two can't silently diverge.
33
- const VERSION = '0.10.0';
33
+ const VERSION = '0.10.2';
34
34
  const HOST = process.env.CHATPANEL_BRIDGE_HOST || '127.0.0.1';
35
35
  const PORT = Number(process.env.CHATPANEL_BRIDGE_PORT) || 4319;
36
36