@chatpanel/bridge 0.10.22 → 0.10.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@chatpanel/bridge",
3
- "version": "0.10.22",
3
+ "version": "0.10.24",
4
4
  "type": "module",
5
- "description": "Local bridge that exposes the AI coding agents installed on your machine Claude Code (CLI), Codex (CLI), and Antigravity CLI (formerly Gemini CLI, which remains available for business/enterprise) to the ChatPanel Chrome extension over a localhost SSE endpoint. Bring your own agent.",
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",
@@ -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
- function makeCliAgent(command, spec, notFoundHint) {
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
+ }
@@ -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
- // Many CLIs emit ANSI colour/escape codes even when piped (kiro-cli does), which
64
- // leak into the answer as `\x1b[38;5;141m…`. Strip them from text output. (We
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, listArgs.split(/\s+/).filter(Boolean), cwd);
121
+ const [bin, argv, opts] = buildSpawnSpec(resolved, argvIn, cwd);
116
122
  opts.env = { ...(opts.env || process.env), NO_COLOR: '1', CLICOLOR: '0' };
117
- const stdout = await new Promise((resolve, reject) => {
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('Listing models timed out.')); }, 20000);
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
- const fmt = ['claude-stream-json', 'opencode-json'].includes(spec.format) ? spec.format : 'text';
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
- pushExtraArgs(args, options.extraArgs, FORBIDDEN.custom, emit);
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
- let streamedAny = false;
495
- let resultText = '';
496
- let jsonBuf = '';
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
- const s = d.toString();
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: streamedAny ? '' : resultText });
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
@@ -11,10 +11,11 @@ import { spawnSync } from 'node:child_process';
11
11
  import { readdirSync, readFileSync, existsSync } from 'node:fs';
12
12
 
13
13
  let enriched = false;
14
+ let envEnriched = false;
14
15
 
15
16
  // The agent CLIs the bridge shells out to. Claude has its own richer resolution
16
17
  // (resolveClaude: native / cli.js / WSL / SDK) below.
17
- export const AGENT_CLIS = ['codex', 'claude', 'agy', 'pi', 'opencode', 'kiro-cli'];
18
+ export const AGENT_CLIS = ['codex', 'claude', 'agy', 'pi', 'opencode', 'kiro-cli', 'copilot', 'dsh'];
18
19
 
19
20
  // Is `name` executable somewhere on the current PATH?
20
21
  function onPath(name) {
@@ -421,6 +422,64 @@ export function commandCandidateFiles(name, home = os.homedir(), platform = proc
421
422
  return unique([...fromDirs, ...agentCandidateBins(name, home, platform, env)]);
422
423
  }
423
424
 
425
+ // Agent credentials a LaunchAgent/systemd unit does NOT inherit.
426
+ //
427
+ // enrichPath() repairs the minimal PATH a service gets; this is the same problem
428
+ // one layer up. A CLI that authenticates from a FILE (claude, codex, copilot:
429
+ // ~/.copilot) works fine under the service, but one that reads an API key from
430
+ // the ENVIRONMENT (dsh -> DEEPSEEK_API_KEY) fails with a missing-credential
431
+ // error that looks like a ChatPanel bug — the launchd job's environment is just
432
+ // SSH_AUTH_SOCK and a bare PATH.
433
+ //
434
+ // So: ask the user's login shell for a NARROW allowlist of agent credential
435
+ // variables and fill in only the ones we don't already have. These are the
436
+ // user's own credentials, on their own machine, handed to CLIs the user
437
+ // configured ChatPanel to launch — exactly what would happen had they run the
438
+ // CLI from their terminal. Values are never logged, and /debug never dumps the
439
+ // environment (it exposes only PATH/home, and only under CHATPANEL_BRIDGE_DEBUG).
440
+ const AGENT_ENV_KEYS = [
441
+ 'DEEPSEEK_API_KEY', 'DSH_HOME',
442
+ 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL',
443
+ 'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_API_BASE',
444
+ 'GEMINI_API_KEY', 'GOOGLE_API_KEY',
445
+ 'OPENROUTER_API_KEY', 'GROQ_API_KEY', 'XAI_API_KEY', 'MISTRAL_API_KEY',
446
+ ];
447
+
448
+ export function enrichAgentEnv() {
449
+ if (envEnriched) return;
450
+ envEnriched = true;
451
+ if (process.platform === 'win32') return; // service env is inherited there
452
+
453
+ const missing = AGENT_ENV_KEYS.filter((k) => !process.env[k]);
454
+ if (!missing.length) return;
455
+ try {
456
+ const shell = process.env.SHELL || '/bin/zsh';
457
+ // Emit NAME\tVALUE for each var that is actually set; trailing `true` keeps
458
+ // the exit status clean when the last test fails.
459
+ const script = `${missing
460
+ .map((k) => `[ -n "\${${k}:-}" ] && printf '%s\\t%s\\n' ${k} "\$${k}"`)
461
+ .join('; ')}; true`;
462
+ // -lic, not -lc: these keys are typically exported from ~/.zshrc (or ~/.bashrc),
463
+ // which a LOGIN-only shell does NOT source — that's interactive-only. TERM=dumb
464
+ // keeps prompt/banner noise down, and the parser below ignores any line that
465
+ // isn't a clean NAME<TAB>VALUE, so rc-file chatter is harmless.
466
+ const r = spawnSync(shell, ['-lic', script], {
467
+ encoding: 'utf8',
468
+ timeout: 6000,
469
+ env: { ...process.env, TERM: 'dumb' },
470
+ });
471
+ for (const line of String(r.stdout || '').split('\n')) {
472
+ const i = line.indexOf('\t');
473
+ if (i <= 0) continue;
474
+ const key = line.slice(0, i).trim();
475
+ const value = line.slice(i + 1);
476
+ if (AGENT_ENV_KEYS.includes(key) && !process.env[key] && value) process.env[key] = value;
477
+ }
478
+ } catch {
479
+ /* no login shell / timeout — agents that need a file-based login still work */
480
+ }
481
+ }
482
+
424
483
  export function enrichPath() {
425
484
  if (enriched) return;
426
485
  enriched = true;
package/src/server.js CHANGED
@@ -26,10 +26,10 @@ 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
- import { AGENT_CLIS, enrichPath, findAgentBin, resolveCommand } from './env.js';
32
+ import { AGENT_CLIS, enrichPath, enrichAgentEnv, findAgentBin, resolveCommand } from './env.js';
33
33
  import { stripHidden } from './sanitize.js';
34
34
  import { checkForUpdate, selfUpdate } from './update.js';
35
35
  import { callLocalMcp } from './mcp-local.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.22';
41
+ const VERSION = '0.10.24';
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).
@@ -826,6 +828,7 @@ function runMcpStdioProxy(url) {
826
828
 
827
829
  function startServer() {
828
830
  enrichPath(); // so codex/agy (Antigravity) are found even under a minimal service PATH
831
+ enrichAgentEnv(); // and so env-authenticated CLIs (dsh) have their key under launchd
829
832
  ensureToken(); // per-install bearer token for privileged routes (defense-in-depth)
830
833
  // Fail LOUD on a port clash. The bridge binds a FIXED 4319 so the extension always
831
834
  // finds it; if it's taken, say how to recover instead of dying on a raw stack trace.