@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 +1 -1
- package/src/engines/antigravity.js +167 -0
- package/src/engines/claude.js +41 -5
- package/src/engines/cli-agents.js +77 -0
- package/src/engines/codex.js +23 -2
- package/src/engines/custom.js +92 -12
- package/src/env.js +2 -2
- package/src/server.js +14 -6
- package/src/engines/gemini.js +0 -122
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/bridge",
|
|
3
|
-
"version": "0.
|
|
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
|
+
}
|
package/src/engines/claude.js
CHANGED
|
@@ -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
|
-
|
|
198
|
-
|
|
199
|
-
const
|
|
200
|
-
|
|
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
|
+
);
|
package/src/engines/codex.js
CHANGED
|
@@ -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
|
-
|
|
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: '' });
|
package/src/engines/custom.js
CHANGED
|
@@ -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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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 ${
|
|
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 ${
|
|
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
|
-
|
|
102
|
-
|
|
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)
|
|
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,
|
|
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', '
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
289
|
+
log('info', 'Open the ChatPanel side panel; installed agents (Claude Code, Codex, Antigravity) appear automatically.');
|
|
282
290
|
});
|
|
283
291
|
}
|
|
284
292
|
|
package/src/engines/gemini.js
DELETED
|
@@ -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
|
-
}
|