@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/bridge",
3
- "version": "0.3.0",
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": [
@@ -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
- const TIMEOUT_MS = Number(process.env.CHATPANEL_CLAUDE_TIMEOUT_MS) || 180_000;
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
- const timer = setTimeout(() => {
90
- child.kill('SIGKILL');
91
- reject(new Error(`Claude Code timed out after ${Math.round(TIMEOUT_MS / 1000)}s.`));
92
- }, TIMEOUT_MS);
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(timer);
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(timer);
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
  });
@@ -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
- const TIMEOUT_MS = Number(process.env.CHATPANEL_CODEX_TIMEOUT_MS) || 180_000;
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
- const timer = setTimeout(() => {
135
- child.kill('SIGKILL');
136
- reject(new Error(`Codex timed out after ${Math.round(TIMEOUT_MS / 1000)}s.`));
137
- }, TIMEOUT_MS);
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(timer);
164
+ clearTimeout(idleTimer);
156
165
  reject(e);
157
166
  });
158
167
  child.on('close', async (code) => {
159
- clearTimeout(timer);
168
+ clearTimeout(idleTimer);
160
169
  let text = '';
161
170
  try {
162
171
  text = (await readFile(outFile, 'utf8')).trim();
@@ -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
- const TIMEOUT_MS = Number(process.env.CHATPANEL_CUSTOM_TIMEOUT_MS) || 180_000;
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
- const timer = setTimeout(() => {
102
- child.kill('SIGKILL');
103
- reject(new Error(`${label} timed out after ${Math.round(TIMEOUT_MS / 1000)}s.`));
104
- }, TIMEOUT_MS);
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(timer);
198
+ clearTimeout(idleTimer);
133
199
  reject(new Error(`Failed to start ${label}: ${e.message}`));
134
200
  });
135
201
  child.on('close', (code) => {
136
- clearTimeout(timer);
202
+ clearTimeout(idleTimer);
137
203
  if (code === 0) {
138
204
  emit({ type: 'done', text: streamedAny ? '' : resultText });
139
205
  resolve();
@@ -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
- const TIMEOUT_MS = Number(process.env.CHATPANEL_GEMINI_TIMEOUT_MS) || 180_000;
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
- const timer = setTimeout(() => {
80
- child.kill('SIGKILL');
81
- reject(new Error(`Gemini timed out after ${Math.round(TIMEOUT_MS / 1000)}s.`));
82
- }, TIMEOUT_MS);
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(timer);
101
+ clearTimeout(idleTimer);
93
102
  reject(new Error(`Failed to start gemini: ${e.message}`));
94
103
  });
95
104
  child.on('close', (code) => {
96
- clearTimeout(timer);
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.0';
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' });