@chatpanel/bridge 0.10.22 → 0.10.23
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 +2 -2
- package/src/engines/args.js +5 -0
- package/src/engines/cli-agents.js +156 -2
- package/src/engines/custom.js +60 -60
- package/src/engines/stream-formats.js +207 -0
- package/src/env.js +1 -1
- package/src/server.js +4 -2
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/bridge",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.23",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Local bridge that exposes the AI coding agents installed on your machine
|
|
5
|
+
"description": "Local bridge that exposes the AI coding agents installed on your machine \u2014 Claude Code (CLI), Codex (CLI), and Antigravity CLI (formerly Gemini CLI, which remains available for business/enterprise) \u2014 to the ChatPanel Chrome extension over a localhost SSE endpoint. Bring your own agent.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"chatpanel",
|
|
8
8
|
"claude-code",
|
package/src/engines/args.js
CHANGED
|
@@ -23,6 +23,11 @@ export const FORBIDDEN = {
|
|
|
23
23
|
// Custom runs an arbitrary CLI, so only clearly-dangerous LONG flags are blocked
|
|
24
24
|
// (no short-flag guesses that might collide with a benign tool option).
|
|
25
25
|
custom: /^--(dangerously[\w-]*|skip-permissions|trust-all-?tools|no-sandbox|bypass|yolo|full-auto|permission-mode|allowed-?tools|disallowed-?tools|mcp-config)\b/i,
|
|
26
|
+
// Copilot's escalation surface is its own family of --allow-* flags (which the
|
|
27
|
+
// `custom` pattern above does NOT cover: "allow-all-tools" != "allowed-tools").
|
|
28
|
+
// Also block re-targeting the working dir (-C / --add-dir) and injecting MCP
|
|
29
|
+
// servers, since the engine sets those deliberately per turn.
|
|
30
|
+
copilot: /^(-C)$|^--(allow-all[\w-]*|allow-tool|allow-url|allow-path|yolo|add-dir|additional-mcp-config|disable-builtin-mcps|deny-tool|deny-url|autopilot|mode)\b/i,
|
|
26
31
|
};
|
|
27
32
|
|
|
28
33
|
// Returns { args, blocked }. `blocked:true` => the whole extraArgs was dropped.
|
|
@@ -7,10 +7,13 @@
|
|
|
7
7
|
// opencode — opencode run "<prompt>" · -m provider/model · -f {path} images · models
|
|
8
8
|
// kiro — kiro-cli chat --no-interactive "<prompt>" · --model · --list-models
|
|
9
9
|
|
|
10
|
-
import { runSpec, listSpecModels } from './custom.js';
|
|
10
|
+
import { runSpec, listSpecModels, runForStdout } from './custom.js';
|
|
11
11
|
import { findAgentBin } from '../env.js';
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
// `listModels` override hook: a CLI whose model listing isn't "one id per line"
|
|
14
|
+
// (Copilot prints a quoted list inside `help config`) passes its own parser here
|
|
15
|
+
// instead of forcing the generic one to grow special cases.
|
|
16
|
+
function makeCliAgent(command, spec, notFoundHint, overrides = {}) {
|
|
14
17
|
let installed = false;
|
|
15
18
|
let lastProbe = 0;
|
|
16
19
|
const resolvedSpec = { ...spec, command };
|
|
@@ -30,6 +33,7 @@ function makeCliAgent(command, spec, notFoundHint) {
|
|
|
30
33
|
return installed ? { ok: true } : { ok: false, reason: notFoundHint };
|
|
31
34
|
},
|
|
32
35
|
listModels(options = {}) {
|
|
36
|
+
if (overrides.listModels) return overrides.listModels(command, options);
|
|
33
37
|
return listSpecModels(command, spec.listModelsArgs, options.workingDir);
|
|
34
38
|
},
|
|
35
39
|
chat(input, emit, opts) {
|
|
@@ -94,3 +98,153 @@ export const kiro = makeCliAgent(
|
|
|
94
98
|
},
|
|
95
99
|
'kiro-cli not found on PATH. Install Kiro CLI, then sign in.',
|
|
96
100
|
);
|
|
101
|
+
|
|
102
|
+
// GitHub Copilot CLI. Verified against 1.0.80 (`copilot`, not the old
|
|
103
|
+
// `gh copilot` extension).
|
|
104
|
+
//
|
|
105
|
+
// The two things that make or break headless Copilot:
|
|
106
|
+
// 1. --allow-all-tools is REQUIRED in -p mode. Without it EVERY tool call dies
|
|
107
|
+
// with "Permission denied and could not request permission from user" — the
|
|
108
|
+
// CLI has no TTY to ask on — so the agent answers as if it had no tools.
|
|
109
|
+
// 2. --output-format json turns the TUI into clean NDJSON we can stream;
|
|
110
|
+
// the default text output is not parseable as a live stream.
|
|
111
|
+
// Both live in the base spec because neither is optional for this integration.
|
|
112
|
+
//
|
|
113
|
+
// Permission mode maps onto Copilot's real permission surface (see
|
|
114
|
+
// permissionArgs): tools always run unattended, while filesystem reach beyond the
|
|
115
|
+
// working dir and arbitrary URL access are what the mode actually escalates.
|
|
116
|
+
const COPILOT_BASE_ARGS = [
|
|
117
|
+
'-p', '{prompt}',
|
|
118
|
+
'--output-format', 'json',
|
|
119
|
+
'--no-color',
|
|
120
|
+
'--log-level', 'none',
|
|
121
|
+
// Headless hygiene: never block asking the user a question, never self-update
|
|
122
|
+
// mid-turn, and don't ship the session to GitHub web/mobile from a ChatPanel
|
|
123
|
+
// turn (Copilot exports by default; ChatPanel keeps chat local by policy).
|
|
124
|
+
'--no-ask-user',
|
|
125
|
+
'--no-auto-update',
|
|
126
|
+
'--no-remote',
|
|
127
|
+
'--no-remote-export',
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
export const copilot = makeCliAgent(
|
|
131
|
+
'copilot',
|
|
132
|
+
{
|
|
133
|
+
args: COPILOT_BASE_ARGS,
|
|
134
|
+
promptVia: 'arg',
|
|
135
|
+
modelArg: '--model {model}',
|
|
136
|
+
// Non-interactive-only flag; one per image.
|
|
137
|
+
imageArg: '--attachment {path}',
|
|
138
|
+
format: 'copilot-json',
|
|
139
|
+
// Copilot takes per-run MCP servers, so browser tools work WITHOUT touching
|
|
140
|
+
// the user's ~/.copilot/mcp-config.json (unlike opencode/kiro, which only
|
|
141
|
+
// read global config). `@` prefix = "this is a file path, not inline JSON";
|
|
142
|
+
// the shape runSpec writes ({mcpServers:{name:{command,args}}}) is the one
|
|
143
|
+
// Copilot expects — verified.
|
|
144
|
+
mcpArg: '--additional-mcp-config @{file}',
|
|
145
|
+
permissionArgs: {
|
|
146
|
+
// Tools yes (or nothing works); no path escape, no arbitrary URLs.
|
|
147
|
+
default: ['--allow-all-tools'],
|
|
148
|
+
// Edits anywhere on disk, still no arbitrary URL fetching.
|
|
149
|
+
acceptEdits: ['--allow-all-tools', '--allow-all-paths'],
|
|
150
|
+
// == --allow-all-tools --allow-all-paths --allow-all-urls
|
|
151
|
+
bypassPermissions: ['--allow-all'],
|
|
152
|
+
},
|
|
153
|
+
forbidden: 'copilot',
|
|
154
|
+
label: 'GitHub Copilot',
|
|
155
|
+
},
|
|
156
|
+
'copilot not found on PATH. Install GitHub Copilot CLI, then run `copilot login`.',
|
|
157
|
+
{ listModels: (command, options) => listCopilotModels(command, options.workingDir) },
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
// Copilot has no `--list-models`, and an invalid --model reports only that the
|
|
161
|
+
// model is unavailable without naming the valid ones. `copilot help config`
|
|
162
|
+
// documents the live list under its `model` key, so read it there — the ids stay
|
|
163
|
+
// in step with the installed CLI instead of being hardcoded here.
|
|
164
|
+
export async function listCopilotModels(command = 'copilot', workingDir) {
|
|
165
|
+
const stdout = await runForStdout(command, ['help', 'config'], workingDir);
|
|
166
|
+
const out = [];
|
|
167
|
+
const seen = new Set();
|
|
168
|
+
let inModelBlock = false;
|
|
169
|
+
for (const raw of String(stdout || '').split('\n')) {
|
|
170
|
+
// Section keys are printed as ` `key`: description`.
|
|
171
|
+
const key = /^\s*`([A-Za-z][\w.]*)`\s*:/.exec(raw);
|
|
172
|
+
if (key) {
|
|
173
|
+
inModelBlock = key[1] === 'model';
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (!inModelBlock) continue;
|
|
177
|
+
const m = /^\s*-\s*"([^"]+)"\s*$/.exec(raw);
|
|
178
|
+
if (!m) continue;
|
|
179
|
+
const id = m[1];
|
|
180
|
+
if (seen.has(id)) continue;
|
|
181
|
+
seen.add(id);
|
|
182
|
+
out.push(id);
|
|
183
|
+
if (out.length >= 200) break;
|
|
184
|
+
}
|
|
185
|
+
// `auto` is a real, documented value (Copilot routes the turn itself) but it is
|
|
186
|
+
// not in the enumerated list — offer it first.
|
|
187
|
+
return out.length ? ['auto', ...out] : [];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// DeepSeek Harness. The launcher is `dsh`; `--profile headless` is the shipped
|
|
191
|
+
// one-shot template ("answer one task, print the final assistant message, exit").
|
|
192
|
+
//
|
|
193
|
+
// Deliberately sparse compared to the others, because the CLI surface is sparse:
|
|
194
|
+
// the headless profile takes ONLY the task text. There is no --model, no JSON
|
|
195
|
+
// output and no resume flag — dsh is a Cordis plugin tree, so the model adapter,
|
|
196
|
+
// tools and MCP are chosen by the PROFILE, not by argv. Power users retarget it
|
|
197
|
+
// with `--patch <file>` through the agent's "Extra arguments" field.
|
|
198
|
+
//
|
|
199
|
+
// Two consequences worth knowing before wiring UI to this:
|
|
200
|
+
// * `modelArg` is absent, so a model picked in Settings is ignored by design
|
|
201
|
+
// (runSpec skips model injection without a template) — configure it in the
|
|
202
|
+
// profile instead.
|
|
203
|
+
// * Output arrives as ONE chunk at the end, not token-by-token, so the turn
|
|
204
|
+
// shows progress but no live typing.
|
|
205
|
+
export const deepseek = makeCliAgent(
|
|
206
|
+
'dsh',
|
|
207
|
+
{
|
|
208
|
+
args: ['--profile', 'headless', '{prompt}'],
|
|
209
|
+
promptVia: 'arg',
|
|
210
|
+
format: 'text',
|
|
211
|
+
// No --model flag exists. dsh takes the model as a CONFIG OVERLAY: --patch
|
|
212
|
+
// replaces one row of the composed plugin tree by id, and the model lives in
|
|
213
|
+
// the `agent-default-model` row. runSpec writes this file per turn.
|
|
214
|
+
modelPatch: {
|
|
215
|
+
arg: '--patch {file}',
|
|
216
|
+
// A patch replaces the row's WHOLE config, so provider must be restated.
|
|
217
|
+
build: (model) => `- id: ${DSH_MODEL_ROW}\n config:\n provider: ${DSH_PROVIDER}\n model: ${JSON.stringify(model)}\n`,
|
|
218
|
+
},
|
|
219
|
+
label: 'DeepSeek Harness',
|
|
220
|
+
},
|
|
221
|
+
'dsh not found on PATH. Install DeepSeek Harness, then run `dsh --profile headless "hi"` once to check the profile.',
|
|
222
|
+
{ listModels: (command, options) => listDshModels(command, options.workingDir) },
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
const DSH_MODEL_ROW = 'agent-default-model';
|
|
226
|
+
const DSH_PROVIDER = 'deepseek-official';
|
|
227
|
+
// dsh ships no model catalog command. The provider names its supported ids only
|
|
228
|
+
// when you send a bad one ("The supported API model names are ..."), so these are
|
|
229
|
+
// those ids, unioned with whatever the profile is actually set to. The field
|
|
230
|
+
// still accepts any id — this is a convenience menu, not a whitelist.
|
|
231
|
+
const DSH_KNOWN_MODELS = ['deepseek-v4-flash', 'deepseek-v4-pro'];
|
|
232
|
+
|
|
233
|
+
// dsh publishes no model catalog — there is no `--list-models`, and the composed
|
|
234
|
+
// config tree carries only the model the profile is CURRENTLY set to. So "Load
|
|
235
|
+
// models" reports that one truthfully rather than inventing a menu; the field
|
|
236
|
+
// still accepts any id the provider serves, applied via the patch above.
|
|
237
|
+
export async function listDshModels(command = 'dsh', workingDir) {
|
|
238
|
+
const stdout = await runForStdout(command, ['--profile', 'headless', '--dump-config'], workingDir, 60000);
|
|
239
|
+
const lines = String(stdout || '').split('\n');
|
|
240
|
+
const rowStart = new RegExp(`^-\\s*id:\\s*${DSH_MODEL_ROW}\\s*$`);
|
|
241
|
+
for (let i = 0; i < lines.length; i++) {
|
|
242
|
+
if (!rowStart.test(lines[i])) continue;
|
|
243
|
+
// Scan this row's block for `model: <id>`, stopping at the next top-level row.
|
|
244
|
+
for (let j = i + 1; j < lines.length && !/^-\s*id:/.test(lines[j]); j++) {
|
|
245
|
+
const m = /^\s*model:\s*['"]?([\w.:-]+)['"]?\s*$/.exec(lines[j]);
|
|
246
|
+
if (m) return [...new Set([m[1], ...DSH_KNOWN_MODELS])];
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return [...DSH_KNOWN_MODELS];
|
|
250
|
+
}
|
package/src/engines/custom.js
CHANGED
|
@@ -20,9 +20,9 @@ import path from 'node:path';
|
|
|
20
20
|
import { resolveCommand, buildSpawnSpec, selfMcpStdio } from '../env.js';
|
|
21
21
|
import { isProEntitled } from '../entitlement.js';
|
|
22
22
|
import { killOnAbort } from '../proc.js';
|
|
23
|
-
import { handleMessage } from './claude.js';
|
|
24
23
|
import { buildCliPrompt } from './prompt.js';
|
|
25
24
|
import { pushExtraArgs, FORBIDDEN } from './args.js';
|
|
25
|
+
import { createStreamParser, STREAM_FORMATS, stripAnsi } from './stream-formats.js';
|
|
26
26
|
|
|
27
27
|
// Write base64 data-URL images to temp files so a custom CLI can take them via
|
|
28
28
|
// its configured `imageArg` template (e.g. "-i {path}", "@{path}"). Returns paths.
|
|
@@ -60,11 +60,8 @@ function imageTokensFor(imageArg, files) {
|
|
|
60
60
|
// CHATPANEL_CUSTOM_TIMEOUT_MS (ms).
|
|
61
61
|
const IDLE_MS = Number(process.env.CHATPANEL_CUSTOM_TIMEOUT_MS) || 180_000;
|
|
62
62
|
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
// also set NO_COLOR on the child env, but this is the robust backstop.)
|
|
66
|
-
const ANSI_RE = /\u001b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
67
|
-
const stripAnsi = (s) => s.replace(ANSI_RE, '');
|
|
63
|
+
// ANSI stripping moved to stream-formats.js (imported above), which is where
|
|
64
|
+
// text output is actually rendered - one implementation for every format.
|
|
68
65
|
const OPENCODE_STABLE_MCP_URL = 'http://127.0.0.1:4319/mcp';
|
|
69
66
|
const CHATPANEL_STABLE_MCP_URL = 'http://127.0.0.1:4319/mcp';
|
|
70
67
|
|
|
@@ -109,22 +106,30 @@ export async function listModels(options = {}) {
|
|
|
109
106
|
export async function listSpecModels(command, listModelsArgs, workingDir) {
|
|
110
107
|
const listArgs = String(listModelsArgs || '').trim();
|
|
111
108
|
if (!command || !listArgs) return [];
|
|
109
|
+
const stdout = await runForStdout(command, listArgs.split(/\s+/).filter(Boolean), workingDir);
|
|
110
|
+
return parseModelList(stdout);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Run a CLI to completion and return its stdout. Shared by every "ask the CLI
|
|
114
|
+
// something" path (model listing and the per-agent listModels overrides) so the
|
|
115
|
+
// spawn/resolve/timeout handling exists once.
|
|
116
|
+
export async function runForStdout(command, argvIn, workingDir, timeoutMs = 20000) {
|
|
117
|
+
if (!command) return '';
|
|
112
118
|
const resolved = resolveCommand(command);
|
|
113
119
|
if (!resolved) throw new Error(`Couldn't find "${command}".`);
|
|
114
120
|
const cwd = workingDir ? path.resolve(workingDir) : null;
|
|
115
|
-
const [bin, argv, opts] = buildSpawnSpec(resolved,
|
|
121
|
+
const [bin, argv, opts] = buildSpawnSpec(resolved, argvIn, cwd);
|
|
116
122
|
opts.env = { ...(opts.env || process.env), NO_COLOR: '1', CLICOLOR: '0' };
|
|
117
|
-
|
|
123
|
+
return new Promise((resolve, reject) => {
|
|
118
124
|
let child;
|
|
119
125
|
try { child = spawn(bin, argv, opts); } catch (e) { return reject(new Error(`Failed to start ${command}: ${e.message}`)); }
|
|
120
126
|
let out = '';
|
|
121
|
-
const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error(
|
|
127
|
+
const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error(`${command} timed out.`)); }, timeoutMs);
|
|
122
128
|
child.stdout.on('data', (d) => (out += d.toString()));
|
|
123
129
|
child.on('error', (e) => { clearTimeout(timer); reject(new Error(`Failed to start ${command}: ${e.message}`)); });
|
|
124
130
|
child.on('close', () => { clearTimeout(timer); resolve(out); });
|
|
125
131
|
try { child.stdin.end(); } catch { /* some CLIs don't read stdin */ }
|
|
126
132
|
});
|
|
127
|
-
return parseModelList(stdout);
|
|
128
133
|
}
|
|
129
134
|
|
|
130
135
|
function mcpToolSpecs(mcp) {
|
|
@@ -380,7 +385,10 @@ export async function runSpec(spec, { messages, system, options = {}, images },
|
|
|
380
385
|
const prompt = buildCliPrompt(messages, system);
|
|
381
386
|
let cwd = options.workingDir ? path.resolve(options.workingDir) : null;
|
|
382
387
|
const label = spec.label || spec.command;
|
|
383
|
-
|
|
388
|
+
// Output dialect — resolved against the stream-format registry, so a new agent
|
|
389
|
+
// brings a format by NAME instead of a new branch in this runner. Unknown /
|
|
390
|
+
// absent names fall back to plain text.
|
|
391
|
+
const fmt = Object.hasOwn(STREAM_FORMATS, spec.format) ? spec.format : 'text';
|
|
384
392
|
|
|
385
393
|
// Args: either a real array or a space-split string. With promptVia:'arg' we
|
|
386
394
|
// substitute {prompt} (or append it if there's no placeholder); otherwise the
|
|
@@ -398,7 +406,10 @@ export async function runSpec(spec, { messages, system, options = {}, images },
|
|
|
398
406
|
// intentional autonomy flags live in their BASE spec (cli-agents.js), not here, so
|
|
399
407
|
// they're unaffected; a custom CLI that genuinely needs such a flag should carry it
|
|
400
408
|
// in its configured command/args, not the extra-args field.
|
|
401
|
-
|
|
409
|
+
// Built-in specs may name a stricter set than the generic `custom` one, since
|
|
410
|
+
// each CLI's escalation flags are spelled differently (Copilot's --allow-all-*
|
|
411
|
+
// family isn't matched by the generic pattern).
|
|
412
|
+
pushExtraArgs(args, options.extraArgs, FORBIDDEN[spec.forbidden] || FORBIDDEN.custom, emit);
|
|
402
413
|
// Inject the selected model via the agent's CONFIGURED model-arg template
|
|
403
414
|
// (e.g. "--model {model}" or, for opencode, "-m {model}" with provider/model).
|
|
404
415
|
// Without a template we can't know how this CLI takes a model, so options.model
|
|
@@ -413,6 +424,16 @@ export async function runSpec(spec, { messages, system, options = {}, images },
|
|
|
413
424
|
// project path, so it never loads opencode.json / its MCP servers.
|
|
414
425
|
args = [...args, ...injected];
|
|
415
426
|
}
|
|
427
|
+
// Permission mode -> flags, declared per agent as
|
|
428
|
+
// permissionArgs: { default: [...], acceptEdits: [...], bypassPermissions: [...] }
|
|
429
|
+
// Agents whose autonomy flags are unconditional keep them in `args`; this is for
|
|
430
|
+
// CLIs (Copilot) with a real permission surface, so the extension's existing
|
|
431
|
+
// per-agent Permission mode actually means something. Unknown mode -> `default`.
|
|
432
|
+
if (spec.permissionArgs) {
|
|
433
|
+
const mode = String(options.permissionMode || 'default');
|
|
434
|
+
const perm = spec.permissionArgs[mode] || spec.permissionArgs.default || [];
|
|
435
|
+
args = [...args, ...perm.map(String)];
|
|
436
|
+
}
|
|
416
437
|
// Images: write to temp files, expand the agent's imageArg template, then place
|
|
417
438
|
// the tokens. An explicit {images} placeholder in args wins; otherwise they go
|
|
418
439
|
// just before the prompt (arg mode) or get appended (stdin mode).
|
|
@@ -446,6 +467,28 @@ export async function runSpec(spec, { messages, system, options = {}, images },
|
|
|
446
467
|
// /mcp endpoint is present before letting the agent answer with no tools.
|
|
447
468
|
if (options.mcp?.url) await ensureStableMcpConfig(spec, cwd, label, emit);
|
|
448
469
|
|
|
470
|
+
// Model via a CONFIG-PATCH FILE, for agents with no --model flag (dsh takes the
|
|
471
|
+
// model as a Cordis config overlay: `--patch <file>` replacing one row by id).
|
|
472
|
+
// `build` is a function, so this can only come from a built-in spec — a custom
|
|
473
|
+
// BYO agent's spec arrives as JSON over HTTP and cannot carry one.
|
|
474
|
+
if (options.model && typeof spec.modelPatch?.build === 'function') {
|
|
475
|
+
// Keep the value to a conservative id charset: it lands in a config file the
|
|
476
|
+
// agent parses, so no quotes/newlines/path characters.
|
|
477
|
+
const safeModel = /^[A-Za-z0-9][\w.:-]{0,79}$/.test(String(options.model)) ? String(options.model) : '';
|
|
478
|
+
if (safeModel) {
|
|
479
|
+
const patchFile = path.join(os.tmpdir(), `chatpanel-modelpatch-${tag}.yml`);
|
|
480
|
+
await writeFile(patchFile, spec.modelPatch.build(safeModel));
|
|
481
|
+
mcpFiles.push(patchFile); // cleaned up with the other temp files
|
|
482
|
+
const tmpl = String(spec.modelPatch.arg || '--patch {file}');
|
|
483
|
+
const tokens = tmpl.includes('{file}')
|
|
484
|
+
? tmpl.replaceAll('{file}', patchFile).split(/\s+/).filter(Boolean)
|
|
485
|
+
: [...tmpl.split(/\s+/).filter(Boolean), patchFile];
|
|
486
|
+
// PREPEND: these are launcher flags and must precede the task text (same
|
|
487
|
+
// reason mcpArg prepends).
|
|
488
|
+
args = [...tokens, ...args];
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
449
492
|
const imageTokens = imageTokensFor(spec.imageArg, imageFiles);
|
|
450
493
|
let placedImages = false;
|
|
451
494
|
if (imageTokens.length) {
|
|
@@ -491,9 +534,9 @@ export async function runSpec(spec, { messages, system, options = {}, images },
|
|
|
491
534
|
const detach = killOnAbort(child, signal); // Stop → terminate the CLI child
|
|
492
535
|
|
|
493
536
|
let stderr = '';
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
537
|
+
// The output dialect is a plugin (stream-formats.js); it owns line buffering,
|
|
538
|
+
// "did anything stream", and the fallback answer text.
|
|
539
|
+
const parser = createStreamParser(fmt, emit);
|
|
497
540
|
|
|
498
541
|
let idleTimer;
|
|
499
542
|
const armIdle = () => {
|
|
@@ -508,50 +551,7 @@ export async function runSpec(spec, { messages, system, options = {}, images },
|
|
|
508
551
|
|
|
509
552
|
child.stdout.on('data', (d) => {
|
|
510
553
|
armIdle();
|
|
511
|
-
|
|
512
|
-
if (fmt === 'claude-stream-json') {
|
|
513
|
-
jsonBuf += s;
|
|
514
|
-
let nl;
|
|
515
|
-
while ((nl = jsonBuf.indexOf('\n')) >= 0) {
|
|
516
|
-
const line = jsonBuf.slice(0, nl).trim();
|
|
517
|
-
jsonBuf = jsonBuf.slice(nl + 1);
|
|
518
|
-
if (!line.startsWith('{')) continue;
|
|
519
|
-
let msg;
|
|
520
|
-
try {
|
|
521
|
-
msg = JSON.parse(line);
|
|
522
|
-
} catch {
|
|
523
|
-
continue;
|
|
524
|
-
}
|
|
525
|
-
const r = handleMessage(msg, emit, streamedAny);
|
|
526
|
-
if (r.streamed) streamedAny = true;
|
|
527
|
-
if (r.result != null) resultText = r.result;
|
|
528
|
-
}
|
|
529
|
-
} else if (fmt === 'opencode-json') {
|
|
530
|
-
// opencode `run --format json` emits newline-delimited events: text parts,
|
|
531
|
-
// tool/tool_use, and errors. Extract the answer text + surface tools/errors.
|
|
532
|
-
jsonBuf += s;
|
|
533
|
-
let nl;
|
|
534
|
-
while ((nl = jsonBuf.indexOf('\n')) >= 0) {
|
|
535
|
-
const line = jsonBuf.slice(0, nl).trim();
|
|
536
|
-
jsonBuf = jsonBuf.slice(nl + 1);
|
|
537
|
-
if (!line.startsWith('{')) continue;
|
|
538
|
-
let ev;
|
|
539
|
-
try { ev = JSON.parse(line); } catch { continue; }
|
|
540
|
-
if (ev.type === 'text' && ev.part?.text) {
|
|
541
|
-
streamedAny = true;
|
|
542
|
-
emit({ type: 'delta', text: ev.part.text });
|
|
543
|
-
} else if (ev.type === 'tool' || ev.type === 'tool_use') {
|
|
544
|
-
const p = ev.part || {};
|
|
545
|
-
emit({ type: 'tool', name: p.tool || p.name || p.type || 'tool', summary: '' });
|
|
546
|
-
} else if (ev.type === 'error') {
|
|
547
|
-
const msg = ev.error?.data?.message || ev.error?.message || ev.error?.name || 'error';
|
|
548
|
-
emit({ type: 'status', text: String(msg).slice(0, 300) });
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
} else {
|
|
552
|
-
streamedAny = true;
|
|
553
|
-
emit({ type: 'delta', text: stripAnsi(s) });
|
|
554
|
-
}
|
|
554
|
+
parser.push(d.toString());
|
|
555
555
|
});
|
|
556
556
|
child.stderr.on('data', (d) => { armIdle(); stderr += d.toString(); });
|
|
557
557
|
child.on('error', (e) => {
|
|
@@ -566,7 +566,7 @@ export async function runSpec(spec, { messages, system, options = {}, images },
|
|
|
566
566
|
cleanup();
|
|
567
567
|
if (signal?.aborted) { resolve(); return; } // Stop pressed — end quietly
|
|
568
568
|
if (code === 0) {
|
|
569
|
-
emit({ type: 'done', text:
|
|
569
|
+
emit({ type: 'done', text: parser.streamed ? '' : parser.finish() });
|
|
570
570
|
resolve();
|
|
571
571
|
} else {
|
|
572
572
|
reject(new Error(`${label} exited ${code}: ${stderr.trim().split('\n').pop() || 'failed'}`));
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Stream-format plugins — the seam that makes "add a CLI agent" a DATA change.
|
|
2
|
+
//
|
|
3
|
+
// Every headless CLI prints its turn in some shape: plain text, or one of a
|
|
4
|
+
// handful of NDJSON dialects. Previously each dialect was an inline `else if`
|
|
5
|
+
// branch in runSpec(), so a new agent with a new dialect meant editing the
|
|
6
|
+
// runner. That's the reinvention this registry removes: a format is a named
|
|
7
|
+
// plugin here, and an agent spec just names it (`format: 'copilot-json'`).
|
|
8
|
+
//
|
|
9
|
+
// Contract — a format is a factory `(emit) => parser` where parser has:
|
|
10
|
+
// push(chunk: string) feed raw stdout; emit deltas/tools/status as they parse
|
|
11
|
+
// finish(): string the final answer when nothing was streamed (fallback)
|
|
12
|
+
// get streamed(): bool true once any delta was emitted
|
|
13
|
+
//
|
|
14
|
+
// Emitted event types match the bridge's SSE vocabulary: delta | reasoning |
|
|
15
|
+
// tool | status. (`done`/`usage`/`error` stay the runner's job.)
|
|
16
|
+
|
|
17
|
+
import { handleMessage } from './claude.js';
|
|
18
|
+
|
|
19
|
+
// Many CLIs colourize even when piped; strip ANSI from anything we treat as text.
|
|
20
|
+
const ANSI_RE = /\u001b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
21
|
+
export const stripAnsi = (s) => String(s).replace(ANSI_RE, '');
|
|
22
|
+
|
|
23
|
+
// Shared NDJSON pump: buffers partial lines and hands complete JSON objects to
|
|
24
|
+
// `onEvent`. Every JSON dialect below is line-delimited, so they all reuse this.
|
|
25
|
+
function ndjson(onEvent) {
|
|
26
|
+
let buf = '';
|
|
27
|
+
return (chunk) => {
|
|
28
|
+
buf += chunk;
|
|
29
|
+
let nl;
|
|
30
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
31
|
+
const line = buf.slice(0, nl).trim();
|
|
32
|
+
buf = buf.slice(nl + 1);
|
|
33
|
+
if (!line.startsWith('{')) continue;
|
|
34
|
+
let ev;
|
|
35
|
+
try {
|
|
36
|
+
ev = JSON.parse(line);
|
|
37
|
+
} catch {
|
|
38
|
+
continue; // a partial/garbage line is not fatal — keep streaming
|
|
39
|
+
}
|
|
40
|
+
onEvent(ev);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// --- text ------------------------------------------------------------------
|
|
46
|
+
// Anything that just prints a reply.
|
|
47
|
+
function textFormat(emit) {
|
|
48
|
+
let streamed = false;
|
|
49
|
+
return {
|
|
50
|
+
push(chunk) {
|
|
51
|
+
streamed = true;
|
|
52
|
+
emit({ type: 'delta', text: stripAnsi(chunk) });
|
|
53
|
+
},
|
|
54
|
+
finish: () => '',
|
|
55
|
+
get streamed() {
|
|
56
|
+
return streamed;
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// --- claude-stream-json ----------------------------------------------------
|
|
62
|
+
// Claude Code's `--output-format stream-json`, parsed by the Claude engine.
|
|
63
|
+
function claudeStreamJson(emit) {
|
|
64
|
+
let streamed = false;
|
|
65
|
+
let result = '';
|
|
66
|
+
const pump = ndjson((msg) => {
|
|
67
|
+
const r = handleMessage(msg, emit, streamed);
|
|
68
|
+
if (r.streamed) streamed = true;
|
|
69
|
+
if (r.result != null) result = r.result;
|
|
70
|
+
});
|
|
71
|
+
return {
|
|
72
|
+
push: pump,
|
|
73
|
+
finish: () => result,
|
|
74
|
+
get streamed() {
|
|
75
|
+
return streamed;
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// --- opencode-json ---------------------------------------------------------
|
|
81
|
+
// opencode `run --format json`: text parts, tool events, errors.
|
|
82
|
+
function opencodeJson(emit) {
|
|
83
|
+
let streamed = false;
|
|
84
|
+
const pump = ndjson((ev) => {
|
|
85
|
+
if (ev.type === 'text' && ev.part?.text) {
|
|
86
|
+
streamed = true;
|
|
87
|
+
emit({ type: 'delta', text: ev.part.text });
|
|
88
|
+
} else if (ev.type === 'tool' || ev.type === 'tool_use') {
|
|
89
|
+
const p = ev.part || {};
|
|
90
|
+
emit({ type: 'tool', name: p.tool || p.name || p.type || 'tool', summary: '' });
|
|
91
|
+
} else if (ev.type === 'error') {
|
|
92
|
+
const msg = ev.error?.data?.message || ev.error?.message || ev.error?.name || 'error';
|
|
93
|
+
emit({ type: 'status', text: String(msg).slice(0, 300) });
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
return {
|
|
97
|
+
push: pump,
|
|
98
|
+
finish: () => '',
|
|
99
|
+
get streamed() {
|
|
100
|
+
return streamed;
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// --- copilot-json ----------------------------------------------------------
|
|
106
|
+
// GitHub Copilot CLI `--output-format json` (verified against 1.0.80).
|
|
107
|
+
//
|
|
108
|
+
// Event vocabulary (only the ones we surface):
|
|
109
|
+
// assistant.message_delta data.deltaContent -> streamed answer text
|
|
110
|
+
// assistant.message data.content -> whole answer (fallback)
|
|
111
|
+
// assistant.reasoning data.content -> thinking (usually opaque/empty)
|
|
112
|
+
// tool.execution_start data.toolName -> activity strip
|
|
113
|
+
// tool.execution_complete data.success/result -> surface failures
|
|
114
|
+
// session.auto_mode_resolved data.chosenModel -> which model `auto` picked
|
|
115
|
+
// result exitCode/usage -> terminal event
|
|
116
|
+
//
|
|
117
|
+
// Copilot reports a denied tool as a COMPLETED call whose result explains the
|
|
118
|
+
// permission gap, so a user on a low permission mode otherwise sees a confident
|
|
119
|
+
// "I can't do that" with no hint that ChatPanel gated it. Detect that shape and
|
|
120
|
+
// say which setting to raise.
|
|
121
|
+
const PERMISSION_RE = /permission denied|could not request permission|not allowed|denied by|requires approval/i;
|
|
122
|
+
|
|
123
|
+
function copilotJson(emit) {
|
|
124
|
+
let streamed = false;
|
|
125
|
+
let result = '';
|
|
126
|
+
let permissionHinted = false;
|
|
127
|
+
|
|
128
|
+
const hintPermissions = (text) => {
|
|
129
|
+
if (permissionHinted || !PERMISSION_RE.test(String(text || ''))) return;
|
|
130
|
+
permissionHinted = true;
|
|
131
|
+
emit({
|
|
132
|
+
type: 'status',
|
|
133
|
+
text: 'Copilot was denied a tool/URL — raise this agent’s Permission mode in Settings.',
|
|
134
|
+
});
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const pump = ndjson((ev) => {
|
|
138
|
+
const type = ev?.type || '';
|
|
139
|
+
const d = ev?.data || {};
|
|
140
|
+
switch (type) {
|
|
141
|
+
case 'assistant.message_delta':
|
|
142
|
+
if (d.deltaContent) {
|
|
143
|
+
streamed = true;
|
|
144
|
+
emit({ type: 'delta', text: d.deltaContent });
|
|
145
|
+
}
|
|
146
|
+
break;
|
|
147
|
+
case 'assistant.message':
|
|
148
|
+
// Full turn text. Keep as the fallback answer for the non-streaming case
|
|
149
|
+
// (`--stream off`, or a turn that only produced a final message).
|
|
150
|
+
if (typeof d.content === 'string' && d.content) result = d.content;
|
|
151
|
+
break;
|
|
152
|
+
case 'assistant.reasoning':
|
|
153
|
+
if (d.content) emit({ type: 'reasoning', text: String(d.content) });
|
|
154
|
+
break;
|
|
155
|
+
case 'tool.execution_start':
|
|
156
|
+
emit({ type: 'tool', name: d.toolName || 'tool', summary: '' });
|
|
157
|
+
break;
|
|
158
|
+
case 'tool.execution_complete':
|
|
159
|
+
if (d.success === false) {
|
|
160
|
+
const msg = typeof d.result === 'string' ? d.result : d.result?.error || d.result?.message || '';
|
|
161
|
+
if (msg) emit({ type: 'status', text: String(msg).slice(0, 300) });
|
|
162
|
+
hintPermissions(msg);
|
|
163
|
+
} else {
|
|
164
|
+
hintPermissions(typeof d.result === 'string' ? d.result : '');
|
|
165
|
+
}
|
|
166
|
+
break;
|
|
167
|
+
case 'session.auto_mode_resolved':
|
|
168
|
+
if (d.chosenModel) emit({ type: 'status', text: `model: ${d.chosenModel}` });
|
|
169
|
+
break;
|
|
170
|
+
case 'session.mcp_server_status_changed':
|
|
171
|
+
// Only worth surfacing when our own browser-tool server fails to attach.
|
|
172
|
+
if (d.status === 'failed' && d.error) {
|
|
173
|
+
emit({ type: 'status', text: `MCP ${d.serverName}: ${String(d.error).slice(0, 160)}` });
|
|
174
|
+
}
|
|
175
|
+
break;
|
|
176
|
+
case 'result':
|
|
177
|
+
// Terminal event; `exitCode` is authoritative for failure (the process
|
|
178
|
+
// can still exit 0 while a tool failed). Surface a non-zero code.
|
|
179
|
+
if (ev.exitCode) emit({ type: 'status', text: `copilot exited ${ev.exitCode}` });
|
|
180
|
+
break;
|
|
181
|
+
default:
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
push: pump,
|
|
188
|
+
finish: () => result,
|
|
189
|
+
get streamed() {
|
|
190
|
+
return streamed;
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// The registry. Adding a dialect = adding one entry here; agent specs reference
|
|
196
|
+
// it by name, so no runner change is needed.
|
|
197
|
+
export const STREAM_FORMATS = {
|
|
198
|
+
text: textFormat,
|
|
199
|
+
'claude-stream-json': claudeStreamJson,
|
|
200
|
+
'opencode-json': opencodeJson,
|
|
201
|
+
'copilot-json': copilotJson,
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
export function createStreamParser(format, emit) {
|
|
205
|
+
const make = STREAM_FORMATS[format] || STREAM_FORMATS.text;
|
|
206
|
+
return make(emit);
|
|
207
|
+
}
|
package/src/env.js
CHANGED
|
@@ -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
|
-
export const AGENT_CLIS = ['codex', 'claude', 'agy', 'pi', 'opencode', 'kiro-cli'];
|
|
17
|
+
export const AGENT_CLIS = ['codex', 'claude', 'agy', 'pi', 'opencode', 'kiro-cli', 'copilot', 'dsh'];
|
|
18
18
|
|
|
19
19
|
// Is `name` executable somewhere on the current PATH?
|
|
20
20
|
function onPath(name) {
|
package/src/server.js
CHANGED
|
@@ -26,7 +26,7 @@ import { join } from 'node:path';
|
|
|
26
26
|
import * as claude from './engines/claude.js';
|
|
27
27
|
import * as codex from './engines/codex.js';
|
|
28
28
|
import * as antigravity from './engines/antigravity.js';
|
|
29
|
-
import { pi, opencode, kiro } from './engines/cli-agents.js';
|
|
29
|
+
import { pi, opencode, kiro, copilot, deepseek } from './engines/cli-agents.js';
|
|
30
30
|
import * as custom from './engines/custom.js';
|
|
31
31
|
import { installService, uninstallService, serviceStatus, restartService } from './service.js';
|
|
32
32
|
import { AGENT_CLIS, enrichPath, findAgentBin, resolveCommand } from './env.js';
|
|
@@ -38,7 +38,7 @@ import { assertPublicHttpUrl, assertPublicWebUrl } from './ssrf.js';
|
|
|
38
38
|
// Hardcoded (not read from package.json) so it survives Bun's single-file
|
|
39
39
|
// --compile, where package.json isn't on a readable FS. CI fails the publish if
|
|
40
40
|
// this drifts from package.json, so the two can't silently diverge.
|
|
41
|
-
const VERSION = '0.10.
|
|
41
|
+
const VERSION = '0.10.23';
|
|
42
42
|
const HOST = process.env.CHATPANEL_BRIDGE_HOST || '127.0.0.1';
|
|
43
43
|
const PORT = Number(process.env.CHATPANEL_BRIDGE_PORT) || 4319;
|
|
44
44
|
|
|
@@ -49,6 +49,8 @@ const ENGINES = {
|
|
|
49
49
|
pi: { engine: pi, label: 'Pi' },
|
|
50
50
|
opencode: { engine: opencode, label: 'OpenCode' },
|
|
51
51
|
kiro: { engine: kiro, label: 'Kiro' },
|
|
52
|
+
copilot: { engine: copilot, label: 'GitHub Copilot' },
|
|
53
|
+
deepseek: { engine: deepseek, label: 'DeepSeek Harness' },
|
|
52
54
|
// "Bring your own" — one engine drives any user-onboarded CLI (Pro). Hidden
|
|
53
55
|
// from /health (it's not a single installable agent; the extension manages the
|
|
54
56
|
// list and validates commands via /agent-check).
|