@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 +12 -0
- package/LICENSE +21 -0
- package/README.md +70 -0
- package/package.json +24 -0
- package/src/engines/claude.js +134 -0
- package/src/engines/codex.js +196 -0
- package/src/server.js +153 -0
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
|
+
});
|