@chatpanel/bridge 0.3.2 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/bridge",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
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": [
@@ -14,10 +14,27 @@
14
14
  // permissionMode is 'acceptEdits'/'bypassPermissions' in ChatPanel Settings.
15
15
 
16
16
  import { spawn } from 'node:child_process';
17
+ import { writeFile, unlink } from 'node:fs/promises';
17
18
  import os from 'node:os';
18
19
  import path from 'node:path';
19
20
  import { resolveClaude, buildSpawnSpec, isCompiledBinary } from '../env.js';
20
21
 
22
+ // Write base64 data-URL images to temp files. Claude Code reads them with its
23
+ // Read tool (which feeds images to the model as vision), so we just reference the
24
+ // paths in the prompt — no custom image flag needed. Returns paths (caller cleans).
25
+ async function writeImages(images, tag) {
26
+ const files = [];
27
+ for (let i = 0; i < (images?.length || 0); i++) {
28
+ const m = /^data:([^;]+);base64,(.+)$/s.exec(images[i]?.dataUrl || '');
29
+ if (!m) continue;
30
+ const ext = (m[1].split('/')[1] || 'png').replace(/[^a-z0-9]/gi, '').slice(0, 5) || 'png';
31
+ const file = path.join(os.tmpdir(), `chatpanel-claude-img-${tag}-${i}.${ext}`);
32
+ await writeFile(file, Buffer.from(m[2], 'base64'));
33
+ files.push(file);
34
+ }
35
+ return files;
36
+ }
37
+
21
38
  // Idle timeout: kill the run only after this long with NO output. The timer
22
39
  // re-arms on every stdout/stderr chunk, so a task that keeps streaming can run
23
40
  // indefinitely — only a truly stuck/silent process is killed. Override with
@@ -173,7 +190,7 @@ export function handleMessage(msg, emit, alreadyStreamed) {
173
190
  return out;
174
191
  }
175
192
 
176
- export async function chat({ messages, system, options }, emit) {
193
+ export async function chat({ messages, system, options, images }, emit) {
177
194
  const permissionMode = options.permissionMode || 'default';
178
195
  // Explicit project dir, else null → CLI runs in home (or WSL home).
179
196
  const cwd = options.workingDir ? path.resolve(options.workingDir) : null;
@@ -194,10 +211,29 @@ export async function chat({ messages, system, options }, emit) {
194
211
  // "Use my local skills & config" off → run clean.
195
212
  if (options.useLocalConfig === false) args.push('--setting-sources', '');
196
213
 
197
- const run = runClaude({ prompt: buildPrompt(messages), args, cwd, emit });
198
- if (run === null) return sdkChat({ messages, system, options }, emit); // no CLI SDK
199
- const { streamedAny, resultText } = await run;
200
- emit({ type: 'done', text: streamedAny ? '' : resultText });
214
+ // Attach images by writing them to temp files and asking Claude Code to Read
215
+ // them its Read tool loads images as vision (no special flag needed).
216
+ const tag = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
217
+ const imageFiles = await writeImages(images, tag);
218
+ const cleanup = () => imageFiles.forEach((f) => unlink(f).catch(() => {}));
219
+ let prompt = buildPrompt(messages);
220
+ if (imageFiles.length) {
221
+ prompt += `\n\nThe user attached ${imageFiles.length} image file(s). Use the Read tool to view ${
222
+ imageFiles.length === 1 ? 'it' : 'them'
223
+ }: ${imageFiles.join(', ')}`;
224
+ }
225
+
226
+ const run = runClaude({ prompt, args, cwd, emit });
227
+ if (run === null) {
228
+ cleanup(); // SDK fallback doesn't take images yet
229
+ return sdkChat({ messages, system, options }, emit);
230
+ }
231
+ try {
232
+ const { streamedAny, resultText } = await run;
233
+ emit({ type: 'done', text: streamedAny ? '' : resultText });
234
+ } finally {
235
+ cleanup();
236
+ }
201
237
  }
202
238
 
203
239
  // A fast, tool-free single-shot completion — used for prompt autocomplete. No
@@ -15,8 +15,8 @@
15
15
  // the agent to point it at a real project.
16
16
 
17
17
  import { spawn, spawnSync } from 'node:child_process';
18
- import { readFile, unlink } from 'node:fs/promises';
19
- import { existsSync, mkdirSync, symlinkSync } from 'node:fs';
18
+ import { readFile, unlink, writeFile } from 'node:fs/promises';
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() {
@@ -94,10 +110,27 @@ function buildPrompt(messages, system) {
94
110
  return p;
95
111
  }
96
112
 
97
- export async function chat({ messages, system, options }, emit) {
113
+ // Write base64 data-URL images to temp files so `codex exec -i <file>` can
114
+ // attach them to the prompt as vision input. Returns the paths (caller cleans up).
115
+ async function writeImages(images, tag) {
116
+ const files = [];
117
+ for (let i = 0; i < (images?.length || 0); i++) {
118
+ const m = /^data:([^;]+);base64,(.+)$/s.exec(images[i]?.dataUrl || '');
119
+ if (!m) continue;
120
+ const ext = (m[1].split('/')[1] || 'png').replace(/[^a-z0-9]/gi, '').slice(0, 5) || 'png';
121
+ const file = path.join(os.tmpdir(), `chatpanel-codex-img-${tag}-${i}.${ext}`);
122
+ await writeFile(file, Buffer.from(m[2], 'base64'));
123
+ files.push(file);
124
+ }
125
+ return files;
126
+ }
127
+
128
+ export async function chat({ messages, system, options, images }, emit) {
98
129
  ensureScratch();
99
130
  const tag = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
100
131
  const outFile = path.join(os.tmpdir(), `chatpanel-codex-${tag}.txt`);
132
+ const imageFiles = await writeImages(images, tag);
133
+ const cleanupImages = () => imageFiles.forEach((f) => unlink(f).catch(() => {}));
101
134
 
102
135
  const cwd = options.workingDir ? path.resolve(options.workingDir) : SCRATCH;
103
136
  const sandbox =
@@ -114,6 +147,7 @@ export async function chat({ messages, system, options }, emit) {
114
147
  args.push('-c', 'approval_policy=never');
115
148
  if (REASONING) args.push('-c', `model_reasoning_effort=${REASONING}`);
116
149
  if (options.model) args.push('-m', options.model);
150
+ for (const f of imageFiles) args.push('-i', f); // attach images to the initial prompt
117
151
  args.push('-');
118
152
 
119
153
  // Default: use the user's skills/config. Opt-out → isolated home.
@@ -129,6 +163,7 @@ export async function chat({ messages, system, options }, emit) {
129
163
  try {
130
164
  child = spawn('codex', args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], env });
131
165
  } catch (e) {
166
+ cleanupImages();
132
167
  return reject(new Error(`Failed to start codex: ${e.message}`));
133
168
  }
134
169
 
@@ -162,6 +197,7 @@ export async function chat({ messages, system, options }, emit) {
162
197
  child.stderr.on('data', (d) => { armIdle(); stderr += d.toString(); });
163
198
  child.on('error', (e) => {
164
199
  clearTimeout(idleTimer);
200
+ cleanupImages();
165
201
  reject(e);
166
202
  });
167
203
  child.on('close', async (code) => {
@@ -173,6 +209,7 @@ export async function chat({ messages, system, options }, emit) {
173
209
  /* no message file */
174
210
  }
175
211
  unlink(outFile).catch(() => {});
212
+ cleanupImages();
176
213
  if (code === 0) {
177
214
  emit({ type: 'delta', text: text || '(no output)' });
178
215
  emit({ type: 'done', text: '' });
@@ -16,11 +16,44 @@
16
16
  // speak it), reusing the Claude engine's parser.
17
17
 
18
18
  import { spawn } from 'node:child_process';
19
+ import { writeFile, unlink } from 'node:fs/promises';
20
+ import os from 'node:os';
19
21
  import path from 'node:path';
20
22
  import { resolveCommand, buildSpawnSpec } from '../env.js';
21
23
  import { isProEntitled } from '../entitlement.js';
22
24
  import { handleMessage } from './claude.js';
23
25
 
26
+ // Write base64 data-URL images to temp files so a custom CLI can take them via
27
+ // its configured `imageArg` template (e.g. "-i {path}", "@{path}"). Returns paths.
28
+ async function writeImages(images, tag) {
29
+ const files = [];
30
+ for (let i = 0; i < (images?.length || 0); i++) {
31
+ const m = /^data:([^;]+);base64,(.+)$/s.exec(images[i]?.dataUrl || '');
32
+ if (!m) continue;
33
+ const ext = (m[1].split('/')[1] || 'png').replace(/[^a-z0-9]/gi, '').slice(0, 5) || 'png';
34
+ const file = path.join(os.tmpdir(), `chatpanel-custom-img-${tag}-${i}.${ext}`);
35
+ await writeFile(file, Buffer.from(m[2], 'base64'));
36
+ files.push(file);
37
+ }
38
+ return files;
39
+ }
40
+
41
+ // Expand the user's `imageArg` template across the image files into argv tokens.
42
+ // "{path}" is substituted per image; a template without it appends the path.
43
+ function imageTokensFor(imageArg, files) {
44
+ const tmpl = String(imageArg || '').trim();
45
+ if (!tmpl || !files.length) return [];
46
+ const tokens = [];
47
+ for (const f of files) {
48
+ tokens.push(
49
+ ...(tmpl.includes('{path}')
50
+ ? tmpl.replaceAll('{path}', f).split(/\s+/).filter(Boolean)
51
+ : [...tmpl.split(/\s+/).filter(Boolean), f]),
52
+ );
53
+ }
54
+ return tokens;
55
+ }
56
+
24
57
  // Idle timeout: re-armed on every stdout/stderr chunk, so a long run that keeps
25
58
  // streaming never trips it — only true silence does. Override with
26
59
  // CHATPANEL_CUSTOM_TIMEOUT_MS (ms).
@@ -92,7 +125,7 @@ function buildPrompt(messages, system) {
92
125
  return p;
93
126
  }
94
127
 
95
- export async function chat({ messages, system, options }, emit) {
128
+ export async function chat({ messages, system, options, images }, emit) {
96
129
  // Pro gate — verified, not just UI. No valid signed entitlement → no run.
97
130
  if (!(await isProEntitled(options.entitlement))) {
98
131
  throw new Error('Custom agents require ChatPanel Pro. Upgrade in Settings to bring your own CLI agent.');
@@ -131,6 +164,24 @@ export async function chat({ messages, system, options }, emit) {
131
164
  : [...tmpl.split(/\s+/).filter(Boolean), options.model];
132
165
  args = [...injected, ...args];
133
166
  }
167
+ // Images: write to temp files, expand the agent's imageArg template, then place
168
+ // the tokens. An explicit {images} placeholder in args wins; otherwise they go
169
+ // just before the prompt (arg mode) or get appended (stdin mode).
170
+ const tag = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
171
+ const imageFiles = spec.imageArg ? await writeImages(images, tag) : [];
172
+ const cleanup = () => imageFiles.forEach((f) => unlink(f).catch(() => {}));
173
+ const imageTokens = imageTokensFor(spec.imageArg, imageFiles);
174
+ let placedImages = false;
175
+ if (imageTokens.length) {
176
+ args = args.flatMap((a) => {
177
+ if (a === '{images}') {
178
+ placedImages = true;
179
+ return imageTokens;
180
+ }
181
+ return [a];
182
+ });
183
+ }
184
+
134
185
  if (promptVia === 'arg') {
135
186
  let placed = false;
136
187
  args = args.map((a) => {
@@ -140,7 +191,12 @@ export async function chat({ messages, system, options }, emit) {
140
191
  }
141
192
  return a;
142
193
  });
143
- if (!placed) args.push(prompt);
194
+ if (!placed) {
195
+ if (imageTokens.length && !placedImages) args.push(...imageTokens); // images, then prompt
196
+ args.push(prompt);
197
+ }
198
+ } else if (imageTokens.length && !placedImages) {
199
+ args.push(...imageTokens); // stdin prompt: image tokens go on argv
144
200
  }
145
201
 
146
202
  const [bin, argv, opts] = buildSpawnSpec(resolved, args, cwd);
@@ -150,6 +206,7 @@ export async function chat({ messages, system, options }, emit) {
150
206
  try {
151
207
  child = spawn(bin, argv, opts);
152
208
  } catch (e) {
209
+ cleanup();
153
210
  return reject(new Error(`Failed to start ${label}: ${e.message}`));
154
211
  }
155
212
 
@@ -163,6 +220,7 @@ export async function chat({ messages, system, options }, emit) {
163
220
  clearTimeout(idleTimer);
164
221
  idleTimer = setTimeout(() => {
165
222
  child.kill('SIGKILL');
223
+ cleanup();
166
224
  reject(new Error(`${label} timed out — no output for ${Math.round(IDLE_MS / 1000)}s.`));
167
225
  }, IDLE_MS);
168
226
  };
@@ -196,10 +254,12 @@ export async function chat({ messages, system, options }, emit) {
196
254
  child.stderr.on('data', (d) => { armIdle(); stderr += d.toString(); });
197
255
  child.on('error', (e) => {
198
256
  clearTimeout(idleTimer);
257
+ cleanup();
199
258
  reject(new Error(`Failed to start ${label}: ${e.message}`));
200
259
  });
201
260
  child.on('close', (code) => {
202
261
  clearTimeout(idleTimer);
262
+ cleanup();
203
263
  if (code === 0) {
204
264
  emit({ type: 'done', text: streamedAny ? '' : resultText });
205
265
  resolve();
@@ -9,7 +9,7 @@
9
9
  // Install: `npm i -g @google/gemini-cli`, then run `gemini` once to sign in.
10
10
 
11
11
  import { spawn, spawnSync } from 'node:child_process';
12
- import { mkdirSync } from 'node:fs';
12
+ import { mkdirSync, writeFileSync, unlinkSync } from 'node:fs';
13
13
  import os from 'node:os';
14
14
  import path from 'node:path';
15
15
  import { findAgentBin } from '../env.js';
@@ -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() {
@@ -38,6 +45,24 @@ export async function available() {
38
45
  : { ok: false, reason: 'gemini not found on PATH. Install @google/gemini-cli, then run `gemini` once to sign in.' };
39
46
  }
40
47
 
48
+ // Write base64 data-URL images into `dir` so Gemini's `@<file>` can read them.
49
+ function writeImages(images, dir) {
50
+ const files = [];
51
+ for (let i = 0; i < (images?.length || 0); i++) {
52
+ const m = /^data:([^;]+);base64,(.+)$/s.exec(images[i]?.dataUrl || '');
53
+ if (!m) continue;
54
+ const ext = (m[1].split('/')[1] || 'png').replace(/[^a-z0-9]/gi, '').slice(0, 5) || 'png';
55
+ const file = path.join(dir, `chatpanel-img-${Date.now()}-${i}.${ext}`);
56
+ try {
57
+ writeFileSync(file, Buffer.from(m[2], 'base64'));
58
+ files.push(file);
59
+ } catch {
60
+ /* skip unwritable */
61
+ }
62
+ }
63
+ return files;
64
+ }
65
+
41
66
  function buildPrompt(messages, system) {
42
67
  let p = system ? `${system}\n\n` : '';
43
68
  const history = messages.slice(0, -1);
@@ -51,7 +76,7 @@ function buildPrompt(messages, system) {
51
76
  return p;
52
77
  }
53
78
 
54
- export async function chat({ messages, system, options }, emit) {
79
+ export async function chat({ messages, system, options, images }, emit) {
55
80
  try {
56
81
  mkdirSync(SCRATCH, { recursive: true });
57
82
  } catch {
@@ -59,10 +84,20 @@ export async function chat({ messages, system, options }, emit) {
59
84
  }
60
85
  const cwd = options.workingDir ? path.resolve(options.workingDir) : SCRATCH;
61
86
 
87
+ // Images: write them into the cwd so Gemini's `@file` reference (which reads
88
+ // files — including images — as multimodal input) can resolve them. Cleaned up
89
+ // after the run. Written into cwd (not tmp) because `@` resolves to the workspace.
90
+ const imageFiles = writeImages(images, cwd);
91
+ const cleanup = () => imageFiles.forEach((f) => { try { unlinkSync(f); } catch { /* gone */ } });
92
+ let prompt = buildPrompt(messages, system);
93
+ if (imageFiles.length) {
94
+ prompt += `\n\nThe user attached image(s): ${imageFiles.map((f) => '@' + path.basename(f)).join(' ')}`;
95
+ }
96
+
62
97
  // `-p` is non-interactive (no TTY prompts). `-m` picks the model. `-y` (yolo)
63
98
  // auto-approves tool calls when the user opted into bypassPermissions — without
64
99
  // it Gemini would block on an approval it can't show in a headless run.
65
- const args = ['-p', buildPrompt(messages, system)];
100
+ const args = ['-p', prompt];
66
101
  if (options.model) args.push('-m', options.model);
67
102
  if (options.permissionMode === 'bypassPermissions') args.push('-y');
68
103
 
@@ -73,6 +108,7 @@ export async function chat({ messages, system, options }, emit) {
73
108
  // interactive "trust this folder?" dialog can block us.
74
109
  child = spawn('gemini', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env } });
75
110
  } catch (e) {
111
+ cleanup();
76
112
  return reject(new Error(`Failed to start gemini: ${e.message}`));
77
113
  }
78
114
 
@@ -84,6 +120,7 @@ export async function chat({ messages, system, options }, emit) {
84
120
  clearTimeout(idleTimer);
85
121
  idleTimer = setTimeout(() => {
86
122
  child.kill('SIGKILL');
123
+ cleanup();
87
124
  reject(new Error(`Gemini timed out — no output for ${Math.round(IDLE_MS / 1000)}s.`));
88
125
  }, IDLE_MS);
89
126
  };
@@ -99,10 +136,12 @@ export async function chat({ messages, system, options }, emit) {
99
136
  child.stderr.on('data', (d) => { armIdle(); err += d.toString(); });
100
137
  child.on('error', (e) => {
101
138
  clearTimeout(idleTimer);
139
+ cleanup();
102
140
  reject(new Error(`Failed to start gemini: ${e.message}`));
103
141
  });
104
142
  child.on('close', (code) => {
105
143
  clearTimeout(idleTimer);
144
+ cleanup();
106
145
  if (code === 0) {
107
146
  if (!streamed) emit({ type: 'delta', text: out.trim() || '(no output)' });
108
147
  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.2';
30
+ const VERSION = '0.4.0';
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
 
@@ -117,6 +117,9 @@ async function handleChat(req, res) {
117
117
  }
118
118
  const target = ENGINES[body.agent];
119
119
  if (!target) return json(res, 404, { error: `Unknown agent "${body.agent}"` });
120
+ if (Array.isArray(body.images) && body.images.length) {
121
+ log('info', `chat: ${body.agent} received ${body.images.length} image(s)`);
122
+ }
120
123
 
121
124
  // Open the SSE stream.
122
125
  res.writeHead(200, {
@@ -138,6 +141,7 @@ async function handleChat(req, res) {
138
141
  messages: Array.isArray(body.messages) ? body.messages : [],
139
142
  system: body.system || '',
140
143
  options: body.options || {},
144
+ images: Array.isArray(body.images) ? body.images : [],
141
145
  },
142
146
  (obj) => {
143
147
  if (!closed) emit(obj);