@chatpanel/bridge 0.10.1 → 0.10.3

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.1",
3
+ "version": "0.10.3",
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": [
@@ -15,6 +15,7 @@ import { mkdirSync, writeFileSync, unlinkSync } from 'node:fs';
15
15
  import os from 'node:os';
16
16
  import path from 'node:path';
17
17
  import { findAgentBin } from '../env.js';
18
+ import { buildCliPrompt } from './prompt.js';
18
19
 
19
20
  const IDLE_MS = Number(process.env.CHATPANEL_AGY_TIMEOUT_MS) || 180_000;
20
21
  const SCRATCH = path.join(os.tmpdir(), 'chatpanel-agy-scratch');
@@ -77,20 +78,6 @@ function writeImages(images, dir) {
77
78
  return files;
78
79
  }
79
80
 
80
- // The bridge is stateless, so replay the conversation as a single prompt.
81
- function buildPrompt(messages, system) {
82
- let p = system ? `${system}\n\n` : '';
83
- const history = messages.slice(0, -1);
84
- const last = messages[messages.length - 1];
85
- if (history.length) {
86
- p += 'Conversation so far:\n';
87
- for (const m of history) p += `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content}\n\n`;
88
- p += '---\n\n';
89
- }
90
- p += last ? last.content : '';
91
- return p;
92
- }
93
-
94
81
  export async function chat({ messages, system, options, images }, emit) {
95
82
  try {
96
83
  mkdirSync(SCRATCH, { recursive: true });
@@ -104,7 +91,7 @@ export async function chat({ messages, system, options, images }, emit) {
104
91
  // read-tool approval is needed in headless `-p` mode. (Confirmed working.)
105
92
  const imageFiles = writeImages(images, cwd);
106
93
  const cleanup = () => imageFiles.forEach((f) => { try { unlinkSync(f); } catch { /* gone */ } });
107
- let prompt = buildPrompt(messages, system);
94
+ let prompt = buildCliPrompt(messages, system);
108
95
  if (imageFiles.length) {
109
96
  prompt += `\n\nThe user attached image(s): ${imageFiles.map((f) => '@' + path.basename(f)).join(' ')}`;
110
97
  }
@@ -17,7 +17,8 @@ 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
+ import { buildCliPrompt } from './prompt.js';
21
22
 
22
23
  // Write base64 data-URL images to temp files. Claude Code reads them with its
23
24
  // Read tool (which feeds images to the model as vision), so we just reference the
@@ -79,18 +80,16 @@ export async function listModels() {
79
80
  return ['opus', 'sonnet', 'haiku'];
80
81
  }
81
82
 
82
- // The bridge is stateless, so we replay the conversation as a single prompt.
83
- function buildPrompt(messages) {
84
- const history = messages.slice(0, -1);
85
- const last = messages[messages.length - 1];
86
- let prompt = '';
87
- if (history.length) {
88
- prompt += 'Conversation so far:\n';
89
- for (const m of history) prompt += `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content}\n\n`;
90
- prompt += '---\n\n';
91
- }
92
- prompt += last ? last.content : '';
93
- return prompt;
83
+ export function claudeMcpConfig(mcp) {
84
+ if (!mcp?.url || !Array.isArray(mcp.specs) || !mcp.specs.length) return null;
85
+ const serverName = mcp.serverName || 'chatpanel_browser';
86
+ const { command, args } = selfMcpStdio(mcp.url);
87
+ const toolNames = [...new Set(mcp.specs.map((s) => s?.name).filter(Boolean))];
88
+ return {
89
+ serverName,
90
+ config: { mcpServers: { [serverName]: { command, args } } },
91
+ allowedTools: toolNames.map((name) => `mcp__${serverName}__${name}`),
92
+ };
94
93
  }
95
94
 
96
95
  // Spawn claude (however it resolves) and stream its stream-json output via
@@ -198,19 +197,20 @@ export async function chat({ messages, system, options, images }, emit) {
198
197
  const tag = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
199
198
  const args = ['--print', '--output-format', 'stream-json', '--include-partial-messages', '--verbose'];
200
199
 
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).
200
+ // Browser-tools relay: ChatPanel hands this CLI the page-action tools through
201
+ // the bridge's stdio MCP proxy, which forwards each call to the per-chat HTTP
202
+ // MCP session and then to the extension.
203
203
  // options.mcp = { url, serverName, specs }. We pre-allow the tools so a headless
204
204
  // run doesn't block on approval, and merge alongside the user's own MCP servers.
205
205
  const mcpFiles = [];
206
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';
207
+ const mcpConfig = claudeMcpConfig(options.mcp);
208
+ if (mcpConfig) {
209
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 } } }));
210
+ await writeFile(cfgFile, JSON.stringify(mcpConfig.config));
211
211
  mcpFiles.push(cfgFile);
212
212
  args.push('--mcp-config', cfgFile);
213
- for (const s of options.mcp.specs) mcpAllow.push(`mcp__${serverName}__${s.name}`);
213
+ mcpAllow.push(...mcpConfig.allowedTools);
214
214
  }
215
215
 
216
216
  // Gate writes/shell behind the chosen mode; otherwise restrict to read-only
@@ -237,7 +237,7 @@ export async function chat({ messages, system, options, images }, emit) {
237
237
  imageFiles.forEach((f) => unlink(f).catch(() => {}));
238
238
  mcpFiles.forEach((f) => unlink(f).catch(() => {}));
239
239
  };
240
- let prompt = buildPrompt(messages);
240
+ let prompt = buildCliPrompt(messages);
241
241
  if (imageFiles.length) {
242
242
  prompt += `\n\nThe user attached ${imageFiles.length} image file(s). Use the Read tool to view ${
243
243
  imageFiles.length === 1 ? 'it' : 'them'
@@ -323,7 +323,7 @@ async function sdkChat({ messages, system, options }, emit) {
323
323
  let streamedAny = false;
324
324
  let resultText = '';
325
325
  const iterator = query({
326
- prompt: buildPrompt(messages),
326
+ prompt: buildCliPrompt(messages),
327
327
  options: {
328
328
  cwd,
329
329
  permissionMode,
@@ -20,6 +20,7 @@ import { existsSync, mkdirSync, symlinkSync, readFileSync } from 'node:fs';
20
20
  import os from 'node:os';
21
21
  import path from 'node:path';
22
22
  import { findAgentBin, selfMcpStdio } from '../env.js';
23
+ import { buildCliPrompt } from './prompt.js';
23
24
 
24
25
  // Idle timeout: re-armed on every stdout/stderr chunk, so a long run that keeps
25
26
  // streaming never trips it — only true silence does. Override with
@@ -97,19 +98,6 @@ export async function available() {
97
98
  : { ok: false, reason: 'codex not found on PATH. Install it and run `codex login`.' };
98
99
  }
99
100
 
100
- function buildPrompt(messages, system) {
101
- let p = system ? `${system}\n\n` : '';
102
- const history = messages.slice(0, -1);
103
- const last = messages[messages.length - 1];
104
- if (history.length) {
105
- p += 'Conversation so far:\n';
106
- for (const m of history) p += `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content}\n\n`;
107
- p += '---\n\n';
108
- }
109
- p += last ? last.content : '';
110
- return p;
111
- }
112
-
113
101
  export function codexMcpConfigArgs(mcp) {
114
102
  if (!mcp?.url) return [];
115
103
  const name = mcp.serverName || 'chatpanel_browser';
@@ -247,7 +235,7 @@ export async function chat({ messages, system, options, images }, emit) {
247
235
  }
248
236
  });
249
237
 
250
- child.stdin.write(buildPrompt(messages, system));
238
+ child.stdin.write(buildCliPrompt(messages, system));
251
239
  child.stdin.end();
252
240
  });
253
241
  }
@@ -22,6 +22,7 @@ import path from 'node:path';
22
22
  import { resolveCommand, buildSpawnSpec, selfMcpStdio } from '../env.js';
23
23
  import { isProEntitled } from '../entitlement.js';
24
24
  import { handleMessage } from './claude.js';
25
+ import { buildCliPrompt } from './prompt.js';
25
26
 
26
27
  // Write base64 data-URL images to temp files so a custom CLI can take them via
27
28
  // its configured `imageArg` template (e.g. "-i {path}", "@{path}"). Returns paths.
@@ -125,20 +126,6 @@ export async function listSpecModels(command, listModelsArgs, workingDir) {
125
126
  return parseModelList(stdout);
126
127
  }
127
128
 
128
- // The bridge is stateless, so replay the conversation as a single prompt.
129
- function buildPrompt(messages, system) {
130
- let p = system ? `${system}\n\n` : '';
131
- const history = messages.slice(0, -1);
132
- const last = messages[messages.length - 1];
133
- if (history.length) {
134
- p += 'Conversation so far:\n';
135
- for (const m of history) p += `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content}\n\n`;
136
- p += '---\n\n';
137
- }
138
- p += last ? last.content : '';
139
- return p;
140
- }
141
-
142
129
  function mcpToolSpecs(mcp) {
143
130
  return (mcp?.specs || []).filter((s) => s?.name);
144
131
  }
@@ -272,7 +259,7 @@ export async function runSpec(spec, { messages, system, options = {}, images },
272
259
  throw new Error(`Couldn't find "${spec.command}". Enter its full path, or install it on your PATH (or in WSL).`);
273
260
  }
274
261
 
275
- const prompt = buildPrompt(messages, system);
262
+ const prompt = buildCliPrompt(messages, system);
276
263
  let cwd = options.workingDir ? path.resolve(options.workingDir) : null;
277
264
  const label = spec.label || spec.command;
278
265
  const fmt = ['claude-stream-json', 'opencode-json'].includes(spec.format) ? spec.format : 'text';
@@ -0,0 +1,28 @@
1
+ function roleLabel(role) {
2
+ return role === 'assistant' ? 'Assistant' : 'User';
3
+ }
4
+
5
+ export function buildCliPrompt(messages = [], system = '') {
6
+ const cleanSystem = String(system || '').trim();
7
+ const list = Array.isArray(messages) ? messages : [];
8
+ const history = list.slice(0, -1);
9
+ const current = list[list.length - 1];
10
+ const parts = [];
11
+
12
+ if (cleanSystem) parts.push(cleanSystem);
13
+
14
+ if (history.length) {
15
+ const transcript = history
16
+ .map((m) => `${roleLabel(m.role)}: ${String(m.content || '').trim()}`)
17
+ .join('\n\n');
18
+ parts.push(`Conversation history (context only; do not answer these prior turns):\n${transcript}`);
19
+ }
20
+
21
+ if (current) {
22
+ parts.push(
23
+ `Current ${roleLabel(current.role).toLowerCase()} message (answer this message now; use the history above only as context):\n${String(current.content || '').trim()}`,
24
+ );
25
+ }
26
+
27
+ return parts.join('\n\n---\n\n');
28
+ }
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.1';
33
+ const VERSION = '0.10.3';
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