@chatpanel/bridge 0.3.1 → 0.3.3
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 +7 -0
- package/src/engines/codex.js +17 -1
- package/src/engines/custom.js +57 -0
- package/src/engines/gemini.js +7 -0
- 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.3",
|
|
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
|
@@ -55,6 +55,13 @@ export async function available() {
|
|
|
55
55
|
return cachedOk ? { ok: true } : { ok: false, reason: lastReason };
|
|
56
56
|
}
|
|
57
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
|
+
|
|
58
65
|
// The bridge is stateless, so we replay the conversation as a single prompt.
|
|
59
66
|
function buildPrompt(messages) {
|
|
60
67
|
const history = messages.slice(0, -1);
|
package/src/engines/codex.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { spawn, spawnSync } from 'node:child_process';
|
|
18
18
|
import { readFile, unlink } from 'node:fs/promises';
|
|
19
|
-
import { existsSync, mkdirSync, symlinkSync } from 'node:fs';
|
|
19
|
+
import { existsSync, mkdirSync, symlinkSync, readFileSync } from 'node:fs';
|
|
20
20
|
import os from 'node:os';
|
|
21
21
|
import path from 'node:path';
|
|
22
22
|
import { findAgentBin } from '../env.js';
|
|
@@ -28,6 +28,22 @@ const IDLE_MS = Number(process.env.CHATPANEL_CODEX_TIMEOUT_MS) || 180_000;
|
|
|
28
28
|
const REASONING = process.env.CHATPANEL_CODEX_EFFORT ?? 'low'; // '' → respect config
|
|
29
29
|
|
|
30
30
|
const SCRATCH = path.join(os.tmpdir(), 'chatpanel-codex-scratch');
|
|
31
|
+
|
|
32
|
+
// Codex has no "list models" command — its model lives in CODEX_HOME/config.toml
|
|
33
|
+
// (e.g. `model = "gpt-5.5"`). Surface the user's REAL configured model(s), read
|
|
34
|
+
// straight from that file, plus a few common ids. The picker still accepts any
|
|
35
|
+
// free-text value, so an out-of-date curated entry is harmless.
|
|
36
|
+
const CODEX_KNOWN = ['gpt-5-codex', 'gpt-5', 'o3', 'o4-mini'];
|
|
37
|
+
export async function listModels() {
|
|
38
|
+
const set = new Set();
|
|
39
|
+
try {
|
|
40
|
+
const home = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
41
|
+
const cfg = readFileSync(path.join(home, 'config.toml'), 'utf8');
|
|
42
|
+
for (const m of cfg.matchAll(/(?:^|\n)\s*model\s*=\s*["']([^"'\n]+)["']/g)) set.add(m[1].trim());
|
|
43
|
+
} catch { /* no config — fall back to the curated set */ }
|
|
44
|
+
for (const m of CODEX_KNOWN) set.add(m);
|
|
45
|
+
return [...set];
|
|
46
|
+
}
|
|
31
47
|
const ISO_HOME = path.join(os.homedir(), '.chatpanel', 'codex-home');
|
|
32
48
|
|
|
33
49
|
function ensureScratch() {
|
package/src/engines/custom.js
CHANGED
|
@@ -32,6 +32,52 @@ export async function available() {
|
|
|
32
32
|
return { ok: true };
|
|
33
33
|
}
|
|
34
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
|
+
|
|
35
81
|
// The bridge is stateless, so replay the conversation as a single prompt.
|
|
36
82
|
function buildPrompt(messages, system) {
|
|
37
83
|
let p = system ? `${system}\n\n` : '';
|
|
@@ -74,6 +120,17 @@ export async function chat({ messages, system, options }, emit) {
|
|
|
74
120
|
: spec.args
|
|
75
121
|
? String(spec.args).split(/\s+/).filter(Boolean)
|
|
76
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
|
+
}
|
|
77
134
|
if (promptVia === 'arg') {
|
|
78
135
|
let placed = false;
|
|
79
136
|
args = args.map((a) => {
|
package/src/engines/gemini.js
CHANGED
|
@@ -20,6 +20,13 @@ import { findAgentBin } from '../env.js';
|
|
|
20
20
|
const IDLE_MS = Number(process.env.CHATPANEL_GEMINI_TIMEOUT_MS) || 180_000;
|
|
21
21
|
const SCRATCH = path.join(os.tmpdir(), 'chatpanel-gemini-scratch');
|
|
22
22
|
|
|
23
|
+
// Gemini CLI has no "list models" command (--help only lists extensions/sessions)
|
|
24
|
+
// and stores no model in settings.json, so offer the common current ids. Free
|
|
25
|
+
// text still accepted.
|
|
26
|
+
export async function listModels() {
|
|
27
|
+
return ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.5-flash-lite'];
|
|
28
|
+
}
|
|
29
|
+
|
|
23
30
|
let installed = false;
|
|
24
31
|
let lastProbe = 0;
|
|
25
32
|
export async function available() {
|
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.3';
|
|
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' });
|