@chatpanel/bridge 0.3.0 → 0.3.2
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 +25 -8
- package/src/engines/codex.js +17 -8
- package/src/engines/custom.js +74 -8
- package/src/engines/gemini.js +17 -8
- package/src/server.js +24 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/bridge",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
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
|
@@ -18,7 +18,11 @@ import os from 'node:os';
|
|
|
18
18
|
import path from 'node:path';
|
|
19
19
|
import { resolveClaude, buildSpawnSpec, isCompiledBinary } from '../env.js';
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
// Idle timeout: kill the run only after this long with NO output. The timer
|
|
22
|
+
// re-arms on every stdout/stderr chunk, so a task that keeps streaming can run
|
|
23
|
+
// indefinitely — only a truly stuck/silent process is killed. Override with
|
|
24
|
+
// CHATPANEL_CLAUDE_TIMEOUT_MS (ms).
|
|
25
|
+
const IDLE_MS = Number(process.env.CHATPANEL_CLAUDE_TIMEOUT_MS) || 180_000;
|
|
22
26
|
// Read-only tools allowed without approval in headless mode; writes/shell are
|
|
23
27
|
// gated behind the agent's permission mode.
|
|
24
28
|
const READONLY_TOOLS = ['Read', 'Grep', 'Glob', 'WebFetch', 'WebSearch', 'TodoWrite', 'Task'];
|
|
@@ -51,6 +55,13 @@ export async function available() {
|
|
|
51
55
|
return cachedOk ? { ok: true } : { ok: false, reason: lastReason };
|
|
52
56
|
}
|
|
53
57
|
|
|
58
|
+
// Claude Code has no "list models" command — it takes stable aliases (or full
|
|
59
|
+
// ids). Return the common aliases so the picker has sensible options; the user
|
|
60
|
+
// can still type any model string (e.g. claude-opus-4-8).
|
|
61
|
+
export async function listModels() {
|
|
62
|
+
return ['opus', 'sonnet', 'haiku'];
|
|
63
|
+
}
|
|
64
|
+
|
|
54
65
|
// The bridge is stateless, so we replay the conversation as a single prompt.
|
|
55
66
|
function buildPrompt(messages) {
|
|
56
67
|
const history = messages.slice(0, -1);
|
|
@@ -86,12 +97,18 @@ function runClaude({ prompt, args, cwd, emit }) {
|
|
|
86
97
|
let streamedAny = false;
|
|
87
98
|
let resultText = '';
|
|
88
99
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
100
|
+
let idleTimer;
|
|
101
|
+
const armIdle = () => {
|
|
102
|
+
clearTimeout(idleTimer);
|
|
103
|
+
idleTimer = setTimeout(() => {
|
|
104
|
+
child.kill('SIGKILL');
|
|
105
|
+
reject(new Error(`Claude Code timed out — no output for ${Math.round(IDLE_MS / 1000)}s.`));
|
|
106
|
+
}, IDLE_MS);
|
|
107
|
+
};
|
|
108
|
+
armIdle();
|
|
93
109
|
|
|
94
110
|
child.stdout.on('data', (d) => {
|
|
111
|
+
armIdle();
|
|
95
112
|
stdout += d.toString();
|
|
96
113
|
let nl;
|
|
97
114
|
while ((nl = stdout.indexOf('\n')) >= 0) {
|
|
@@ -109,13 +126,13 @@ function runClaude({ prompt, args, cwd, emit }) {
|
|
|
109
126
|
if (r.result != null) resultText = r.result;
|
|
110
127
|
}
|
|
111
128
|
});
|
|
112
|
-
child.stderr.on('data', (d) => (stderr += d.toString())
|
|
129
|
+
child.stderr.on('data', (d) => { armIdle(); stderr += d.toString(); });
|
|
113
130
|
child.on('error', (e) => {
|
|
114
|
-
clearTimeout(
|
|
131
|
+
clearTimeout(idleTimer);
|
|
115
132
|
reject(new Error(`Failed to start claude (${bin}): ${e.message}`));
|
|
116
133
|
});
|
|
117
134
|
child.on('close', (code) => {
|
|
118
|
-
clearTimeout(
|
|
135
|
+
clearTimeout(idleTimer);
|
|
119
136
|
if (code === 0) resolve({ streamedAny, resultText });
|
|
120
137
|
else reject(new Error(`Claude Code exited ${code}: ${stderr.trim().split('\n').pop() || 'failed'}`));
|
|
121
138
|
});
|
package/src/engines/codex.js
CHANGED
|
@@ -21,7 +21,10 @@ import os from 'node:os';
|
|
|
21
21
|
import path from 'node:path';
|
|
22
22
|
import { findAgentBin } from '../env.js';
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
// Idle timeout: re-armed on every stdout/stderr chunk, so a long run that keeps
|
|
25
|
+
// streaming never trips it — only true silence does. Override with
|
|
26
|
+
// CHATPANEL_CODEX_TIMEOUT_MS (ms).
|
|
27
|
+
const IDLE_MS = Number(process.env.CHATPANEL_CODEX_TIMEOUT_MS) || 180_000;
|
|
25
28
|
const REASONING = process.env.CHATPANEL_CODEX_EFFORT ?? 'low'; // '' → respect config
|
|
26
29
|
|
|
27
30
|
const SCRATCH = path.join(os.tmpdir(), 'chatpanel-codex-scratch');
|
|
@@ -131,12 +134,18 @@ export async function chat({ messages, system, options }, emit) {
|
|
|
131
134
|
|
|
132
135
|
let stdout = '';
|
|
133
136
|
let stderr = '';
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
137
|
+
let idleTimer;
|
|
138
|
+
const armIdle = () => {
|
|
139
|
+
clearTimeout(idleTimer);
|
|
140
|
+
idleTimer = setTimeout(() => {
|
|
141
|
+
child.kill('SIGKILL');
|
|
142
|
+
reject(new Error(`Codex timed out — no output for ${Math.round(IDLE_MS / 1000)}s.`));
|
|
143
|
+
}, IDLE_MS);
|
|
144
|
+
};
|
|
145
|
+
armIdle();
|
|
138
146
|
|
|
139
147
|
child.stdout.on('data', (d) => {
|
|
148
|
+
armIdle();
|
|
140
149
|
stdout += d.toString();
|
|
141
150
|
let nl;
|
|
142
151
|
while ((nl = stdout.indexOf('\n')) >= 0) {
|
|
@@ -150,13 +159,13 @@ export async function chat({ messages, system, options }, emit) {
|
|
|
150
159
|
}
|
|
151
160
|
}
|
|
152
161
|
});
|
|
153
|
-
child.stderr.on('data', (d) => (stderr += d.toString())
|
|
162
|
+
child.stderr.on('data', (d) => { armIdle(); stderr += d.toString(); });
|
|
154
163
|
child.on('error', (e) => {
|
|
155
|
-
clearTimeout(
|
|
164
|
+
clearTimeout(idleTimer);
|
|
156
165
|
reject(e);
|
|
157
166
|
});
|
|
158
167
|
child.on('close', async (code) => {
|
|
159
|
-
clearTimeout(
|
|
168
|
+
clearTimeout(idleTimer);
|
|
160
169
|
let text = '';
|
|
161
170
|
try {
|
|
162
171
|
text = (await readFile(outFile, 'utf8')).trim();
|
package/src/engines/custom.js
CHANGED
|
@@ -21,7 +21,10 @@ import { resolveCommand, buildSpawnSpec } from '../env.js';
|
|
|
21
21
|
import { isProEntitled } from '../entitlement.js';
|
|
22
22
|
import { handleMessage } from './claude.js';
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
// Idle timeout: re-armed on every stdout/stderr chunk, so a long run that keeps
|
|
25
|
+
// streaming never trips it — only true silence does. Override with
|
|
26
|
+
// CHATPANEL_CUSTOM_TIMEOUT_MS (ms).
|
|
27
|
+
const IDLE_MS = Number(process.env.CHATPANEL_CUSTOM_TIMEOUT_MS) || 180_000;
|
|
25
28
|
|
|
26
29
|
export async function available() {
|
|
27
30
|
// The engine ships in every bridge; individual custom agents are user-defined
|
|
@@ -29,6 +32,52 @@ export async function available() {
|
|
|
29
32
|
return { ok: true };
|
|
30
33
|
}
|
|
31
34
|
|
|
35
|
+
// Parse a CLI's "list models" stdout into model ids. Tools format this very
|
|
36
|
+
// differently (one-per-line, a table, "provider/model", …), so this is
|
|
37
|
+
// best-effort: take the first token of each line that looks like an id and skip
|
|
38
|
+
// obvious headers/prose. The picker always allows a custom value as a fallback.
|
|
39
|
+
function parseModelList(stdout) {
|
|
40
|
+
const out = [];
|
|
41
|
+
const seen = new Set();
|
|
42
|
+
for (const raw of String(stdout || '').split('\n')) {
|
|
43
|
+
const tok = raw.trim().split(/\s+/)[0] || '';
|
|
44
|
+
if (!/^[A-Za-z0-9][\w./:-]{1,79}$/.test(tok)) continue;
|
|
45
|
+
if (/^(name|model|models|id|provider|available|usage|options|commands)$/i.test(tok)) continue;
|
|
46
|
+
if (seen.has(tok)) continue;
|
|
47
|
+
seen.add(tok);
|
|
48
|
+
out.push(tok);
|
|
49
|
+
if (out.length >= 200) break;
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Unified model listing: run the agent's CONFIGURED list-models invocation
|
|
55
|
+
// (e.g. pi `--list-models`, opencode `models`) and parse the output. Returns []
|
|
56
|
+
// when not configured. Pro-gated like chat (it runs the user's CLI).
|
|
57
|
+
export async function listModels(options = {}) {
|
|
58
|
+
if (!(await isProEntitled(options.entitlement))) {
|
|
59
|
+
throw new Error('Custom agents require ChatPanel Pro.');
|
|
60
|
+
}
|
|
61
|
+
const spec = options.custom || {};
|
|
62
|
+
const listArgs = String(spec.listModelsArgs || '').trim();
|
|
63
|
+
if (!spec.command || !listArgs) return [];
|
|
64
|
+
const resolved = resolveCommand(spec.command);
|
|
65
|
+
if (!resolved) throw new Error(`Couldn't find "${spec.command}".`);
|
|
66
|
+
const cwd = options.workingDir ? path.resolve(options.workingDir) : null;
|
|
67
|
+
const [bin, argv, opts] = buildSpawnSpec(resolved, listArgs.split(/\s+/).filter(Boolean), cwd);
|
|
68
|
+
const stdout = await new Promise((resolve, reject) => {
|
|
69
|
+
let child;
|
|
70
|
+
try { child = spawn(bin, argv, opts); } catch (e) { return reject(new Error(`Failed to start ${spec.command}: ${e.message}`)); }
|
|
71
|
+
let out = '';
|
|
72
|
+
const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('Listing models timed out.')); }, 20000);
|
|
73
|
+
child.stdout.on('data', (d) => (out += d.toString()));
|
|
74
|
+
child.on('error', (e) => { clearTimeout(timer); reject(new Error(`Failed to start ${spec.command}: ${e.message}`)); });
|
|
75
|
+
child.on('close', () => { clearTimeout(timer); resolve(out); });
|
|
76
|
+
try { child.stdin.end(); } catch { /* some CLIs don't read stdin */ }
|
|
77
|
+
});
|
|
78
|
+
return parseModelList(stdout);
|
|
79
|
+
}
|
|
80
|
+
|
|
32
81
|
// The bridge is stateless, so replay the conversation as a single prompt.
|
|
33
82
|
function buildPrompt(messages, system) {
|
|
34
83
|
let p = system ? `${system}\n\n` : '';
|
|
@@ -71,6 +120,17 @@ export async function chat({ messages, system, options }, emit) {
|
|
|
71
120
|
: spec.args
|
|
72
121
|
? String(spec.args).split(/\s+/).filter(Boolean)
|
|
73
122
|
: [];
|
|
123
|
+
// Inject the selected model via the agent's CONFIGURED model-arg template
|
|
124
|
+
// (e.g. "--model {model}" or, for opencode, "-m {model}" with provider/model).
|
|
125
|
+
// Without a template we can't know how this CLI takes a model, so options.model
|
|
126
|
+
// is ignored — preserving back-compat with agents that bake the model into args.
|
|
127
|
+
if (options.model && spec.modelArg) {
|
|
128
|
+
const tmpl = String(spec.modelArg);
|
|
129
|
+
const injected = tmpl.includes('{model}')
|
|
130
|
+
? tmpl.replaceAll('{model}', options.model).split(/\s+/).filter(Boolean)
|
|
131
|
+
: [...tmpl.split(/\s+/).filter(Boolean), options.model];
|
|
132
|
+
args = [...injected, ...args];
|
|
133
|
+
}
|
|
74
134
|
if (promptVia === 'arg') {
|
|
75
135
|
let placed = false;
|
|
76
136
|
args = args.map((a) => {
|
|
@@ -98,12 +158,18 @@ export async function chat({ messages, system, options }, emit) {
|
|
|
98
158
|
let resultText = '';
|
|
99
159
|
let jsonBuf = '';
|
|
100
160
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
161
|
+
let idleTimer;
|
|
162
|
+
const armIdle = () => {
|
|
163
|
+
clearTimeout(idleTimer);
|
|
164
|
+
idleTimer = setTimeout(() => {
|
|
165
|
+
child.kill('SIGKILL');
|
|
166
|
+
reject(new Error(`${label} timed out — no output for ${Math.round(IDLE_MS / 1000)}s.`));
|
|
167
|
+
}, IDLE_MS);
|
|
168
|
+
};
|
|
169
|
+
armIdle();
|
|
105
170
|
|
|
106
171
|
child.stdout.on('data', (d) => {
|
|
172
|
+
armIdle();
|
|
107
173
|
const s = d.toString();
|
|
108
174
|
if (fmt === 'claude-stream-json') {
|
|
109
175
|
jsonBuf += s;
|
|
@@ -127,13 +193,13 @@ export async function chat({ messages, system, options }, emit) {
|
|
|
127
193
|
emit({ type: 'delta', text: s });
|
|
128
194
|
}
|
|
129
195
|
});
|
|
130
|
-
child.stderr.on('data', (d) => (stderr += d.toString())
|
|
196
|
+
child.stderr.on('data', (d) => { armIdle(); stderr += d.toString(); });
|
|
131
197
|
child.on('error', (e) => {
|
|
132
|
-
clearTimeout(
|
|
198
|
+
clearTimeout(idleTimer);
|
|
133
199
|
reject(new Error(`Failed to start ${label}: ${e.message}`));
|
|
134
200
|
});
|
|
135
201
|
child.on('close', (code) => {
|
|
136
|
-
clearTimeout(
|
|
202
|
+
clearTimeout(idleTimer);
|
|
137
203
|
if (code === 0) {
|
|
138
204
|
emit({ type: 'done', text: streamedAny ? '' : resultText });
|
|
139
205
|
resolve();
|
package/src/engines/gemini.js
CHANGED
|
@@ -14,7 +14,10 @@ import os from 'node:os';
|
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import { findAgentBin } from '../env.js';
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
// Idle timeout: re-armed on every stdout/stderr chunk, so a long run that keeps
|
|
18
|
+
// streaming never trips it — only true silence does. Override with
|
|
19
|
+
// CHATPANEL_GEMINI_TIMEOUT_MS (ms).
|
|
20
|
+
const IDLE_MS = Number(process.env.CHATPANEL_GEMINI_TIMEOUT_MS) || 180_000;
|
|
18
21
|
const SCRATCH = path.join(os.tmpdir(), 'chatpanel-gemini-scratch');
|
|
19
22
|
|
|
20
23
|
let installed = false;
|
|
@@ -76,24 +79,30 @@ export async function chat({ messages, system, options }, emit) {
|
|
|
76
79
|
let out = '';
|
|
77
80
|
let err = '';
|
|
78
81
|
let streamed = false;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
82
|
+
let idleTimer;
|
|
83
|
+
const armIdle = () => {
|
|
84
|
+
clearTimeout(idleTimer);
|
|
85
|
+
idleTimer = setTimeout(() => {
|
|
86
|
+
child.kill('SIGKILL');
|
|
87
|
+
reject(new Error(`Gemini timed out — no output for ${Math.round(IDLE_MS / 1000)}s.`));
|
|
88
|
+
}, IDLE_MS);
|
|
89
|
+
};
|
|
90
|
+
armIdle();
|
|
83
91
|
|
|
84
92
|
child.stdout.on('data', (d) => {
|
|
93
|
+
armIdle();
|
|
85
94
|
const s = d.toString();
|
|
86
95
|
out += s;
|
|
87
96
|
streamed = true;
|
|
88
97
|
emit({ type: 'delta', text: s });
|
|
89
98
|
});
|
|
90
|
-
child.stderr.on('data', (d) => (err += d.toString())
|
|
99
|
+
child.stderr.on('data', (d) => { armIdle(); err += d.toString(); });
|
|
91
100
|
child.on('error', (e) => {
|
|
92
|
-
clearTimeout(
|
|
101
|
+
clearTimeout(idleTimer);
|
|
93
102
|
reject(new Error(`Failed to start gemini: ${e.message}`));
|
|
94
103
|
});
|
|
95
104
|
child.on('close', (code) => {
|
|
96
|
-
clearTimeout(
|
|
105
|
+
clearTimeout(idleTimer);
|
|
97
106
|
if (code === 0) {
|
|
98
107
|
if (!streamed) emit({ type: 'delta', text: out.trim() || '(no output)' });
|
|
99
108
|
emit({ type: 'done', text: '' });
|
package/src/server.js
CHANGED
|
@@ -27,7 +27,7 @@ import { checkForUpdate, selfUpdate } from './update.js';
|
|
|
27
27
|
// Hardcoded (not read from package.json) so it survives Bun's single-file
|
|
28
28
|
// --compile, where package.json isn't on a readable FS. CI fails the publish if
|
|
29
29
|
// this drifts from package.json, so the two can't silently diverge.
|
|
30
|
-
const VERSION = '0.3.
|
|
30
|
+
const VERSION = '0.3.2';
|
|
31
31
|
const HOST = process.env.CHATPANEL_BRIDGE_HOST || '127.0.0.1';
|
|
32
32
|
const PORT = Number(process.env.CHATPANEL_BRIDGE_PORT) || 4319;
|
|
33
33
|
|
|
@@ -191,6 +191,28 @@ async function handleComplete(req, res) {
|
|
|
191
191
|
}
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
+
// POST /list-models → { agent, options } → { models } — the unified model-list
|
|
195
|
+
// interface. Each engine decides HOW to enumerate (claude → known aliases; custom
|
|
196
|
+
// → runs the agent's configured `listModelsArgs`, e.g. pi `--list-models` /
|
|
197
|
+
// opencode `models`, and parses stdout). Engines without a lister return [].
|
|
198
|
+
async function handleListModels(req, res) {
|
|
199
|
+
let body;
|
|
200
|
+
try {
|
|
201
|
+
body = await readBody(req);
|
|
202
|
+
} catch (e) {
|
|
203
|
+
return json(res, 400, { error: 'Bad JSON: ' + e.message });
|
|
204
|
+
}
|
|
205
|
+
const target = ENGINES[body.agent];
|
|
206
|
+
if (!target) return json(res, 404, { error: `Unknown agent "${body.agent}"` });
|
|
207
|
+
if (typeof target.engine.listModels !== 'function') return json(res, 200, { models: [] });
|
|
208
|
+
try {
|
|
209
|
+
const models = await target.engine.listModels(body.options || {});
|
|
210
|
+
return json(res, 200, { models: Array.isArray(models) ? models : [] });
|
|
211
|
+
} catch (e) {
|
|
212
|
+
return json(res, 502, { error: e?.message || String(e) });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
194
216
|
// POST /agent-check → { command } → { ok, via } — does this command resolve on
|
|
195
217
|
// this machine? Powers the "✓ found" indicator when onboarding a custom agent.
|
|
196
218
|
// `via` tells the user HOW it resolved (native / script / cmd / wsl) so a Windows
|
|
@@ -233,6 +255,7 @@ const server = createServer(async (req, res) => {
|
|
|
233
255
|
}
|
|
234
256
|
if (req.method === 'POST' && url.pathname === '/chat') return handleChat(req, res);
|
|
235
257
|
if (req.method === 'POST' && url.pathname === '/complete') return handleComplete(req, res);
|
|
258
|
+
if (req.method === 'POST' && url.pathname === '/list-models') return handleListModels(req, res);
|
|
236
259
|
if (req.method === 'POST' && url.pathname === '/agent-check') return handleAgentCheck(req, res);
|
|
237
260
|
if (req.method === 'POST' && url.pathname === '/update') return handleUpdate(res);
|
|
238
261
|
json(res, 404, { error: 'Not found' });
|