@chatpanel/bridge 0.10.23 → 0.10.26

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.10.23",
3
+ "version": "0.10.26",
4
4
  "type": "module",
5
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": [
@@ -0,0 +1,100 @@
1
+ // What an agent can already reach, read from its own configuration.
2
+ //
3
+ // A CLI agent brings connectors ChatPanel cannot see: a Slack MCP, a Jira MCP, a filesystem.
4
+ // ChatPanel relays its own tools to that agent and says a great deal about them — all of it
5
+ // restrictive, because each line was written to stop one substitution — so an agent asked
6
+ // about an internal thread read the page, saw the reference, and told the user to go and look
7
+ // it up, while holding a connector that reaches it.
8
+ //
9
+ // Knowing the names turns a guess into a fact. It lets the harness say "you have slack
10
+ // connected — use it" instead of listing connectors the agent may not have, and lets routing
11
+ // treat "can reach Slack" as a capability rather than a hope.
12
+ //
13
+ // NAMES ONLY, AND NEVER THE CREDENTIALS. A server's name is what the agent already knows and
14
+ // what a prompt needs; its URL, argv and env are what a leak would be made of. This reads
15
+ // config files that belong to the user's own agents and returns a list of strings.
16
+ //
17
+ // CONFIGURED, NOT PROVEN. A server listed here may still fail to start. That is honest and
18
+ // useful — it is exactly what the agent itself will try — and probing for real would cost a
19
+ // process spawn per agent on every health poll.
20
+
21
+ import { readFile } from 'node:fs/promises';
22
+ import os from 'node:os';
23
+ import path from 'node:path';
24
+
25
+ /** Read a file and parse it as JSON, or null. Missing and malformed are the same answer. */
26
+ async function readJson(file) {
27
+ try { return JSON.parse(await readFile(file, 'utf8')); } catch { return null; }
28
+ }
29
+
30
+ /** Every key of a `{ mcpServers: { name: {...} } }` block, wherever it appears. */
31
+ function fromMcpServers(obj) {
32
+ const m = obj && typeof obj === 'object' ? obj.mcpServers || obj.mcp_servers : null;
33
+ return m && typeof m === 'object' ? Object.keys(m) : [];
34
+ }
35
+
36
+ /**
37
+ * `[mcp_servers.NAME]` table headers out of a TOML file.
38
+ *
39
+ * Deliberately a regex rather than a TOML parser: the bridge is zero-runtime-dependency by
40
+ * design, and the only thing wanted here is the set of table names. Anything this misses
41
+ * simply is not reported, which is the safe direction — a missing name costs a sentence in a
42
+ * prompt, an invented one sends an agent looking for a connector it does not have.
43
+ */
44
+ function fromToml(text) {
45
+ const out = [];
46
+ const re = /^\s*\[\s*mcp_servers\s*\.\s*([A-Za-z0-9._-]+)\s*\]/gm;
47
+ for (const m of String(text || '').matchAll(re)) out.push(m[1]);
48
+ return out;
49
+ }
50
+
51
+ async function readToml(file) {
52
+ try { return fromToml(await readFile(file, 'utf8')); } catch { return []; }
53
+ }
54
+
55
+ const home = () => os.homedir();
56
+ const configHome = () => process.env.XDG_CONFIG_HOME || path.join(home(), '.config');
57
+
58
+ // Where each agent keeps the list. One entry per agent id in the /health registry; an agent
59
+ // with no entry simply reports nothing, which is what an unknown agent should do.
60
+ const SOURCES = {
61
+ claude: async () => {
62
+ const [main, project] = await Promise.all([
63
+ readJson(path.join(home(), '.claude.json')),
64
+ readJson(path.join(process.cwd(), '.mcp.json')),
65
+ ]);
66
+ return [...fromMcpServers(main), ...fromMcpServers(project)];
67
+ },
68
+ codex: async () => readToml(path.join(process.env.CODEX_HOME || path.join(home(), '.codex'), 'config.toml')),
69
+ opencode: async () => {
70
+ for (const f of [
71
+ path.join(configHome(), 'opencode', 'opencode.json'),
72
+ path.join(home(), 'Library', 'Application Support', 'opencode', 'opencode.jsonc'),
73
+ ]) {
74
+ const j = await readJson(f);
75
+ const names = [...fromMcpServers(j), ...(j && j.mcp && typeof j.mcp === 'object' ? Object.keys(j.mcp) : [])];
76
+ if (names.length) return names;
77
+ }
78
+ return [];
79
+ },
80
+ };
81
+
82
+ /**
83
+ * The connector names an agent is configured with — deduped, sorted, and bounded.
84
+ *
85
+ * Bounded because this rides in a health response and then into a system prompt: a user with
86
+ * forty servers should cost a line, not a paragraph. Sorted so the value is stable across
87
+ * polls and a settings page does not reorder itself.
88
+ */
89
+ export async function connectorsFor(agentId, { max = 24 } = {}) {
90
+ const read = SOURCES[agentId];
91
+ if (!read) return [];
92
+ try {
93
+ const names = await read();
94
+ return [...new Set(names.filter((n) => typeof n === 'string' && n && n.length <= 64))].sort().slice(0, max);
95
+ } catch {
96
+ // A config we cannot read is a config we say nothing about. Never a reason to fail a
97
+ // health check the extension needs in order to show the agent at all.
98
+ return [];
99
+ }
100
+ }
@@ -16,7 +16,7 @@ import os from 'node:os';
16
16
  import path from 'node:path';
17
17
  import { findAgentBin } from '../env.js';
18
18
  import { buildCliPrompt } from './prompt.js';
19
- import { killOnAbort } from '../proc.js';
19
+ import { killOnAbort, spawnGroupOpts } from '../proc.js';
20
20
  import { pushExtraArgs, FORBIDDEN } from './args.js';
21
21
 
22
22
  const IDLE_MS = Number(process.env.CHATPANEL_AGY_TIMEOUT_MS) || 180_000;
@@ -110,7 +110,7 @@ export async function chat({ messages, system, options, images }, emit, { signal
110
110
  await new Promise((resolve, reject) => {
111
111
  let child;
112
112
  try {
113
- child = spawn('agy', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env } });
113
+ child = spawn('agy', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env }, ...spawnGroupOpts });
114
114
  } catch (e) {
115
115
  cleanup();
116
116
  return reject(new Error(`Failed to start agy: ${e.message}`));
@@ -15,7 +15,7 @@
15
15
  // the agent to point it at a real project.
16
16
 
17
17
  import { spawn, spawnSync } from 'node:child_process';
18
- import { killOnAbort } from '../proc.js';
18
+ import { killOnAbort, spawnGroupOpts } from '../proc.js';
19
19
  import { readFile, unlink, writeFile } from 'node:fs/promises';
20
20
  import { existsSync, mkdirSync, symlinkSync, readFileSync } from 'node:fs';
21
21
  import os from 'node:os';
@@ -180,7 +180,7 @@ export async function chat({ messages, system, options, images }, emit, { signal
180
180
  await new Promise((resolve, reject) => {
181
181
  let child;
182
182
  try {
183
- child = spawn('codex', args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], env });
183
+ child = spawn('codex', args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], env, ...spawnGroupOpts });
184
184
  } catch (e) {
185
185
  cleanupImages();
186
186
  return reject(new Error(`Failed to start codex: ${e.message}`));
package/src/env.js CHANGED
@@ -6,11 +6,13 @@
6
6
  // by (1) asking your login shell for its PATH and (2) adding common bin dirs.
7
7
 
8
8
  import os from 'node:os';
9
+ import { spawnGroupOpts } from './proc.js';
9
10
  import path from 'node:path';
10
11
  import { spawnSync } from 'node:child_process';
11
12
  import { readdirSync, readFileSync, existsSync } from 'node:fs';
12
13
 
13
14
  let enriched = false;
15
+ let envEnriched = false;
14
16
 
15
17
  // The agent CLIs the bridge shells out to. Claude has its own richer resolution
16
18
  // (resolveClaude: native / cli.js / WSL / SDK) below.
@@ -235,9 +237,15 @@ export function buildSpawnSpec(spec, args, cwd) {
235
237
  if (wslCwd) pre.push('--cd', wslCwd); // else: run in WSL home
236
238
  }
237
239
  const argv = [...pre, '-e', 'bash', '-lic', `exec ${spec.command} "$@"`, 'chatpanel', ...args];
238
- return ['wsl.exe', argv, { stdio: ['pipe', 'pipe', 'pipe'], env: process.env, windowsHide: true }];
240
+ return ['wsl.exe', argv, { stdio: ['pipe', 'pipe', 'pipe'], env: process.env, windowsHide: true, ...spawnGroupOpts }];
239
241
  }
240
- const opts = { cwd: cwd || os.homedir(), stdio: ['pipe', 'pipe', 'pipe'], env: process.env, windowsHide: true };
242
+ // Every spawned CLI leads its own process group, so Stop can signal the whole tree. An
243
+ // agent CLI is not one process — it runs shell commands and tools of its own — and killing
244
+ // only the pid we hold leaves those running after the user has stopped the turn.
245
+ const opts = {
246
+ cwd: cwd || os.homedir(), stdio: ['pipe', 'pipe', 'pipe'], env: process.env, windowsHide: true,
247
+ ...spawnGroupOpts,
248
+ };
241
249
  if (spec.kind === 'script') return [process.execPath, [spec.script, ...args], opts];
242
250
  if (spec.kind === 'cmd') return ['cmd.exe', ['/d', '/s', '/c', spec.bin, ...args], opts];
243
251
  return [spec.bin, args, opts]; // native
@@ -421,6 +429,64 @@ export function commandCandidateFiles(name, home = os.homedir(), platform = proc
421
429
  return unique([...fromDirs, ...agentCandidateBins(name, home, platform, env)]);
422
430
  }
423
431
 
432
+ // Agent credentials a LaunchAgent/systemd unit does NOT inherit.
433
+ //
434
+ // enrichPath() repairs the minimal PATH a service gets; this is the same problem
435
+ // one layer up. A CLI that authenticates from a FILE (claude, codex, copilot:
436
+ // ~/.copilot) works fine under the service, but one that reads an API key from
437
+ // the ENVIRONMENT (dsh -> DEEPSEEK_API_KEY) fails with a missing-credential
438
+ // error that looks like a ChatPanel bug — the launchd job's environment is just
439
+ // SSH_AUTH_SOCK and a bare PATH.
440
+ //
441
+ // So: ask the user's login shell for a NARROW allowlist of agent credential
442
+ // variables and fill in only the ones we don't already have. These are the
443
+ // user's own credentials, on their own machine, handed to CLIs the user
444
+ // configured ChatPanel to launch — exactly what would happen had they run the
445
+ // CLI from their terminal. Values are never logged, and /debug never dumps the
446
+ // environment (it exposes only PATH/home, and only under CHATPANEL_BRIDGE_DEBUG).
447
+ const AGENT_ENV_KEYS = [
448
+ 'DEEPSEEK_API_KEY', 'DSH_HOME',
449
+ 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL',
450
+ 'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_API_BASE',
451
+ 'GEMINI_API_KEY', 'GOOGLE_API_KEY',
452
+ 'OPENROUTER_API_KEY', 'GROQ_API_KEY', 'XAI_API_KEY', 'MISTRAL_API_KEY',
453
+ ];
454
+
455
+ export function enrichAgentEnv() {
456
+ if (envEnriched) return;
457
+ envEnriched = true;
458
+ if (process.platform === 'win32') return; // service env is inherited there
459
+
460
+ const missing = AGENT_ENV_KEYS.filter((k) => !process.env[k]);
461
+ if (!missing.length) return;
462
+ try {
463
+ const shell = process.env.SHELL || '/bin/zsh';
464
+ // Emit NAME\tVALUE for each var that is actually set; trailing `true` keeps
465
+ // the exit status clean when the last test fails.
466
+ const script = `${missing
467
+ .map((k) => `[ -n "\${${k}:-}" ] && printf '%s\\t%s\\n' ${k} "\$${k}"`)
468
+ .join('; ')}; true`;
469
+ // -lic, not -lc: these keys are typically exported from ~/.zshrc (or ~/.bashrc),
470
+ // which a LOGIN-only shell does NOT source — that's interactive-only. TERM=dumb
471
+ // keeps prompt/banner noise down, and the parser below ignores any line that
472
+ // isn't a clean NAME<TAB>VALUE, so rc-file chatter is harmless.
473
+ const r = spawnSync(shell, ['-lic', script], {
474
+ encoding: 'utf8',
475
+ timeout: 6000,
476
+ env: { ...process.env, TERM: 'dumb' },
477
+ });
478
+ for (const line of String(r.stdout || '').split('\n')) {
479
+ const i = line.indexOf('\t');
480
+ if (i <= 0) continue;
481
+ const key = line.slice(0, i).trim();
482
+ const value = line.slice(i + 1);
483
+ if (AGENT_ENV_KEYS.includes(key) && !process.env[key] && value) process.env[key] = value;
484
+ }
485
+ } catch {
486
+ /* no login shell / timeout — agents that need a file-based login still work */
487
+ }
488
+ }
489
+
424
490
  export function enrichPath() {
425
491
  if (enriched) return;
426
492
  enriched = true;
package/src/proc.js CHANGED
@@ -1,18 +1,88 @@
1
+ import { execFileSync } from 'node:child_process';
1
2
  // Terminate a spawned CLI child when an AbortSignal fires. The extension's Stop
2
3
  // button aborts the /chat request; server.js turns that disconnect into an abort on
3
4
  // this signal. Without this, the agent CLI (codex / claude / agy / custom) keeps
4
5
  // running to completion in the background after Stop — burning tokens and holding the
5
6
  // session — and only the 3-minute idle timer would eventually reap it.
6
7
  //
7
- // SIGTERM first so the CLI can flush + exit cleanly (its own child procs get the
8
- // signal via the process group where the platform delivers it), then SIGKILL after a
9
- // short grace if it's still alive. Returns a detach() to drop the listener once the
10
- // child exits normally.
8
+ // SIGTERM first so the CLI can flush + exit cleanly, then SIGKILL after a short grace if
9
+ // it's still alive. Returns a detach() to drop the listener once the child exits normally.
10
+ //
11
+ // THE SIGNAL MUST REACH THE GRANDCHILDREN. An agent CLI is not one process: codex runs shell
12
+ // commands, claude runs tools, and each of those is a child of the child. `child.kill()`
13
+ // signals exactly one pid, so Stop killed the CLI and left its shell running — a user pressed
14
+ // Stop, watched the panel go quiet, and found the process still going.
15
+ //
16
+ // Signalling the process GROUP fixes that, and only works if the child leads a group of its
17
+ // own — which is what spawnGroupOpts is for. Without that spawn option the child sits in the
18
+ // bridge's own group, and a group kill would signal the bridge.
19
+
20
+ /**
21
+ * Spawn options that make a child its own process-group leader, so the whole tree can be
22
+ * signalled together. No-op on Windows, which has no process groups in this sense — there
23
+ * the taskkill fallback in killTree covers it.
24
+ */
25
+ export const spawnGroupOpts = process.platform === 'win32' ? {} : { detached: true };
26
+
27
+ /**
28
+ * Every pid descended from `pid`, snapshotted from the process table.
29
+ *
30
+ * A process-group kill is not enough on its own. codex runs each shell step in its OWN
31
+ * process group (`sleep 90` came back with PGID == its own pid), so signalling the group we
32
+ * created deliberately misses it — and once its parent dies it reparents to init, where
33
+ * nothing connects it to the run that started it.
34
+ *
35
+ * So the tree is read BEFORE anything is signalled. Afterwards the links are gone.
36
+ */
37
+ function descendantsOf(pid) {
38
+ if (!pid || process.platform === 'win32') return [];
39
+ let table = '';
40
+ try {
41
+ table = execFileSync('ps', ['-eo', 'pid=,ppid='], { encoding: 'utf8', timeout: 2000 });
42
+ } catch { return []; }
43
+ const kids = new Map();
44
+ for (const line of table.split('\n')) {
45
+ const [p, pp] = line.trim().split(/\s+/).map(Number);
46
+ if (!p || !pp) continue;
47
+ if (!kids.has(pp)) kids.set(pp, []);
48
+ kids.get(pp).push(p);
49
+ }
50
+ const out = [];
51
+ const walk = (root, depth = 0) => {
52
+ // A depth cap rather than a visited set: the table is a snapshot of a tree, and a cycle
53
+ // would mean the kernel lied. The cap is there so a malformed read cannot hang a Stop.
54
+ if (depth > 20) return;
55
+ for (const k of kids.get(root) || []) { out.push(k); walk(k, depth + 1); }
56
+ };
57
+ walk(pid);
58
+ return out;
59
+ }
60
+
61
+ /** Signal a child and everything it spawned. Falls back to the single process. */
62
+ function killTree(child, sig, known = []) {
63
+ if (!child?.pid) return;
64
+ // Descendants first, while the parent is still alive to be traced through. Orphaned
65
+ // grandchildren are the ones the user cannot find or stop afterwards.
66
+ for (const pid of known.length ? known : descendantsOf(child.pid)) {
67
+ try { process.kill(pid, sig); } catch { /* already gone */ }
68
+ }
69
+ try {
70
+ // Negative pid = the whole group. Only valid for a detached child; the catch covers a
71
+ // child spawned without it, which is still better killed alone than not at all.
72
+ process.kill(-child.pid, sig);
73
+ return;
74
+ } catch { /* not a group leader, or already gone */ }
75
+ try { child.kill(sig); } catch { /* already exited */ }
76
+ }
77
+
11
78
  export function killOnAbort(child, signal, { graceMs = 1500 } = {}) {
12
79
  if (!signal || !child) return () => {};
13
80
  const onAbort = () => {
14
- try { child.kill('SIGTERM'); } catch { /* already exited */ }
15
- const t = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ } }, graceMs);
81
+ // Snapshot ONCE, up front: by the time the grace period expires the parent is gone and
82
+ // the tree cannot be walked, so the escalation would have nothing left to aim at.
83
+ const tree = descendantsOf(child.pid);
84
+ killTree(child, 'SIGTERM', tree);
85
+ const t = setTimeout(() => killTree(child, 'SIGKILL', tree), graceMs);
16
86
  if (t.unref) t.unref(); // don't keep the event loop alive just for the grace timer
17
87
  };
18
88
  if (signal.aborted) { onAbort(); return () => {}; }
package/src/runs.js ADDED
@@ -0,0 +1,49 @@
1
+ // Every in-flight run, so Stop is an INSTRUCTION rather than an inference.
2
+ //
3
+ // Cancellation used to depend on Node noticing the client's socket close and firing
4
+ // `req.on('close')`. That is a signal about a socket, not about intent: it can arrive late,
5
+ // and on a request whose body was already consumed it does not reliably arrive at all — so
6
+ // a codex `shell:` step ran on for minutes after Stop with nothing left listening to it.
7
+ //
8
+ // A run registered here can be cancelled by name. The socket-close path stays as a safety
9
+ // net for a panel that is closed or crashes, but the button no longer depends on it.
10
+ const runs = new Map();
11
+
12
+ export function startRun(id) {
13
+ const ac = new AbortController();
14
+ const children = new Set();
15
+ const run = {
16
+ id,
17
+ signal: ac.signal,
18
+ // Children are tracked as well as signalled, because an agent that spawns sub-agents
19
+ // spawns processes this module never sees at spawn time. Whoever creates one registers
20
+ // it, and Stop reaches all of them.
21
+ track(child) { if (child?.pid) { children.add(child); child.once?.('exit', () => children.delete(child)); } },
22
+ cancel(reason = 'stopped') {
23
+ if (run.cancelled) return false;
24
+ run.cancelled = reason;
25
+ ac.abort();
26
+ return true;
27
+ },
28
+ cancelled: null,
29
+ children,
30
+ };
31
+ runs.set(id, run);
32
+ return run;
33
+ }
34
+
35
+ export function endRun(id) { runs.delete(id); }
36
+
37
+ export function cancelRun(id, reason = 'stopped') {
38
+ const run = runs.get(id);
39
+ return run ? run.cancel(reason) : false;
40
+ }
41
+
42
+ /** Used on shutdown: leaving a CLI running after the bridge exits is how orphans are made. */
43
+ export function cancelAll(reason = 'shutdown') {
44
+ let n = 0;
45
+ for (const run of runs.values()) if (run.cancel(reason)) n += 1;
46
+ return n;
47
+ }
48
+
49
+ export const activeRuns = () => runs.size;
package/src/server.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // running on this machine (Claude Code, Codex and Antigravity, each via its CLI) to
4
4
  // the ChatPanel Chrome extension. Zero runtime dependencies.
5
5
  //
6
- // GET /health → { ok, version, agents: [...], update: {current,latest,…} }
6
+ // GET /health → { ok, version, agents: [{id,label,available,reason,connectors}], update }
7
7
  // POST /update → self-update to the latest release (compiled binary installs)
8
8
  // POST /chat → Server-Sent Events stream of { type, ... }:
9
9
  // {type:'delta', text} incremental assistant text
@@ -27,18 +27,20 @@ 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
29
  import { pi, opencode, kiro, copilot, deepseek } from './engines/cli-agents.js';
30
+ import { connectorsFor } from './connectors.js';
30
31
  import * as custom from './engines/custom.js';
31
32
  import { installService, uninstallService, serviceStatus, restartService } from './service.js';
32
- import { AGENT_CLIS, enrichPath, findAgentBin, resolveCommand } from './env.js';
33
+ import { AGENT_CLIS, enrichPath, enrichAgentEnv, findAgentBin, resolveCommand } from './env.js';
33
34
  import { stripHidden } from './sanitize.js';
34
35
  import { checkForUpdate, selfUpdate } from './update.js';
35
36
  import { callLocalMcp } from './mcp-local.js';
36
37
  import { assertPublicHttpUrl, assertPublicWebUrl } from './ssrf.js';
38
+ import { startRun, endRun, cancelRun, cancelAll, activeRuns } from './runs.js';
37
39
 
38
40
  // Hardcoded (not read from package.json) so it survives Bun's single-file
39
41
  // --compile, where package.json isn't on a readable FS. CI fails the publish if
40
42
  // this drifts from package.json, so the two can't silently diverge.
41
- const VERSION = '0.10.23';
43
+ const VERSION = '0.10.26';
42
44
  const HOST = process.env.CHATPANEL_BRIDGE_HOST || '127.0.0.1';
43
45
  const PORT = Number(process.env.CHATPANEL_BRIDGE_PORT) || 4319;
44
46
 
@@ -238,6 +240,10 @@ const PRIVILEGED_POST = new Set([
238
240
  '/agent-check',
239
241
  '/update',
240
242
  '/tool-result',
243
+ // Cancelling someone else's run is a denial of service, small but real — and every other
244
+ // endpoint that touches a run is already guarded. An unauthenticated hole next to nine
245
+ // guarded neighbours is a hole regardless of how little it grants.
246
+ '/cancel',
241
247
  ]);
242
248
  const PRIVILEGED_GET = new Set(['/debug']);
243
249
 
@@ -294,7 +300,13 @@ async function handleHealth(res) {
294
300
  .filter(([, e]) => !e.hidden)
295
301
  .map(async ([id, { engine, label }]) => {
296
302
  const a = await engine.available().catch((e) => ({ ok: false, reason: String(e?.message || e) }));
297
- return { id, label, available: a.ok, reason: a.reason };
303
+ // WHAT THIS AGENT CAN ALREADY REACH, so the client stops guessing. A CLI agent brings
304
+ // its own connectors — a Slack MCP, a Jira MCP — that the extension cannot see, and
305
+ // an agent that was never told it may use them answers "go and look it up yourself".
306
+ // NAMES ONLY: a server's name is what a prompt needs; its URL, argv and env are what
307
+ // a leak would be made of. Additive, so an older extension ignores it.
308
+ const connectors = await connectorsFor(id).catch(() => []);
309
+ return { id, label, available: a.ok, reason: a.reason, connectors };
298
310
  }),
299
311
  );
300
312
  const update = await checkForUpdate(VERSION).catch(() => ({ current: VERSION, updateAvailable: false }));
@@ -341,10 +353,22 @@ async function handleChat(req, res) {
341
353
  // finish in the background. Older engines ignore the signal (harmless); the spawn
342
354
  // engines honor it via killOnAbort.
343
355
  let closed = false;
344
- const ac = new AbortController();
345
- req.on('close', () => { closed = true; ac.abort(); });
356
+ // A run id the client can cancel BY NAME. Emitted first, before anything else, so Stop
357
+ // works from the first millisecond rather than from whenever the engine gets going.
358
+ const runId = `run_${Math.random().toString(36).slice(2, 10)}`;
359
+ const run = startRun(runId);
360
+ const ac = { signal: run.signal, abort: () => run.cancel('client') };
361
+ // BOTH close events. On a request whose body was already consumed, req 'close' does not
362
+ // reliably signal a client disconnect — res 'close' does. Keeping both means a panel that
363
+ // crashes or is closed still tears the CLI down, while the Stop button no longer depends
364
+ // on either of them.
365
+ const onGone = () => { closed = true; run.cancel('disconnected'); };
366
+ req.on('close', onGone);
367
+ res.on('close', onGone);
368
+ res.on('error', onGone);
346
369
 
347
370
  const safeEmit = (obj) => { if (!closed) emit(obj); };
371
+ emit({ type: 'run', id: runId });
348
372
 
349
373
  // Browser-tools relay: when the extension sends page-tool specs, host an MCP
350
374
  // server for this turn and tell the engine to point the CLI at it.
@@ -374,11 +398,31 @@ async function handleChat(req, res) {
374
398
  log('error', `${body.agent} chat failed: ${e?.message || e}`);
375
399
  emit({ type: 'error', error: e?.message || String(e) });
376
400
  } finally {
401
+ endRun(runId);
377
402
  if (session) deleteSession(session.id);
378
403
  if (!res.writableEnded) res.end();
379
404
  }
380
405
  }
381
406
 
407
+ /**
408
+ * POST /cancel { id } — stop a run by name.
409
+ *
410
+ * Stop is now an instruction, not something inferred from a socket. Answers 200 whether or
411
+ * not the run was still live: 'already finished' and 'cancelled' are the same outcome to the
412
+ * caller, and returning 404 would make a harmless race look like a failure.
413
+ */
414
+ async function handleCancel(req, res) {
415
+ // readBody already PARSES. Wrapping it in JSON.parse threw on every call, so the id was
416
+ // always empty and Stop silently cancelled nothing — the unit tests covered the registry
417
+ // and not the handler that feeds it, which is exactly where this hid.
418
+ let body = {};
419
+ try { body = (await readBody(req)) || {}; } catch { /* an empty body cancels nothing */ }
420
+ const id = String(body.id || '').trim();
421
+ const cancelled = id ? cancelRun(id, 'stopped') : false;
422
+ if (cancelled) log('info', `cancel: ${id} stopped by client`);
423
+ return json(res, 200, { ok: true, cancelled });
424
+ }
425
+
382
426
  // POST /mcp/<session> (per-run, bridge-injected) OR POST /mcp (stable: routes to
383
427
  // the active chat — for CLIs configured once, e.g. `opencode mcp add … …/mcp`).
384
428
  // JSON-RPC; tools/call relays to the extension and waits for /tool-result.
@@ -754,6 +798,7 @@ const server = createServer(async (req, res) => {
754
798
  if (req.method === 'GET') { res.writeHead(405); return res.end(); } // no server-initiated stream
755
799
  if (req.method === 'DELETE') { deleteSession(sid); res.writeHead(204); return res.end(); }
756
800
  }
801
+ if (req.method === 'POST' && url.pathname === '/cancel') return handleCancel(req, res);
757
802
  if (req.method === 'POST' && url.pathname === '/tool-result') return handleToolResult(req, res);
758
803
  if (req.method === 'POST' && url.pathname === '/mcp-local') return handleMcpLocal(req, res);
759
804
  if (req.method === 'POST' && url.pathname === '/mcp-remote') return handleMcpRemote(req, res);
@@ -828,6 +873,7 @@ function runMcpStdioProxy(url) {
828
873
 
829
874
  function startServer() {
830
875
  enrichPath(); // so codex/agy (Antigravity) are found even under a minimal service PATH
876
+ enrichAgentEnv(); // and so env-authenticated CLIs (dsh) have their key under launchd
831
877
  ensureToken(); // per-install bearer token for privileged routes (defense-in-depth)
832
878
  // Fail LOUD on a port clash. The bridge binds a FIXED 4319 so the extension always
833
879
  // finds it; if it's taken, say how to recover instead of dying on a raw stack trace.
@@ -840,6 +886,18 @@ function startServer() {
840
886
  log('error', `bridge server error: ${e?.message || e}`);
841
887
  process.exit(1);
842
888
  });
889
+ // Leaving a CLI running after the bridge exits is how orphans are made — and the user has
890
+ // no way to find or stop them, because the thing that spawned them is gone.
891
+ for (const sig of ['SIGINT', 'SIGTERM']) {
892
+ process.on(sig, () => {
893
+ const n = cancelAll('shutdown');
894
+ if (n) log('info', `shutdown: stopped ${n} running agent${n === 1 ? '' : 's'}`);
895
+ // Give SIGTERM a moment to land before the process goes; killTree escalates on its own.
896
+ setTimeout(() => process.exit(0), n ? 300 : 0).unref?.();
897
+ if (!n) process.exit(0);
898
+ });
899
+ }
900
+
843
901
  server.listen(PORT, HOST, async () => {
844
902
  log('info', `listening on http://${HOST}:${PORT}`);
845
903
  // M7: a non-loopback bind disables the anti-DNS-rebinding Host check (hostAllowed