@chatpanel/bridge 0.9.0 → 0.10.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.9.0",
3
+ "version": "0.10.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": [
@@ -54,10 +54,17 @@ export const pi = makeCliAgent(
54
54
  export const opencode = makeCliAgent(
55
55
  'opencode',
56
56
  {
57
- args: 'run',
57
+ // `--format json` → clean NDJSON events (the default emits a TUI that's
58
+ // garbage when piped). --dangerously-skip-permissions so headless tool use
59
+ // (incl. our relayed browser tools) doesn't block on an approval prompt.
60
+ args: 'run --format json --dangerously-skip-permissions',
58
61
  promptVia: 'arg',
59
62
  modelArg: '-m {model}',
60
63
  imageArg: '-f {path}',
64
+ format: 'opencode-json',
65
+ // Browser tools come via the bridge's STABLE /mcp endpoint, registered once
66
+ // with `opencode mcp add chatpanel --url http://127.0.0.1:4319/mcp` (opencode
67
+ // only loads MCP from its global config, not a per-run file).
61
68
  listModelsArgs: 'models',
62
69
  label: 'OpenCode',
63
70
  },
@@ -158,9 +158,9 @@ export async function runSpec(spec, { messages, system, options = {}, images },
158
158
  }
159
159
 
160
160
  const prompt = buildPrompt(messages, system);
161
- const cwd = options.workingDir ? path.resolve(options.workingDir) : null;
161
+ let cwd = options.workingDir ? path.resolve(options.workingDir) : null;
162
162
  const label = spec.label || spec.command;
163
- const fmt = spec.format === 'claude-stream-json' ? 'claude-stream-json' : 'text';
163
+ const fmt = ['claude-stream-json', 'opencode-json'].includes(spec.format) ? spec.format : 'text';
164
164
 
165
165
  // Args: either a real array or a space-split string. With promptVia:'arg' we
166
166
  // substitute {prompt} (or append it if there's no placeholder); otherwise the
@@ -184,7 +184,10 @@ export async function runSpec(spec, { messages, system, options = {}, images },
184
184
  const injected = tmpl.includes('{model}')
185
185
  ? tmpl.replaceAll('{model}', options.model).split(/\s+/).filter(Boolean)
186
186
  : [...tmpl.split(/\s+/).filter(Boolean), options.model];
187
- args = [...injected, ...args];
187
+ // APPEND (not prepend): subcommand CLIs (opencode `run`, kiro `chat`) must
188
+ // keep the subcommand first — `opencode -m X run` makes `run` look like a
189
+ // project path, so it never loads opencode.json / its MCP servers.
190
+ args = [...args, ...injected];
188
191
  }
189
192
  // Images: write to temp files, expand the agent's imageArg template, then place
190
193
  // the tokens. An explicit {images} placeholder in args wins; otherwise they go
@@ -210,6 +213,10 @@ export async function runSpec(spec, { messages, system, options = {}, images },
210
213
  : [...tmpl.split(/\s+/).filter(Boolean), cfgFile];
211
214
  args = [...tokens, ...args];
212
215
  }
216
+ // NOTE: opencode only loads MCP from its GLOBAL config (~/.config/opencode),
217
+ // never a per-run/project file — so we can't inject it here. opencode reaches
218
+ // the browser tools via the bridge's STABLE /mcp endpoint, registered once with
219
+ // `opencode mcp add chatpanel --url http://127.0.0.1:4319/mcp`.
213
220
 
214
221
  const imageTokens = imageTokensFor(spec.imageArg, imageFiles);
215
222
  let placedImages = false;
@@ -289,6 +296,28 @@ export async function runSpec(spec, { messages, system, options = {}, images },
289
296
  if (r.streamed) streamedAny = true;
290
297
  if (r.result != null) resultText = r.result;
291
298
  }
299
+ } else if (fmt === 'opencode-json') {
300
+ // opencode `run --format json` emits newline-delimited events: text parts,
301
+ // tool/tool_use, and errors. Extract the answer text + surface tools/errors.
302
+ jsonBuf += s;
303
+ let nl;
304
+ while ((nl = jsonBuf.indexOf('\n')) >= 0) {
305
+ const line = jsonBuf.slice(0, nl).trim();
306
+ jsonBuf = jsonBuf.slice(nl + 1);
307
+ if (!line.startsWith('{')) continue;
308
+ let ev;
309
+ try { ev = JSON.parse(line); } catch { continue; }
310
+ if (ev.type === 'text' && ev.part?.text) {
311
+ streamedAny = true;
312
+ emit({ type: 'delta', text: ev.part.text });
313
+ } else if (ev.type === 'tool' || ev.type === 'tool_use') {
314
+ const p = ev.part || {};
315
+ emit({ type: 'tool', name: p.tool || p.name || p.type || 'tool', summary: '' });
316
+ } else if (ev.type === 'error') {
317
+ const msg = ev.error?.data?.message || ev.error?.message || ev.error?.name || 'error';
318
+ emit({ type: 'status', text: String(msg).slice(0, 300) });
319
+ }
320
+ }
292
321
  } else {
293
322
  streamedAny = true;
294
323
  emit({ type: 'delta', text: stripAnsi(s) });
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.9.0';
33
+ const VERSION = '0.10.0';
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
 
@@ -55,11 +55,13 @@ const ENGINES = {
55
55
  // result POSTed back to /tool-result. The bridge itself never touches the page.
56
56
  // --------------------------------------------------------------------------
57
57
  const sessions = new Map(); // sessionId -> { id, emit, specs, pending: Map, nextId }
58
+ let latestSessionId = null; // for the stable /mcp endpoint (CLIs configured once)
58
59
 
59
60
  function createSession(emit, specs) {
60
61
  const id = randomUUID();
61
62
  const s = { id, emit, specs, pending: new Map(), nextId: 0 };
62
63
  sessions.set(id, s);
64
+ latestSessionId = id;
63
65
  return s;
64
66
  }
65
67
 
@@ -68,6 +70,17 @@ function deleteSession(id) {
68
70
  if (!s) return;
69
71
  for (const p of s.pending.values()) p.reject(new Error('chat ended'));
70
72
  sessions.delete(id);
73
+ if (latestSessionId === id) {
74
+ // fall back to the most-recently-created surviving session, if any
75
+ const ids = [...sessions.keys()];
76
+ latestSessionId = ids.length ? ids[ids.length - 1] : null;
77
+ }
78
+ }
79
+
80
+ // The session a sessionless /mcp request maps to (CLIs configured once with a
81
+ // stable URL — e.g. `opencode mcp add chatpanel --url …/mcp`). The active chat.
82
+ function activeSession() {
83
+ return (latestSessionId && sessions.get(latestSessionId)) || null;
71
84
  }
72
85
 
73
86
  // Ask the extension to run a tool and await its result. Resolves to MCP content.
@@ -228,8 +241,9 @@ async function handleChat(req, res) {
228
241
  }
229
242
  }
230
243
 
231
- // POST /mcp/<session> the HTTP MCP server the CLI agent connects to. JSON-RPC
232
- // over POST; tools/call relays to the extension and waits for /tool-result.
244
+ // POST /mcp/<session> (per-run, bridge-injected) OR POST /mcp (stable: routes to
245
+ // the active chat for CLIs configured once, e.g. `opencode mcp add … …/mcp`).
246
+ // JSON-RPC; tools/call relays to the extension and waits for /tool-result.
233
247
  async function handleMcp(req, res, sessionId) {
234
248
  let msg;
235
249
  try {
@@ -237,9 +251,10 @@ async function handleMcp(req, res, sessionId) {
237
251
  } catch {
238
252
  return json(res, 200, { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } });
239
253
  }
240
- const session = sessions.get(sessionId);
254
+ // Explicit session id (per-run URL) or the active chat (stable /mcp).
255
+ const session = sessionId ? sessions.get(sessionId) : activeSession();
241
256
  const reply = (result) => {
242
- if (sessionId) res.setHeader('Mcp-Session-Id', sessionId);
257
+ if (session) res.setHeader('Mcp-Session-Id', session.id);
243
258
  json(res, 200, { jsonrpc: '2.0', id: msg.id ?? null, result });
244
259
  };
245
260
  const fail = (code, message) => json(res, 200, { jsonrpc: '2.0', id: msg.id ?? null, error: { code, message } });
@@ -254,7 +269,12 @@ async function handleMcp(req, res, sessionId) {
254
269
  serverInfo: { name: 'chatpanel-browser', version: VERSION },
255
270
  });
256
271
  }
257
- if (!session) return fail(-32001, 'Session not found (chat already ended)');
272
+ // No active chat → advertise zero tools rather than erroring, so a CLI with a
273
+ // standing /mcp config (run outside ChatPanel) starts cleanly instead of failing.
274
+ if (!session) {
275
+ if (msg.method === 'tools/list') return reply({ tools: [] });
276
+ return fail(-32001, 'No active ChatPanel session — open a chat with “Act on page” on.');
277
+ }
258
278
  if (msg.method === 'tools/list') {
259
279
  return reply({
260
280
  tools: session.specs.map((s) => ({
@@ -424,6 +444,12 @@ const server = createServer(async (req, res) => {
424
444
  });
425
445
  }
426
446
  if (req.method === 'POST' && url.pathname === '/chat') return handleChat(req, res);
447
+ // Stable endpoint: routes to the active chat. For CLIs configured once with a
448
+ // fixed URL (e.g. `opencode mcp add chatpanel --url http://127.0.0.1:4319/mcp`).
449
+ if (url.pathname === '/mcp') {
450
+ if (req.method === 'POST') return handleMcp(req, res, null);
451
+ if (req.method === 'GET') { res.writeHead(405); return res.end(); }
452
+ }
427
453
  if (url.pathname.startsWith('/mcp/')) {
428
454
  const sid = decodeURIComponent(url.pathname.slice(5));
429
455
  if (req.method === 'POST') return handleMcp(req, res, sid);