@chatpanel/bridge 0.10.24 → 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.24",
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,6 +6,7 @@
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';
@@ -236,9 +237,15 @@ export function buildSpawnSpec(spec, args, cwd) {
236
237
  if (wslCwd) pre.push('--cd', wslCwd); // else: run in WSL home
237
238
  }
238
239
  const argv = [...pre, '-e', 'bash', '-lic', `exec ${spec.command} "$@"`, 'chatpanel', ...args];
239
- 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 }];
240
241
  }
241
- 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
+ };
242
249
  if (spec.kind === 'script') return [process.execPath, [spec.script, ...args], opts];
243
250
  if (spec.kind === 'cmd') return ['cmd.exe', ['/d', '/s', '/c', spec.bin, ...args], opts];
244
251
  return [spec.bin, args, opts]; // native
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,6 +27,7 @@ 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
33
  import { AGENT_CLIS, enrichPath, enrichAgentEnv, findAgentBin, resolveCommand } from './env.js';
@@ -34,11 +35,12 @@ 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.24';
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);
@@ -841,6 +886,18 @@ function startServer() {
841
886
  log('error', `bridge server error: ${e?.message || e}`);
842
887
  process.exit(1);
843
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
+
844
901
  server.listen(PORT, HOST, async () => {
845
902
  log('info', `listening on http://${HOST}:${PORT}`);
846
903
  // M7: a non-loopback bind disables the anti-DNS-rebinding Host check (hostAllowed