claude-code-hub 0.41.0 → 1.0.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/server.js CHANGED
@@ -8,8 +8,13 @@ const http = require('http');
8
8
  const tcp = require('net');
9
9
  const os = require('os');
10
10
  const path = require('path');
11
- const { selectApps } = require('./lib/apps');
12
- const { createNetGuard } = require('./lib/net-guard');
11
+ const { actionTable } = require('./lib/actions');
12
+ const { selectApps, loadApp, conflicts } = require('./lib/apps');
13
+ const { stripCookie } = require('./lib/cookies');
14
+ const { createNetGuard, KEEP_ALIVE_MS } = require('./lib/net-guard');
15
+ const { ping } = require('./lib/ping');
16
+ const { killChild } = require('./lib/kill-child');
17
+ const { readRegistry, themeConfig } = require('./lib/themes');
13
18
 
14
19
  function getArg(name) {
15
20
  const idx = process.argv.findIndex((a) => a.startsWith(`--${name}`));
@@ -20,6 +25,7 @@ function getArg(name) {
20
25
  }
21
26
 
22
27
  const HUB_PORT = parseInt(getArg('port') || process.env.PORT || '3540', 10);
28
+ let hubPort = HUB_PORT;
23
29
 
24
30
  const POOL_SIZE = Math.max(1, parseInt(getArg('pool-size') || '3', 10));
25
31
  const RESTART_DELAY_MS = 500;
@@ -40,9 +46,25 @@ function activePool() {
40
46
  // Every sub-app resolves CLAUDE_CONFIG_DIR once at startup into a module constant, so switching
41
47
  // the dir means restarting the children. Kept on the server, not in the browser: the dir has to
42
48
  // be known before the first spawn, and the hub already has one localStorage key to reason about.
43
- const HUB_DIR = path.join(os.homedir(), '.claude-hub');
49
+ const HUB_DIR = path.resolve(expandHome(getArg('hub-dir') || process.env.CLAUDE_HUB_DIR || '~/.claude-hub'));
44
50
  const HUB_CONFIG_FILE = path.join(HUB_DIR, 'config.json');
45
51
 
52
+ if (process.argv.includes('--install') || process.argv.includes('--uninstall')) {
53
+ const { runInstall, runUninstall } = require('./lib/install');
54
+ const dir = getArg('dir') || process.env.CLAUDE_CONFIG_DIR || '~/.claude';
55
+ let configDir;
56
+ try {
57
+ configDir = canonicalDir(dir);
58
+ } catch {
59
+ console.error(`Config dir not found: ${dir}`);
60
+ process.exit(1);
61
+ }
62
+ const ok = process.argv.includes('--install')
63
+ ? runInstall({ hubDir: HUB_DIR, configDir })
64
+ : runUninstall({ configDir });
65
+ process.exit(ok ? 0 : 1);
66
+ }
67
+
46
68
  // The dir the hub would use with no saved config. Set by loadHubConfig; the client uses it to keep
47
69
  // the default dir out of the window title.
48
70
  let defaultConfigDir = null;
@@ -104,13 +126,25 @@ function saveHubConfig() {
104
126
 
105
127
  const hubConfig = loadHubConfig();
106
128
 
107
- const { apps: ENABLED_APPS, unknown: unknownApps } = selectApps(hubConfig.apps);
108
- for (const id of unknownApps) console.log(`Unknown app "${id}" in ${HUB_CONFIG_FILE}, ignored`);
109
- if (!ENABLED_APPS.length) {
129
+ const { apps: selectedApps, unknown: unknownApps } = selectApps(hubConfig.apps, path.dirname(HUB_CONFIG_FILE));
130
+ const ENABLED_APPS = selectedApps.map((a) => loadApp(a, { flagPort: getArg(`${a.id}-port`) })).filter(Boolean);
131
+ for (const id of unknownApps) console.log(`Unknown app "${id}" in ${HUB_CONFIG_FILE} has no "path", ignored`);
132
+ for (const a of selectedApps) if (a.path) console.log(`${a.id} runs from ${a.path}`);
133
+ for (const line of conflicts(ENABLED_APPS)) console.log(line);
134
+ if (!selectedApps.length) {
110
135
  console.error(`Every app is disabled in ${HUB_CONFIG_FILE}. Enable at least one.`);
111
136
  process.exit(1);
112
137
  }
113
- const appEnabled = (id) => ENABLED_APPS.some((a) => a.id === id);
138
+ if (!ENABLED_APPS.length) {
139
+ const ids = selectedApps.map((a) => a.id).join(', ');
140
+ console.error(`No enabled app could load (${ids}). The reasons are logged above.`);
141
+ process.exit(1);
142
+ }
143
+ const ACTIONS = actionTable(ENABLED_APPS);
144
+ // The first enabled app in tab order that declares the capability in its manifest.
145
+ const provider = (cap) => ENABLED_APPS.find((a) => a.provides[cap]);
146
+ const PROJECTS_APP = provider('projects');
147
+ const TERMINAL_APP = provider('terminal');
114
148
 
115
149
  // Every keystroke into cck's terminal passes through the upgrade proxy below. Windows only, for the
116
150
  // reasons in cck/lib/priority.js; the children spawned after this still start at normal.
@@ -134,8 +168,7 @@ const net = createNetGuard({ appName: 'Claude Code Hub' });
134
168
  // the WebSocket. cck applies the rest of its policy (exposure refusal, Origin, session cap) itself.
135
169
  const TERMINAL = {
136
170
  ...(hubConfig.terminal || {}),
137
- enabled:
138
- appEnabled('kanban') && !process.argv.includes('--disable-terminal') && hubConfig.terminal?.enabled !== false,
171
+ enabled: !!TERMINAL_APP && !process.argv.includes('--disable-terminal') && hubConfig.terminal?.enabled !== false,
139
172
  };
140
173
  const TERMINAL_TOKEN = TERMINAL.enabled ? crypto.randomBytes(32).toString('hex') : null;
141
174
 
@@ -147,6 +180,7 @@ const TOKEN_COOKIE = 'hub_token';
147
180
  const TOKEN_COOKIE_RE = /(?:^|;\s*)hub_token=([^;]*)/;
148
181
  const TOKEN_COOKIE_MAX_AGE_MS = 400 * 24 * 60 * 60 * 1000;
149
182
  const LOCKED_PAGE = path.join(__dirname, 'public', 'locked.html');
183
+ const HUB_SDK_SERVER = path.join(__dirname, 'packages', 'claude-hub-sdk', 'src', 'server.js');
150
184
 
151
185
  function loadHubToken() {
152
186
  try {
@@ -191,11 +225,12 @@ function childEnv(pool, name) {
191
225
  PORT: '0',
192
226
  CLAUDE_CONFIG_DIR: pool.dir,
193
227
  CLAUDE_HUB: '1',
194
- HUB_URL: `http://localhost:${HUB_PORT}`,
228
+ HUB_URL: `http://localhost:${hubPort}`,
195
229
  HOST: net.BIND_HOST,
196
230
  ALLOWED_HOSTS: net.ALLOWED_HOSTS,
231
+ HUB_SDK_SERVER,
197
232
  };
198
- if (name === 'kanban' && TERMINAL.enabled) {
233
+ if (TERMINAL.enabled && name === TERMINAL_APP.id) {
199
234
  env.CCK_TERMINAL = JSON.stringify(TERMINAL);
200
235
  env.CCK_TERMINAL_TOKEN = TERMINAL_TOKEN;
201
236
  }
@@ -206,7 +241,7 @@ function spawnApp(pool, name, cmd, args) {
206
241
  const child = spawn(cmd, args, {
207
242
  cwd: __dirname,
208
243
  env: childEnv(pool, name),
209
- stdio: ['ignore', 'pipe', 'pipe'],
244
+ stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
210
245
  });
211
246
 
212
247
  let stdoutBuf = '';
@@ -231,8 +266,9 @@ function spawnApp(pool, name, cmd, args) {
231
266
  child.stderr.on('data', (d) => process.stderr.write(`[${name}] ${d}`));
232
267
  child.on('exit', (code) => {
233
268
  console.log(`[${name}] exited (code ${code})`);
234
- // The port dies with the child and the OS may hand it to a stranger, so stop dialing it.
235
- delete pool.ports[name];
269
+ // The port dies with the child and the OS may hand it to a stranger, so stop dialing it. A child
270
+ // that restartApp replaced can exit after its successor printed a port.
271
+ if (pool.children.get(name) === child) delete pool.ports[name];
236
272
  markReady();
237
273
  if (pool.retired || pool.children.get(name) !== child) return;
238
274
  // Nothing else would ever bring this app back: a pool is only rebuilt when its dir is
@@ -240,6 +276,7 @@ function spawnApp(pool, name, cmd, args) {
240
276
  const recent = (pool.restarts[name] || []).filter((t) => Date.now() - t < RESTART_WINDOW_MS);
241
277
  if (recent.length >= MAX_RESTARTS) {
242
278
  console.log(`[${name}] gave up after ${MAX_RESTARTS} restarts in ${RESTART_WINDOW_MS / 1000}s`);
279
+ child.gaveUp = true;
243
280
  return;
244
281
  }
245
282
  recent.push(Date.now());
@@ -255,22 +292,27 @@ function spawnApp(pool, name, cmd, args) {
255
292
  return child;
256
293
  }
257
294
 
258
- function killChild(child) {
259
- if (child.exitCode !== null || child.killed) return;
260
- if (process.platform === 'win32') {
261
- spawn('taskkill', ['/pid', String(child.pid), '/f', '/t'], { stdio: 'ignore' });
262
- } else {
263
- child.kill();
264
- }
295
+ // Asked for by the user, so it does not count toward MAX_RESTARTS and it brings back an app that gave up.
296
+ async function restartApp(pool, app) {
297
+ const { id } = app;
298
+ const old = pool.children.get(id);
299
+ pool.children.delete(id);
300
+ delete pool.ports[id];
301
+ pool.restarts[id] = [];
302
+ if (old) await killChild(old);
303
+ if (pool.retired) return false;
304
+ const child = spawnOne(pool, app);
305
+ await withTimeout(child.ready, 20000);
306
+ return !!pool.ports[id];
265
307
  }
266
308
 
267
309
  function killPool(pool) {
268
310
  pool.retired = true;
269
- for (const child of pool.children.values()) killChild(child);
311
+ return Promise.all([...pool.children.values()].map((child) => killChild(child)));
270
312
  }
271
313
 
272
314
  function killAll() {
273
- for (const pool of pools.values()) killPool(pool);
315
+ return Promise.all([...pools.values()].map(killPool));
274
316
  }
275
317
 
276
318
  function withTimeout(promise, ms) {
@@ -303,10 +345,12 @@ async function ensurePool(dir) {
303
345
  }
304
346
 
305
347
  async function terminalCount(pool) {
306
- const port = pool.ports.kanban;
348
+ const port = pool.ports[TERMINAL_APP.id];
307
349
  if (!port) return 0;
308
350
  try {
309
- const r = await fetch(`http://127.0.0.1:${port}/api/terminals`, { signal: AbortSignal.timeout(1500) });
351
+ const r = await fetch(`http://127.0.0.1:${port}${TERMINAL_APP.provides.terminal.liveWork}`, {
352
+ signal: AbortSignal.timeout(1500),
353
+ });
310
354
  return r.ok ? (await r.json()).sessions?.length || 0 : 0;
311
355
  } catch {
312
356
  return 0;
@@ -340,9 +384,12 @@ function dropPool(dir) {
340
384
  killPool(pool);
341
385
  }
342
386
 
387
+ // Waits for the children, so a POSIX child that ignores SIGTERM still gets SIGKILL before the hub exits.
388
+ let shuttingDown = false;
343
389
  function shutdown() {
344
- killAll();
345
- process.exit(0);
390
+ if (shuttingDown) return;
391
+ shuttingDown = true;
392
+ killAll().then(() => process.exit(0));
346
393
  }
347
394
 
348
395
  process.on('SIGINT', shutdown);
@@ -364,17 +411,6 @@ process.stdin.on('data', (data) => {
364
411
  process.stdin.on('end', shutdown);
365
412
  process.stdin.on('close', shutdown);
366
413
 
367
- function resolveApp(submoduleDir, npmPackage) {
368
- const local = path.join(__dirname, submoduleDir, 'server.js');
369
- try {
370
- require.resolve(local);
371
- return local;
372
- } catch {}
373
- return require.resolve(`${npmPackage}/server.js`);
374
- }
375
-
376
- const appPaths = Object.fromEntries(ENABLED_APPS.map((a) => [a.id, resolveApp(a.dir, a.pkg)]));
377
-
378
414
  // Raise header size limit to 64KB — localhost cookies from sibling apps can pile up and
379
415
  // trip Node's default 16KB limit, breaking iframes with HTTP 431.
380
416
  const HDR_BYTES = 65536;
@@ -383,13 +419,15 @@ const NODE_HDR = `--max-http-header-size=${HDR_BYTES}`;
383
419
  // Children bind ephemeral ports; the public ports below belong to the hub's proxies. Browser
384
420
  // localStorage is keyed by origin, so the sub-app origin has to stay put across switches or the
385
421
  // user loses pins and filters every time the active set changes.
422
+ function spawnOne(pool, app) {
423
+ return spawnApp(pool, app.id, process.execPath, [NODE_HDR, app.entry]);
424
+ }
425
+
386
426
  function spawnChildren(pool) {
387
- for (const a of ENABLED_APPS) spawnApp(pool, a.id, process.execPath, [NODE_HDR, appPaths[a.id]]);
427
+ for (const a of ENABLED_APPS) spawnOne(pool, a);
388
428
  }
389
429
 
390
- const publicPorts = Object.fromEntries(
391
- ENABLED_APPS.map((a) => [a.id, parseInt(getArg(`${a.id}-port`) || String(a.port), 10)]),
392
- );
430
+ const publicPorts = Object.fromEntries(ENABLED_APPS.map((a) => [a.id, a.port]));
393
431
 
394
432
  function rewriteOrigin(origin, publicPort, childPort) {
395
433
  try {
@@ -402,7 +440,9 @@ function rewriteOrigin(origin, publicPort, childPort) {
402
440
  }
403
441
  }
404
442
 
405
- const proxyAgent = new http.Agent({ keepAlive: true, maxSockets: 64 });
443
+ // The socket timeout drops idle sockets before the app's server closes them (KEEP_ALIVE_MS), so a
444
+ // request never lands on a socket being closed. It does not cut a slow response.
445
+ const proxyAgent = new http.Agent({ keepAlive: true, maxSockets: 64, timeout: KEEP_ALIVE_MS - 5000 });
406
446
 
407
447
  // Two failures look the same from here and both recover on their own: a pooled socket the child
408
448
  // closed under us, and a child that is restarting. The port is re-read per attempt, so a replay
@@ -434,24 +474,33 @@ async function waitForPort(name, deadline) {
434
474
  const pool = activePool();
435
475
  const port = pool?.ports[name];
436
476
  if (port) return port;
437
- if (pool && !pool.children.has(name)) return null;
477
+ if (pool?.children.get(name)?.gaveUp) return null;
438
478
  const remaining = deadline - Date.now();
439
479
  if (remaining <= 0) return null;
440
480
  await Promise.race([pool?.children.get(name)?.ready, delay(Math.min(remaining, 250))]);
441
481
  }
442
482
  }
443
483
 
484
+ function noPortReason(name) {
485
+ const child = activePool()?.children.get(name);
486
+ if (child?.gaveUp) {
487
+ const detail = `${name} exited after ${MAX_RESTARTS} restarts in ${RESTART_WINDOW_MS / 1000}s. The hub stopped restarting it`;
488
+ return { headline: 'stopped', detail };
489
+ }
490
+ return { headline: 'is starting', detail: `${name} is starting` };
491
+ }
492
+
444
493
  const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
445
494
 
446
495
  // A document load that fails gets a page that reloads itself — the reload the user would do by
447
496
  // hand. The cap is small because the server already waited NAV_PORT_WAIT_MS before serving this,
448
497
  // and each reload re-spends that wait. sessionStorage throws when site data is blocked, and a page
449
498
  // whose only job is to reload must still reload there.
450
- function retryPage(name, detail) {
499
+ function retryPage(name, headline, detail) {
451
500
  return `<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(name)}</title>
452
501
  <style>:root{color-scheme:light dark}body{font:14px system-ui,sans-serif;margin:0;display:grid;place-items:center;height:100vh}
453
502
  div{text-align:center;opacity:.7}code{font-size:12px;opacity:.6}</style>
454
- </head><body><div><p id="m">${escapeHtml(name)} is restarting…</p><p><code>${escapeHtml(detail)}</code></p></div>
503
+ </head><body><div><p id="m">${escapeHtml(name)} ${escapeHtml(headline)}…</p><p><code>${escapeHtml(detail)}</code></p></div>
455
504
  <script>
456
505
  let n = 0;
457
506
  const k = 'hub-retry:' + location.pathname;
@@ -461,11 +510,11 @@ else { try { sessionStorage.removeItem(k); } catch {} document.getElementById('m
461
510
  </script></body></html>`;
462
511
  }
463
512
 
464
- function sendUnavailable(req, res, name, status, detail) {
513
+ function sendUnavailable(req, res, name, status, detail, headline) {
465
514
  if (res.destroyed || res.headersSent) return;
466
515
  if (isNavigation(req)) {
467
516
  res.writeHead(status, { 'Retry-After': '1', 'Content-Type': 'text/html; charset=utf-8' });
468
- res.end(retryPage(name, detail));
517
+ res.end(retryPage(name, headline, detail));
469
518
  return;
470
519
  }
471
520
  // The sub-app's own callers hand the body to .json(): plain text surfaces as a SyntaxError
@@ -474,26 +523,98 @@ function sendUnavailable(req, res, name, status, detail) {
474
523
  res.end(JSON.stringify({ error: detail }));
475
524
  }
476
525
 
526
+ // The child on childPort, or null when it has been replaced since the connect was tried.
527
+ function currentChild(name, childPort) {
528
+ const pool = activePool();
529
+ return pool?.ports[name] === childPort ? pool.children.get(name) : null;
530
+ }
531
+
532
+ // A busy event loop (cck's cache warmup) must not read as a dead app.
533
+ const PING_TIMEOUT_MS = 3000;
534
+ // A drop burst fails many requests at once; they share one ping per child.
535
+ const pings = new WeakMap();
536
+ function pingChild(name, childPort, err) {
537
+ const child = currentChild(name, childPort);
538
+ if (!child) return Promise.resolve(false);
539
+ if (!pings.has(child)) {
540
+ const alive = ping(child, PING_TIMEOUT_MS).then((ok) => {
541
+ pings.delete(child);
542
+ if (ok) console.warn(`[${name}] answers over IPC at 127.0.0.1:${childPort} (${err.code}) - keeping it`);
543
+ return ok;
544
+ });
545
+ pings.set(child, alive);
546
+ }
547
+ return pings.get(child);
548
+ }
549
+
550
+ // A drop burst fails dozens of connects that the next attempt fixes, so retries are counted and
551
+ // logged as one line per app every RETRY_LOG_MS. The IPC check, a replace and a failure log at once.
552
+ const RETRY_LOG_MS = 5 * 60_000;
553
+ const retryCounts = new Map();
554
+ function countRetry(name, code) {
555
+ const key = `${name} ${code}`;
556
+ const entry = retryCounts.get(key) || { name, code, n: 0 };
557
+ entry.n++;
558
+ retryCounts.set(key, entry);
559
+ }
560
+ setInterval(() => {
561
+ for (const { name, code, n } of retryCounts.values()) {
562
+ console.log(`[${name}] ${n} connects failed (${code}) and were retried in the last ${RETRY_LOG_MS / 60_000} min`);
563
+ }
564
+ retryCounts.clear();
565
+ }, RETRY_LOG_MS).unref();
566
+
567
+ const ALIVE_WAIT_MS = 30000;
568
+ const MAX_BACKOFF_MS = 2000;
569
+
570
+ // Returns {wait} before the next connect attempt, or {headline, detail} to give up with. A pong
571
+ // moves budget.deadline to ALIVE_WAIT_MS from now: the child runs, so the request waits out the drop.
572
+ async function afterConnectFailure(name, childPort, err, attempt, budget) {
573
+ const next = await nextAttempt(name, childPort, err, attempt, budget);
574
+ if (next.wait) countRetry(name, err.code);
575
+ return next;
576
+ }
577
+
578
+ async function nextAttempt(name, childPort, err, attempt, budget) {
579
+ if (attempt < MAX_ATTEMPTS) return { wait: ATTEMPT_DELAY_MS };
580
+ if (attempt === MAX_ATTEMPTS) {
581
+ if (!(await pingChild(name, childPort, err))) {
582
+ replaceChild(name, childPort, err);
583
+ return { headline: 'is restarting', detail: `${name} did not answer over TCP (${err.code}) or IPC` };
584
+ }
585
+ budget.deadline = Math.max(budget.deadline, Date.now() + ALIVE_WAIT_MS);
586
+ }
587
+ const wait = Math.min(ATTEMPT_DELAY_MS * 2 ** (attempt - MAX_ATTEMPTS + 1), MAX_BACKOFF_MS);
588
+ if (Date.now() + wait < budget.deadline) return { wait };
589
+ // Its listener is gone or stuck while the process lives on. A new process gets a new port.
590
+ if (currentChild(name, childPort)) {
591
+ console.warn(`[${name}] answers over IPC, but connects to 127.0.0.1:${childPort} kept failing (${err.code})`);
592
+ replaceChild(name, childPort, err);
593
+ }
594
+ return {
595
+ headline: 'is restarting',
596
+ detail: `${name} answers over IPC, but connects to it kept failing (${err.code}) for ${ALIVE_WAIT_MS / 1000}s`,
597
+ };
598
+ }
599
+
477
600
  // A pooled socket the child closed under us is normal and recovers on the next attempt. A port that
478
601
  // never completes a connect does not, so the child is replaced and the respawn gives it a new one.
479
602
  function replaceChild(name, childPort, err) {
480
- const pool = activePool();
481
- const child = pool?.children.get(name);
482
- if (!child || pool.ports[name] !== childPort) return;
603
+ const child = currentChild(name, childPort);
604
+ if (!child) return;
483
605
  console.log(`[${name}] unreachable at 127.0.0.1:${childPort} (${err.code}) - replacing it`);
484
- delete pool.ports[name];
606
+ delete activePool().ports[name];
485
607
  killChild(child);
486
608
  }
487
609
 
610
+ const connectTimeoutError = (name, childPort) =>
611
+ Object.assign(new Error(`no connect to ${name} at 127.0.0.1:${childPort}`), { code: 'ETIMEDOUT' });
612
+
488
613
  // Fires only while the socket is still connecting, so a slow handler is never interrupted.
489
614
  function capConnect(upstream, name, childPort) {
490
615
  upstream.on('socket', (socket) => {
491
616
  if (!socket.connecting) return;
492
- const timer = setTimeout(() => {
493
- const err = new Error(`no connect to ${name} at 127.0.0.1:${childPort}`);
494
- err.code = 'ETIMEDOUT';
495
- upstream.destroy(err);
496
- }, CONNECT_TIMEOUT_MS);
617
+ const timer = setTimeout(() => upstream.destroy(connectTimeoutError(name, childPort)), CONNECT_TIMEOUT_MS);
497
618
  const clear = () => clearTimeout(timer);
498
619
  socket.once('connect', clear);
499
620
  upstream.once('close', clear);
@@ -502,7 +623,29 @@ function capConnect(upstream, name, childPort) {
502
623
 
503
624
  // Resolves null once the response is committed (or the client is gone), or the error when nothing
504
625
  // has been written yet and the caller may still retry.
505
- function forward(name, req, res, childPort, headers, replayable, onUpstream) {
626
+ const MAX_REPLAY_BODY = 1024 * 1024;
627
+
628
+ // Resolves the whole body when it is small enough to keep for a retry, else null (the body then
629
+ // streams once). A chunked body has no length up front, so it streams too.
630
+ function readReplayBody(req) {
631
+ const te = req.headers['transfer-encoding'];
632
+ const cl = req.headers['content-length'];
633
+ if (te) return Promise.resolve(null);
634
+ if (cl === undefined) return Promise.resolve(Buffer.alloc(0));
635
+ const len = Number(cl);
636
+ if (!Number.isSafeInteger(len) || len > MAX_REPLAY_BODY) return Promise.resolve(null);
637
+ return new Promise((resolve) => {
638
+ const chunks = [];
639
+ req.on('data', (c) => chunks.push(c));
640
+ req.once('end', () => resolve(Buffer.concat(chunks)));
641
+ req.once('error', () => resolve(undefined));
642
+ req.once('close', () => {
643
+ if (!req.complete) resolve(undefined);
644
+ });
645
+ });
646
+ }
647
+
648
+ function forward(name, req, res, childPort, headers, body, onUpstream) {
506
649
  return new Promise((resolve) => {
507
650
  const upstream = http.request(
508
651
  { host: '127.0.0.1', port: childPort, method: req.method, path: req.url, headers, agent: proxyAgent },
@@ -522,47 +665,64 @@ function forward(name, req, res, childPort, headers, replayable, onUpstream) {
522
665
  }
523
666
  resolve(err);
524
667
  });
525
- if (replayable) upstream.end();
668
+ if (body) upstream.end(body.length ? body : undefined);
526
669
  else req.pipe(upstream);
527
670
  });
528
671
  }
529
672
 
673
+ // RFC 9110 §7.6.1. The client's "Connection: close" would otherwise close the pooled socket to the
674
+ // app too. Transfer-Encoding stays: it frames the body that is piped on.
675
+ function stripHopByHop(headers) {
676
+ for (const h of (headers.connection || '').split(',')) delete headers[h.trim().toLowerCase()];
677
+ for (const h of ['connection', 'keep-alive', 'proxy-connection', 'te', 'trailer', 'upgrade']) delete headers[h];
678
+ }
679
+
530
680
  // Host is forwarded untouched so the child's hostGuard still sees what the browser sent. Origin is
531
681
  // the one header the child cannot judge on its own: its guard compares the port to its own
532
682
  // ephemeral one, so the public-port spelling is mapped to the child's; anything else passes
533
- // through as-is and the child rejects it.
683
+ // through as-is and the child rejects it. Cookies on localhost are shared across ports, so the
684
+ // browser sends the hub's own token cookie to every app; it never reaches a child.
534
685
  function proxyHandler(name) {
535
686
  return async (req, res) => {
536
687
  const headers = { ...req.headers };
537
- // req is consumed by the first attempt, so only a request with no body can be re-sent. Waiting
538
- // for a port is not a replay — nothing has been sent yet — so every method gets the same wait.
539
- const replayable = req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS';
688
+ const cookie = stripCookie(headers.cookie, TOKEN_COOKIE);
689
+ if (cookie) headers.cookie = cookie;
690
+ else delete headers.cookie;
691
+ stripHopByHop(headers);
692
+ // req is consumed by the first attempt, so a request is re-sent only from its kept body. Waiting
693
+ // for a port is not a replay — nothing has been sent yet — so every request gets the same wait.
694
+ const body = await readReplayBody(req);
695
+ if (body === undefined) return;
540
696
  // One deadline for the whole request, not one per attempt: a retry must not re-spend the wait.
541
- const deadline = Date.now() + (isNavigation(req) ? NAV_PORT_WAIT_MS : API_PORT_WAIT_MS);
697
+ const budget = { deadline: Date.now() + (isNavigation(req) ? NAV_PORT_WAIT_MS : API_PORT_WAIT_MS) };
542
698
  // One listener for the request, however many attempts it takes.
543
699
  let current = null;
544
700
  res.once('close', () => current?.destroy());
545
701
 
546
702
  for (let attempt = 1; !res.destroyed; attempt++) {
547
- const childPort = await waitForPort(name, deadline);
703
+ const childPort = await waitForPort(name, budget.deadline);
548
704
  if (!childPort) {
549
- sendUnavailable(req, res, name, 503, `${name} is starting`);
705
+ const { headline, detail } = noPortReason(name);
706
+ sendUnavailable(req, res, name, 503, detail, headline);
550
707
  return;
551
708
  }
552
709
  if (req.headers.origin) {
553
710
  headers.origin = rewriteOrigin(req.headers.origin, publicPorts[name], childPort);
554
711
  }
555
- const err = await forward(name, req, res, childPort, headers, replayable, (u) => {
712
+ const err = await forward(name, req, res, childPort, headers, body, (u) => {
556
713
  current = u;
557
714
  });
558
715
  if (!err) return;
559
- if (!replayable || attempt >= MAX_ATTEMPTS || !RETRYABLE.has(err.code)) {
560
- // A port that ate every attempt is not coming back; anything less is a transient close.
561
- if (attempt >= MAX_ATTEMPTS) replaceChild(name, childPort, err);
562
- sendUnavailable(req, res, name, 502, err.message);
716
+ if (body === null || !RETRYABLE.has(err.code)) {
717
+ sendUnavailable(req, res, name, 502, err.message, 'did not respond');
718
+ return;
719
+ }
720
+ const next = await afterConnectFailure(name, childPort, err, attempt, budget);
721
+ if (!next.wait) {
722
+ sendUnavailable(req, res, name, 503, next.detail, next.headline);
563
723
  return;
564
724
  }
565
- await delay(ATTEMPT_DELAY_MS);
725
+ await delay(next.wait);
566
726
  }
567
727
  };
568
728
  }
@@ -580,24 +740,51 @@ function proxyUpgrade(name) {
580
740
  }
581
741
  const lines = [`${req.method} ${req.url} HTTP/${req.httpVersion}`];
582
742
  for (let i = 0; i < req.rawHeaders.length; i += 2) {
743
+ const header = req.rawHeaders[i].toLowerCase();
583
744
  let value = req.rawHeaders[i + 1];
584
- if (req.rawHeaders[i].toLowerCase() === 'origin') value = rewriteOrigin(value, publicPorts[name], childPort);
745
+ if (header === 'origin') value = rewriteOrigin(value, publicPorts[name], childPort);
746
+ if (header === 'cookie' && !(value = stripCookie(value, TOKEN_COOKIE))) continue;
585
747
  lines.push(`${req.rawHeaders[i]}: ${value}`);
586
748
  }
587
- const upstream = tcp.connect(childPort, '127.0.0.1', () => {
588
- clearTimeout(connectTimer);
589
- upstream.write(`${lines.join('\r\n')}\r\n\r\n`);
590
- if (head?.length) upstream.write(head);
591
- upstream.pipe(socket);
592
- socket.pipe(upstream);
593
- });
594
- const connectTimer = setTimeout(() => upstream.destroy(), CONNECT_TIMEOUT_MS);
595
- upstream.on('error', () => socket.destroy());
596
- socket.on('close', () => upstream.destroy());
597
- upstream.on('close', () => socket.destroy());
749
+ const budget = { deadline: Date.now() + NAV_PORT_WAIT_MS };
750
+ tunnel(name, childPort, `${lines.join('\r\n')}\r\n\r\n`, head, socket, 1, budget);
598
751
  };
599
752
  }
600
753
 
754
+ // A connect that never opened follows the same retry rule as proxyHandler.
755
+ function tunnel(name, childPort, request, head, socket, attempt, budget) {
756
+ let connected = false;
757
+ const upstream = tcp.connect(childPort, '127.0.0.1', () => {
758
+ connected = true;
759
+ clearTimeout(connectTimer);
760
+ upstream.write(request);
761
+ if (head?.length) upstream.write(head);
762
+ upstream.pipe(socket);
763
+ socket.pipe(upstream);
764
+ });
765
+ const connectTimer = setTimeout(() => upstream.destroy(connectTimeoutError(name, childPort)), CONNECT_TIMEOUT_MS);
766
+ const onSocketClose = () => upstream.destroy();
767
+ socket.on('close', onSocketClose);
768
+ upstream.on('error', async (err) => {
769
+ if (connected || socket.destroyed) {
770
+ socket.destroy();
771
+ return;
772
+ }
773
+ clearTimeout(connectTimer);
774
+ socket.off('close', onSocketClose);
775
+ upstream.removeAllListeners('close');
776
+ const next = await afterConnectFailure(name, childPort, err, attempt, budget);
777
+ if (!next.wait || socket.destroyed) {
778
+ socket.destroy();
779
+ return;
780
+ }
781
+ await delay(next.wait);
782
+ if (socket.destroyed) return;
783
+ tunnel(name, childPort, request, head, socket, attempt + 1, budget);
784
+ });
785
+ upstream.on('close', () => socket.destroy());
786
+ }
787
+
601
788
  function listenWithFallback(handler, port, onReady, label, onUpgrade) {
602
789
  const opts = { maxHeaderSize: HDR_BYTES, onUpgrade };
603
790
  const server = net.listenLoopback(handler, port, onReady, opts);
@@ -621,8 +808,6 @@ for (const [name, port] of Object.entries(publicPorts)) {
621
808
  );
622
809
  }
623
810
 
624
- ensurePool(hubConfig.activeConfigDir);
625
-
626
811
  const app = express();
627
812
 
628
813
  // Mounted before the routes below, which are registered ahead of the first
@@ -635,40 +820,36 @@ app.use(express.json());
635
820
 
636
821
  // The hub's CSS variables per color theme and mode, read from the same registry generate-themes.mjs
637
822
  // compiles into each sub-app's themes.css. Served rather than hand-copied into public/app.js so there
638
- // is one source of truth. --bg is the sub-apps' --bg-deep, so the loading screen hands off to an
639
- // iframe without a flash of another color.
823
+ // is one source of truth.
640
824
  // Read once — themes.json only changes when the generator is re-run, which restarts the hub anyway.
641
- const hubVars = (p) => ({
642
- '--accent': p.ember,
643
- '--bg': p.field,
644
- '--surface': p.surface,
645
- '--surface-hover': p.hover,
646
- '--border': p.border,
647
- '--text': p.ink1,
648
- '--text-dim': p.inkMuted,
649
- });
650
- const themePalettes = (() => {
651
- try {
652
- const themes = JSON.parse(fs.readFileSync(path.join(__dirname, 'scripts/themes.json'), 'utf8'));
653
- return Object.fromEntries(themes.map((t) => [t.id, { dark: hubVars(t.dark), light: hubVars(t.light) }]));
654
- } catch {
655
- // Palette falls back to the --accent in index.html; not worth failing startup over.
656
- return {};
657
- }
658
- })();
825
+ const THEME_REGISTRY = readRegistry();
826
+ // Read on each /api/config, so an edit applies on the next page load.
827
+ const USER_THEMES_FILE = path.join(HUB_DIR, 'themes.json');
659
828
 
660
829
  function appsConfig() {
661
830
  return Object.fromEntries(
662
831
  ENABLED_APPS.map((a) => {
663
- const entry = { name: a.name, url: `http://localhost:${publicPorts[a.id]}`, icon: a.icon };
664
- if (a.id === 'kanban' && TERMINAL_TOKEN) entry.terminalToken = TERMINAL_TOKEN;
832
+ const entry = {
833
+ name: a.name,
834
+ url: `http://localhost:${publicPorts[a.id]}`,
835
+ icon: a.icon,
836
+ loading: a.loading,
837
+ publishes: a.publishes,
838
+ };
839
+ if (TERMINAL_TOKEN && a.id === TERMINAL_APP.id) entry.terminalToken = TERMINAL_TOKEN;
665
840
  return [a.id, entry];
666
841
  }),
667
842
  );
668
843
  }
669
844
 
670
845
  app.get('/api/config', (_req, res) => {
671
- res.json({ themePalettes, apps: appsConfig(), activeConfigDir: hubConfig.activeConfigDir, defaultConfigDir });
846
+ res.json({
847
+ ...themeConfig(THEME_REGISTRY, { userFile: USER_THEMES_FILE }),
848
+ apps: appsConfig(),
849
+ actions: ACTIONS,
850
+ activeConfigDir: hubConfig.activeConfigDir,
851
+ defaultConfigDir,
852
+ });
672
853
  });
673
854
 
674
855
  app.get('/api/config-dirs', (_req, res) => {
@@ -733,30 +914,67 @@ app.post('/api/config-dirs/activate', async (req, res) => {
733
914
  res.json({ apps: appsConfig() });
734
915
  });
735
916
 
736
- // Project list for the switcher palette, proxied from kanban — it is the only sub-app that
737
- // enumerates projects. The port is read per request because kanban's real port is only known
738
- // once its banner has been scraped (see spawnApp).
917
+ // Restarting the terminal provider ends its live terminals, so that takes force: true.
918
+ const restarting = new Set();
919
+ app.post('/api/apps/:id/restart', async (req, res) => {
920
+ const pool = activePool();
921
+ const { id } = req.params;
922
+ const target = ENABLED_APPS.find((a) => a.id === id);
923
+ if (!pool || !target) {
924
+ res.status(404).json({ error: 'Unknown app' });
925
+ return;
926
+ }
927
+ const key = `${pool.dir}\n${id}`;
928
+ if (restarting.has(key)) {
929
+ res.status(409).json({ error: `${target.name} is already restarting` });
930
+ return;
931
+ }
932
+ restarting.add(key);
933
+ try {
934
+ if (TERMINAL.enabled && id === TERMINAL_APP.id && req.body?.force !== true) {
935
+ const terminals = await terminalCount(pool);
936
+ if (terminals) {
937
+ res.status(409).json({ error: `${target.name} has live terminals`, terminals });
938
+ return;
939
+ }
940
+ }
941
+ console.log(`[${id}] restart asked for by the hub page`);
942
+ if (await restartApp(pool, target)) res.json({ ok: true });
943
+ else res.status(503).json({ error: `${target.name} did not start` });
944
+ } finally {
945
+ restarting.delete(key);
946
+ }
947
+ });
948
+
949
+ // Project list for the switcher palette, proxied from the app that provides `projects`. The port is
950
+ // read per request because the provider's real port is only known once its banner has been scraped
951
+ // (see spawnApp).
739
952
  app.get('/api/projects', async (_req, res) => {
740
953
  res.setHeader('Cache-Control', 'no-store');
954
+ if (!PROJECTS_APP) {
955
+ res.status(503).json({ error: 'no enabled app provides projects' });
956
+ return;
957
+ }
958
+ const { id } = PROJECTS_APP;
741
959
  // Waits like the proxy does, so the palette is not the one thing that hard-fails during a restart.
742
- const kanbanPort = await waitForPort('kanban', Date.now() + API_PORT_WAIT_MS);
743
- if (!kanbanPort) {
744
- res.status(503).json({ error: 'kanban is starting' });
960
+ const port = await waitForPort(id, Date.now() + API_PORT_WAIT_MS);
961
+ if (!port) {
962
+ res.status(503).json({ error: `${id} is starting` });
745
963
  return;
746
964
  }
747
965
  try {
748
966
  // 127.0.0.1, not localhost — Node's verbatim DNS ordering tries ::1 first on Windows.
749
- const upstream = await fetch(`http://127.0.0.1:${kanbanPort}/api/projects`, {
967
+ const upstream = await fetch(`http://127.0.0.1:${port}${PROJECTS_APP.provides.projects.path}`, {
750
968
  signal: AbortSignal.timeout(4000),
751
969
  });
752
970
  if (!upstream.ok) {
753
- res.status(502).json({ error: `kanban responded ${upstream.status}` });
971
+ res.status(502).json({ error: `${id} responded ${upstream.status}` });
754
972
  return;
755
973
  }
756
974
  res.json(await upstream.json());
757
975
  } catch (err) {
758
976
  // Soft-fail so the palette still opens and offers literal-path entry.
759
- res.status(502).json({ error: `kanban unavailable: ${err.message}` });
977
+ res.status(502).json({ error: `${id} unavailable: ${err.message}` });
760
978
  }
761
979
  });
762
980
 
@@ -770,7 +988,11 @@ app.get('/api/resolve-path', (req, res) => {
770
988
 
771
989
  app.use(express.static(path.join(__dirname, 'public')));
772
990
 
991
+ // Children get HUB_URL at spawn and trust only that origin, so the first pool waits for the port
992
+ // the hub actually bound: HUB_PORT may be busy and fall back to a random one.
773
993
  const onReady = (actual) => {
994
+ hubPort = actual;
995
+ ensurePool(hubConfig.activeConfigDir);
774
996
  printBanner(actual);
775
997
  if (process.argv.includes('--open')) {
776
998
  import('open').then((m) => m.default(accessUrl(actual)));