@chatpanel/bridge 0.1.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/.env.example ADDED
@@ -0,0 +1,12 @@
1
+ # ChatPanel Bridge configuration (all optional)
2
+
3
+ # Bind address / port (must match the Bridge URL in ChatPanel Settings).
4
+ CHATPANEL_BRIDGE_HOST=127.0.0.1
5
+ CHATPANEL_BRIDGE_PORT=4319
6
+
7
+ # Claude: omit to use your local Claude Code login; or set a key explicitly.
8
+ # ANTHROPIC_API_KEY=sk-ant-...
9
+
10
+ # Safety rails.
11
+ CHATPANEL_MAX_TURNS=20
12
+ CHATPANEL_CODEX_TIMEOUT_MS=180000
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ChatPanel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,70 @@
1
+ # ChatPanel Bridge
2
+
3
+ A tiny localhost server that lets the ChatPanel Chrome extension talk to the
4
+ coding agents on your machine. A browser extension can't spawn local processes,
5
+ so this bridges the gap.
6
+
7
+ - **Claude Code** — embedded via `@anthropic-ai/claude-agent-sdk`, using your
8
+ existing Claude Code login (or `ANTHROPIC_API_KEY`).
9
+ - **Codex** — driven via the `codex exec` CLI, using your `codex login`.
10
+
11
+ ## Run it
12
+
13
+ ```bash
14
+ cd bridge
15
+ npm install # installs the Claude Agent SDK (Codex just needs the CLI)
16
+ npm start # → http://127.0.0.1:4319
17
+ ```
18
+
19
+ Prerequisites:
20
+
21
+ - **Claude Code**: be signed in (`claude`) or set `ANTHROPIC_API_KEY`.
22
+ - **Codex**: `codex` on your `PATH` and `codex login` done.
23
+
24
+ The extension polls `/health` and shows each agent as available/unavailable.
25
+
26
+ ## API
27
+
28
+ | Method | Path | Purpose |
29
+ |--------|------|---------|
30
+ | `GET` | `/health` | `{ ok, version, agents:[{id,label,available,reason}] }` |
31
+ | `POST` | `/chat` | SSE stream — body `{ agent, system, options, messages }` |
32
+
33
+ `/chat` streams Server-Sent Events: `{type:'delta',text}` as the answer is
34
+ generated, `{type:'tool',name,summary}` / `{type:'status'}` for activity, and a
35
+ final `{type:'done'}` (or `{type:'error',error}`).
36
+
37
+ `options` per agent (set in ChatPanel Settings):
38
+
39
+ ```jsonc
40
+ {
41
+ "workingDir": "/path/to/project", // where the agent reads/works
42
+ "permissionMode": "default", // default | acceptEdits | bypassPermissions
43
+ "model": "" // optional model override
44
+ }
45
+ ```
46
+
47
+ ## Safety
48
+
49
+ - Binds to `127.0.0.1` only; CORS accepts the extension origin and localhost.
50
+ - Claude tools are **read-only by default** (Read/Grep/Glob/WebFetch). Writes and
51
+ shell only run when an agent's permission mode is `acceptEdits` or
52
+ `bypassPermissions`.
53
+ - Point each agent at a working directory you trust.
54
+
55
+ ## Run as a background service (optional)
56
+
57
+ macOS (launchd) example — save to `~/Library/LaunchAgents/app.chatpanel.bridge.plist`:
58
+
59
+ ```xml
60
+ <?xml version="1.0" encoding="UTF-8"?>
61
+ <plist version="1.0"><dict>
62
+ <key>Label</key><string>app.chatpanel.bridge</string>
63
+ <key>ProgramArguments</key>
64
+ <array><string>/usr/local/bin/node</string><string>/ABSOLUTE/PATH/chatpanel/bridge/src/server.js</string></array>
65
+ <key>RunAtLoad</key><true/>
66
+ <key>KeepAlive</key><true/>
67
+ </dict></plist>
68
+ ```
69
+
70
+ Then `launchctl load ~/Library/LaunchAgents/app.chatpanel.bridge.plist`.
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@chatpanel/bridge",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Local bridge that exposes Claude Code (Agent SDK) and Codex (CLI) to the ChatPanel Chrome extension over a localhost SSE endpoint.",
6
+ "keywords": ["chatpanel", "claude-code", "codex", "chrome-extension", "ai-agents", "bridge"],
7
+ "homepage": "https://chatpanel.net",
8
+ "repository": { "type": "git", "url": "git+https://github.com/chatpanel/chatpanel-bridge.git" },
9
+ "license": "MIT",
10
+ "bin": { "chatpanel-bridge": "src/server.js" },
11
+ "files": ["src", "README.md", "LICENSE", ".env.example"],
12
+ "engines": { "node": ">=18" },
13
+ "scripts": {
14
+ "start": "node src/server.js",
15
+ "dev": "node --watch src/server.js"
16
+ },
17
+ "dependencies": {
18
+ "@anthropic-ai/claude-agent-sdk": "^0.1.0"
19
+ },
20
+ "publishConfig": {
21
+ "registry": "https://registry.npmjs.org/",
22
+ "access": "public"
23
+ }
24
+ }
@@ -0,0 +1,134 @@
1
+ // Claude Code engine — embeds the Claude Agent SDK using your *local* Claude
2
+ // Code login (or ANTHROPIC_API_KEY). It streams text deltas and surfaces tool
3
+ // use so the extension can show what the agent is doing.
4
+ //
5
+ // By default the agent can READ your code (Read/Grep/Glob/WebFetch) but cannot
6
+ // write or run shell commands unless the agent's permissionMode is set to
7
+ // 'acceptEdits' or 'bypassPermissions' in ChatPanel Settings. The working
8
+ // directory comes from the agent config (defaults to the bridge's cwd).
9
+
10
+ import path from 'node:path';
11
+ import os from 'node:os';
12
+
13
+ // Always-on guidance so the coding agent behaves like a browser assistant by
14
+ // default: prefer the page context the extension attaches over scanning files.
15
+ const BASE_GUIDANCE =
16
+ 'You are ChatPanel, an AI assistant living in a browser side panel. When the ' +
17
+ "user's message includes <context> blocks (extracted web pages or selections), " +
18
+ 'answer primarily from those. Only read or modify local files when the user ' +
19
+ 'explicitly asks about code or a project.';
20
+
21
+ let sdkPromise = null;
22
+ function loadSdk() {
23
+ if (!sdkPromise) sdkPromise = import('@anthropic-ai/claude-agent-sdk').catch(() => null);
24
+ return sdkPromise;
25
+ }
26
+
27
+ export async function available() {
28
+ const sdk = await loadSdk();
29
+ if (!sdk) {
30
+ return { ok: false, reason: 'Agent SDK not installed (npm i in bridge/)' };
31
+ }
32
+ if (!process.env.ANTHROPIC_API_KEY) {
33
+ // Not fatal — the SDK can use your local Claude Code login.
34
+ return { ok: true };
35
+ }
36
+ return { ok: true };
37
+ }
38
+
39
+ const READONLY_TOOLS = new Set(['Read', 'Grep', 'Glob', 'WebFetch', 'WebSearch', 'TodoWrite', 'Task']);
40
+
41
+ // Build a single prompt that carries the conversation history. The bridge is
42
+ // stateless, so we replay the chat each turn.
43
+ function buildPrompt(messages) {
44
+ const history = messages.slice(0, -1);
45
+ const last = messages[messages.length - 1];
46
+ let prompt = '';
47
+ if (history.length) {
48
+ prompt += 'Conversation so far:\n';
49
+ for (const m of history) {
50
+ prompt += `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content}\n\n`;
51
+ }
52
+ prompt += '---\n\n';
53
+ }
54
+ prompt += last ? last.content : '';
55
+ return prompt;
56
+ }
57
+
58
+ export async function chat({ messages, system, options }, emit) {
59
+ const sdk = await loadSdk();
60
+ if (!sdk) throw new Error('Claude Agent SDK not installed. Run `npm install` in bridge/.');
61
+ const { query } = sdk;
62
+
63
+ const permissionMode = options.permissionMode || 'default';
64
+ // No project configured → a neutral cwd, so the agent doesn't fixate on
65
+ // whatever directory the bridge happens to be running in.
66
+ const cwd = options.workingDir ? path.resolve(options.workingDir) : os.homedir();
67
+ const writesAllowed = permissionMode === 'acceptEdits' || permissionMode === 'bypassPermissions';
68
+
69
+ // Approve read-only tools always; gate writes/shell behind the chosen mode.
70
+ const canUseTool = async (toolName) => {
71
+ if (READONLY_TOOLS.has(toolName) || writesAllowed) return { behavior: 'allow', updatedInput: undefined };
72
+ return { behavior: 'deny', message: `${toolName} blocked — set this agent's permission mode to acceptEdits/bypassPermissions in ChatPanel to enable it.` };
73
+ };
74
+
75
+ let streamedAny = false;
76
+ let resultText = '';
77
+
78
+ const iterator = query({
79
+ prompt: buildPrompt(messages),
80
+ options: {
81
+ cwd,
82
+ permissionMode,
83
+ includePartialMessages: true,
84
+ canUseTool,
85
+ // Default: load your ~/.claude + project settings so your skills, MCP
86
+ // servers and CLAUDE.md apply. Turn the agent's "Use my local skills &
87
+ // config" off to run clean.
88
+ settingSources: options.useLocalConfig === false ? [] : ['user', 'project'],
89
+ systemPrompt: {
90
+ type: 'preset',
91
+ preset: 'claude_code',
92
+ append: [BASE_GUIDANCE, system].filter(Boolean).join('\n\n'),
93
+ },
94
+ ...(options.model ? { model: options.model } : {}),
95
+ ...(process.env.CHATPANEL_MAX_TURNS ? { maxTurns: Number(process.env.CHATPANEL_MAX_TURNS) } : {}),
96
+ },
97
+ });
98
+
99
+ for await (const message of iterator) {
100
+ if (message.type === 'stream_event') {
101
+ const ev = message.event;
102
+ if (ev?.type === 'content_block_delta' && ev.delta?.type === 'text_delta') {
103
+ streamedAny = true;
104
+ emit({ type: 'delta', text: ev.delta.text });
105
+ }
106
+ } else if (message.type === 'assistant') {
107
+ for (const block of message.message.content) {
108
+ if (block.type === 'tool_use') {
109
+ emit({ type: 'tool', name: block.name, summary: toolSummary(block) });
110
+ } else if (block.type === 'text' && !streamedAny) {
111
+ // Partials were unavailable — stream the whole block.
112
+ streamedAny = true;
113
+ emit({ type: 'delta', text: block.text });
114
+ }
115
+ }
116
+ } else if (message.type === 'result') {
117
+ if (message.subtype === 'success') resultText = message.result || '';
118
+ else if (message.subtype !== 'success') {
119
+ emit({ type: 'status', text: `(${message.subtype})` });
120
+ }
121
+ }
122
+ }
123
+
124
+ emit({ type: 'done', text: streamedAny ? '' : resultText });
125
+ }
126
+
127
+ function toolSummary(block) {
128
+ const i = block.input || {};
129
+ if (i.command) return String(i.command).slice(0, 60);
130
+ if (i.file_path) return path.basename(i.file_path);
131
+ if (i.pattern) return i.pattern;
132
+ if (i.url) return i.url;
133
+ return '';
134
+ }
@@ -0,0 +1,196 @@
1
+ // Codex engine — drives the Codex CLI (`codex exec`) using your local Codex login.
2
+ //
3
+ // Two modes, chosen per-agent in ChatPanel Settings ("Use my local skills & config"):
4
+ //
5
+ // useLocalConfig: true (DEFAULT) — your real CODEX_HOME loads, so your skills,
6
+ // MCP servers and config.toml all work. Best for "it should behave like my
7
+ // Codex." Can be slower if your global skills do a lot of work.
8
+ //
9
+ // useLocalConfig: false — run against an ISOLATED CODEX_HOME (just a symlink to
10
+ // your auth so you stay logged in). Skips your global AGENTS.md / skills, so
11
+ // it answers fast and never crawls files (~9x faster in practice).
12
+ //
13
+ // In BOTH modes we run in an EMPTY scratch dir for general chat, so Codex never
14
+ // references the bridge's own code or an unrelated project. Set a working dir on
15
+ // the agent to point it at a real project.
16
+
17
+ import { spawn, spawnSync } from 'node:child_process';
18
+ import { readFile, unlink } from 'node:fs/promises';
19
+ import { existsSync, mkdirSync, symlinkSync } from 'node:fs';
20
+ import os from 'node:os';
21
+ import path from 'node:path';
22
+
23
+ const TIMEOUT_MS = Number(process.env.CHATPANEL_CODEX_TIMEOUT_MS) || 180_000;
24
+ const REASONING = process.env.CHATPANEL_CODEX_EFFORT ?? 'low'; // '' → respect config
25
+
26
+ const SCRATCH = path.join(os.tmpdir(), 'chatpanel-codex-scratch');
27
+ const ISO_HOME = path.join(os.homedir(), '.chatpanel', 'codex-home');
28
+
29
+ function ensureScratch() {
30
+ try {
31
+ mkdirSync(SCRATCH, { recursive: true });
32
+ } catch {
33
+ /* best effort */
34
+ }
35
+ }
36
+
37
+ // Build (once) an isolated CODEX_HOME that has only a link to your auth, so the
38
+ // global skills/config don't load. Returns the path, or null on failure.
39
+ let isoReady = false;
40
+ function ensureIsolatedHome() {
41
+ if (!isoReady) {
42
+ try {
43
+ mkdirSync(ISO_HOME, { recursive: true });
44
+ const realAuth = path.join(os.homedir(), '.codex', 'auth.json');
45
+ const linkAuth = path.join(ISO_HOME, 'auth.json');
46
+ if (existsSync(realAuth) && !existsSync(linkAuth)) {
47
+ try {
48
+ symlinkSync(realAuth, linkAuth);
49
+ } catch {
50
+ /* auth errors surface clearly downstream */
51
+ }
52
+ }
53
+ isoReady = true;
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+ return ISO_HOME;
59
+ }
60
+
61
+ const BASE_GUIDANCE =
62
+ 'You are ChatPanel, an AI assistant in a browser side panel. Answer the ' +
63
+ "user's question using the <context> blocks provided (web pages or selections). " +
64
+ 'Do NOT search the filesystem or read local files for general questions — ' +
65
+ 'everything you need is in the prompt. Only inspect files if the user explicitly ' +
66
+ 'asks about local code or a project. When asked for a table, return GitHub-' +
67
+ 'flavored Markdown.';
68
+
69
+ let installed = null;
70
+ export async function available() {
71
+ if (installed === null) {
72
+ try {
73
+ const r = spawnSync('codex', ['--version'], { stdio: 'ignore', timeout: 5000 });
74
+ installed = r.status === 0 || (r.status === null && r.error === undefined);
75
+ } catch {
76
+ installed = false;
77
+ }
78
+ }
79
+ return installed
80
+ ? { ok: true }
81
+ : { ok: false, reason: 'codex not found on PATH. Install it and run `codex login`.' };
82
+ }
83
+
84
+ function buildPrompt(messages, system) {
85
+ let p = `${[BASE_GUIDANCE, system].filter(Boolean).join('\n\n')}\n\n`;
86
+ const history = messages.slice(0, -1);
87
+ const last = messages[messages.length - 1];
88
+ if (history.length) {
89
+ p += 'Conversation so far:\n';
90
+ for (const m of history) p += `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content}\n\n`;
91
+ p += '---\n\n';
92
+ }
93
+ p += last ? last.content : '';
94
+ return p;
95
+ }
96
+
97
+ export async function chat({ messages, system, options }, emit) {
98
+ ensureScratch();
99
+ const tag = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
100
+ const outFile = path.join(os.tmpdir(), `chatpanel-codex-${tag}.txt`);
101
+
102
+ const cwd = options.workingDir ? path.resolve(options.workingDir) : SCRATCH;
103
+ const sandbox =
104
+ options.permissionMode === 'bypassPermissions'
105
+ ? 'danger-full-access'
106
+ : options.permissionMode === 'acceptEdits'
107
+ ? 'workspace-write'
108
+ : 'read-only';
109
+
110
+ const args = ['exec', '--json', '--skip-git-repo-check', '-s', sandbox, '-o', outFile];
111
+ // Headless exec has no human to approve commands. Auto-run within the sandbox
112
+ // so skill/startup reads don't get "approval declined" (and skills can load in
113
+ // local-config mode). The sandbox above still bounds what can actually happen.
114
+ args.push('-c', 'approval_policy=never');
115
+ if (REASONING) args.push('-c', `model_reasoning_effort=${REASONING}`);
116
+ if (options.model) args.push('-m', options.model);
117
+ args.push('-');
118
+
119
+ // Default: use the user's skills/config. Opt-out → isolated home.
120
+ const useLocal = options.useLocalConfig !== false;
121
+ const env = { ...process.env };
122
+ if (!useLocal) {
123
+ const home = ensureIsolatedHome();
124
+ if (home) env.CODEX_HOME = home;
125
+ }
126
+
127
+ await new Promise((resolve, reject) => {
128
+ let child;
129
+ try {
130
+ child = spawn('codex', args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], env });
131
+ } catch (e) {
132
+ return reject(new Error(`Failed to start codex: ${e.message}`));
133
+ }
134
+
135
+ let stdout = '';
136
+ let stderr = '';
137
+ const timer = setTimeout(() => {
138
+ child.kill('SIGKILL');
139
+ reject(new Error(`Codex timed out after ${Math.round(TIMEOUT_MS / 1000)}s.`));
140
+ }, TIMEOUT_MS);
141
+
142
+ child.stdout.on('data', (d) => {
143
+ stdout += d.toString();
144
+ let nl;
145
+ while ((nl = stdout.indexOf('\n')) >= 0) {
146
+ const line = stdout.slice(0, nl).trim();
147
+ stdout = stdout.slice(nl + 1);
148
+ if (!line.startsWith('{')) continue;
149
+ try {
150
+ forwardEvent(JSON.parse(line), emit);
151
+ } catch {
152
+ /* not a JSON event line */
153
+ }
154
+ }
155
+ });
156
+ child.stderr.on('data', (d) => (stderr += d.toString()));
157
+ child.on('error', (e) => {
158
+ clearTimeout(timer);
159
+ reject(e);
160
+ });
161
+ child.on('close', async (code) => {
162
+ clearTimeout(timer);
163
+ let text = '';
164
+ try {
165
+ text = (await readFile(outFile, 'utf8')).trim();
166
+ } catch {
167
+ /* no message file */
168
+ }
169
+ unlink(outFile).catch(() => {});
170
+ if (code === 0) {
171
+ emit({ type: 'delta', text: text || '(no output)' });
172
+ emit({ type: 'done', text: '' });
173
+ resolve();
174
+ } else {
175
+ reject(new Error(`Codex exited ${code}: ${stderr.trim() || 'failed'}`));
176
+ }
177
+ });
178
+
179
+ child.stdin.write(buildPrompt(messages, system));
180
+ child.stdin.end();
181
+ });
182
+ }
183
+
184
+ function forwardEvent(ev, emit) {
185
+ const t = ev.type || '';
186
+ const item = ev.item || {};
187
+ if (item.type === 'command_execution' || t.includes('command')) {
188
+ emit({ type: 'tool', name: 'shell', summary: (item.command || '').slice(0, 60) });
189
+ } else if (item.type === 'reasoning' || t.includes('reasoning')) {
190
+ emit({ type: 'reasoning' });
191
+ } else if (item.type === 'file_change' || t.includes('patch')) {
192
+ emit({ type: 'tool', name: 'edit', summary: '' });
193
+ } else if (t === 'turn.started' || t === 'thread.started') {
194
+ emit({ type: 'status', text: 'Codex working' });
195
+ }
196
+ }
package/src/server.js ADDED
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env node
2
+ // ChatPanel Bridge — a tiny localhost server that exposes the coding agents
3
+ // running on this machine (Claude Code via the Agent SDK, Codex via its CLI) to
4
+ // the ChatPanel Chrome extension. Zero runtime dependencies beyond the optional
5
+ // Claude Agent SDK.
6
+ //
7
+ // GET /health → { ok, version, agents: [{id,label,available,reason}] }
8
+ // POST /chat → Server-Sent Events stream of { type, ... }:
9
+ // {type:'delta', text} incremental assistant text
10
+ // {type:'tool', name, summary}
11
+ // {type:'status'|'reasoning', text?}
12
+ // {type:'done', text?} (text only if not streamed)
13
+ // {type:'error', error}
14
+ //
15
+ // Binds to 127.0.0.1 only and accepts requests from the extension origin.
16
+
17
+ import { createServer } from 'node:http';
18
+ import * as claude from './engines/claude.js';
19
+ import * as codex from './engines/codex.js';
20
+
21
+ const VERSION = '0.1.0';
22
+ const HOST = process.env.CHATPANEL_BRIDGE_HOST || '127.0.0.1';
23
+ const PORT = Number(process.env.CHATPANEL_BRIDGE_PORT) || 4319;
24
+
25
+ const ENGINES = {
26
+ claude: { engine: claude, label: 'Claude Code' },
27
+ codex: { engine: codex, label: 'Codex' },
28
+ };
29
+
30
+ // --------------------------------------------------------------------------
31
+ // CORS — allow the extension (chrome-extension://…) and localhost dev origins.
32
+ // --------------------------------------------------------------------------
33
+ function cors(req, res) {
34
+ const origin = req.headers.origin || '';
35
+ const allow =
36
+ !origin ||
37
+ origin.startsWith('chrome-extension://') ||
38
+ origin.startsWith('moz-extension://') ||
39
+ origin.startsWith('http://localhost') ||
40
+ origin.startsWith('http://127.0.0.1');
41
+ res.setHeader('Access-Control-Allow-Origin', allow ? origin || '*' : 'null');
42
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
43
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
44
+ }
45
+
46
+ function json(res, code, obj) {
47
+ res.writeHead(code, { 'Content-Type': 'application/json' });
48
+ res.end(JSON.stringify(obj));
49
+ }
50
+
51
+ function readBody(req) {
52
+ return new Promise((resolve, reject) => {
53
+ let data = '';
54
+ req.on('data', (c) => {
55
+ data += c;
56
+ if (data.length > 50 * 1024 * 1024) reject(new Error('Body too large'));
57
+ });
58
+ req.on('end', () => {
59
+ try {
60
+ resolve(data ? JSON.parse(data) : {});
61
+ } catch (e) {
62
+ reject(e);
63
+ }
64
+ });
65
+ req.on('error', reject);
66
+ });
67
+ }
68
+
69
+ // --------------------------------------------------------------------------
70
+ // Routes
71
+ // --------------------------------------------------------------------------
72
+ async function handleHealth(res) {
73
+ const agents = await Promise.all(
74
+ Object.entries(ENGINES).map(async ([id, { engine, label }]) => {
75
+ const a = await engine.available().catch((e) => ({ ok: false, reason: String(e?.message || e) }));
76
+ return { id, label, available: a.ok, reason: a.reason };
77
+ }),
78
+ );
79
+ json(res, 200, { ok: true, version: VERSION, agents });
80
+ }
81
+
82
+ async function handleChat(req, res) {
83
+ let body;
84
+ try {
85
+ body = await readBody(req);
86
+ } catch (e) {
87
+ return json(res, 400, { error: 'Bad JSON: ' + e.message });
88
+ }
89
+ const target = ENGINES[body.agent];
90
+ if (!target) return json(res, 404, { error: `Unknown agent "${body.agent}"` });
91
+
92
+ // Open the SSE stream.
93
+ res.writeHead(200, {
94
+ 'Content-Type': 'text/event-stream',
95
+ 'Cache-Control': 'no-cache',
96
+ Connection: 'keep-alive',
97
+ });
98
+ const emit = (obj) => {
99
+ if (!res.writableEnded) res.write(`data: ${JSON.stringify(obj)}\n\n`);
100
+ };
101
+
102
+ // If the client disconnects, stop caring about late writes.
103
+ let closed = false;
104
+ req.on('close', () => (closed = true));
105
+
106
+ try {
107
+ await target.engine.chat(
108
+ {
109
+ messages: Array.isArray(body.messages) ? body.messages : [],
110
+ system: body.system || '',
111
+ options: body.options || {},
112
+ },
113
+ (obj) => {
114
+ if (!closed) emit(obj);
115
+ },
116
+ );
117
+ } catch (e) {
118
+ log('error', `${body.agent} chat failed: ${e?.message || e}`);
119
+ emit({ type: 'error', error: e?.message || String(e) });
120
+ } finally {
121
+ if (!res.writableEnded) res.end();
122
+ }
123
+ }
124
+
125
+ const server = createServer(async (req, res) => {
126
+ cors(req, res);
127
+ if (req.method === 'OPTIONS') {
128
+ res.writeHead(204);
129
+ return res.end();
130
+ }
131
+ const url = new URL(req.url, `http://${req.headers.host}`);
132
+ try {
133
+ if (req.method === 'GET' && url.pathname === '/health') return handleHealth(res);
134
+ if (req.method === 'POST' && url.pathname === '/chat') return handleChat(req, res);
135
+ json(res, 404, { error: 'Not found' });
136
+ } catch (e) {
137
+ json(res, 500, { error: e?.message || String(e) });
138
+ }
139
+ });
140
+
141
+ function log(level, msg) {
142
+ const fn = level === 'error' ? console.error : console.log;
143
+ fn(`[chatpanel-bridge] ${msg}`);
144
+ }
145
+
146
+ server.listen(PORT, HOST, async () => {
147
+ log('info', `listening on http://${HOST}:${PORT}`);
148
+ for (const [id, { engine, label }] of Object.entries(ENGINES)) {
149
+ const a = await engine.available().catch(() => ({ ok: false }));
150
+ log('info', ` ${a.ok ? '✓' : '✕'} ${label}${a.ok ? '' : ' — ' + (a.reason || 'unavailable')}`);
151
+ }
152
+ log('info', 'Open the ChatPanel side panel; Claude Code & Codex will appear as agents.');
153
+ });