@chatpanel/bridge 0.2.16 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/bridge",
3
- "version": "0.2.16",
3
+ "version": "0.3.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": [
@@ -16,7 +16,7 @@
16
16
  import { spawn } from 'node:child_process';
17
17
  import os from 'node:os';
18
18
  import path from 'node:path';
19
- import { resolveClaude, toWslPath, isCompiledBinary } from '../env.js';
19
+ import { resolveClaude, buildSpawnSpec, isCompiledBinary } from '../env.js';
20
20
 
21
21
  const TIMEOUT_MS = Number(process.env.CHATPANEL_CLAUDE_TIMEOUT_MS) || 180_000;
22
22
  // Read-only tools allowed without approval in headless mode; writes/shell are
@@ -65,46 +65,13 @@ function buildPrompt(messages) {
65
65
  return prompt;
66
66
  }
67
67
 
68
- // Turn a launch spec + the claude CLI args into a concrete [bin, argv, options]
69
- // for spawn(). `cwd` is the resolved working dir (Windows path on win32), or null
70
- // to use the home directory.
71
- function buildSpawn(spec, args, cwd) {
72
- if (spec.kind === 'wsl') {
73
- // Run claude inside WSL's login shell so nvm/etc. PATH resolves it. The
74
- // `'exec claude "$@"'` + 'chatpanel' ($0) trick passes our args through as a
75
- // proper argv array — no manual quoting, even for multi-line system prompts.
76
- const pre = [];
77
- if (cwd) {
78
- const wslCwd = toWslPath(cwd);
79
- if (wslCwd) pre.push('--cd', wslCwd); // else: run in WSL home
80
- }
81
- const argv = [...pre, '-e', 'bash', '-lic', 'exec claude "$@"', 'chatpanel', ...args];
82
- return ['wsl.exe', argv, { stdio: ['pipe', 'pipe', 'pipe'], env: process.env, windowsHide: true }];
83
- }
84
-
85
- const spawnCwd = cwd || os.homedir();
86
- const opts = { cwd: spawnCwd, stdio: ['pipe', 'pipe', 'pipe'], env: process.env, windowsHide: true };
87
- if (spec.kind === 'script') {
88
- // Run cli.js with the interpreter already running the bridge (node/bun).
89
- return [process.execPath, [spec.script, ...args], opts];
90
- }
91
- if (spec.kind === 'cmd') {
92
- // Launch the .cmd/.bat shim via cmd.exe with a real argv (shell:false). Node
93
- // applies cmd.exe-aware quoting here, so args are passed safely — unlike
94
- // spawn(..., { shell: true }), which concatenates (DEP0190).
95
- return ['cmd.exe', ['/d', '/s', '/c', spec.bin, ...args], opts];
96
- }
97
- // kind === 'native' — a directly executable file (mac/linux binary or .exe).
98
- return [spec.bin, args, opts];
99
- }
100
-
101
68
  // Spawn claude (however it resolves) and stream its stream-json output via
102
69
  // `emit`. Resolves with { streamedAny, resultText } once it closes 0. Returns
103
70
  // null (no spawn) when claude can't be resolved, so the caller can fall back.
104
71
  function runClaude({ prompt, args, cwd, emit }) {
105
72
  const spec = resolveClaude();
106
73
  if (!spec) return null;
107
- const [bin, argv, opts] = buildSpawn(spec, args, cwd);
74
+ const [bin, argv, opts] = buildSpawnSpec(spec, args, cwd);
108
75
 
109
76
  return new Promise((resolve, reject) => {
110
77
  let child;
@@ -159,8 +126,9 @@ function runClaude({ prompt, args, cwd, emit }) {
159
126
  }
160
127
 
161
128
  // Map one stream-json message to emit() calls. Returns { streamed, result }.
162
- // The CLI's stream-json mirrors the SDK message shapes.
163
- function handleMessage(msg, emit, alreadyStreamed) {
129
+ // The CLI's stream-json mirrors the SDK message shapes. Exported so the custom
130
+ // engine can reuse it for agents that emit Claude-style stream-json.
131
+ export function handleMessage(msg, emit, alreadyStreamed) {
164
132
  const out = { streamed: false, result: null };
165
133
  if (msg.type === 'stream_event') {
166
134
  const ev = msg.event;
@@ -0,0 +1,148 @@
1
+ // Custom ("bring your own") engine — runs ANY CLI the user onboards from the
2
+ // extension's Agents settings (opencode, pi, ollama, a shell script, …) WITHOUT
3
+ // a bridge code change per tool. The command spec travels in the chat request's
4
+ // `options.custom`; this engine resolves the command cross-platform (PATH /
5
+ // Windows cli.js+.cmd / WSL — same launcher as Claude), pipes the prompt in, and
6
+ // streams output back.
7
+ //
8
+ // HARD Pro gate: a custom agent only runs if the request carries a valid,
9
+ // server-signed entitlement token (verified OFFLINE here — no network). A forked
10
+ // client or a raw POST can't forge it, so this is real gating, not UI.
11
+ //
12
+ // Output formats:
13
+ // 'text' (default) — stream stdout straight through as text deltas. Works for
14
+ // any program that prints a reply.
15
+ // 'claude-stream-json' — parse Claude Code-style stream-json (for tools that
16
+ // speak it), reusing the Claude engine's parser.
17
+
18
+ import { spawn } from 'node:child_process';
19
+ import path from 'node:path';
20
+ import { resolveCommand, buildSpawnSpec } from '../env.js';
21
+ import { isProEntitled } from '../entitlement.js';
22
+ import { handleMessage } from './claude.js';
23
+
24
+ const TIMEOUT_MS = Number(process.env.CHATPANEL_CUSTOM_TIMEOUT_MS) || 180_000;
25
+
26
+ export async function available() {
27
+ // The engine ships in every bridge; individual custom agents are user-defined
28
+ // (Pro) and validated per request and via /agent-check.
29
+ return { ok: true };
30
+ }
31
+
32
+ // The bridge is stateless, so replay the conversation as a single prompt.
33
+ function buildPrompt(messages, system) {
34
+ let p = system ? `${system}\n\n` : '';
35
+ const history = messages.slice(0, -1);
36
+ const last = messages[messages.length - 1];
37
+ if (history.length) {
38
+ p += 'Conversation so far:\n';
39
+ for (const m of history) p += `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content}\n\n`;
40
+ p += '---\n\n';
41
+ }
42
+ p += last ? last.content : '';
43
+ return p;
44
+ }
45
+
46
+ export async function chat({ messages, system, options }, emit) {
47
+ // Pro gate — verified, not just UI. No valid signed entitlement → no run.
48
+ if (!(await isProEntitled(options.entitlement))) {
49
+ throw new Error('Custom agents require ChatPanel Pro. Upgrade in Settings to bring your own CLI agent.');
50
+ }
51
+
52
+ const spec = options.custom || {};
53
+ if (!spec.command) throw new Error('This custom agent has no command configured.');
54
+
55
+ const resolved = resolveCommand(spec.command);
56
+ if (!resolved) {
57
+ throw new Error(`Couldn't find "${spec.command}". Enter its full path, or install it on your PATH (or in WSL).`);
58
+ }
59
+
60
+ const prompt = buildPrompt(messages, system);
61
+ const cwd = options.workingDir ? path.resolve(options.workingDir) : null;
62
+ const label = spec.label || spec.command;
63
+ const fmt = spec.format === 'claude-stream-json' ? 'claude-stream-json' : 'text';
64
+
65
+ // Args: either a real array or a space-split string. With promptVia:'arg' we
66
+ // substitute {prompt} (or append it if there's no placeholder); otherwise the
67
+ // prompt goes in on stdin.
68
+ const promptVia = spec.promptVia === 'arg' ? 'arg' : 'stdin';
69
+ let args = Array.isArray(spec.args)
70
+ ? spec.args.slice()
71
+ : spec.args
72
+ ? String(spec.args).split(/\s+/).filter(Boolean)
73
+ : [];
74
+ if (promptVia === 'arg') {
75
+ let placed = false;
76
+ args = args.map((a) => {
77
+ if (a.includes('{prompt}')) {
78
+ placed = true;
79
+ return a.replaceAll('{prompt}', prompt);
80
+ }
81
+ return a;
82
+ });
83
+ if (!placed) args.push(prompt);
84
+ }
85
+
86
+ const [bin, argv, opts] = buildSpawnSpec(resolved, args, cwd);
87
+
88
+ await new Promise((resolve, reject) => {
89
+ let child;
90
+ try {
91
+ child = spawn(bin, argv, opts);
92
+ } catch (e) {
93
+ return reject(new Error(`Failed to start ${label}: ${e.message}`));
94
+ }
95
+
96
+ let stderr = '';
97
+ let streamedAny = false;
98
+ let resultText = '';
99
+ let jsonBuf = '';
100
+
101
+ const timer = setTimeout(() => {
102
+ child.kill('SIGKILL');
103
+ reject(new Error(`${label} timed out after ${Math.round(TIMEOUT_MS / 1000)}s.`));
104
+ }, TIMEOUT_MS);
105
+
106
+ child.stdout.on('data', (d) => {
107
+ const s = d.toString();
108
+ if (fmt === 'claude-stream-json') {
109
+ jsonBuf += s;
110
+ let nl;
111
+ while ((nl = jsonBuf.indexOf('\n')) >= 0) {
112
+ const line = jsonBuf.slice(0, nl).trim();
113
+ jsonBuf = jsonBuf.slice(nl + 1);
114
+ if (!line.startsWith('{')) continue;
115
+ let msg;
116
+ try {
117
+ msg = JSON.parse(line);
118
+ } catch {
119
+ continue;
120
+ }
121
+ const r = handleMessage(msg, emit, streamedAny);
122
+ if (r.streamed) streamedAny = true;
123
+ if (r.result != null) resultText = r.result;
124
+ }
125
+ } else {
126
+ streamedAny = true;
127
+ emit({ type: 'delta', text: s });
128
+ }
129
+ });
130
+ child.stderr.on('data', (d) => (stderr += d.toString()));
131
+ child.on('error', (e) => {
132
+ clearTimeout(timer);
133
+ reject(new Error(`Failed to start ${label}: ${e.message}`));
134
+ });
135
+ child.on('close', (code) => {
136
+ clearTimeout(timer);
137
+ if (code === 0) {
138
+ emit({ type: 'done', text: streamedAny ? '' : resultText });
139
+ resolve();
140
+ } else {
141
+ reject(new Error(`${label} exited ${code}: ${stderr.trim().split('\n').pop() || 'failed'}`));
142
+ }
143
+ });
144
+
145
+ if (promptVia === 'stdin') child.stdin.write(prompt);
146
+ child.stdin.end();
147
+ });
148
+ }
@@ -0,0 +1,81 @@
1
+ // Offline Pro/Team entitlement verification — the HARD gate for paid features
2
+ // (e.g. custom "bring your own CLI" agents).
3
+ //
4
+ // The license server (Cloudflare Worker) signs a compact entitlement token with
5
+ // an ECDSA P-256 private key that lives ONLY there. The bridge ships the matching
6
+ // PUBLIC key and verifies the signature locally — no network, no secret. A forked
7
+ // client or a raw `curl` to the bridge can't forge entitlement without the
8
+ // private key, so this is a real cryptographic gate, not a UI check.
9
+ //
10
+ // Token format (identical to the extension's, extension/js/license.js):
11
+ // token = base64url(JSON payload) + "." + base64url(raw ECDSA signature)
12
+ // signed over UTF-8(head); payload = { typ:'ent', plan, install_id, sub, exp }
13
+ //
14
+ // Keep ENTITLEMENT_PUBLIC_JWK in sync with the extension's copy.
15
+
16
+ import { webcrypto } from 'node:crypto';
17
+
18
+ const ENTITLEMENT_PUBLIC_JWK = {
19
+ kty: 'EC',
20
+ crv: 'P-256',
21
+ x: 'CmgKLC4e3xDMvwhbjVqF7jbDe1JhC1KKQi8JN3qVX_4',
22
+ y: 'r40l6fQiyCcJYqW-SvB4VoSyn4F36yhSt82ZAOSo78E',
23
+ };
24
+
25
+ const PRO_PLANS = new Set(['pro', 'team']);
26
+
27
+ const b64urlToBytes = (s) => {
28
+ const norm = s.replace(/-/g, '+').replace(/_/g, '/');
29
+ return new Uint8Array(Buffer.from(norm, 'base64'));
30
+ };
31
+
32
+ let keyPromise = null;
33
+ function publicKey() {
34
+ if (!keyPromise) {
35
+ keyPromise = webcrypto.subtle.importKey(
36
+ 'jwk',
37
+ ENTITLEMENT_PUBLIC_JWK,
38
+ { name: 'ECDSA', namedCurve: 'P-256' },
39
+ false,
40
+ ['verify'],
41
+ );
42
+ }
43
+ return keyPromise;
44
+ }
45
+
46
+ // Verify a server entitlement token. Returns its payload, or null. Checks the
47
+ // ECDSA signature (unforgeable without the private key), the token type, and
48
+ // expiry. install_id binding is the extension's concern — for the bridge gate the
49
+ // signature is what matters.
50
+ export async function verifyEntitlement(token) {
51
+ if (!token || typeof token !== 'string' || token.indexOf('.') < 0) return null;
52
+ const [head, sig] = token.split('.');
53
+ const enc = new TextEncoder();
54
+ let ok = false;
55
+ try {
56
+ ok = await webcrypto.subtle.verify(
57
+ { name: 'ECDSA', hash: 'SHA-256' },
58
+ await publicKey(),
59
+ b64urlToBytes(sig),
60
+ enc.encode(head),
61
+ );
62
+ } catch {
63
+ return null;
64
+ }
65
+ if (!ok) return null;
66
+ let payload;
67
+ try {
68
+ payload = JSON.parse(new TextDecoder().decode(b64urlToBytes(head)));
69
+ } catch {
70
+ return null;
71
+ }
72
+ if (payload.typ !== 'ent') return null;
73
+ if (payload.exp && Date.now() > payload.exp) return null;
74
+ return payload;
75
+ }
76
+
77
+ // True when `token` is a valid, unexpired Pro (or Team) entitlement.
78
+ export async function isProEntitled(token) {
79
+ const p = await verifyEntitlement(token);
80
+ return !!(p && PRO_PLANS.has(p.plan));
81
+ }
package/src/env.js CHANGED
@@ -91,52 +91,74 @@ function shellWhich(name) {
91
91
  // than escapes (Node DEP0190 / a real injection surface). The 'script' and 'cmd'
92
92
  // kinds run the shim safely with a proper argv instead.
93
93
  export function resolveClaude() {
94
- const override = process.env.CHATPANEL_CLAUDE_PATH;
95
- if (override) {
96
- const ext = path.extname(override).toLowerCase();
97
- if (!isCompiledBinary() && /^\.(c?js|mjs)$/.test(ext)) return { kind: 'script', script: override };
98
- if (process.platform === 'win32' && (ext === '.cmd' || ext === '.bat')) return { kind: 'cmd', bin: override };
99
- return { kind: 'native', bin: override };
94
+ if (process.env.CHATPANEL_CLAUDE_PATH) return resolveCommand(process.env.CHATPANEL_CLAUDE_PATH);
95
+ // The Claude npm package additionally ships a cli.js we prefer; otherwise this
96
+ // is the same generic resolution every command uses.
97
+ return resolveCommand('claude');
98
+ }
99
+
100
+ // Resolve ANY command (a bare name like `opencode`, or an absolute/relative path)
101
+ // to a launch spec, the same way resolveClaude does — so custom user-onboarded
102
+ // agents get identical cross-platform launching (PATH, Windows cli.js/.cmd, WSL).
103
+ //
104
+ // Returns one of:
105
+ // { kind: 'native', bin } → spawn(bin, args)
106
+ // { kind: 'script', script } → spawn(process.execPath, [script, ...args])
107
+ // { kind: 'cmd', bin } → spawn('cmd.exe', ['/c', bin, ...args])
108
+ // { kind: 'wsl', command } → spawn('wsl.exe', [prefix, command, ...args])
109
+ // null → not found
110
+ //
111
+ // We never use spawn's `shell: true` — with an args array it concatenates rather
112
+ // than escapes (Node DEP0190 / a real injection surface). The 'script'/'cmd'/'wsl'
113
+ // kinds run things safely with a proper argv instead.
114
+ export function resolveCommand(command) {
115
+ if (!command) return null;
116
+ const looksLikePath = command.includes('/') || command.includes('\\') || /\.[a-z0-9]+$/i.test(command);
117
+
118
+ if (looksLikePath) {
119
+ if (!existsSync(command)) return null; // an explicit path: don't PATH-search
120
+ const ext = path.extname(command).toLowerCase();
121
+ if (!isCompiledBinary() && /^\.(c?js|mjs)$/.test(ext)) return { kind: 'script', script: command };
122
+ if (process.platform === 'win32' && (ext === '.cmd' || ext === '.bat')) return { kind: 'cmd', bin: command };
123
+ return { kind: 'native', bin: command };
100
124
  }
101
125
 
102
126
  if (process.platform === 'win32') {
103
- const win = findClaudeWindows();
127
+ const win = findCommandWindows(command);
104
128
  if (win) return win;
105
- if (claudeInWsl()) return { kind: 'wsl' };
129
+ // Common: tool only installed inside WSL. Only probe safe, simple names.
130
+ if (/^[\w.-]+$/.test(command) && commandInWsl(command)) return { kind: 'wsl', command };
106
131
  return null;
107
132
  }
108
133
 
109
- // macOS / Linux / WSL-native: same resolution the engine used before.
110
- const bin = findAgentBin('claude');
134
+ const bin = findAgentBin(command);
111
135
  return bin ? { kind: 'native', bin } : null;
112
136
  }
113
137
 
114
- // Locate a runnable Claude Code on Windows. Prefer the package's cli.js (run
115
- // with our own Node/Bun — clean arg passing, no cmd.exe quoting), then a real
116
- // .exe, then a .cmd/.bat shim launched safely via cmd.exe.
117
- function findClaudeWindows() {
138
+ // Locate a runnable command on Windows. Prefer a runnable JS entry (run with our
139
+ // own Node/Bun — clean arg passing, no cmd.exe quoting), then a real .exe, then a
140
+ // .cmd/.bat shim launched safely via cmd.exe.
141
+ function findCommandWindows(name) {
118
142
  const dirs = (process.env.PATH || '').split(path.delimiter);
119
143
  for (const d of dirs) {
120
144
  if (!d) continue;
121
- const hasShim = ['claude', 'claude.cmd', 'claude.exe', 'claude.ps1', 'claude.bat'].some((n) =>
122
- existsSync(path.join(d, n)),
123
- );
124
- if (!hasShim) continue;
145
+ const exts = ['', '.cmd', '.exe', '.ps1', '.bat'];
146
+ if (!exts.some((e) => existsSync(path.join(d, name + e)))) continue;
125
147
  // Running cli.js with our own interpreter only works under a real Node/Bun,
126
148
  // not inside a compiled single-file binary (which is not a JS interpreter).
127
149
  if (!isCompiledBinary()) {
128
- const js = claudeCliJs(d) || shimTarget(d);
150
+ const js = (name === 'claude' && claudeCliJs(d)) || shimTarget(d, name);
129
151
  if (js) return { kind: 'script', script: js };
130
152
  }
131
- if (existsSync(path.join(d, 'claude.exe'))) return { kind: 'native', bin: path.join(d, 'claude.exe') };
132
- if (existsSync(path.join(d, 'claude.cmd'))) return { kind: 'cmd', bin: path.join(d, 'claude.cmd') };
133
- if (existsSync(path.join(d, 'claude.bat'))) return { kind: 'cmd', bin: path.join(d, 'claude.bat') };
153
+ if (existsSync(path.join(d, name + '.exe'))) return { kind: 'native', bin: path.join(d, name + '.exe') };
154
+ if (existsSync(path.join(d, name + '.cmd'))) return { kind: 'cmd', bin: path.join(d, name + '.cmd') };
155
+ if (existsSync(path.join(d, name + '.bat'))) return { kind: 'cmd', bin: path.join(d, name + '.bat') };
134
156
  }
135
157
  return null;
136
158
  }
137
159
 
138
- // The npm shim usually sits next to (or one level up from) the claude-code
139
- // package — quick static guesses before parsing the shim itself.
160
+ // The Claude npm package additionally ships a cli.js we prefer (static guesses
161
+ // before parsing the shim).
140
162
  function claudeCliJs(dir) {
141
163
  const rels = [
142
164
  ['node_modules', '@anthropic-ai', 'claude-code', 'cli.js'],
@@ -150,12 +172,12 @@ function claudeCliJs(dir) {
150
172
  return null;
151
173
  }
152
174
 
153
- // Robust fallback: every npm/pnpm/yarn/volta shim literally names the JS entry it
154
- // runs, relative to the shim dir (`%dp0%\…\cli.js` in .cmd, `$basedir/…/cli.js`
155
- // in the sh/.ps1 shims). Extract that so any install layout resolves to a real
156
- // cli.js we can run with our own interpreter.
157
- function shimTarget(dir) {
158
- for (const shim of ['claude.cmd', 'claude', 'claude.ps1']) {
175
+ // Robust, general fallback: every npm/pnpm/yarn/volta shim literally names the JS
176
+ // entry it runs, relative to the shim dir (`%dp0%\…\cli.js` in .cmd,
177
+ // `$basedir/…/cli.js` in the sh/.ps1 shims). Extract that so any install layout
178
+ // resolves to a real JS entry we can run with our own interpreter.
179
+ function shimTarget(dir, name) {
180
+ for (const shim of [`${name}.cmd`, name, `${name}.ps1`]) {
159
181
  let txt;
160
182
  try {
161
183
  txt = readFileSync(path.join(dir, shim), 'utf8');
@@ -171,25 +193,46 @@ function shimTarget(dir) {
171
193
  return null;
172
194
  }
173
195
 
174
- // Is `claude` reachable inside the default WSL distro's login shell? Cached;
175
- // re-probed (throttled) while not found so it self-heals once WSL/claude appear.
176
- let wslClaude = null;
177
- let wslProbe = 0;
178
- function claudeInWsl() {
179
- if (wslClaude === null || (!wslClaude && Date.now() - wslProbe > 4000)) {
180
- wslProbe = Date.now();
181
- try {
182
- const r = spawnSync('wsl.exe', ['-e', 'bash', '-lic', 'command -v claude'], {
183
- encoding: 'utf8',
184
- timeout: 8000,
185
- windowsHide: true,
186
- });
187
- wslClaude = r.status === 0 && /\S/.test(stripBom(r.stdout || ''));
188
- } catch {
189
- wslClaude = false;
196
+ // Is `name` reachable inside the default WSL distro's login shell? Cached per
197
+ // name; re-probed (throttled) while not found so it self-heals once it appears.
198
+ const wslSeen = new Map(); // name -> { ok, at }
199
+ function commandInWsl(name) {
200
+ const c = wslSeen.get(name);
201
+ if (c && (c.ok || Date.now() - c.at < 4000)) return c.ok;
202
+ let ok = false;
203
+ try {
204
+ const r = spawnSync('wsl.exe', ['-e', 'bash', '-lic', `command -v ${name}`], {
205
+ encoding: 'utf8',
206
+ timeout: 8000,
207
+ windowsHide: true,
208
+ });
209
+ ok = r.status === 0 && /\S/.test(stripBom(r.stdout || ''));
210
+ } catch {
211
+ ok = false;
212
+ }
213
+ wslSeen.set(name, { ok, at: Date.now() });
214
+ return ok;
215
+ }
216
+
217
+ // Turn a launch spec + CLI args into a concrete [bin, argv, opts] for spawn().
218
+ // `cwd` is the resolved working dir (a Windows path on win32), or null for home.
219
+ export function buildSpawnSpec(spec, args, cwd) {
220
+ if (spec.kind === 'wsl') {
221
+ // Run inside WSL's login shell so nvm/etc. PATH resolves the tool. The
222
+ // `exec <cmd> "$@"` + 'chatpanel' ($0) trick passes our args through as a
223
+ // proper argv array — no manual quoting, even for multi-line prompts.
224
+ const pre = [];
225
+ if (cwd) {
226
+ const wslCwd = toWslPath(cwd);
227
+ if (wslCwd) pre.push('--cd', wslCwd); // else: run in WSL home
190
228
  }
229
+ const argv = [...pre, '-e', 'bash', '-lic', `exec ${spec.command} "$@"`, 'chatpanel', ...args];
230
+ return ['wsl.exe', argv, { stdio: ['pipe', 'pipe', 'pipe'], env: process.env, windowsHide: true }];
191
231
  }
192
- return wslClaude;
232
+ const opts = { cwd: cwd || os.homedir(), stdio: ['pipe', 'pipe', 'pipe'], env: process.env, windowsHide: true };
233
+ if (spec.kind === 'script') return [process.execPath, [spec.script, ...args], opts];
234
+ if (spec.kind === 'cmd') return ['cmd.exe', ['/d', '/s', '/c', spec.bin, ...args], opts];
235
+ return [spec.bin, args, opts]; // native
193
236
  }
194
237
 
195
238
  // Translate a Windows path to its WSL (/mnt/c/…) equivalent. Returns null on
package/src/server.js CHANGED
@@ -19,14 +19,15 @@ import os from 'node:os';
19
19
  import * as claude from './engines/claude.js';
20
20
  import * as codex from './engines/codex.js';
21
21
  import * as gemini from './engines/gemini.js';
22
+ import * as custom from './engines/custom.js';
22
23
  import { installService, uninstallService, serviceStatus, restartService } from './service.js';
23
- import { enrichPath, findAgentBin } from './env.js';
24
+ import { enrichPath, findAgentBin, resolveCommand } from './env.js';
24
25
  import { checkForUpdate, selfUpdate } from './update.js';
25
26
 
26
27
  // Hardcoded (not read from package.json) so it survives Bun's single-file
27
28
  // --compile, where package.json isn't on a readable FS. CI fails the publish if
28
29
  // this drifts from package.json, so the two can't silently diverge.
29
- const VERSION = '0.2.16';
30
+ const VERSION = '0.3.0';
30
31
  const HOST = process.env.CHATPANEL_BRIDGE_HOST || '127.0.0.1';
31
32
  const PORT = Number(process.env.CHATPANEL_BRIDGE_PORT) || 4319;
32
33
 
@@ -34,6 +35,10 @@ const ENGINES = {
34
35
  claude: { engine: claude, label: 'Claude Code' },
35
36
  codex: { engine: codex, label: 'Codex' },
36
37
  gemini: { engine: gemini, label: 'Gemini CLI' },
38
+ // "Bring your own" — one engine drives any user-onboarded CLI (Pro). Hidden
39
+ // from /health (it's not a single installable agent; the extension manages the
40
+ // list and validates commands via /agent-check).
41
+ custom: { engine: custom, label: 'Custom', hidden: true },
37
42
  };
38
43
 
39
44
  // --------------------------------------------------------------------------
@@ -80,10 +85,12 @@ function readBody(req) {
80
85
  // --------------------------------------------------------------------------
81
86
  async function handleHealth(res) {
82
87
  const agents = await Promise.all(
83
- Object.entries(ENGINES).map(async ([id, { engine, label }]) => {
84
- const a = await engine.available().catch((e) => ({ ok: false, reason: String(e?.message || e) }));
85
- return { id, label, available: a.ok, reason: a.reason };
86
- }),
88
+ Object.entries(ENGINES)
89
+ .filter(([, e]) => !e.hidden)
90
+ .map(async ([id, { engine, label }]) => {
91
+ const a = await engine.available().catch((e) => ({ ok: false, reason: String(e?.message || e) }));
92
+ return { id, label, available: a.ok, reason: a.reason };
93
+ }),
87
94
  );
88
95
  const update = await checkForUpdate(VERSION).catch(() => ({ current: VERSION, updateAvailable: false }));
89
96
  json(res, 200, { ok: true, version: VERSION, agents, update });
@@ -184,6 +191,28 @@ async function handleComplete(req, res) {
184
191
  }
185
192
  }
186
193
 
194
+ // POST /agent-check → { command } → { ok, via } — does this command resolve on
195
+ // this machine? Powers the "✓ found" indicator when onboarding a custom agent.
196
+ // `via` tells the user HOW it resolved (native / script / cmd / wsl) so a Windows
197
+ // user sees e.g. "found in WSL". No execution, no entitlement needed (read-only).
198
+ async function handleAgentCheck(req, res) {
199
+ let body;
200
+ try {
201
+ body = await readBody(req);
202
+ } catch (e) {
203
+ return json(res, 400, { error: 'Bad JSON: ' + e.message });
204
+ }
205
+ const command = String(body.command || '').trim();
206
+ if (!command) return json(res, 400, { error: 'No command' });
207
+ let spec = null;
208
+ try {
209
+ spec = resolveCommand(command);
210
+ } catch {
211
+ spec = null;
212
+ }
213
+ return json(res, 200, { ok: !!spec, via: spec ? spec.kind : null });
214
+ }
215
+
187
216
  const server = createServer(async (req, res) => {
188
217
  cors(req, res);
189
218
  if (req.method === 'OPTIONS') {
@@ -204,6 +233,7 @@ const server = createServer(async (req, res) => {
204
233
  }
205
234
  if (req.method === 'POST' && url.pathname === '/chat') return handleChat(req, res);
206
235
  if (req.method === 'POST' && url.pathname === '/complete') return handleComplete(req, res);
236
+ if (req.method === 'POST' && url.pathname === '/agent-check') return handleAgentCheck(req, res);
207
237
  if (req.method === 'POST' && url.pathname === '/update') return handleUpdate(res);
208
238
  json(res, 404, { error: 'Not found' });
209
239
  } catch (e) {
@@ -220,7 +250,8 @@ function startServer() {
220
250
  enrichPath(); // so codex/gemini are found even under a minimal service PATH
221
251
  server.listen(PORT, HOST, async () => {
222
252
  log('info', `listening on http://${HOST}:${PORT}`);
223
- for (const [, { engine, label }] of Object.entries(ENGINES)) {
253
+ for (const [, { engine, label, hidden }] of Object.entries(ENGINES)) {
254
+ if (hidden) continue;
224
255
  const a = await engine.available().catch(() => ({ ok: false }));
225
256
  log('info', ` ${a.ok ? '✓' : '✕'} ${label}${a.ok ? '' : ' — ' + (a.reason || 'unavailable')}`);
226
257
  }