@chatpanel/bridge 0.4.0 → 0.6.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/{gemini.js → antigravity.js} +49 -36
- package/src/engines/claude.js +26 -5
- package/src/engines/cli-agents.js +77 -0
- package/src/engines/custom.js +30 -10
- package/src/env.js +2 -2
- package/src/server.js +152 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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": [
|
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
//
|
|
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).
|
|
2
4
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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.
|
|
5
|
+
// agy -p "<prompt>" → run one prompt non-interactively, print the answer, exit
|
|
6
|
+
// agy --model <id> → pick the model agy models → list models
|
|
8
7
|
//
|
|
9
|
-
//
|
|
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.
|
|
10
12
|
|
|
11
13
|
import { spawn, spawnSync } from 'node:child_process';
|
|
12
14
|
import { mkdirSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
@@ -14,38 +16,50 @@ import os from 'node:os';
|
|
|
14
16
|
import path from 'node:path';
|
|
15
17
|
import { findAgentBin } from '../env.js';
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
|
|
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');
|
|
19
|
+
const IDLE_MS = Number(process.env.CHATPANEL_AGY_TIMEOUT_MS) || 180_000;
|
|
20
|
+
const SCRATCH = path.join(os.tmpdir(), 'chatpanel-agy-scratch');
|
|
22
21
|
|
|
23
|
-
//
|
|
24
|
-
// and
|
|
25
|
-
// text still accepted.
|
|
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".
|
|
26
24
|
export async function listModels() {
|
|
27
|
-
|
|
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
|
+
}
|
|
28
43
|
}
|
|
29
44
|
|
|
30
45
|
let installed = false;
|
|
31
46
|
let lastProbe = 0;
|
|
32
47
|
export async function available() {
|
|
33
|
-
// Cache a positive result
|
|
34
|
-
//
|
|
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.
|
|
35
50
|
if (!installed && Date.now() - lastProbe > 4000) {
|
|
36
51
|
lastProbe = Date.now();
|
|
37
52
|
try {
|
|
38
|
-
installed = !!findAgentBin('
|
|
53
|
+
installed = !!findAgentBin('agy');
|
|
39
54
|
} catch {
|
|
40
55
|
installed = false;
|
|
41
56
|
}
|
|
42
57
|
}
|
|
43
58
|
return installed
|
|
44
59
|
? { ok: true }
|
|
45
|
-
: { ok: false, reason: '
|
|
60
|
+
: { ok: false, reason: 'agy not found on PATH. Install Antigravity, then run `agy` once to sign in.' };
|
|
46
61
|
}
|
|
47
62
|
|
|
48
|
-
// Write base64 data-URL images into `dir` so Gemini's `@<file>` can read them.
|
|
49
63
|
function writeImages(images, dir) {
|
|
50
64
|
const files = [];
|
|
51
65
|
for (let i = 0; i < (images?.length || 0); i++) {
|
|
@@ -63,6 +77,7 @@ function writeImages(images, dir) {
|
|
|
63
77
|
return files;
|
|
64
78
|
}
|
|
65
79
|
|
|
80
|
+
// The bridge is stateless, so replay the conversation as a single prompt.
|
|
66
81
|
function buildPrompt(messages, system) {
|
|
67
82
|
let p = system ? `${system}\n\n` : '';
|
|
68
83
|
const history = messages.slice(0, -1);
|
|
@@ -84,9 +99,9 @@ export async function chat({ messages, system, options, images }, emit) {
|
|
|
84
99
|
}
|
|
85
100
|
const cwd = options.workingDir ? path.resolve(options.workingDir) : SCRATCH;
|
|
86
101
|
|
|
87
|
-
// Images: write
|
|
88
|
-
// files
|
|
89
|
-
//
|
|
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.)
|
|
90
105
|
const imageFiles = writeImages(images, cwd);
|
|
91
106
|
const cleanup = () => imageFiles.forEach((f) => { try { unlinkSync(f); } catch { /* gone */ } });
|
|
92
107
|
let prompt = buildPrompt(messages, system);
|
|
@@ -94,22 +109,20 @@ export async function chat({ messages, system, options, images }, emit) {
|
|
|
94
109
|
prompt += `\n\nThe user attached image(s): ${imageFiles.map((f) => '@' + path.basename(f)).join(' ')}`;
|
|
95
110
|
}
|
|
96
111
|
|
|
97
|
-
// `-p`
|
|
98
|
-
// auto-approves tool
|
|
99
|
-
//
|
|
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.
|
|
100
115
|
const args = ['-p', prompt];
|
|
101
|
-
if (options.model) args.push('
|
|
102
|
-
if (options.permissionMode === 'bypassPermissions') args.push('-
|
|
116
|
+
if (options.model) args.push('--model', options.model);
|
|
117
|
+
if (options.permissionMode === 'bypassPermissions') args.push('--dangerously-skip-permissions');
|
|
103
118
|
|
|
104
119
|
await new Promise((resolve, reject) => {
|
|
105
120
|
let child;
|
|
106
121
|
try {
|
|
107
|
-
|
|
108
|
-
// interactive "trust this folder?" dialog can block us.
|
|
109
|
-
child = spawn('gemini', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env } });
|
|
122
|
+
child = spawn('agy', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env } });
|
|
110
123
|
} catch (e) {
|
|
111
124
|
cleanup();
|
|
112
|
-
return reject(new Error(`Failed to start
|
|
125
|
+
return reject(new Error(`Failed to start agy: ${e.message}`));
|
|
113
126
|
}
|
|
114
127
|
|
|
115
128
|
let out = '';
|
|
@@ -121,7 +134,7 @@ export async function chat({ messages, system, options, images }, emit) {
|
|
|
121
134
|
idleTimer = setTimeout(() => {
|
|
122
135
|
child.kill('SIGKILL');
|
|
123
136
|
cleanup();
|
|
124
|
-
reject(new Error(`
|
|
137
|
+
reject(new Error(`Antigravity timed out — no output for ${Math.round(IDLE_MS / 1000)}s.`));
|
|
125
138
|
}, IDLE_MS);
|
|
126
139
|
};
|
|
127
140
|
armIdle();
|
|
@@ -137,7 +150,7 @@ export async function chat({ messages, system, options, images }, emit) {
|
|
|
137
150
|
child.on('error', (e) => {
|
|
138
151
|
clearTimeout(idleTimer);
|
|
139
152
|
cleanup();
|
|
140
|
-
reject(new Error(`Failed to start
|
|
153
|
+
reject(new Error(`Failed to start agy: ${e.message}`));
|
|
141
154
|
});
|
|
142
155
|
child.on('close', (code) => {
|
|
143
156
|
clearTimeout(idleTimer);
|
|
@@ -147,7 +160,7 @@ export async function chat({ messages, system, options, images }, emit) {
|
|
|
147
160
|
emit({ type: 'done', text: '' });
|
|
148
161
|
resolve();
|
|
149
162
|
} else {
|
|
150
|
-
reject(new Error(`
|
|
163
|
+
reject(new Error(`Antigravity exited ${code}: ${err.trim() || out.trim() || 'failed'}`));
|
|
151
164
|
}
|
|
152
165
|
});
|
|
153
166
|
});
|
package/src/engines/claude.js
CHANGED
|
@@ -195,13 +195,32 @@ export async function chat({ messages, system, options, images }, emit) {
|
|
|
195
195
|
// Explicit project dir, else null → CLI runs in home (or WSL home).
|
|
196
196
|
const cwd = options.workingDir ? path.resolve(options.workingDir) : null;
|
|
197
197
|
|
|
198
|
+
const tag = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
198
199
|
const args = ['--print', '--output-format', 'stream-json', '--include-partial-messages', '--verbose'];
|
|
199
200
|
|
|
201
|
+
// Browser-tools relay: ChatPanel hands this CLI the page-action tools over an
|
|
202
|
+
// HTTP MCP server the bridge hosts (which relays each call to the extension).
|
|
203
|
+
// options.mcp = { url, serverName, specs }. We pre-allow the tools so a headless
|
|
204
|
+
// run doesn't block on approval, and merge alongside the user's own MCP servers.
|
|
205
|
+
const mcpFiles = [];
|
|
206
|
+
const mcpAllow = [];
|
|
207
|
+
if (options.mcp?.url && Array.isArray(options.mcp.specs) && options.mcp.specs.length) {
|
|
208
|
+
const serverName = options.mcp.serverName || 'chatpanel_browser';
|
|
209
|
+
const cfgFile = path.join(os.tmpdir(), `chatpanel-mcp-${tag}.json`);
|
|
210
|
+
await writeFile(cfgFile, JSON.stringify({ mcpServers: { [serverName]: { type: 'http', url: options.mcp.url } } }));
|
|
211
|
+
mcpFiles.push(cfgFile);
|
|
212
|
+
args.push('--mcp-config', cfgFile);
|
|
213
|
+
for (const s of options.mcp.specs) mcpAllow.push(`mcp__${serverName}__${s.name}`);
|
|
214
|
+
}
|
|
215
|
+
|
|
200
216
|
// Gate writes/shell behind the chosen mode; otherwise restrict to read-only
|
|
201
|
-
// tools so headless runs never block on an approval prompt.
|
|
217
|
+
// tools so headless runs never block on an approval prompt. The relayed browser
|
|
218
|
+
// tools are always pre-allowed (the user explicitly armed them this turn).
|
|
202
219
|
if (permissionMode === 'bypassPermissions') args.push('--permission-mode', 'bypassPermissions');
|
|
203
|
-
else if (permissionMode === 'acceptEdits')
|
|
204
|
-
|
|
220
|
+
else if (permissionMode === 'acceptEdits') {
|
|
221
|
+
args.push('--permission-mode', 'acceptEdits');
|
|
222
|
+
if (mcpAllow.length) args.push('--allowedTools', ...mcpAllow);
|
|
223
|
+
} else args.push('--allowedTools', ...READONLY_TOOLS, ...mcpAllow);
|
|
205
224
|
|
|
206
225
|
// Native Claude Code behavior; append the user's own system prompt if they set
|
|
207
226
|
// one (no ChatPanel persona injected).
|
|
@@ -213,9 +232,11 @@ export async function chat({ messages, system, options, images }, emit) {
|
|
|
213
232
|
|
|
214
233
|
// Attach images by writing them to temp files and asking Claude Code to Read
|
|
215
234
|
// them — its Read tool loads images as vision (no special flag needed).
|
|
216
|
-
const tag = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
217
235
|
const imageFiles = await writeImages(images, tag);
|
|
218
|
-
const cleanup = () =>
|
|
236
|
+
const cleanup = () => {
|
|
237
|
+
imageFiles.forEach((f) => unlink(f).catch(() => {}));
|
|
238
|
+
mcpFiles.forEach((f) => unlink(f).catch(() => {}));
|
|
239
|
+
};
|
|
219
240
|
let prompt = buildPrompt(messages);
|
|
220
241
|
if (imageFiles.length) {
|
|
221
242
|
prompt += `\n\nThe user attached ${imageFiles.length} image file(s). Use the Read tool to view ${
|
|
@@ -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/custom.js
CHANGED
|
@@ -59,6 +59,12 @@ function imageTokensFor(imageArg, files) {
|
|
|
59
59
|
// CHATPANEL_CUSTOM_TIMEOUT_MS (ms).
|
|
60
60
|
const IDLE_MS = Number(process.env.CHATPANEL_CUSTOM_TIMEOUT_MS) || 180_000;
|
|
61
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
|
+
|
|
62
68
|
export async function available() {
|
|
63
69
|
// The engine ships in every bridge; individual custom agents are user-defined
|
|
64
70
|
// (Pro) and validated per request and via /agent-check.
|
|
@@ -92,19 +98,26 @@ export async function listModels(options = {}) {
|
|
|
92
98
|
throw new Error('Custom agents require ChatPanel Pro.');
|
|
93
99
|
}
|
|
94
100
|
const spec = options.custom || {};
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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;
|
|
100
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' };
|
|
101
114
|
const stdout = await new Promise((resolve, reject) => {
|
|
102
115
|
let child;
|
|
103
|
-
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}`)); }
|
|
104
117
|
let out = '';
|
|
105
118
|
const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('Listing models timed out.')); }, 20000);
|
|
106
119
|
child.stdout.on('data', (d) => (out += d.toString()));
|
|
107
|
-
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}`)); });
|
|
108
121
|
child.on('close', () => { clearTimeout(timer); resolve(out); });
|
|
109
122
|
try { child.stdin.end(); } catch { /* some CLIs don't read stdin */ }
|
|
110
123
|
});
|
|
@@ -130,9 +143,14 @@ export async function chat({ messages, system, options, images }, emit) {
|
|
|
130
143
|
if (!(await isProEntitled(options.entitlement))) {
|
|
131
144
|
throw new Error('Custom agents require ChatPanel Pro. Upgrade in Settings to bring your own CLI agent.');
|
|
132
145
|
}
|
|
146
|
+
return runSpec(options.custom || {}, { messages, system, options, images }, emit);
|
|
147
|
+
}
|
|
133
148
|
|
|
134
|
-
|
|
135
|
-
|
|
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.');
|
|
136
154
|
|
|
137
155
|
const resolved = resolveCommand(spec.command);
|
|
138
156
|
if (!resolved) {
|
|
@@ -200,6 +218,8 @@ export async function chat({ messages, system, options, images }, emit) {
|
|
|
200
218
|
}
|
|
201
219
|
|
|
202
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' };
|
|
203
223
|
|
|
204
224
|
await new Promise((resolve, reject) => {
|
|
205
225
|
let child;
|
|
@@ -248,7 +268,7 @@ export async function chat({ messages, system, options, images }, emit) {
|
|
|
248
268
|
}
|
|
249
269
|
} else {
|
|
250
270
|
streamedAny = true;
|
|
251
|
-
emit({ type: 'delta', text: s });
|
|
271
|
+
emit({ type: 'delta', text: stripAnsi(s) });
|
|
252
272
|
}
|
|
253
273
|
});
|
|
254
274
|
child.stderr.on('data', (d) => { armIdle(); stderr += d.toString(); });
|
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,…} }
|
|
@@ -16,9 +16,11 @@
|
|
|
16
16
|
|
|
17
17
|
import { createServer } from 'node:http';
|
|
18
18
|
import os from 'node:os';
|
|
19
|
+
import { randomUUID } from 'node:crypto';
|
|
19
20
|
import * as claude from './engines/claude.js';
|
|
20
21
|
import * as codex from './engines/codex.js';
|
|
21
|
-
import * as
|
|
22
|
+
import * as antigravity from './engines/antigravity.js';
|
|
23
|
+
import { pi, opencode, kiro } from './engines/cli-agents.js';
|
|
22
24
|
import * as custom from './engines/custom.js';
|
|
23
25
|
import { installService, uninstallService, serviceStatus, restartService } from './service.js';
|
|
24
26
|
import { enrichPath, findAgentBin, resolveCommand } from './env.js';
|
|
@@ -27,20 +29,76 @@ import { checkForUpdate, selfUpdate } from './update.js';
|
|
|
27
29
|
// Hardcoded (not read from package.json) so it survives Bun's single-file
|
|
28
30
|
// --compile, where package.json isn't on a readable FS. CI fails the publish if
|
|
29
31
|
// this drifts from package.json, so the two can't silently diverge.
|
|
30
|
-
const VERSION = '0.
|
|
32
|
+
const VERSION = '0.6.0';
|
|
31
33
|
const HOST = process.env.CHATPANEL_BRIDGE_HOST || '127.0.0.1';
|
|
32
34
|
const PORT = Number(process.env.CHATPANEL_BRIDGE_PORT) || 4319;
|
|
33
35
|
|
|
34
36
|
const ENGINES = {
|
|
35
37
|
claude: { engine: claude, label: 'Claude Code' },
|
|
36
38
|
codex: { engine: codex, label: 'Codex' },
|
|
37
|
-
|
|
39
|
+
antigravity: { engine: antigravity, label: 'Antigravity' },
|
|
40
|
+
pi: { engine: pi, label: 'Pi' },
|
|
41
|
+
opencode: { engine: opencode, label: 'OpenCode' },
|
|
42
|
+
kiro: { engine: kiro, label: 'Kiro' },
|
|
38
43
|
// "Bring your own" — one engine drives any user-onboarded CLI (Pro). Hidden
|
|
39
44
|
// from /health (it's not a single installable agent; the extension manages the
|
|
40
45
|
// list and validates commands via /agent-check).
|
|
41
46
|
custom: { engine: custom, label: 'Custom', hidden: true },
|
|
42
47
|
};
|
|
43
48
|
|
|
49
|
+
// --------------------------------------------------------------------------
|
|
50
|
+
// Browser-tools relay. When the extension arms "Act on page" for a CLI agent, it
|
|
51
|
+
// sends the tool specs in /chat. We host an HTTP MCP server (/mcp/<session>) the
|
|
52
|
+
// CLI connects to; each tools/call is RELAYED to the extension over the chat SSE
|
|
53
|
+
// stream (a `tool_request` event), executed there (it owns the browser), and the
|
|
54
|
+
// result POSTed back to /tool-result. The bridge itself never touches the page.
|
|
55
|
+
// --------------------------------------------------------------------------
|
|
56
|
+
const sessions = new Map(); // sessionId -> { id, emit, specs, pending: Map, nextId }
|
|
57
|
+
|
|
58
|
+
function createSession(emit, specs) {
|
|
59
|
+
const id = randomUUID();
|
|
60
|
+
const s = { id, emit, specs, pending: new Map(), nextId: 0 };
|
|
61
|
+
sessions.set(id, s);
|
|
62
|
+
return s;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function deleteSession(id) {
|
|
66
|
+
const s = sessions.get(id);
|
|
67
|
+
if (!s) return;
|
|
68
|
+
for (const p of s.pending.values()) p.reject(new Error('chat ended'));
|
|
69
|
+
sessions.delete(id);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Ask the extension to run a tool and await its result. Resolves to MCP content.
|
|
73
|
+
function relayToolCall(session, name, input) {
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
const id = `t${++session.nextId}`;
|
|
76
|
+
const timer = setTimeout(() => {
|
|
77
|
+
session.pending.delete(id);
|
|
78
|
+
reject(new Error('tool call timed out'));
|
|
79
|
+
}, 120_000);
|
|
80
|
+
session.pending.set(id, {
|
|
81
|
+
resolve: (result) => { clearTimeout(timer); resolve(toMcpContent(result)); },
|
|
82
|
+
reject: (e) => { clearTimeout(timer); reject(e); },
|
|
83
|
+
});
|
|
84
|
+
session.emit({ type: 'tool_request', session: session.id, id, name, input });
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The extension returns a string OR { text, image(dataURL) }; map to MCP content.
|
|
89
|
+
function toMcpContent(result) {
|
|
90
|
+
if (result == null) return { content: [{ type: 'text', text: 'ok' }] };
|
|
91
|
+
if (typeof result === 'string') return { content: [{ type: 'text', text: result }] };
|
|
92
|
+
const content = [];
|
|
93
|
+
if (result.text) content.push({ type: 'text', text: String(result.text) });
|
|
94
|
+
if (typeof result.image === 'string') {
|
|
95
|
+
const m = /^data:([^;]+);base64,(.+)$/s.exec(result.image);
|
|
96
|
+
if (m) content.push({ type: 'image', data: m[2], mimeType: m[1] });
|
|
97
|
+
}
|
|
98
|
+
if (!content.length) content.push({ type: 'text', text: 'ok' });
|
|
99
|
+
return { content };
|
|
100
|
+
}
|
|
101
|
+
|
|
44
102
|
// --------------------------------------------------------------------------
|
|
45
103
|
// CORS — allow the extension (chrome-extension://…) and localhost dev origins.
|
|
46
104
|
// --------------------------------------------------------------------------
|
|
@@ -135,26 +193,103 @@ async function handleChat(req, res) {
|
|
|
135
193
|
let closed = false;
|
|
136
194
|
req.on('close', () => (closed = true));
|
|
137
195
|
|
|
196
|
+
const safeEmit = (obj) => { if (!closed) emit(obj); };
|
|
197
|
+
|
|
198
|
+
// Browser-tools relay: when the extension sends page-tool specs, host an MCP
|
|
199
|
+
// server for this turn and tell the engine to point the CLI at it.
|
|
200
|
+
const options = { ...(body.options || {}) };
|
|
201
|
+
let session = null;
|
|
202
|
+
if (body.pageTools?.specs?.length) {
|
|
203
|
+
session = createSession(safeEmit, body.pageTools.specs);
|
|
204
|
+
options.mcp = {
|
|
205
|
+
url: `http://${HOST}:${PORT}/mcp/${session.id}`,
|
|
206
|
+
serverName: 'chatpanel_browser',
|
|
207
|
+
specs: body.pageTools.specs,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
138
211
|
try {
|
|
139
212
|
await target.engine.chat(
|
|
140
213
|
{
|
|
141
214
|
messages: Array.isArray(body.messages) ? body.messages : [],
|
|
142
215
|
system: body.system || '',
|
|
143
|
-
options
|
|
216
|
+
options,
|
|
144
217
|
images: Array.isArray(body.images) ? body.images : [],
|
|
145
218
|
},
|
|
146
|
-
|
|
147
|
-
if (!closed) emit(obj);
|
|
148
|
-
},
|
|
219
|
+
safeEmit,
|
|
149
220
|
);
|
|
150
221
|
} catch (e) {
|
|
151
222
|
log('error', `${body.agent} chat failed: ${e?.message || e}`);
|
|
152
223
|
emit({ type: 'error', error: e?.message || String(e) });
|
|
153
224
|
} finally {
|
|
225
|
+
if (session) deleteSession(session.id);
|
|
154
226
|
if (!res.writableEnded) res.end();
|
|
155
227
|
}
|
|
156
228
|
}
|
|
157
229
|
|
|
230
|
+
// POST /mcp/<session> — the HTTP MCP server the CLI agent connects to. JSON-RPC
|
|
231
|
+
// over POST; tools/call relays to the extension and waits for /tool-result.
|
|
232
|
+
async function handleMcp(req, res, sessionId) {
|
|
233
|
+
let msg;
|
|
234
|
+
try {
|
|
235
|
+
msg = await readBody(req);
|
|
236
|
+
} catch {
|
|
237
|
+
return json(res, 200, { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } });
|
|
238
|
+
}
|
|
239
|
+
const session = sessions.get(sessionId);
|
|
240
|
+
const reply = (result) => {
|
|
241
|
+
if (sessionId) res.setHeader('Mcp-Session-Id', sessionId);
|
|
242
|
+
json(res, 200, { jsonrpc: '2.0', id: msg.id ?? null, result });
|
|
243
|
+
};
|
|
244
|
+
const fail = (code, message) => json(res, 200, { jsonrpc: '2.0', id: msg.id ?? null, error: { code, message } });
|
|
245
|
+
|
|
246
|
+
// Notifications (no id) — ack and ignore.
|
|
247
|
+
if (msg.id == null) { res.writeHead(202); return res.end(); }
|
|
248
|
+
|
|
249
|
+
if (msg.method === 'initialize') {
|
|
250
|
+
return reply({
|
|
251
|
+
protocolVersion: msg.params?.protocolVersion || '2025-06-18',
|
|
252
|
+
capabilities: { tools: { listChanged: false } },
|
|
253
|
+
serverInfo: { name: 'chatpanel-browser', version: VERSION },
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
if (!session) return fail(-32001, 'Session not found (chat already ended)');
|
|
257
|
+
if (msg.method === 'tools/list') {
|
|
258
|
+
return reply({
|
|
259
|
+
tools: session.specs.map((s) => ({
|
|
260
|
+
name: s.name,
|
|
261
|
+
description: s.description,
|
|
262
|
+
inputSchema: s.parameters || { type: 'object', properties: {} },
|
|
263
|
+
})),
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
if (msg.method === 'tools/call') {
|
|
267
|
+
try {
|
|
268
|
+
return reply(await relayToolCall(session, msg.params?.name, msg.params?.arguments || {}));
|
|
269
|
+
} catch (e) {
|
|
270
|
+
return reply({ content: [{ type: 'text', text: `error: ${e?.message || e}` }], isError: true });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return fail(-32601, `Method not found: ${msg.method}`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// POST /tool-result — the extension returns a relayed tool's result.
|
|
277
|
+
async function handleToolResult(req, res) {
|
|
278
|
+
let body;
|
|
279
|
+
try {
|
|
280
|
+
body = await readBody(req);
|
|
281
|
+
} catch (e) {
|
|
282
|
+
return json(res, 400, { error: 'Bad JSON: ' + e.message });
|
|
283
|
+
}
|
|
284
|
+
const session = sessions.get(body.session);
|
|
285
|
+
if (!session) return json(res, 404, { error: 'no such session' });
|
|
286
|
+
const pending = session.pending.get(body.id);
|
|
287
|
+
if (!pending) return json(res, 404, { error: 'no such pending call' });
|
|
288
|
+
session.pending.delete(body.id);
|
|
289
|
+
pending.resolve(body.result);
|
|
290
|
+
return json(res, 200, { ok: true });
|
|
291
|
+
}
|
|
292
|
+
|
|
158
293
|
// POST /complete → { agent, prompt, model? } → { text } — a fast, single-shot
|
|
159
294
|
// completion for prompt autocomplete. Uses the engine's complete() if it has one
|
|
160
295
|
// (Claude: Haiku, no tools), else a one-shot chat collected into text.
|
|
@@ -253,11 +388,18 @@ const server = createServer(async (req, res) => {
|
|
|
253
388
|
version: VERSION,
|
|
254
389
|
home: os.homedir(),
|
|
255
390
|
codex: findAgentBin('codex') || null,
|
|
256
|
-
|
|
391
|
+
agy: findAgentBin('agy') || null,
|
|
257
392
|
path: process.env.PATH,
|
|
258
393
|
});
|
|
259
394
|
}
|
|
260
395
|
if (req.method === 'POST' && url.pathname === '/chat') return handleChat(req, res);
|
|
396
|
+
if (url.pathname.startsWith('/mcp/')) {
|
|
397
|
+
const sid = decodeURIComponent(url.pathname.slice(5));
|
|
398
|
+
if (req.method === 'POST') return handleMcp(req, res, sid);
|
|
399
|
+
if (req.method === 'GET') { res.writeHead(405); return res.end(); } // no server-initiated stream
|
|
400
|
+
if (req.method === 'DELETE') { deleteSession(sid); res.writeHead(204); return res.end(); }
|
|
401
|
+
}
|
|
402
|
+
if (req.method === 'POST' && url.pathname === '/tool-result') return handleToolResult(req, res);
|
|
261
403
|
if (req.method === 'POST' && url.pathname === '/complete') return handleComplete(req, res);
|
|
262
404
|
if (req.method === 'POST' && url.pathname === '/list-models') return handleListModels(req, res);
|
|
263
405
|
if (req.method === 'POST' && url.pathname === '/agent-check') return handleAgentCheck(req, res);
|
|
@@ -282,7 +424,7 @@ function startServer() {
|
|
|
282
424
|
const a = await engine.available().catch(() => ({ ok: false }));
|
|
283
425
|
log('info', ` ${a.ok ? '✓' : '✕'} ${label}${a.ok ? '' : ' — ' + (a.reason || 'unavailable')}`);
|
|
284
426
|
}
|
|
285
|
-
log('info', 'Open the ChatPanel side panel; installed agents (Claude Code, Codex,
|
|
427
|
+
log('info', 'Open the ChatPanel side panel; installed agents (Claude Code, Codex, Antigravity) appear automatically.');
|
|
286
428
|
});
|
|
287
429
|
}
|
|
288
430
|
|