@chatpanel/bridge 0.5.0 → 0.7.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 +1 -1
- package/src/engines/claude.js +26 -5
- package/src/engines/codex.js +10 -1
- package/src/engines/custom.js +21 -2
- package/src/env.js +13 -0
- package/src/mcp-local.js +0 -0
- package/src/server.js +237 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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": [
|
package/src/engines/claude.js
CHANGED
|
@@ -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')
|
|
204
|
-
|
|
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 = () =>
|
|
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/engines/codex.js
CHANGED
|
@@ -19,7 +19,7 @@ import { readFile, unlink, writeFile } from 'node:fs/promises';
|
|
|
19
19
|
import { existsSync, mkdirSync, symlinkSync, readFileSync } from 'node:fs';
|
|
20
20
|
import os from 'node:os';
|
|
21
21
|
import path from 'node:path';
|
|
22
|
-
import { findAgentBin } from '../env.js';
|
|
22
|
+
import { findAgentBin, selfMcpStdio } from '../env.js';
|
|
23
23
|
|
|
24
24
|
// Idle timeout: re-armed on every stdout/stderr chunk, so a long run that keeps
|
|
25
25
|
// streaming never trips it — only true silence does. Override with
|
|
@@ -146,6 +146,15 @@ export async function chat({ messages, system, options, images }, emit) {
|
|
|
146
146
|
// local-config mode). The sandbox above still bounds what can actually happen.
|
|
147
147
|
args.push('-c', 'approval_policy=never');
|
|
148
148
|
if (REASONING) args.push('-c', `model_reasoning_effort=${REASONING}`);
|
|
149
|
+
// Browser tools: register the bridge's MCP server as a stdio MCP server (the
|
|
150
|
+
// bridge binary in --mcp-stdio mode), so Codex can call our page-action tools.
|
|
151
|
+
// `-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
|
+
}
|
|
149
158
|
if (options.model) args.push('-m', options.model);
|
|
150
159
|
for (const f of imageFiles) args.push('-i', f); // attach images to the initial prompt
|
|
151
160
|
args.push('-');
|
package/src/engines/custom.js
CHANGED
|
@@ -19,7 +19,7 @@ import { spawn } from 'node:child_process';
|
|
|
19
19
|
import { writeFile, unlink } from 'node:fs/promises';
|
|
20
20
|
import os from 'node:os';
|
|
21
21
|
import path from 'node:path';
|
|
22
|
-
import { resolveCommand, buildSpawnSpec } from '../env.js';
|
|
22
|
+
import { resolveCommand, buildSpawnSpec, selfMcpStdio } from '../env.js';
|
|
23
23
|
import { isProEntitled } from '../entitlement.js';
|
|
24
24
|
import { handleMessage } from './claude.js';
|
|
25
25
|
|
|
@@ -187,7 +187,26 @@ export async function runSpec(spec, { messages, system, options = {}, images },
|
|
|
187
187
|
// just before the prompt (arg mode) or get appended (stdin mode).
|
|
188
188
|
const tag = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
189
189
|
const imageFiles = spec.imageArg ? await writeImages(images, tag) : [];
|
|
190
|
-
const
|
|
190
|
+
const mcpFiles = [];
|
|
191
|
+
const cleanup = () => [...imageFiles, ...mcpFiles].forEach((f) => unlink(f).catch(() => {}));
|
|
192
|
+
|
|
193
|
+
// Browser tools: when armed (options.mcp) AND this CLI knows how to take an MCP
|
|
194
|
+
// config file (spec.mcpArg, e.g. "--mcp-config {file}"), write a standard
|
|
195
|
+
// mcpServers JSON pointing at the bridge's stdio MCP proxy and inject the flag.
|
|
196
|
+
// Covers any CLI that reads the de-facto {mcpServers:{name:{command,args}}} shape.
|
|
197
|
+
if (options.mcp?.url && spec.mcpArg) {
|
|
198
|
+
const name = options.mcp.serverName || 'chatpanel_browser';
|
|
199
|
+
const { command, args: pargs } = selfMcpStdio(options.mcp.url);
|
|
200
|
+
const cfgFile = path.join(os.tmpdir(), `chatpanel-mcp-${tag}.json`);
|
|
201
|
+
await writeFile(cfgFile, JSON.stringify({ mcpServers: { [name]: { command, args: pargs } } }));
|
|
202
|
+
mcpFiles.push(cfgFile);
|
|
203
|
+
const tmpl = String(spec.mcpArg);
|
|
204
|
+
const tokens = tmpl.includes('{file}')
|
|
205
|
+
? tmpl.replaceAll('{file}', cfgFile).split(/\s+/).filter(Boolean)
|
|
206
|
+
: [...tmpl.split(/\s+/).filter(Boolean), cfgFile];
|
|
207
|
+
args = [...tokens, ...args];
|
|
208
|
+
}
|
|
209
|
+
|
|
191
210
|
const imageTokens = imageTokensFor(spec.imageArg, imageFiles);
|
|
192
211
|
let placedImages = false;
|
|
193
212
|
if (imageTokens.length) {
|
package/src/env.js
CHANGED
|
@@ -48,6 +48,19 @@ export function isCompiledBinary() {
|
|
|
48
48
|
return !(base.startsWith('node') || base.startsWith('bun'));
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
// How to re-invoke THIS bridge as a child process running the stdio↔HTTP MCP
|
|
52
|
+
// proxy (`--mcp-stdio <url>`). Lets ANY stdio-capable MCP CLI (Codex, a custom
|
|
53
|
+
// CLI) reach the bridge's HTTP MCP server using the bridge itself as the server
|
|
54
|
+
// command — no extra runtime to install. Compiled binary → the binary; Node →
|
|
55
|
+
// node + this entry script. Returns { command, args }.
|
|
56
|
+
export function selfMcpStdio(url) {
|
|
57
|
+
const args = ['--mcp-stdio', url];
|
|
58
|
+
if (isCompiledBinary()) return { command: process.execPath, args };
|
|
59
|
+
// Node/Bun: re-run this same entry script (server.js) under the same runtime.
|
|
60
|
+
const entry = process.argv[1] || path.join(process.cwd(), 'src', 'server.js');
|
|
61
|
+
return { command: process.execPath, args: [entry, ...args] };
|
|
62
|
+
}
|
|
63
|
+
|
|
51
64
|
// Ask the user's login shell to locate a command — no hardcoded locations, works
|
|
52
65
|
// wherever the user actually installed it.
|
|
53
66
|
function shellWhich(name) {
|
package/src/mcp-local.js
ADDED
|
Binary file
|
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';
|
|
@@ -24,11 +25,12 @@ import * as custom from './engines/custom.js';
|
|
|
24
25
|
import { installService, uninstallService, serviceStatus, restartService } from './service.js';
|
|
25
26
|
import { enrichPath, findAgentBin, resolveCommand } from './env.js';
|
|
26
27
|
import { checkForUpdate, selfUpdate } from './update.js';
|
|
28
|
+
import { callLocalMcp } from './mcp-local.js';
|
|
27
29
|
|
|
28
30
|
// Hardcoded (not read from package.json) so it survives Bun's single-file
|
|
29
31
|
// --compile, where package.json isn't on a readable FS. CI fails the publish if
|
|
30
32
|
// this drifts from package.json, so the two can't silently diverge.
|
|
31
|
-
const VERSION = '0.
|
|
33
|
+
const VERSION = '0.7.0';
|
|
32
34
|
const HOST = process.env.CHATPANEL_BRIDGE_HOST || '127.0.0.1';
|
|
33
35
|
const PORT = Number(process.env.CHATPANEL_BRIDGE_PORT) || 4319;
|
|
34
36
|
|
|
@@ -45,6 +47,59 @@ const ENGINES = {
|
|
|
45
47
|
custom: { engine: custom, label: 'Custom', hidden: true },
|
|
46
48
|
};
|
|
47
49
|
|
|
50
|
+
// --------------------------------------------------------------------------
|
|
51
|
+
// Browser-tools relay. When the extension arms "Act on page" for a CLI agent, it
|
|
52
|
+
// sends the tool specs in /chat. We host an HTTP MCP server (/mcp/<session>) the
|
|
53
|
+
// CLI connects to; each tools/call is RELAYED to the extension over the chat SSE
|
|
54
|
+
// stream (a `tool_request` event), executed there (it owns the browser), and the
|
|
55
|
+
// result POSTed back to /tool-result. The bridge itself never touches the page.
|
|
56
|
+
// --------------------------------------------------------------------------
|
|
57
|
+
const sessions = new Map(); // sessionId -> { id, emit, specs, pending: Map, nextId }
|
|
58
|
+
|
|
59
|
+
function createSession(emit, specs) {
|
|
60
|
+
const id = randomUUID();
|
|
61
|
+
const s = { id, emit, specs, pending: new Map(), nextId: 0 };
|
|
62
|
+
sessions.set(id, s);
|
|
63
|
+
return s;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function deleteSession(id) {
|
|
67
|
+
const s = sessions.get(id);
|
|
68
|
+
if (!s) return;
|
|
69
|
+
for (const p of s.pending.values()) p.reject(new Error('chat ended'));
|
|
70
|
+
sessions.delete(id);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Ask the extension to run a tool and await its result. Resolves to MCP content.
|
|
74
|
+
function relayToolCall(session, name, input) {
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
const id = `t${++session.nextId}`;
|
|
77
|
+
const timer = setTimeout(() => {
|
|
78
|
+
session.pending.delete(id);
|
|
79
|
+
reject(new Error('tool call timed out'));
|
|
80
|
+
}, 120_000);
|
|
81
|
+
session.pending.set(id, {
|
|
82
|
+
resolve: (result) => { clearTimeout(timer); resolve(toMcpContent(result)); },
|
|
83
|
+
reject: (e) => { clearTimeout(timer); reject(e); },
|
|
84
|
+
});
|
|
85
|
+
session.emit({ type: 'tool_request', session: session.id, id, name, input });
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The extension returns a string OR { text, image(dataURL) }; map to MCP content.
|
|
90
|
+
function toMcpContent(result) {
|
|
91
|
+
if (result == null) return { content: [{ type: 'text', text: 'ok' }] };
|
|
92
|
+
if (typeof result === 'string') return { content: [{ type: 'text', text: result }] };
|
|
93
|
+
const content = [];
|
|
94
|
+
if (result.text) content.push({ type: 'text', text: String(result.text) });
|
|
95
|
+
if (typeof result.image === 'string') {
|
|
96
|
+
const m = /^data:([^;]+);base64,(.+)$/s.exec(result.image);
|
|
97
|
+
if (m) content.push({ type: 'image', data: m[2], mimeType: m[1] });
|
|
98
|
+
}
|
|
99
|
+
if (!content.length) content.push({ type: 'text', text: 'ok' });
|
|
100
|
+
return { content };
|
|
101
|
+
}
|
|
102
|
+
|
|
48
103
|
// --------------------------------------------------------------------------
|
|
49
104
|
// CORS — allow the extension (chrome-extension://…) and localhost dev origins.
|
|
50
105
|
// --------------------------------------------------------------------------
|
|
@@ -139,26 +194,133 @@ async function handleChat(req, res) {
|
|
|
139
194
|
let closed = false;
|
|
140
195
|
req.on('close', () => (closed = true));
|
|
141
196
|
|
|
197
|
+
const safeEmit = (obj) => { if (!closed) emit(obj); };
|
|
198
|
+
|
|
199
|
+
// Browser-tools relay: when the extension sends page-tool specs, host an MCP
|
|
200
|
+
// server for this turn and tell the engine to point the CLI at it.
|
|
201
|
+
const options = { ...(body.options || {}) };
|
|
202
|
+
let session = null;
|
|
203
|
+
if (body.pageTools?.specs?.length) {
|
|
204
|
+
session = createSession(safeEmit, body.pageTools.specs);
|
|
205
|
+
options.mcp = {
|
|
206
|
+
url: `http://${HOST}:${PORT}/mcp/${session.id}`,
|
|
207
|
+
serverName: 'chatpanel_browser',
|
|
208
|
+
specs: body.pageTools.specs,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
142
212
|
try {
|
|
143
213
|
await target.engine.chat(
|
|
144
214
|
{
|
|
145
215
|
messages: Array.isArray(body.messages) ? body.messages : [],
|
|
146
216
|
system: body.system || '',
|
|
147
|
-
options
|
|
217
|
+
options,
|
|
148
218
|
images: Array.isArray(body.images) ? body.images : [],
|
|
149
219
|
},
|
|
150
|
-
|
|
151
|
-
if (!closed) emit(obj);
|
|
152
|
-
},
|
|
220
|
+
safeEmit,
|
|
153
221
|
);
|
|
154
222
|
} catch (e) {
|
|
155
223
|
log('error', `${body.agent} chat failed: ${e?.message || e}`);
|
|
156
224
|
emit({ type: 'error', error: e?.message || String(e) });
|
|
157
225
|
} finally {
|
|
226
|
+
if (session) deleteSession(session.id);
|
|
158
227
|
if (!res.writableEnded) res.end();
|
|
159
228
|
}
|
|
160
229
|
}
|
|
161
230
|
|
|
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.
|
|
233
|
+
async function handleMcp(req, res, sessionId) {
|
|
234
|
+
let msg;
|
|
235
|
+
try {
|
|
236
|
+
msg = await readBody(req);
|
|
237
|
+
} catch {
|
|
238
|
+
return json(res, 200, { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } });
|
|
239
|
+
}
|
|
240
|
+
const session = sessions.get(sessionId);
|
|
241
|
+
const reply = (result) => {
|
|
242
|
+
if (sessionId) res.setHeader('Mcp-Session-Id', sessionId);
|
|
243
|
+
json(res, 200, { jsonrpc: '2.0', id: msg.id ?? null, result });
|
|
244
|
+
};
|
|
245
|
+
const fail = (code, message) => json(res, 200, { jsonrpc: '2.0', id: msg.id ?? null, error: { code, message } });
|
|
246
|
+
|
|
247
|
+
// Notifications (no id) — ack and ignore.
|
|
248
|
+
if (msg.id == null) { res.writeHead(202); return res.end(); }
|
|
249
|
+
|
|
250
|
+
if (msg.method === 'initialize') {
|
|
251
|
+
return reply({
|
|
252
|
+
protocolVersion: msg.params?.protocolVersion || '2025-06-18',
|
|
253
|
+
capabilities: { tools: { listChanged: false } },
|
|
254
|
+
serverInfo: { name: 'chatpanel-browser', version: VERSION },
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
if (!session) return fail(-32001, 'Session not found (chat already ended)');
|
|
258
|
+
if (msg.method === 'tools/list') {
|
|
259
|
+
return reply({
|
|
260
|
+
tools: session.specs.map((s) => ({
|
|
261
|
+
name: s.name,
|
|
262
|
+
description: s.description,
|
|
263
|
+
inputSchema: s.parameters || { type: 'object', properties: {} },
|
|
264
|
+
})),
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
if (msg.method === 'tools/call') {
|
|
268
|
+
try {
|
|
269
|
+
return reply(await relayToolCall(session, msg.params?.name, msg.params?.arguments || {}));
|
|
270
|
+
} catch (e) {
|
|
271
|
+
return reply({ content: [{ type: 'text', text: `error: ${e?.message || e}` }], isError: true });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return fail(-32601, `Method not found: ${msg.method}`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// POST /mcp-local — proxy one JSON-RPC message to a user-configured STDIO MCP
|
|
278
|
+
// server that the bridge spawns and keeps alive. Body: { server:{id,command,args,
|
|
279
|
+
// env?,cwd?}, message }. Returns the full JSON-RPC response (or 202 for a
|
|
280
|
+
// notification). Lets the extension use local MCP servers it can't spawn itself.
|
|
281
|
+
async function handleMcpLocal(req, res) {
|
|
282
|
+
let body;
|
|
283
|
+
try {
|
|
284
|
+
body = await readBody(req);
|
|
285
|
+
} catch (e) {
|
|
286
|
+
return json(res, 400, { error: 'Bad JSON: ' + e.message });
|
|
287
|
+
}
|
|
288
|
+
const server = body.server || {};
|
|
289
|
+
const message = body.message;
|
|
290
|
+
if (!server.command || !message) return json(res, 400, { error: 'need server.command and message' });
|
|
291
|
+
try {
|
|
292
|
+
const result = await callLocalMcp(
|
|
293
|
+
{ key: server.id, command: server.command, args: server.args, env: server.env, cwd: server.cwd },
|
|
294
|
+
message,
|
|
295
|
+
);
|
|
296
|
+
if (message.id == null) { res.writeHead(202); return res.end(); }
|
|
297
|
+
return json(res, 200, result); // the full JSON-RPC response message
|
|
298
|
+
} catch (e) {
|
|
299
|
+
return json(res, 200, {
|
|
300
|
+
jsonrpc: '2.0',
|
|
301
|
+
id: message.id ?? null,
|
|
302
|
+
error: { code: -32000, message: String(e?.message || e) },
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// POST /tool-result — the extension returns a relayed tool's result.
|
|
308
|
+
async function handleToolResult(req, res) {
|
|
309
|
+
let body;
|
|
310
|
+
try {
|
|
311
|
+
body = await readBody(req);
|
|
312
|
+
} catch (e) {
|
|
313
|
+
return json(res, 400, { error: 'Bad JSON: ' + e.message });
|
|
314
|
+
}
|
|
315
|
+
const session = sessions.get(body.session);
|
|
316
|
+
if (!session) return json(res, 404, { error: 'no such session' });
|
|
317
|
+
const pending = session.pending.get(body.id);
|
|
318
|
+
if (!pending) return json(res, 404, { error: 'no such pending call' });
|
|
319
|
+
session.pending.delete(body.id);
|
|
320
|
+
pending.resolve(body.result);
|
|
321
|
+
return json(res, 200, { ok: true });
|
|
322
|
+
}
|
|
323
|
+
|
|
162
324
|
// POST /complete → { agent, prompt, model? } → { text } — a fast, single-shot
|
|
163
325
|
// completion for prompt autocomplete. Uses the engine's complete() if it has one
|
|
164
326
|
// (Claude: Haiku, no tools), else a one-shot chat collected into text.
|
|
@@ -262,6 +424,14 @@ const server = createServer(async (req, res) => {
|
|
|
262
424
|
});
|
|
263
425
|
}
|
|
264
426
|
if (req.method === 'POST' && url.pathname === '/chat') return handleChat(req, res);
|
|
427
|
+
if (url.pathname.startsWith('/mcp/')) {
|
|
428
|
+
const sid = decodeURIComponent(url.pathname.slice(5));
|
|
429
|
+
if (req.method === 'POST') return handleMcp(req, res, sid);
|
|
430
|
+
if (req.method === 'GET') { res.writeHead(405); return res.end(); } // no server-initiated stream
|
|
431
|
+
if (req.method === 'DELETE') { deleteSession(sid); res.writeHead(204); return res.end(); }
|
|
432
|
+
}
|
|
433
|
+
if (req.method === 'POST' && url.pathname === '/tool-result') return handleToolResult(req, res);
|
|
434
|
+
if (req.method === 'POST' && url.pathname === '/mcp-local') return handleMcpLocal(req, res);
|
|
265
435
|
if (req.method === 'POST' && url.pathname === '/complete') return handleComplete(req, res);
|
|
266
436
|
if (req.method === 'POST' && url.pathname === '/list-models') return handleListModels(req, res);
|
|
267
437
|
if (req.method === 'POST' && url.pathname === '/agent-check') return handleAgentCheck(req, res);
|
|
@@ -277,6 +447,59 @@ function log(level, msg) {
|
|
|
277
447
|
fn(`[chatpanel-bridge] ${msg}`);
|
|
278
448
|
}
|
|
279
449
|
|
|
450
|
+
// `--mcp-stdio <url>` — run as a stdio↔HTTP MCP proxy: read newline-delimited
|
|
451
|
+
// JSON-RPC from stdin, forward each message to the bridge's HTTP MCP endpoint
|
|
452
|
+
// (<url> = http://127.0.0.1:PORT/mcp/<session>), and write responses to stdout.
|
|
453
|
+
// This lets ANY stdio-MCP CLI (Codex, a custom CLI) use the browser tools with
|
|
454
|
+
// the bridge binary itself as the MCP server command — no extra runtime needed.
|
|
455
|
+
function runMcpStdioProxy(url) {
|
|
456
|
+
let buf = '';
|
|
457
|
+
const queue = [];
|
|
458
|
+
let draining = false;
|
|
459
|
+
let ended = false;
|
|
460
|
+
const maybeExit = () => { if (ended && !draining && !queue.length) process.exit(0); };
|
|
461
|
+
const drain = async () => {
|
|
462
|
+
if (draining) return;
|
|
463
|
+
draining = true;
|
|
464
|
+
while (queue.length) {
|
|
465
|
+
const line = queue.shift();
|
|
466
|
+
let msg;
|
|
467
|
+
try { msg = JSON.parse(line); } catch { continue; }
|
|
468
|
+
try {
|
|
469
|
+
const res = await fetch(url, {
|
|
470
|
+
method: 'POST',
|
|
471
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
472
|
+
body: line,
|
|
473
|
+
});
|
|
474
|
+
if (msg.id == null) continue; // notification — no response expected
|
|
475
|
+
const text = (await res.text()).trim();
|
|
476
|
+
if (text) process.stdout.write(text + '\n');
|
|
477
|
+
} catch (e) {
|
|
478
|
+
if (msg.id != null) {
|
|
479
|
+
process.stdout.write(
|
|
480
|
+
JSON.stringify({ jsonrpc: '2.0', id: msg.id, error: { code: -32000, message: String(e?.message || e) } }) + '\n',
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
draining = false;
|
|
486
|
+
maybeExit(); // stdin closed mid-flight → exit only after the queue is drained
|
|
487
|
+
};
|
|
488
|
+
process.stdin.setEncoding('utf8');
|
|
489
|
+
process.stdin.on('data', (chunk) => {
|
|
490
|
+
buf += chunk;
|
|
491
|
+
let nl;
|
|
492
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
493
|
+
const line = buf.slice(0, nl).trim();
|
|
494
|
+
buf = buf.slice(nl + 1);
|
|
495
|
+
if (line) queue.push(line);
|
|
496
|
+
}
|
|
497
|
+
drain();
|
|
498
|
+
});
|
|
499
|
+
process.stdin.on('end', () => { ended = true; maybeExit(); });
|
|
500
|
+
process.stdin.resume();
|
|
501
|
+
}
|
|
502
|
+
|
|
280
503
|
function startServer() {
|
|
281
504
|
enrichPath(); // so codex/gemini are found even under a minimal service PATH
|
|
282
505
|
server.listen(PORT, HOST, async () => {
|
|
@@ -350,7 +573,15 @@ function runCli() {
|
|
|
350
573
|
return false;
|
|
351
574
|
}
|
|
352
575
|
|
|
353
|
-
|
|
576
|
+
const mcpStdioIdx = process.argv.indexOf('--mcp-stdio');
|
|
577
|
+
if (mcpStdioIdx >= 0) {
|
|
578
|
+
const url = process.argv[mcpStdioIdx + 1];
|
|
579
|
+
if (!url) {
|
|
580
|
+
console.error('--mcp-stdio requires a URL');
|
|
581
|
+
process.exit(1);
|
|
582
|
+
}
|
|
583
|
+
runMcpStdioProxy(url);
|
|
584
|
+
} else if (process.argv.includes('--update')) {
|
|
354
585
|
(async () => {
|
|
355
586
|
try {
|
|
356
587
|
const r = await selfUpdate(VERSION);
|