@chatpanel/bridge 0.3.3 → 0.5.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.5.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": [
@@ -0,0 +1,167 @@
1
+ // Antigravity engine — drives the Antigravity CLI (`agy -p`) using your local
2
+ // login. This replaces Gemini CLI as the default Google-model agent (the Gemini
3
+ // CLI is being deprecated for individual users).
4
+ //
5
+ // agy -p "<prompt>" → run one prompt non-interactively, print the answer, exit
6
+ // agy --model <id> → pick the model agy models → list models
7
+ //
8
+ // Images: Antigravity has no image flag, but it READS image files referenced by
9
+ // path in the prompt (vision) — same approach as Claude Code. We write the image
10
+ // into the workspace (cwd), grant read access with --add-dir, and reference the
11
+ // path so the model opens it.
12
+
13
+ import { spawn, spawnSync } from 'node:child_process';
14
+ import { mkdirSync, writeFileSync, unlinkSync } from 'node:fs';
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+ import { findAgentBin } from '../env.js';
18
+
19
+ const IDLE_MS = Number(process.env.CHATPANEL_AGY_TIMEOUT_MS) || 180_000;
20
+ const SCRATCH = path.join(os.tmpdir(), 'chatpanel-agy-scratch');
21
+
22
+ // `agy models` lists available models. Parse ids best-effort; free text is still
23
+ // accepted by the picker, and [] just means "type a model or use the default".
24
+ export async function listModels() {
25
+ try {
26
+ const bin = findAgentBin('agy') || 'agy';
27
+ const r = spawnSync(bin, ['models'], { encoding: 'utf8', timeout: 15000 });
28
+ const ids = [];
29
+ const seen = new Set();
30
+ for (const line of String(r.stdout || '').split('\n')) {
31
+ const tok = line.trim().split(/\s+/)[0] || '';
32
+ if (!/^[A-Za-z0-9][\w./:-]{1,79}$/.test(tok)) continue;
33
+ if (/^(name|model|models|id|provider|available)$/i.test(tok)) continue;
34
+ if (seen.has(tok)) continue;
35
+ seen.add(tok);
36
+ ids.push(tok);
37
+ if (ids.length >= 100) break;
38
+ }
39
+ return ids;
40
+ } catch {
41
+ return [];
42
+ }
43
+ }
44
+
45
+ let installed = false;
46
+ let lastProbe = 0;
47
+ export async function available() {
48
+ // Cache a positive result; keep re-probing (throttled) while not found so it
49
+ // self-heals once agy appears on PATH — never cache a negative forever.
50
+ if (!installed && Date.now() - lastProbe > 4000) {
51
+ lastProbe = Date.now();
52
+ try {
53
+ installed = !!findAgentBin('agy');
54
+ } catch {
55
+ installed = false;
56
+ }
57
+ }
58
+ return installed
59
+ ? { ok: true }
60
+ : { ok: false, reason: 'agy not found on PATH. Install Antigravity, then run `agy` once to sign in.' };
61
+ }
62
+
63
+ function writeImages(images, dir) {
64
+ const files = [];
65
+ for (let i = 0; i < (images?.length || 0); i++) {
66
+ const m = /^data:([^;]+);base64,(.+)$/s.exec(images[i]?.dataUrl || '');
67
+ if (!m) continue;
68
+ const ext = (m[1].split('/')[1] || 'png').replace(/[^a-z0-9]/gi, '').slice(0, 5) || 'png';
69
+ const file = path.join(dir, `chatpanel-img-${Date.now()}-${i}.${ext}`);
70
+ try {
71
+ writeFileSync(file, Buffer.from(m[2], 'base64'));
72
+ files.push(file);
73
+ } catch {
74
+ /* skip unwritable */
75
+ }
76
+ }
77
+ return files;
78
+ }
79
+
80
+ // The bridge is stateless, so replay the conversation as a single prompt.
81
+ function buildPrompt(messages, system) {
82
+ let p = system ? `${system}\n\n` : '';
83
+ const history = messages.slice(0, -1);
84
+ const last = messages[messages.length - 1];
85
+ if (history.length) {
86
+ p += 'Conversation so far:\n';
87
+ for (const m of history) p += `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content}\n\n`;
88
+ p += '---\n\n';
89
+ }
90
+ p += last ? last.content : '';
91
+ return p;
92
+ }
93
+
94
+ export async function chat({ messages, system, options, images }, emit) {
95
+ try {
96
+ mkdirSync(SCRATCH, { recursive: true });
97
+ } catch {
98
+ /* best effort */
99
+ }
100
+ const cwd = options.workingDir ? path.resolve(options.workingDir) : SCRATCH;
101
+
102
+ // Images: write into the cwd (workspace) and reference with `@<file>` — agy
103
+ // reads @-referenced files (incl. images) inline as multimodal input, so no
104
+ // read-tool approval is needed in headless `-p` mode. (Confirmed working.)
105
+ const imageFiles = writeImages(images, cwd);
106
+ const cleanup = () => imageFiles.forEach((f) => { try { unlinkSync(f); } catch { /* gone */ } });
107
+ let prompt = buildPrompt(messages, system);
108
+ if (imageFiles.length) {
109
+ prompt += `\n\nThe user attached image(s): ${imageFiles.map((f) => '@' + path.basename(f)).join(' ')}`;
110
+ }
111
+
112
+ // `-p` runs one prompt non-interactively. --model picks the model.
113
+ // --dangerously-skip-permissions auto-approves tool use (headless has no human
114
+ // approver) only when the user opted into bypassPermissions.
115
+ const args = ['-p', prompt];
116
+ if (options.model) args.push('--model', options.model);
117
+ if (options.permissionMode === 'bypassPermissions') args.push('--dangerously-skip-permissions');
118
+
119
+ await new Promise((resolve, reject) => {
120
+ let child;
121
+ try {
122
+ child = spawn('agy', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env } });
123
+ } catch (e) {
124
+ cleanup();
125
+ return reject(new Error(`Failed to start agy: ${e.message}`));
126
+ }
127
+
128
+ let out = '';
129
+ let err = '';
130
+ let streamed = false;
131
+ let idleTimer;
132
+ const armIdle = () => {
133
+ clearTimeout(idleTimer);
134
+ idleTimer = setTimeout(() => {
135
+ child.kill('SIGKILL');
136
+ cleanup();
137
+ reject(new Error(`Antigravity timed out — no output for ${Math.round(IDLE_MS / 1000)}s.`));
138
+ }, IDLE_MS);
139
+ };
140
+ armIdle();
141
+
142
+ child.stdout.on('data', (d) => {
143
+ armIdle();
144
+ const s = d.toString();
145
+ out += s;
146
+ streamed = true;
147
+ emit({ type: 'delta', text: s });
148
+ });
149
+ child.stderr.on('data', (d) => { armIdle(); err += d.toString(); });
150
+ child.on('error', (e) => {
151
+ clearTimeout(idleTimer);
152
+ cleanup();
153
+ reject(new Error(`Failed to start agy: ${e.message}`));
154
+ });
155
+ child.on('close', (code) => {
156
+ clearTimeout(idleTimer);
157
+ cleanup();
158
+ if (code === 0) {
159
+ if (!streamed) emit({ type: 'delta', text: out.trim() || '(no output)' });
160
+ emit({ type: 'done', text: '' });
161
+ resolve();
162
+ } else {
163
+ reject(new Error(`Antigravity exited ${code}: ${err.trim() || out.trim() || 'failed'}`));
164
+ }
165
+ });
166
+ });
167
+ }
@@ -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
@@ -0,0 +1,77 @@
1
+ // Built-in CLI agents — pi, opencode, kiro — that reuse the shared custom-engine
2
+ // runner (runSpec) with a FIXED spec each. Unlike the Pro "custom" engine, these
3
+ // are NOT entitlement-gated: they ship as built-ins, and the extension bounds
4
+ // free users to a single usable agent (FREE_LIMITS.bridgeAgents). Bring-your-own
5
+ // arbitrary CLIs stay Pro via custom.js.
6
+ //
7
+ // Specs come from each CLI's actual flags:
8
+ // pi — pi -p "<prompt>" · --model · @{path} images · --list-models
9
+ // opencode — opencode run "<prompt>" · -m provider/model · -f {path} images · models
10
+ // kiro — kiro-cli chat --no-interactive "<prompt>" · --model · --list-models
11
+
12
+ import { runSpec, listSpecModels } from './custom.js';
13
+ import { findAgentBin } from '../env.js';
14
+
15
+ function makeCliAgent(command, spec, notFoundHint) {
16
+ let installed = false;
17
+ let lastProbe = 0;
18
+ return {
19
+ async available() {
20
+ // Cache a positive result; re-probe (throttled) while not found so it
21
+ // self-heals once the CLI appears on PATH.
22
+ if (!installed && Date.now() - lastProbe > 4000) {
23
+ lastProbe = Date.now();
24
+ try {
25
+ installed = !!findAgentBin(command);
26
+ } catch {
27
+ installed = false;
28
+ }
29
+ }
30
+ return installed ? { ok: true } : { ok: false, reason: notFoundHint };
31
+ },
32
+ listModels(options = {}) {
33
+ return listSpecModels(command, spec.listModelsArgs, options.workingDir);
34
+ },
35
+ chat(input, emit) {
36
+ return runSpec({ ...spec, command }, input, emit);
37
+ },
38
+ };
39
+ }
40
+
41
+ export const pi = makeCliAgent(
42
+ 'pi',
43
+ {
44
+ args: '-p',
45
+ promptVia: 'arg',
46
+ modelArg: '--model {model}',
47
+ imageArg: '@{path}',
48
+ listModelsArgs: '--list-models',
49
+ label: 'Pi',
50
+ },
51
+ 'pi not found on PATH. Install Pi, then run `pi` once to sign in.',
52
+ );
53
+
54
+ export const opencode = makeCliAgent(
55
+ 'opencode',
56
+ {
57
+ args: 'run',
58
+ promptVia: 'arg',
59
+ modelArg: '-m {model}',
60
+ imageArg: '-f {path}',
61
+ listModelsArgs: 'models',
62
+ label: 'OpenCode',
63
+ },
64
+ 'opencode not found on PATH. Install opencode, then sign in.',
65
+ );
66
+
67
+ export const kiro = makeCliAgent(
68
+ 'kiro-cli',
69
+ {
70
+ args: 'chat --no-interactive',
71
+ promptVia: 'arg',
72
+ modelArg: '--model {model}',
73
+ listModelsArgs: '--list-models',
74
+ label: 'Kiro',
75
+ },
76
+ 'kiro-cli not found on PATH. Install Kiro CLI, then sign in.',
77
+ );
@@ -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,16 +16,55 @@
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).
27
60
  const IDLE_MS = Number(process.env.CHATPANEL_CUSTOM_TIMEOUT_MS) || 180_000;
28
61
 
62
+ // Many CLIs emit ANSI colour/escape codes even when piped (kiro-cli does), which
63
+ // leak into the answer as `\x1b[38;5;141m…`. Strip them from text output. (We
64
+ // also set NO_COLOR on the child env, but this is the robust backstop.)
65
+ const ANSI_RE = /\u001b\[[0-9;?]*[ -/]*[@-~]/g;
66
+ const stripAnsi = (s) => s.replace(ANSI_RE, '');
67
+
29
68
  export async function available() {
30
69
  // The engine ships in every bridge; individual custom agents are user-defined
31
70
  // (Pro) and validated per request and via /agent-check.
@@ -59,19 +98,26 @@ export async function listModels(options = {}) {
59
98
  throw new Error('Custom agents require ChatPanel Pro.');
60
99
  }
61
100
  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;
101
+ return listSpecModels(spec.command, spec.listModelsArgs, options.workingDir);
102
+ }
103
+
104
+ // Shared model listing (no Pro gate): run a CLI's list-models invocation and
105
+ // parse it. Used by the Pro custom engine (gated above) AND built-in CLI agents.
106
+ export async function listSpecModels(command, listModelsArgs, workingDir) {
107
+ const listArgs = String(listModelsArgs || '').trim();
108
+ if (!command || !listArgs) return [];
109
+ const resolved = resolveCommand(command);
110
+ if (!resolved) throw new Error(`Couldn't find "${command}".`);
111
+ const cwd = workingDir ? path.resolve(workingDir) : null;
67
112
  const [bin, argv, opts] = buildSpawnSpec(resolved, listArgs.split(/\s+/).filter(Boolean), cwd);
113
+ opts.env = { ...(opts.env || process.env), NO_COLOR: '1', CLICOLOR: '0' };
68
114
  const stdout = await new Promise((resolve, reject) => {
69
115
  let child;
70
- try { child = spawn(bin, argv, opts); } catch (e) { return reject(new Error(`Failed to start ${spec.command}: ${e.message}`)); }
116
+ try { child = spawn(bin, argv, opts); } catch (e) { return reject(new Error(`Failed to start ${command}: ${e.message}`)); }
71
117
  let out = '';
72
118
  const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('Listing models timed out.')); }, 20000);
73
119
  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}`)); });
120
+ child.on('error', (e) => { clearTimeout(timer); reject(new Error(`Failed to start ${command}: ${e.message}`)); });
75
121
  child.on('close', () => { clearTimeout(timer); resolve(out); });
76
122
  try { child.stdin.end(); } catch { /* some CLIs don't read stdin */ }
77
123
  });
@@ -92,14 +138,19 @@ function buildPrompt(messages, system) {
92
138
  return p;
93
139
  }
94
140
 
95
- export async function chat({ messages, system, options }, emit) {
141
+ export async function chat({ messages, system, options, images }, emit) {
96
142
  // Pro gate — verified, not just UI. No valid signed entitlement → no run.
97
143
  if (!(await isProEntitled(options.entitlement))) {
98
144
  throw new Error('Custom agents require ChatPanel Pro. Upgrade in Settings to bring your own CLI agent.');
99
145
  }
146
+ return runSpec(options.custom || {}, { messages, system, options, images }, emit);
147
+ }
100
148
 
101
- const spec = options.custom || {};
102
- if (!spec.command) throw new Error('This custom agent has no command configured.');
149
+ // Run a CLI agent from a spec SHARED by the Pro custom engine (gated in chat()
150
+ // above) and the built-in CLI engines (pi/opencode/kiro). This never gates; the
151
+ // built-in agents are bounded instead by the extension's free 1-agent limit.
152
+ export async function runSpec(spec, { messages, system, options = {}, images }, emit) {
153
+ if (!spec.command) throw new Error('This agent has no command configured.');
103
154
 
104
155
  const resolved = resolveCommand(spec.command);
105
156
  if (!resolved) {
@@ -131,6 +182,24 @@ export async function chat({ messages, system, options }, emit) {
131
182
  : [...tmpl.split(/\s+/).filter(Boolean), options.model];
132
183
  args = [...injected, ...args];
133
184
  }
185
+ // Images: write to temp files, expand the agent's imageArg template, then place
186
+ // the tokens. An explicit {images} placeholder in args wins; otherwise they go
187
+ // just before the prompt (arg mode) or get appended (stdin mode).
188
+ const tag = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
189
+ const imageFiles = spec.imageArg ? await writeImages(images, tag) : [];
190
+ const cleanup = () => imageFiles.forEach((f) => unlink(f).catch(() => {}));
191
+ const imageTokens = imageTokensFor(spec.imageArg, imageFiles);
192
+ let placedImages = false;
193
+ if (imageTokens.length) {
194
+ args = args.flatMap((a) => {
195
+ if (a === '{images}') {
196
+ placedImages = true;
197
+ return imageTokens;
198
+ }
199
+ return [a];
200
+ });
201
+ }
202
+
134
203
  if (promptVia === 'arg') {
135
204
  let placed = false;
136
205
  args = args.map((a) => {
@@ -140,16 +209,24 @@ export async function chat({ messages, system, options }, emit) {
140
209
  }
141
210
  return a;
142
211
  });
143
- if (!placed) args.push(prompt);
212
+ if (!placed) {
213
+ if (imageTokens.length && !placedImages) args.push(...imageTokens); // images, then prompt
214
+ args.push(prompt);
215
+ }
216
+ } else if (imageTokens.length && !placedImages) {
217
+ args.push(...imageTokens); // stdin prompt: image tokens go on argv
144
218
  }
145
219
 
146
220
  const [bin, argv, opts] = buildSpawnSpec(resolved, args, cwd);
221
+ // Discourage CLIs from colourizing output (kiro-cli etc.) when piped.
222
+ opts.env = { ...(opts.env || process.env), NO_COLOR: '1', FORCE_COLOR: '0', CLICOLOR: '0', TERM: 'dumb' };
147
223
 
148
224
  await new Promise((resolve, reject) => {
149
225
  let child;
150
226
  try {
151
227
  child = spawn(bin, argv, opts);
152
228
  } catch (e) {
229
+ cleanup();
153
230
  return reject(new Error(`Failed to start ${label}: ${e.message}`));
154
231
  }
155
232
 
@@ -163,6 +240,7 @@ export async function chat({ messages, system, options }, emit) {
163
240
  clearTimeout(idleTimer);
164
241
  idleTimer = setTimeout(() => {
165
242
  child.kill('SIGKILL');
243
+ cleanup();
166
244
  reject(new Error(`${label} timed out — no output for ${Math.round(IDLE_MS / 1000)}s.`));
167
245
  }, IDLE_MS);
168
246
  };
@@ -190,16 +268,18 @@ export async function chat({ messages, system, options }, emit) {
190
268
  }
191
269
  } else {
192
270
  streamedAny = true;
193
- emit({ type: 'delta', text: s });
271
+ emit({ type: 'delta', text: stripAnsi(s) });
194
272
  }
195
273
  });
196
274
  child.stderr.on('data', (d) => { armIdle(); stderr += d.toString(); });
197
275
  child.on('error', (e) => {
198
276
  clearTimeout(idleTimer);
277
+ cleanup();
199
278
  reject(new Error(`Failed to start ${label}: ${e.message}`));
200
279
  });
201
280
  child.on('close', (code) => {
202
281
  clearTimeout(idleTimer);
282
+ cleanup();
203
283
  if (code === 0) {
204
284
  emit({ type: 'done', text: streamedAny ? '' : resultText });
205
285
  resolve();
package/src/env.js CHANGED
@@ -1,4 +1,4 @@
1
- // Resolve a usable PATH for spawning the agent CLIs (codex, gemini).
1
+ // Resolve a usable PATH for spawning the agent CLIs (codex, agy).
2
2
  //
3
3
  // When the bridge runs as a login service (LaunchAgent / Scheduled Task) — or as
4
4
  // a double-clicked app — it inherits a MINIMAL PATH, not your interactive shell's.
@@ -14,7 +14,7 @@ let enriched = false;
14
14
 
15
15
  // The agent CLIs the bridge shells out to. Claude has its own richer resolution
16
16
  // (resolveClaude: native / cli.js / WSL / SDK) below.
17
- const AGENT_CLIS = ['codex', 'gemini', 'claude'];
17
+ const AGENT_CLIS = ['codex', 'claude', 'agy', 'pi', 'opencode', 'kiro-cli'];
18
18
 
19
19
  // Is `name` executable somewhere on the current PATH?
20
20
  function onPath(name) {
package/src/server.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // ChatPanel Bridge — a tiny localhost server that exposes the coding agents
3
- // running on this machine (Claude Code, Codex and Gemini, each via its CLI) to
3
+ // running on this machine (Claude Code, Codex and Antigravity, each via its CLI) to
4
4
  // the ChatPanel Chrome extension. Zero runtime dependencies.
5
5
  //
6
6
  // GET /health → { ok, version, agents: [...], update: {current,latest,…} }
@@ -18,7 +18,8 @@ import { createServer } from 'node:http';
18
18
  import os from 'node:os';
19
19
  import * as claude from './engines/claude.js';
20
20
  import * as codex from './engines/codex.js';
21
- import * as gemini from './engines/gemini.js';
21
+ import * as antigravity from './engines/antigravity.js';
22
+ import { pi, opencode, kiro } from './engines/cli-agents.js';
22
23
  import * as custom from './engines/custom.js';
23
24
  import { installService, uninstallService, serviceStatus, restartService } from './service.js';
24
25
  import { enrichPath, findAgentBin, resolveCommand } from './env.js';
@@ -27,14 +28,17 @@ import { checkForUpdate, selfUpdate } from './update.js';
27
28
  // Hardcoded (not read from package.json) so it survives Bun's single-file
28
29
  // --compile, where package.json isn't on a readable FS. CI fails the publish if
29
30
  // this drifts from package.json, so the two can't silently diverge.
30
- const VERSION = '0.3.3';
31
+ const VERSION = '0.5.0';
31
32
  const HOST = process.env.CHATPANEL_BRIDGE_HOST || '127.0.0.1';
32
33
  const PORT = Number(process.env.CHATPANEL_BRIDGE_PORT) || 4319;
33
34
 
34
35
  const ENGINES = {
35
36
  claude: { engine: claude, label: 'Claude Code' },
36
37
  codex: { engine: codex, label: 'Codex' },
37
- gemini: { engine: gemini, label: 'Gemini CLI' },
38
+ antigravity: { engine: antigravity, label: 'Antigravity' },
39
+ pi: { engine: pi, label: 'Pi' },
40
+ opencode: { engine: opencode, label: 'OpenCode' },
41
+ kiro: { engine: kiro, label: 'Kiro' },
38
42
  // "Bring your own" — one engine drives any user-onboarded CLI (Pro). Hidden
39
43
  // from /health (it's not a single installable agent; the extension manages the
40
44
  // list and validates commands via /agent-check).
@@ -117,6 +121,9 @@ async function handleChat(req, res) {
117
121
  }
118
122
  const target = ENGINES[body.agent];
119
123
  if (!target) return json(res, 404, { error: `Unknown agent "${body.agent}"` });
124
+ if (Array.isArray(body.images) && body.images.length) {
125
+ log('info', `chat: ${body.agent} received ${body.images.length} image(s)`);
126
+ }
120
127
 
121
128
  // Open the SSE stream.
122
129
  res.writeHead(200, {
@@ -138,6 +145,7 @@ async function handleChat(req, res) {
138
145
  messages: Array.isArray(body.messages) ? body.messages : [],
139
146
  system: body.system || '',
140
147
  options: body.options || {},
148
+ images: Array.isArray(body.images) ? body.images : [],
141
149
  },
142
150
  (obj) => {
143
151
  if (!closed) emit(obj);
@@ -249,7 +257,7 @@ const server = createServer(async (req, res) => {
249
257
  version: VERSION,
250
258
  home: os.homedir(),
251
259
  codex: findAgentBin('codex') || null,
252
- gemini: findAgentBin('gemini') || null,
260
+ agy: findAgentBin('agy') || null,
253
261
  path: process.env.PATH,
254
262
  });
255
263
  }
@@ -278,7 +286,7 @@ function startServer() {
278
286
  const a = await engine.available().catch(() => ({ ok: false }));
279
287
  log('info', ` ${a.ok ? '✓' : '✕'} ${label}${a.ok ? '' : ' — ' + (a.reason || 'unavailable')}`);
280
288
  }
281
- log('info', 'Open the ChatPanel side panel; installed agents (Claude Code, Codex, Gemini CLI) appear automatically.');
289
+ log('info', 'Open the ChatPanel side panel; installed agents (Claude Code, Codex, Antigravity) appear automatically.');
282
290
  });
283
291
  }
284
292
 
@@ -1,122 +0,0 @@
1
- // Gemini engine — drives the Gemini CLI (`gemini -p`) using your local login.
2
- //
3
- // Like the Codex engine, this shells out to the installed `gemini` binary in
4
- // non-interactive mode: `gemini -p "<prompt>"` runs once, prints the answer to
5
- // stdout, and exits. We run in an empty scratch dir for general chat so Gemini
6
- // never crawls the bridge's own files; set a working dir on the agent to point
7
- // it at a real project.
8
- //
9
- // Install: `npm i -g @google/gemini-cli`, then run `gemini` once to sign in.
10
-
11
- import { spawn, spawnSync } from 'node:child_process';
12
- import { mkdirSync } from 'node:fs';
13
- import os from 'node:os';
14
- import path from 'node:path';
15
- import { findAgentBin } from '../env.js';
16
-
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;
21
- const SCRATCH = path.join(os.tmpdir(), 'chatpanel-gemini-scratch');
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
-
30
- let installed = false;
31
- let lastProbe = 0;
32
- export async function available() {
33
- // Cache a positive result, but keep re-probing (throttled) while not found, so
34
- // it self-heals once gemini appears on PATH — never cache a negative forever.
35
- if (!installed && Date.now() - lastProbe > 4000) {
36
- lastProbe = Date.now();
37
- try {
38
- installed = !!findAgentBin('gemini');
39
- } catch {
40
- installed = false;
41
- }
42
- }
43
- return installed
44
- ? { ok: true }
45
- : { ok: false, reason: 'gemini not found on PATH. Install @google/gemini-cli, then run `gemini` once to sign in.' };
46
- }
47
-
48
- function buildPrompt(messages, system) {
49
- let p = system ? `${system}\n\n` : '';
50
- const history = messages.slice(0, -1);
51
- const last = messages[messages.length - 1];
52
- if (history.length) {
53
- p += 'Conversation so far:\n';
54
- for (const m of history) p += `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content}\n\n`;
55
- p += '---\n\n';
56
- }
57
- p += last ? last.content : '';
58
- return p;
59
- }
60
-
61
- export async function chat({ messages, system, options }, emit) {
62
- try {
63
- mkdirSync(SCRATCH, { recursive: true });
64
- } catch {
65
- /* best effort */
66
- }
67
- const cwd = options.workingDir ? path.resolve(options.workingDir) : SCRATCH;
68
-
69
- // `-p` is non-interactive (no TTY prompts). `-m` picks the model. `-y` (yolo)
70
- // auto-approves tool calls when the user opted into bypassPermissions — without
71
- // it Gemini would block on an approval it can't show in a headless run.
72
- const args = ['-p', buildPrompt(messages, system)];
73
- if (options.model) args.push('-m', options.model);
74
- if (options.permissionMode === 'bypassPermissions') args.push('-y');
75
-
76
- await new Promise((resolve, reject) => {
77
- let child;
78
- try {
79
- // stdin ignored: the prompt is passed via -p, and no TTY means no
80
- // interactive "trust this folder?" dialog can block us.
81
- child = spawn('gemini', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env } });
82
- } catch (e) {
83
- return reject(new Error(`Failed to start gemini: ${e.message}`));
84
- }
85
-
86
- let out = '';
87
- let err = '';
88
- let streamed = false;
89
- let idleTimer;
90
- const armIdle = () => {
91
- clearTimeout(idleTimer);
92
- idleTimer = setTimeout(() => {
93
- child.kill('SIGKILL');
94
- reject(new Error(`Gemini timed out — no output for ${Math.round(IDLE_MS / 1000)}s.`));
95
- }, IDLE_MS);
96
- };
97
- armIdle();
98
-
99
- child.stdout.on('data', (d) => {
100
- armIdle();
101
- const s = d.toString();
102
- out += s;
103
- streamed = true;
104
- emit({ type: 'delta', text: s });
105
- });
106
- child.stderr.on('data', (d) => { armIdle(); err += d.toString(); });
107
- child.on('error', (e) => {
108
- clearTimeout(idleTimer);
109
- reject(new Error(`Failed to start gemini: ${e.message}`));
110
- });
111
- child.on('close', (code) => {
112
- clearTimeout(idleTimer);
113
- if (code === 0) {
114
- if (!streamed) emit({ type: 'delta', text: out.trim() || '(no output)' });
115
- emit({ type: 'done', text: '' });
116
- resolve();
117
- } else {
118
- reject(new Error(`Gemini exited ${code}: ${err.trim() || out.trim() || 'failed'}`));
119
- }
120
- });
121
- });
122
- }