@chatpanel/bridge 0.3.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/bridge",
3
- "version": "0.3.1",
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": [
@@ -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);
@@ -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/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.1';
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' });