claude-code-hub 0.42.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,9 +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');
11
+ const { actionTable } = require('./lib/actions');
12
+ const { selectApps, loadApp, conflicts } = require('./lib/apps');
12
13
  const { stripCookie } = require('./lib/cookies');
13
- const { createNetGuard } = require('./lib/net-guard');
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');
14
18
 
15
19
  function getArg(name) {
16
20
  const idx = process.argv.findIndex((a) => a.startsWith(`--${name}`));
@@ -42,9 +46,25 @@ function activePool() {
42
46
  // Every sub-app resolves CLAUDE_CONFIG_DIR once at startup into a module constant, so switching
43
47
  // the dir means restarting the children. Kept on the server, not in the browser: the dir has to
44
48
  // be known before the first spawn, and the hub already has one localStorage key to reason about.
45
- 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'));
46
50
  const HUB_CONFIG_FILE = path.join(HUB_DIR, 'config.json');
47
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
+
48
68
  // The dir the hub would use with no saved config. Set by loadHubConfig; the client uses it to keep
49
69
  // the default dir out of the window title.
50
70
  let defaultConfigDir = null;
@@ -106,13 +126,25 @@ function saveHubConfig() {
106
126
 
107
127
  const hubConfig = loadHubConfig();
108
128
 
109
- const { apps: ENABLED_APPS, unknown: unknownApps } = selectApps(hubConfig.apps);
110
- for (const id of unknownApps) console.log(`Unknown app "${id}" in ${HUB_CONFIG_FILE}, ignored`);
111
- 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) {
112
135
  console.error(`Every app is disabled in ${HUB_CONFIG_FILE}. Enable at least one.`);
113
136
  process.exit(1);
114
137
  }
115
- 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');
116
148
 
117
149
  // Every keystroke into cck's terminal passes through the upgrade proxy below. Windows only, for the
118
150
  // reasons in cck/lib/priority.js; the children spawned after this still start at normal.
@@ -136,8 +168,7 @@ const net = createNetGuard({ appName: 'Claude Code Hub' });
136
168
  // the WebSocket. cck applies the rest of its policy (exposure refusal, Origin, session cap) itself.
137
169
  const TERMINAL = {
138
170
  ...(hubConfig.terminal || {}),
139
- enabled:
140
- appEnabled('kanban') && !process.argv.includes('--disable-terminal') && hubConfig.terminal?.enabled !== false,
171
+ enabled: !!TERMINAL_APP && !process.argv.includes('--disable-terminal') && hubConfig.terminal?.enabled !== false,
141
172
  };
142
173
  const TERMINAL_TOKEN = TERMINAL.enabled ? crypto.randomBytes(32).toString('hex') : null;
143
174
 
@@ -149,6 +180,7 @@ const TOKEN_COOKIE = 'hub_token';
149
180
  const TOKEN_COOKIE_RE = /(?:^|;\s*)hub_token=([^;]*)/;
150
181
  const TOKEN_COOKIE_MAX_AGE_MS = 400 * 24 * 60 * 60 * 1000;
151
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');
152
184
 
153
185
  function loadHubToken() {
154
186
  try {
@@ -196,8 +228,9 @@ function childEnv(pool, name) {
196
228
  HUB_URL: `http://localhost:${hubPort}`,
197
229
  HOST: net.BIND_HOST,
198
230
  ALLOWED_HOSTS: net.ALLOWED_HOSTS,
231
+ HUB_SDK_SERVER,
199
232
  };
200
- if (name === 'kanban' && TERMINAL.enabled) {
233
+ if (TERMINAL.enabled && name === TERMINAL_APP.id) {
201
234
  env.CCK_TERMINAL = JSON.stringify(TERMINAL);
202
235
  env.CCK_TERMINAL_TOKEN = TERMINAL_TOKEN;
203
236
  }
@@ -208,7 +241,7 @@ function spawnApp(pool, name, cmd, args) {
208
241
  const child = spawn(cmd, args, {
209
242
  cwd: __dirname,
210
243
  env: childEnv(pool, name),
211
- stdio: ['ignore', 'pipe', 'pipe'],
244
+ stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
212
245
  });
213
246
 
214
247
  let stdoutBuf = '';
@@ -233,8 +266,9 @@ function spawnApp(pool, name, cmd, args) {
233
266
  child.stderr.on('data', (d) => process.stderr.write(`[${name}] ${d}`));
234
267
  child.on('exit', (code) => {
235
268
  console.log(`[${name}] exited (code ${code})`);
236
- // The port dies with the child and the OS may hand it to a stranger, so stop dialing it.
237
- 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];
238
272
  markReady();
239
273
  if (pool.retired || pool.children.get(name) !== child) return;
240
274
  // Nothing else would ever bring this app back: a pool is only rebuilt when its dir is
@@ -242,6 +276,7 @@ function spawnApp(pool, name, cmd, args) {
242
276
  const recent = (pool.restarts[name] || []).filter((t) => Date.now() - t < RESTART_WINDOW_MS);
243
277
  if (recent.length >= MAX_RESTARTS) {
244
278
  console.log(`[${name}] gave up after ${MAX_RESTARTS} restarts in ${RESTART_WINDOW_MS / 1000}s`);
279
+ child.gaveUp = true;
245
280
  return;
246
281
  }
247
282
  recent.push(Date.now());
@@ -257,22 +292,27 @@ function spawnApp(pool, name, cmd, args) {
257
292
  return child;
258
293
  }
259
294
 
260
- function killChild(child) {
261
- if (child.exitCode !== null || child.killed) return;
262
- if (process.platform === 'win32') {
263
- spawn('taskkill', ['/pid', String(child.pid), '/f', '/t'], { stdio: 'ignore' });
264
- } else {
265
- child.kill();
266
- }
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];
267
307
  }
268
308
 
269
309
  function killPool(pool) {
270
310
  pool.retired = true;
271
- for (const child of pool.children.values()) killChild(child);
311
+ return Promise.all([...pool.children.values()].map((child) => killChild(child)));
272
312
  }
273
313
 
274
314
  function killAll() {
275
- for (const pool of pools.values()) killPool(pool);
315
+ return Promise.all([...pools.values()].map(killPool));
276
316
  }
277
317
 
278
318
  function withTimeout(promise, ms) {
@@ -305,10 +345,12 @@ async function ensurePool(dir) {
305
345
  }
306
346
 
307
347
  async function terminalCount(pool) {
308
- const port = pool.ports.kanban;
348
+ const port = pool.ports[TERMINAL_APP.id];
309
349
  if (!port) return 0;
310
350
  try {
311
- 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
+ });
312
354
  return r.ok ? (await r.json()).sessions?.length || 0 : 0;
313
355
  } catch {
314
356
  return 0;
@@ -342,9 +384,12 @@ function dropPool(dir) {
342
384
  killPool(pool);
343
385
  }
344
386
 
387
+ // Waits for the children, so a POSIX child that ignores SIGTERM still gets SIGKILL before the hub exits.
388
+ let shuttingDown = false;
345
389
  function shutdown() {
346
- killAll();
347
- process.exit(0);
390
+ if (shuttingDown) return;
391
+ shuttingDown = true;
392
+ killAll().then(() => process.exit(0));
348
393
  }
349
394
 
350
395
  process.on('SIGINT', shutdown);
@@ -366,17 +411,6 @@ process.stdin.on('data', (data) => {
366
411
  process.stdin.on('end', shutdown);
367
412
  process.stdin.on('close', shutdown);
368
413
 
369
- function resolveApp(submoduleDir, npmPackage) {
370
- const local = path.join(__dirname, submoduleDir, 'server.js');
371
- try {
372
- require.resolve(local);
373
- return local;
374
- } catch {}
375
- return require.resolve(`${npmPackage}/server.js`);
376
- }
377
-
378
- const appPaths = Object.fromEntries(ENABLED_APPS.map((a) => [a.id, resolveApp(a.dir, a.pkg)]));
379
-
380
414
  // Raise header size limit to 64KB — localhost cookies from sibling apps can pile up and
381
415
  // trip Node's default 16KB limit, breaking iframes with HTTP 431.
382
416
  const HDR_BYTES = 65536;
@@ -385,13 +419,15 @@ const NODE_HDR = `--max-http-header-size=${HDR_BYTES}`;
385
419
  // Children bind ephemeral ports; the public ports below belong to the hub's proxies. Browser
386
420
  // localStorage is keyed by origin, so the sub-app origin has to stay put across switches or the
387
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
+
388
426
  function spawnChildren(pool) {
389
- 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);
390
428
  }
391
429
 
392
- const publicPorts = Object.fromEntries(
393
- ENABLED_APPS.map((a) => [a.id, parseInt(getArg(`${a.id}-port`) || String(a.port), 10)]),
394
- );
430
+ const publicPorts = Object.fromEntries(ENABLED_APPS.map((a) => [a.id, a.port]));
395
431
 
396
432
  function rewriteOrigin(origin, publicPort, childPort) {
397
433
  try {
@@ -404,7 +440,9 @@ function rewriteOrigin(origin, publicPort, childPort) {
404
440
  }
405
441
  }
406
442
 
407
- 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 });
408
446
 
409
447
  // Two failures look the same from here and both recover on their own: a pooled socket the child
410
448
  // closed under us, and a child that is restarting. The port is re-read per attempt, so a replay
@@ -436,24 +474,33 @@ async function waitForPort(name, deadline) {
436
474
  const pool = activePool();
437
475
  const port = pool?.ports[name];
438
476
  if (port) return port;
439
- if (pool && !pool.children.has(name)) return null;
477
+ if (pool?.children.get(name)?.gaveUp) return null;
440
478
  const remaining = deadline - Date.now();
441
479
  if (remaining <= 0) return null;
442
480
  await Promise.race([pool?.children.get(name)?.ready, delay(Math.min(remaining, 250))]);
443
481
  }
444
482
  }
445
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
+
446
493
  const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
447
494
 
448
495
  // A document load that fails gets a page that reloads itself — the reload the user would do by
449
496
  // hand. The cap is small because the server already waited NAV_PORT_WAIT_MS before serving this,
450
497
  // and each reload re-spends that wait. sessionStorage throws when site data is blocked, and a page
451
498
  // whose only job is to reload must still reload there.
452
- function retryPage(name, detail) {
499
+ function retryPage(name, headline, detail) {
453
500
  return `<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(name)}</title>
454
501
  <style>:root{color-scheme:light dark}body{font:14px system-ui,sans-serif;margin:0;display:grid;place-items:center;height:100vh}
455
502
  div{text-align:center;opacity:.7}code{font-size:12px;opacity:.6}</style>
456
- </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>
457
504
  <script>
458
505
  let n = 0;
459
506
  const k = 'hub-retry:' + location.pathname;
@@ -463,11 +510,11 @@ else { try { sessionStorage.removeItem(k); } catch {} document.getElementById('m
463
510
  </script></body></html>`;
464
511
  }
465
512
 
466
- function sendUnavailable(req, res, name, status, detail) {
513
+ function sendUnavailable(req, res, name, status, detail, headline) {
467
514
  if (res.destroyed || res.headersSent) return;
468
515
  if (isNavigation(req)) {
469
516
  res.writeHead(status, { 'Retry-After': '1', 'Content-Type': 'text/html; charset=utf-8' });
470
- res.end(retryPage(name, detail));
517
+ res.end(retryPage(name, headline, detail));
471
518
  return;
472
519
  }
473
520
  // The sub-app's own callers hand the body to .json(): plain text surfaces as a SyntaxError
@@ -476,26 +523,98 @@ function sendUnavailable(req, res, name, status, detail) {
476
523
  res.end(JSON.stringify({ error: detail }));
477
524
  }
478
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
+
479
600
  // A pooled socket the child closed under us is normal and recovers on the next attempt. A port that
480
601
  // never completes a connect does not, so the child is replaced and the respawn gives it a new one.
481
602
  function replaceChild(name, childPort, err) {
482
- const pool = activePool();
483
- const child = pool?.children.get(name);
484
- if (!child || pool.ports[name] !== childPort) return;
603
+ const child = currentChild(name, childPort);
604
+ if (!child) return;
485
605
  console.log(`[${name}] unreachable at 127.0.0.1:${childPort} (${err.code}) - replacing it`);
486
- delete pool.ports[name];
606
+ delete activePool().ports[name];
487
607
  killChild(child);
488
608
  }
489
609
 
610
+ const connectTimeoutError = (name, childPort) =>
611
+ Object.assign(new Error(`no connect to ${name} at 127.0.0.1:${childPort}`), { code: 'ETIMEDOUT' });
612
+
490
613
  // Fires only while the socket is still connecting, so a slow handler is never interrupted.
491
614
  function capConnect(upstream, name, childPort) {
492
615
  upstream.on('socket', (socket) => {
493
616
  if (!socket.connecting) return;
494
- const timer = setTimeout(() => {
495
- const err = new Error(`no connect to ${name} at 127.0.0.1:${childPort}`);
496
- err.code = 'ETIMEDOUT';
497
- upstream.destroy(err);
498
- }, CONNECT_TIMEOUT_MS);
617
+ const timer = setTimeout(() => upstream.destroy(connectTimeoutError(name, childPort)), CONNECT_TIMEOUT_MS);
499
618
  const clear = () => clearTimeout(timer);
500
619
  socket.once('connect', clear);
501
620
  upstream.once('close', clear);
@@ -504,7 +623,29 @@ function capConnect(upstream, name, childPort) {
504
623
 
505
624
  // Resolves null once the response is committed (or the client is gone), or the error when nothing
506
625
  // has been written yet and the caller may still retry.
507
- 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) {
508
649
  return new Promise((resolve) => {
509
650
  const upstream = http.request(
510
651
  { host: '127.0.0.1', port: childPort, method: req.method, path: req.url, headers, agent: proxyAgent },
@@ -524,11 +665,18 @@ function forward(name, req, res, childPort, headers, replayable, onUpstream) {
524
665
  }
525
666
  resolve(err);
526
667
  });
527
- if (replayable) upstream.end();
668
+ if (body) upstream.end(body.length ? body : undefined);
528
669
  else req.pipe(upstream);
529
670
  });
530
671
  }
531
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
+
532
680
  // Host is forwarded untouched so the child's hostGuard still sees what the browser sent. Origin is
533
681
  // the one header the child cannot judge on its own: its guard compares the port to its own
534
682
  // ephemeral one, so the public-port spelling is mapped to the child's; anything else passes
@@ -540,38 +688,41 @@ function proxyHandler(name) {
540
688
  const cookie = stripCookie(headers.cookie, TOKEN_COOKIE);
541
689
  if (cookie) headers.cookie = cookie;
542
690
  else delete headers.cookie;
543
- // req is consumed by the first attempt, so only a request with no body can be re-sent. Waiting
544
- // for a port is not a replay — nothing has been sent yet — so every method gets the same wait.
545
- const replayable = req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS';
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;
546
696
  // One deadline for the whole request, not one per attempt: a retry must not re-spend the wait.
547
- 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) };
548
698
  // One listener for the request, however many attempts it takes.
549
699
  let current = null;
550
700
  res.once('close', () => current?.destroy());
551
701
 
552
702
  for (let attempt = 1; !res.destroyed; attempt++) {
553
- const childPort = await waitForPort(name, deadline);
703
+ const childPort = await waitForPort(name, budget.deadline);
554
704
  if (!childPort) {
555
- sendUnavailable(req, res, name, 503, `${name} is starting`);
705
+ const { headline, detail } = noPortReason(name);
706
+ sendUnavailable(req, res, name, 503, detail, headline);
556
707
  return;
557
708
  }
558
709
  if (req.headers.origin) {
559
710
  headers.origin = rewriteOrigin(req.headers.origin, publicPorts[name], childPort);
560
711
  }
561
- const err = await forward(name, req, res, childPort, headers, replayable, (u) => {
712
+ const err = await forward(name, req, res, childPort, headers, body, (u) => {
562
713
  current = u;
563
714
  });
564
715
  if (!err) return;
565
- if (!replayable || attempt >= MAX_ATTEMPTS || !RETRYABLE.has(err.code)) {
566
- // A port that ate every attempt is not coming back; anything less is a transient close.
567
- if (attempt >= MAX_ATTEMPTS) replaceChild(name, childPort, err);
568
- 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');
569
718
  return;
570
719
  }
571
- console.warn(
572
- `[${name}] ${req.method} ${req.url.split('?')[0]} to 127.0.0.1:${childPort} failed (${err.code}), retrying`,
573
- );
574
- await delay(ATTEMPT_DELAY_MS);
720
+ const next = await afterConnectFailure(name, childPort, err, attempt, budget);
721
+ if (!next.wait) {
722
+ sendUnavailable(req, res, name, 503, next.detail, next.headline);
723
+ return;
724
+ }
725
+ await delay(next.wait);
575
726
  }
576
727
  };
577
728
  }
@@ -595,13 +746,13 @@ function proxyUpgrade(name) {
595
746
  if (header === 'cookie' && !(value = stripCookie(value, TOKEN_COOKIE))) continue;
596
747
  lines.push(`${req.rawHeaders[i]}: ${value}`);
597
748
  }
598
- tunnel(name, childPort, `${lines.join('\r\n')}\r\n\r\n`, head, socket, 1);
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);
599
751
  };
600
752
  }
601
753
 
602
- // A fresh loopback connect to a child sometimes fails at once with ETIMEDOUT on Windows (cause
603
- // unknown, seen only on a just-started hub), so a connect that never opened is tried once more.
604
- function tunnel(name, childPort, request, head, socket, retries) {
754
+ // A connect that never opened follows the same retry rule as proxyHandler.
755
+ function tunnel(name, childPort, request, head, socket, attempt, budget) {
605
756
  let connected = false;
606
757
  const upstream = tcp.connect(childPort, '127.0.0.1', () => {
607
758
  connected = true;
@@ -611,20 +762,25 @@ function tunnel(name, childPort, request, head, socket, retries) {
611
762
  upstream.pipe(socket);
612
763
  socket.pipe(upstream);
613
764
  });
614
- const connectTimer = setTimeout(() => upstream.destroy(), CONNECT_TIMEOUT_MS);
765
+ const connectTimer = setTimeout(() => upstream.destroy(connectTimeoutError(name, childPort)), CONNECT_TIMEOUT_MS);
615
766
  const onSocketClose = () => upstream.destroy();
616
767
  socket.on('close', onSocketClose);
617
- upstream.on('error', (err) => {
618
- if (connected || socket.destroyed || retries < 1) {
619
- if (!connected) console.warn(`[${name}] upgrade connect to 127.0.0.1:${childPort} failed (${err.code})`);
768
+ upstream.on('error', async (err) => {
769
+ if (connected || socket.destroyed) {
620
770
  socket.destroy();
621
771
  return;
622
772
  }
623
- console.warn(`[${name}] upgrade connect to 127.0.0.1:${childPort} failed (${err.code}), retrying`);
624
773
  clearTimeout(connectTimer);
625
774
  socket.off('close', onSocketClose);
626
775
  upstream.removeAllListeners('close');
627
- tunnel(name, childPort, request, head, socket, retries - 1);
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);
628
784
  });
629
785
  upstream.on('close', () => socket.destroy());
630
786
  }
@@ -664,40 +820,36 @@ app.use(express.json());
664
820
 
665
821
  // The hub's CSS variables per color theme and mode, read from the same registry generate-themes.mjs
666
822
  // compiles into each sub-app's themes.css. Served rather than hand-copied into public/app.js so there
667
- // is one source of truth. --bg is the sub-apps' --bg-deep, so the loading screen hands off to an
668
- // iframe without a flash of another color.
823
+ // is one source of truth.
669
824
  // Read once — themes.json only changes when the generator is re-run, which restarts the hub anyway.
670
- const hubVars = (p) => ({
671
- '--accent': p.ember,
672
- '--bg': p.field,
673
- '--surface': p.surface,
674
- '--surface-hover': p.hover,
675
- '--border': p.border,
676
- '--text': p.ink1,
677
- '--text-dim': p.inkMuted,
678
- });
679
- const themePalettes = (() => {
680
- try {
681
- const themes = JSON.parse(fs.readFileSync(path.join(__dirname, 'lib/themes.json'), 'utf8'));
682
- return Object.fromEntries(themes.map((t) => [t.id, { dark: hubVars(t.dark), light: hubVars(t.light) }]));
683
- } catch {
684
- // Palette falls back to the --accent in index.html; not worth failing startup over.
685
- return {};
686
- }
687
- })();
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');
688
828
 
689
829
  function appsConfig() {
690
830
  return Object.fromEntries(
691
831
  ENABLED_APPS.map((a) => {
692
- const entry = { name: a.name, url: `http://localhost:${publicPorts[a.id]}`, icon: a.icon };
693
- 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;
694
840
  return [a.id, entry];
695
841
  }),
696
842
  );
697
843
  }
698
844
 
699
845
  app.get('/api/config', (_req, res) => {
700
- 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
+ });
701
853
  });
702
854
 
703
855
  app.get('/api/config-dirs', (_req, res) => {
@@ -762,30 +914,67 @@ app.post('/api/config-dirs/activate', async (req, res) => {
762
914
  res.json({ apps: appsConfig() });
763
915
  });
764
916
 
765
- // Project list for the switcher palette, proxied from kanban — it is the only sub-app that
766
- // enumerates projects. The port is read per request because kanban's real port is only known
767
- // 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).
768
952
  app.get('/api/projects', async (_req, res) => {
769
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;
770
959
  // Waits like the proxy does, so the palette is not the one thing that hard-fails during a restart.
771
- const kanbanPort = await waitForPort('kanban', Date.now() + API_PORT_WAIT_MS);
772
- if (!kanbanPort) {
773
- 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` });
774
963
  return;
775
964
  }
776
965
  try {
777
966
  // 127.0.0.1, not localhost — Node's verbatim DNS ordering tries ::1 first on Windows.
778
- 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}`, {
779
968
  signal: AbortSignal.timeout(4000),
780
969
  });
781
970
  if (!upstream.ok) {
782
- res.status(502).json({ error: `kanban responded ${upstream.status}` });
971
+ res.status(502).json({ error: `${id} responded ${upstream.status}` });
783
972
  return;
784
973
  }
785
974
  res.json(await upstream.json());
786
975
  } catch (err) {
787
976
  // Soft-fail so the palette still opens and offers literal-path entry.
788
- res.status(502).json({ error: `kanban unavailable: ${err.message}` });
977
+ res.status(502).json({ error: `${id} unavailable: ${err.message}` });
789
978
  }
790
979
  });
791
980