@chatpanel/bridge 0.3.3 → 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.3",
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,7 +15,7 @@
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';
18
+ import { readFile, unlink, writeFile } from 'node:fs/promises';
19
19
  import { existsSync, mkdirSync, symlinkSync, readFileSync } from 'node:fs';
20
20
  import os from 'node:os';
21
21
  import path from 'node:path';
@@ -110,10 +110,27 @@ function buildPrompt(messages, system) {
110
110
  return p;
111
111
  }
112
112
 
113
- 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) {
114
129
  ensureScratch();
115
130
  const tag = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
116
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(() => {}));
117
134
 
118
135
  const cwd = options.workingDir ? path.resolve(options.workingDir) : SCRATCH;
119
136
  const sandbox =
@@ -130,6 +147,7 @@ export async function chat({ messages, system, options }, emit) {
130
147
  args.push('-c', 'approval_policy=never');
131
148
  if (REASONING) args.push('-c', `model_reasoning_effort=${REASONING}`);
132
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
133
151
  args.push('-');
134
152
 
135
153
  // Default: use the user's skills/config. Opt-out → isolated home.
@@ -145,6 +163,7 @@ export async function chat({ messages, system, options }, emit) {
145
163
  try {
146
164
  child = spawn('codex', args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], env });
147
165
  } catch (e) {
166
+ cleanupImages();
148
167
  return reject(new Error(`Failed to start codex: ${e.message}`));
149
168
  }
150
169
 
@@ -178,6 +197,7 @@ export async function chat({ messages, system, options }, emit) {
178
197
  child.stderr.on('data', (d) => { armIdle(); stderr += d.toString(); });
179
198
  child.on('error', (e) => {
180
199
  clearTimeout(idleTimer);
200
+ cleanupImages();
181
201
  reject(e);
182
202
  });
183
203
  child.on('close', async (code) => {
@@ -189,6 +209,7 @@ export async function chat({ messages, system, options }, emit) {
189
209
  /* no message file */
190
210
  }
191
211
  unlink(outFile).catch(() => {});
212
+ cleanupImages();
192
213
  if (code === 0) {
193
214
  emit({ type: 'delta', text: text || '(no output)' });
194
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';
@@ -45,6 +45,24 @@ export async function available() {
45
45
  : { ok: false, reason: 'gemini not found on PATH. Install @google/gemini-cli, then run `gemini` once to sign in.' };
46
46
  }
47
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
+
48
66
  function buildPrompt(messages, system) {
49
67
  let p = system ? `${system}\n\n` : '';
50
68
  const history = messages.slice(0, -1);
@@ -58,7 +76,7 @@ function buildPrompt(messages, system) {
58
76
  return p;
59
77
  }
60
78
 
61
- export async function chat({ messages, system, options }, emit) {
79
+ export async function chat({ messages, system, options, images }, emit) {
62
80
  try {
63
81
  mkdirSync(SCRATCH, { recursive: true });
64
82
  } catch {
@@ -66,10 +84,20 @@ export async function chat({ messages, system, options }, emit) {
66
84
  }
67
85
  const cwd = options.workingDir ? path.resolve(options.workingDir) : SCRATCH;
68
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
+
69
97
  // `-p` is non-interactive (no TTY prompts). `-m` picks the model. `-y` (yolo)
70
98
  // auto-approves tool calls when the user opted into bypassPermissions — without
71
99
  // it Gemini would block on an approval it can't show in a headless run.
72
- const args = ['-p', buildPrompt(messages, system)];
100
+ const args = ['-p', prompt];
73
101
  if (options.model) args.push('-m', options.model);
74
102
  if (options.permissionMode === 'bypassPermissions') args.push('-y');
75
103
 
@@ -80,6 +108,7 @@ export async function chat({ messages, system, options }, emit) {
80
108
  // interactive "trust this folder?" dialog can block us.
81
109
  child = spawn('gemini', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env } });
82
110
  } catch (e) {
111
+ cleanup();
83
112
  return reject(new Error(`Failed to start gemini: ${e.message}`));
84
113
  }
85
114
 
@@ -91,6 +120,7 @@ export async function chat({ messages, system, options }, emit) {
91
120
  clearTimeout(idleTimer);
92
121
  idleTimer = setTimeout(() => {
93
122
  child.kill('SIGKILL');
123
+ cleanup();
94
124
  reject(new Error(`Gemini timed out — no output for ${Math.round(IDLE_MS / 1000)}s.`));
95
125
  }, IDLE_MS);
96
126
  };
@@ -106,10 +136,12 @@ export async function chat({ messages, system, options }, emit) {
106
136
  child.stderr.on('data', (d) => { armIdle(); err += d.toString(); });
107
137
  child.on('error', (e) => {
108
138
  clearTimeout(idleTimer);
139
+ cleanup();
109
140
  reject(new Error(`Failed to start gemini: ${e.message}`));
110
141
  });
111
142
  child.on('close', (code) => {
112
143
  clearTimeout(idleTimer);
144
+ cleanup();
113
145
  if (code === 0) {
114
146
  if (!streamed) emit({ type: 'delta', text: out.trim() || '(no output)' });
115
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.3';
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);