claude-code-hub 0.37.0 → 0.39.0-rc.1

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
@@ -1,9 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  const { spawn } = require('child_process');
4
+ const crypto = require('crypto');
4
5
  const express = require('express');
5
6
  const fs = require('fs');
6
7
  const http = require('http');
8
+ const tcp = require('net');
7
9
  const os = require('os');
8
10
  const path = require('path');
9
11
  const { createNetGuard } = require('./lib/net-guard');
@@ -24,6 +26,12 @@ const MEMORY_PORT = parseInt(getArg('memory-port') || '3544', 10);
24
26
 
25
27
  const POOL_SIZE = Math.max(1, parseInt(getArg('pool-size') || '3', 10));
26
28
  const DEFAULT_PORTS = { marketplace: MARKETPLACE_PORT, kanban: KANBAN_PORT, cost: COST_PORT, memory: MEMORY_PORT };
29
+ const RESTART_DELAY_MS = 500;
30
+ // A rate window, not a total: a child may die and recover all session, but one that cannot stay up
31
+ // stops being restarted. A consecutive counter cannot express that — anything that starts at all
32
+ // clears it, so a child that starts and dies forever restarts forever.
33
+ const MAX_RESTARTS = 5;
34
+ const RESTART_WINDOW_MS = 60_000;
27
35
 
28
36
  // One child set per config dir, kept alive after a switch so switching back is instant. Map
29
37
  // insertion order doubles as LRU: activating a dir re-inserts it at the end.
@@ -36,7 +44,8 @@ function activePool() {
36
44
  // Every sub-app resolves CLAUDE_CONFIG_DIR once at startup into a module constant, so switching
37
45
  // the dir means restarting the children. Kept on the server, not in the browser: the dir has to
38
46
  // be known before the first spawn, and the hub already has one localStorage key to reason about.
39
- const HUB_CONFIG_FILE = path.join(os.homedir(), '.claude-hub', 'config.json');
47
+ const HUB_DIR = path.join(os.homedir(), '.claude-hub');
48
+ const HUB_CONFIG_FILE = path.join(HUB_DIR, 'config.json');
40
49
 
41
50
  // The dir the hub would use with no saved config. Set by loadHubConfig; the client uses it to keep
42
51
  // the default dir out of the window title.
@@ -76,6 +85,8 @@ function loadHubConfig() {
76
85
  const savedActive = typeof saved.activeConfigDir === 'string' ? canonical(saved.activeConfigDir) : null;
77
86
  const active = dirs.includes(savedActive) ? savedActive : fallback;
78
87
  const config = { configDirs: dirs, activeConfigDir: active };
88
+ // Hand-edited only; kept verbatim so a rewrite of the file does not drop it.
89
+ if (saved.terminal && typeof saved.terminal === 'object') config.terminal = saved.terminal;
79
90
  // Configs saved before canonicalization can hold one dir under two spellings; persist the merge.
80
91
  if (serializeConfig(config) !== raw) writeHubConfig(config);
81
92
  return config;
@@ -101,18 +112,83 @@ const hubConfig = loadHubConfig();
101
112
  // while every iframe 403'd on its own Host check.
102
113
  const net = createNetGuard({ appName: 'Claude Code Hub' });
103
114
 
115
+ // cck's embedded terminal, on by default under the hub (standalone cck keeps it opt-in).
116
+ // `"terminal": {"enabled": false}` or --disable-terminal turns it off. One token per hub launch,
117
+ // shared by every pool: the hub puts it in the kanban iframe's URL fragment and cck checks it on
118
+ // the WebSocket. cck applies the rest of its policy (exposure refusal, Origin, session cap) itself.
119
+ const TERMINAL = {
120
+ ...(hubConfig.terminal || {}),
121
+ enabled: !process.argv.includes('--disable-terminal') && hubConfig.terminal?.enabled !== false,
122
+ };
123
+ const TERMINAL_TOKEN = TERMINAL.enabled ? crypto.randomBytes(32).toString('hex') : null;
124
+
125
+ // Loopback is not a user boundary: any local account can reach the hub port, and /api/config carries
126
+ // the terminal token. Persisted, unlike the terminal token, so an installed PWA's cookie outlives a
127
+ // hub restart.
128
+ const HUB_TOKEN_FILE = path.join(HUB_DIR, 'token');
129
+ const TOKEN_COOKIE = 'hub_token';
130
+ const TOKEN_COOKIE_RE = /(?:^|;\s*)hub_token=([^;]*)/;
131
+ const TOKEN_COOKIE_MAX_AGE_MS = 400 * 24 * 60 * 60 * 1000;
132
+ const LOCKED_PAGE = path.join(__dirname, 'public', 'locked.html');
133
+
134
+ function loadHubToken() {
135
+ try {
136
+ const saved = fs.readFileSync(HUB_TOKEN_FILE, 'utf8').trim();
137
+ if (/^[0-9a-f]{64}$/.test(saved)) return saved;
138
+ } catch {}
139
+ const token = crypto.randomBytes(32).toString('hex');
140
+ fs.mkdirSync(HUB_DIR, { recursive: true, mode: 0o700 });
141
+ fs.writeFileSync(HUB_TOKEN_FILE, `${token}\n`, { mode: 0o600 });
142
+ return token;
143
+ }
144
+
145
+ const HUB_TOKEN = loadHubToken();
146
+ const HUB_TOKEN_BUF = Buffer.from(HUB_TOKEN);
147
+
148
+ function tokenMatches(candidate) {
149
+ if (typeof candidate !== 'string' || candidate.length !== HUB_TOKEN.length) return false;
150
+ return crypto.timingSafeEqual(Buffer.from(candidate), HUB_TOKEN_BUF);
151
+ }
152
+
153
+ // Scripts, styles, icons and the manifest stay public: they hold no secrets, and Chrome fetches the
154
+ // manifest without cookies, so gating it would break PWA install.
155
+ function tokenGuard(req, res, next) {
156
+ const isApi = req.path.startsWith('/api/');
157
+ if (!isApi && req.path !== '/' && req.path !== '/index.html') return next();
158
+ if (tokenMatches(req.query.token)) {
159
+ res.cookie(TOKEN_COOKIE, HUB_TOKEN, { httpOnly: true, sameSite: 'strict', maxAge: TOKEN_COOKIE_MAX_AGE_MS });
160
+ if (isApi) return next();
161
+ const url = new URL(req.originalUrl, 'http://hub');
162
+ url.searchParams.delete('token');
163
+ return res.redirect(302, url.pathname + url.search);
164
+ }
165
+ if (tokenMatches(TOKEN_COOKIE_RE.exec(req.headers.cookie || '')?.[1])) return next();
166
+ res.setHeader('Cache-Control', 'no-store');
167
+ if (isApi) return res.status(401).json({ error: 'hub token required' });
168
+ res.status(401).sendFile(LOCKED_PAGE, { cacheControl: false });
169
+ }
170
+
171
+ function childEnv(pool, name) {
172
+ const env = {
173
+ ...process.env,
174
+ PORT: '0',
175
+ CLAUDE_CONFIG_DIR: pool.dir,
176
+ CLAUDE_HUB: '1',
177
+ HUB_URL: `http://localhost:${HUB_PORT}`,
178
+ HOST: net.BIND_HOST,
179
+ ALLOWED_HOSTS: net.ALLOWED_HOSTS,
180
+ };
181
+ if (name === 'kanban' && TERMINAL.enabled) {
182
+ env.CCK_TERMINAL = JSON.stringify(TERMINAL);
183
+ env.CCK_TERMINAL_TOKEN = TERMINAL_TOKEN;
184
+ }
185
+ return env;
186
+ }
187
+
104
188
  function spawnApp(pool, name, cmd, args) {
105
189
  const child = spawn(cmd, args, {
106
190
  cwd: __dirname,
107
- env: {
108
- ...process.env,
109
- PORT: '0',
110
- CLAUDE_CONFIG_DIR: pool.dir,
111
- CLAUDE_HUB: '1',
112
- HUB_URL: `http://localhost:${HUB_PORT}`,
113
- HOST: net.BIND_HOST,
114
- ALLOWED_HOSTS: net.ALLOWED_HOSTS,
115
- },
191
+ env: childEnv(pool, name),
116
192
  stdio: ['ignore', 'pipe', 'pipe'],
117
193
  });
118
194
 
@@ -138,24 +214,44 @@ function spawnApp(pool, name, cmd, args) {
138
214
  child.stderr.on('data', (d) => process.stderr.write(`[${name}] ${d}`));
139
215
  child.on('exit', (code) => {
140
216
  console.log(`[${name}] exited (code ${code})`);
217
+ // The port dies with the child and the OS may hand it to a stranger, so stop dialing it.
218
+ delete pool.ports[name];
141
219
  markReady();
220
+ if (pool.retired || pool.children.get(name) !== child) return;
221
+ // Nothing else would ever bring this app back: a pool is only rebuilt when its dir is
222
+ // re-activated.
223
+ const recent = (pool.restarts[name] || []).filter((t) => Date.now() - t < RESTART_WINDOW_MS);
224
+ if (recent.length >= MAX_RESTARTS) {
225
+ console.log(`[${name}] gave up after ${MAX_RESTARTS} restarts in ${RESTART_WINDOW_MS / 1000}s`);
226
+ return;
227
+ }
228
+ recent.push(Date.now());
229
+ pool.restarts[name] = recent;
230
+ setTimeout(() => {
231
+ if (pool.retired) return;
232
+ console.log(`[${name}] restarting (${recent.length}/${MAX_RESTARTS})`);
233
+ spawnApp(pool, name, cmd, args);
234
+ }, RESTART_DELAY_MS).unref();
142
235
  });
143
236
 
144
- pool.children.push(child);
237
+ pool.children.set(name, child);
145
238
  return child;
146
239
  }
147
240
 
148
- function killPool(pool) {
149
- for (const child of pool.children) {
150
- if (child.exitCode !== null || child.killed) continue;
151
- if (process.platform === 'win32') {
152
- spawn('taskkill', ['/pid', String(child.pid), '/f', '/t'], { stdio: 'ignore' });
153
- } else {
154
- child.kill();
155
- }
241
+ function killChild(child) {
242
+ if (child.exitCode !== null || child.killed) return;
243
+ if (process.platform === 'win32') {
244
+ spawn('taskkill', ['/pid', String(child.pid), '/f', '/t'], { stdio: 'ignore' });
245
+ } else {
246
+ child.kill();
156
247
  }
157
248
  }
158
249
 
250
+ function killPool(pool) {
251
+ pool.retired = true;
252
+ for (const child of pool.children.values()) killChild(child);
253
+ }
254
+
159
255
  function killAll() {
160
256
  for (const pool of pools.values()) killPool(pool);
161
257
  }
@@ -178,18 +274,48 @@ async function ensurePool(dir) {
178
274
  pools.set(dir, pool);
179
275
  return pool.ready;
180
276
  }
181
- while (pools.size >= POOL_SIZE) {
182
- const oldestDir = pools.keys().next().value;
183
- console.log(`Evicting children for ${oldestDir}`);
184
- dropPool(oldestDir);
185
- }
186
- pool = { dir, children: [], ports: {} };
277
+ if (pools.size >= POOL_SIZE) await evictPools(dir);
278
+ pool = pools.get(dir);
279
+ if (pool) return pool.ready;
280
+ pool = { dir, children: new Map(), ports: {}, restarts: {}, retired: false };
187
281
  pools.set(dir, pool);
188
282
  spawnChildren(pool);
189
- pool.ready = withTimeout(Promise.all(pool.children.map((c) => c.ready)), 20000).then(() => pool);
283
+ const ready = [...pool.children.values()].map((c) => c.ready);
284
+ pool.ready = withTimeout(Promise.all(ready), 20000).then(() => pool);
190
285
  return pool.ready;
191
286
  }
192
287
 
288
+ async function terminalCount(pool) {
289
+ const port = pool.ports.kanban;
290
+ if (!port) return 0;
291
+ try {
292
+ const r = await fetch(`http://127.0.0.1:${port}/api/terminals`, { signal: AbortSignal.timeout(1500) });
293
+ return r.ok ? (await r.json()).sessions?.length || 0 : 0;
294
+ } catch {
295
+ return 0;
296
+ }
297
+ }
298
+
299
+ // A pool whose cck holds live terminals is pinned: killing it would kill running claude sessions.
300
+ // When every candidate is pinned the pool size is exceeded rather than losing work.
301
+ async function evictPools(keepDir) {
302
+ let pinned = new Set();
303
+ if (TERMINAL.enabled) {
304
+ const candidates = [...pools.values()].filter((p) => p.dir !== keepDir);
305
+ const counts = await Promise.all(candidates.map(terminalCount));
306
+ pinned = new Set(candidates.filter((_, i) => counts[i]).map((p) => p.dir));
307
+ }
308
+ while (pools.size >= POOL_SIZE) {
309
+ const victim = [...pools.keys()].find((d) => d !== keepDir && !pinned.has(d));
310
+ if (!victim) {
311
+ console.log(`Pool size ${POOL_SIZE} exceeded: every other pool has live terminals`);
312
+ return;
313
+ }
314
+ console.log(`Evicting children for ${victim}`);
315
+ dropPool(victim);
316
+ }
317
+ }
318
+
193
319
  function dropPool(dir) {
194
320
  const pool = pools.get(dir);
195
321
  if (!pool) return;
@@ -265,42 +391,206 @@ function rewriteOrigin(origin, publicPort, childPort) {
265
391
 
266
392
  const proxyAgent = new http.Agent({ keepAlive: true, maxSockets: 64 });
267
393
 
268
- // Host is forwarded untouched so the child's hostGuard still sees what the browser sent. Origin is
269
- // the one header the child cannot judge on its own: its guard compares the port to its own
270
- // ephemeral one, so the public-port spelling is mapped to the child's; anything else passes
271
- // through as-is and the child rejects it.
272
- function proxyHandler(name) {
273
- return (req, res) => {
274
- const childPort = activePool()?.ports[name];
275
- if (!childPort) {
276
- res.writeHead(503, { 'Retry-After': '1' });
277
- res.end(`${name} is starting`);
278
- return;
279
- }
280
- const headers = { ...req.headers };
281
- if (headers.origin) headers.origin = rewriteOrigin(headers.origin, publicPorts[name], childPort);
394
+ // Two failures look the same from here and both recover on their own: a pooled socket the child
395
+ // closed under us, and a child that is restarting. The port is re-read per attempt, so a replay
396
+ // reaches the replacement child rather than the ephemeral port that died with the old one.
397
+ const RETRYABLE = new Set(['ECONNRESET', 'EPIPE', 'ECONNABORTED', 'ECONNREFUSED', 'ETIMEDOUT']);
398
+ const MAX_ATTEMPTS = 3;
399
+ const ATTEMPT_DELAY_MS = 250;
400
+ // A connect to a freshly-bound loopback port is sometimes black-holed on Windows (the child shows
401
+ // as LISTENING and answers nothing) and the OS gives up only after ~20s. This caps the connect
402
+ // alone, never the response: a sub-app handler may legitimately take minutes on a cold scan, and
403
+ // capping time-to-first-byte would kill that request, replay it, and then kill a healthy child.
404
+ const CONNECT_TIMEOUT_MS = 3000;
405
+ // A restart costs about a second, so a document load waits it out instead of showing an error.
406
+ const NAV_PORT_WAIT_MS = 10000;
407
+ const API_PORT_WAIT_MS = 2000;
408
+
409
+ const delay = (ms) => new Promise((r) => setTimeout(r, ms).unref());
410
+
411
+ // An iframe document load, as opposed to the sub-app's own fetch/XHR.
412
+ function isNavigation(req) {
413
+ if (req.headers['sec-fetch-mode']) return req.headers['sec-fetch-mode'] === 'navigate';
414
+ return (req.headers.accept || '').includes('text/html');
415
+ }
416
+
417
+ // Races the child's own ready promise rather than polling it, so a restarted app starts serving the
418
+ // moment it prints its port instead of on the next tick.
419
+ async function waitForPort(name, deadline) {
420
+ for (;;) {
421
+ const pool = activePool();
422
+ const port = pool?.ports[name];
423
+ if (port) return port;
424
+ const remaining = deadline - Date.now();
425
+ if (remaining <= 0) return null;
426
+ await Promise.race([pool?.children.get(name)?.ready, delay(Math.min(remaining, 250))]);
427
+ }
428
+ }
429
+
430
+ const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
431
+
432
+ // A document load that fails gets a page that reloads itself — the reload the user would do by
433
+ // hand. The cap is small because the server already waited NAV_PORT_WAIT_MS before serving this,
434
+ // and each reload re-spends that wait. sessionStorage throws when site data is blocked, and a page
435
+ // whose only job is to reload must still reload there.
436
+ function retryPage(name, detail) {
437
+ return `<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(name)}</title>
438
+ <style>:root{color-scheme:light dark}body{font:14px system-ui,sans-serif;margin:0;display:grid;place-items:center;height:100vh}
439
+ div{text-align:center;opacity:.7}code{font-size:12px;opacity:.6}</style>
440
+ </head><body><div><p id="m">${escapeHtml(name)} is restarting…</p><p><code>${escapeHtml(detail)}</code></p></div>
441
+ <script>
442
+ let n = 0;
443
+ const k = 'hub-retry:' + location.pathname;
444
+ try { n = +(sessionStorage.getItem(k) || 0); sessionStorage.setItem(k, n + 1); } catch {}
445
+ if (n < 3) setTimeout(() => location.reload(), 1000);
446
+ else { try { sessionStorage.removeItem(k); } catch {} document.getElementById('m').textContent = 'not responding'; }
447
+ </script></body></html>`;
448
+ }
449
+
450
+ function sendUnavailable(req, res, name, status, detail) {
451
+ if (res.destroyed || res.headersSent) return;
452
+ if (isNavigation(req)) {
453
+ res.writeHead(status, { 'Retry-After': '1', 'Content-Type': 'text/html; charset=utf-8' });
454
+ res.end(retryPage(name, detail));
455
+ return;
456
+ }
457
+ // The sub-app's own callers hand the body to .json(): plain text surfaces as a SyntaxError
458
+ // instead of the reason.
459
+ res.writeHead(status, { 'Retry-After': '1', 'Content-Type': 'application/json' });
460
+ res.end(JSON.stringify({ error: detail }));
461
+ }
462
+
463
+ // A pooled socket the child closed under us is normal and recovers on the next attempt. A port that
464
+ // never completes a connect does not, so the child is replaced and the respawn gives it a new one.
465
+ function replaceChild(name, childPort, err) {
466
+ const pool = activePool();
467
+ const child = pool?.children.get(name);
468
+ if (!child || pool.ports[name] !== childPort) return;
469
+ console.log(`[${name}] unreachable at 127.0.0.1:${childPort} (${err.code}) - replacing it`);
470
+ delete pool.ports[name];
471
+ killChild(child);
472
+ }
473
+
474
+ // Fires only while the socket is still connecting, so a slow handler is never interrupted.
475
+ function capConnect(upstream, name, childPort) {
476
+ upstream.on('socket', (socket) => {
477
+ if (!socket.connecting) return;
478
+ const timer = setTimeout(() => {
479
+ const err = new Error(`no connect to ${name} at 127.0.0.1:${childPort}`);
480
+ err.code = 'ETIMEDOUT';
481
+ upstream.destroy(err);
482
+ }, CONNECT_TIMEOUT_MS);
483
+ const clear = () => clearTimeout(timer);
484
+ socket.once('connect', clear);
485
+ upstream.once('close', clear);
486
+ });
487
+ }
488
+
489
+ // Resolves null once the response is committed (or the client is gone), or the error when nothing
490
+ // has been written yet and the caller may still retry.
491
+ function forward(name, req, res, childPort, headers, replayable, onUpstream) {
492
+ return new Promise((resolve) => {
282
493
  const upstream = http.request(
283
494
  { host: '127.0.0.1', port: childPort, method: req.method, path: req.url, headers, agent: proxyAgent },
284
495
  (u) => {
285
496
  res.writeHead(u.statusCode, u.headers);
286
497
  u.pipe(res);
498
+ resolve(null);
287
499
  },
288
500
  );
501
+ onUpstream(upstream);
502
+ capConnect(upstream, name, childPort);
289
503
  upstream.on('error', (err) => {
290
- if (!res.headersSent) res.writeHead(502);
291
- res.end(err.message);
504
+ if (res.headersSent) {
505
+ res.end();
506
+ resolve(null);
507
+ return;
508
+ }
509
+ resolve(err);
510
+ });
511
+ if (replayable) upstream.end();
512
+ else req.pipe(upstream);
513
+ });
514
+ }
515
+
516
+ // Host is forwarded untouched so the child's hostGuard still sees what the browser sent. Origin is
517
+ // the one header the child cannot judge on its own: its guard compares the port to its own
518
+ // ephemeral one, so the public-port spelling is mapped to the child's; anything else passes
519
+ // through as-is and the child rejects it.
520
+ function proxyHandler(name) {
521
+ return async (req, res) => {
522
+ const headers = { ...req.headers };
523
+ // req is consumed by the first attempt, so only a request with no body can be re-sent. Waiting
524
+ // for a port is not a replay — nothing has been sent yet — so every method gets the same wait.
525
+ const replayable = req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS';
526
+ // One deadline for the whole request, not one per attempt: a retry must not re-spend the wait.
527
+ const deadline = Date.now() + (isNavigation(req) ? NAV_PORT_WAIT_MS : API_PORT_WAIT_MS);
528
+ // One listener for the request, however many attempts it takes.
529
+ let current = null;
530
+ res.once('close', () => current?.destroy());
531
+
532
+ for (let attempt = 1; !res.destroyed; attempt++) {
533
+ const childPort = await waitForPort(name, deadline);
534
+ if (!childPort) {
535
+ sendUnavailable(req, res, name, 503, `${name} is starting`);
536
+ return;
537
+ }
538
+ if (req.headers.origin) {
539
+ headers.origin = rewriteOrigin(req.headers.origin, publicPorts[name], childPort);
540
+ }
541
+ const err = await forward(name, req, res, childPort, headers, replayable, (u) => {
542
+ current = u;
543
+ });
544
+ if (!err) return;
545
+ if (!replayable || attempt >= MAX_ATTEMPTS || !RETRYABLE.has(err.code)) {
546
+ // A port that ate every attempt is not coming back; anything less is a transient close.
547
+ if (attempt >= MAX_ATTEMPTS) replaceChild(name, childPort, err);
548
+ sendUnavailable(req, res, name, 502, err.message);
549
+ return;
550
+ }
551
+ await delay(ATTEMPT_DELAY_MS);
552
+ }
553
+ };
554
+ }
555
+
556
+ // WebSocket upgrades (cck's terminal) are tunnelled as raw bytes for every app. Host and Origin get
557
+ // the same treatment as proxyHandler; the child runs its own upgrade checks, and a child with no
558
+ // upgrade listener closes the socket.
559
+ function proxyUpgrade(name) {
560
+ return async (req, socket, head) => {
561
+ socket.on('error', () => socket.destroy());
562
+ const childPort = await waitForPort(name, Date.now() + API_PORT_WAIT_MS);
563
+ if (!childPort || socket.destroyed) {
564
+ socket.destroy();
565
+ return;
566
+ }
567
+ const lines = [`${req.method} ${req.url} HTTP/${req.httpVersion}`];
568
+ for (let i = 0; i < req.rawHeaders.length; i += 2) {
569
+ let value = req.rawHeaders[i + 1];
570
+ if (req.rawHeaders[i].toLowerCase() === 'origin') value = rewriteOrigin(value, publicPorts[name], childPort);
571
+ lines.push(`${req.rawHeaders[i]}: ${value}`);
572
+ }
573
+ const upstream = tcp.connect(childPort, '127.0.0.1', () => {
574
+ clearTimeout(connectTimer);
575
+ upstream.write(`${lines.join('\r\n')}\r\n\r\n`);
576
+ if (head?.length) upstream.write(head);
577
+ upstream.pipe(socket);
578
+ socket.pipe(upstream);
292
579
  });
293
- res.on('close', () => upstream.destroy());
294
- req.pipe(upstream);
580
+ const connectTimer = setTimeout(() => upstream.destroy(), CONNECT_TIMEOUT_MS);
581
+ upstream.on('error', () => socket.destroy());
582
+ socket.on('close', () => upstream.destroy());
583
+ upstream.on('close', () => socket.destroy());
295
584
  };
296
585
  }
297
586
 
298
- function listenWithFallback(handler, port, onReady, label) {
299
- const server = net.listenLoopback(handler, port, onReady, { maxHeaderSize: HDR_BYTES });
587
+ function listenWithFallback(handler, port, onReady, label, onUpgrade) {
588
+ const opts = { maxHeaderSize: HDR_BYTES, onUpgrade };
589
+ const server = net.listenLoopback(handler, port, onReady, opts);
300
590
  server.on('error', (err) => {
301
591
  if (err.code !== 'EADDRINUSE' && err.code !== 'EACCES') throw err;
302
592
  console.log(`${label}port ${port} in use, trying random port...`);
303
- net.listenLoopback(handler, 0, onReady, { maxHeaderSize: HDR_BYTES });
593
+ net.listenLoopback(handler, 0, onReady, opts);
304
594
  });
305
595
  return server;
306
596
  }
@@ -313,28 +603,40 @@ for (const [name, port] of Object.entries(DEFAULT_PORTS)) {
313
603
  publicPorts[name] = actual;
314
604
  },
315
605
  `[${name}] `,
606
+ proxyUpgrade(name),
316
607
  );
317
608
  }
318
609
 
319
610
  ensurePool(hubConfig.activeConfigDir);
320
611
 
321
612
  const app = express();
322
- app.use(express.json());
323
613
 
324
614
  // Mounted before the routes below, which are registered ahead of the first
325
615
  // app.use() and would otherwise bypass the guards entirely.
326
616
  app.use(net.hostGuard);
327
617
  app.use(net.frameGuard);
328
618
  app.use(net.originGuard);
619
+ app.use(tokenGuard);
620
+ app.use(express.json());
329
621
 
330
- // The accent hex per color theme, read from the same registry generate-themes.mjs compiles into each
331
- // sub-app's themes.css. Served rather than hand-copied into public/app.js so there is one source of
332
- // truth; the palette is the hub's only themed surface, so only ember (the accent role) is needed.
622
+ // The hub's CSS variables per color theme and mode, read from the same registry generate-themes.mjs
623
+ // compiles into each sub-app's themes.css. Served rather than hand-copied into public/app.js so there
624
+ // is one source of truth. --bg is the sub-apps' --bg-deep, so the loading screen hands off to an
625
+ // iframe without a flash of another color.
333
626
  // Read once — themes.json only changes when the generator is re-run, which restarts the hub anyway.
334
- const themeAccents = (() => {
627
+ const hubVars = (p) => ({
628
+ '--accent': p.ember,
629
+ '--bg': p.field,
630
+ '--surface': p.surface,
631
+ '--surface-hover': p.hover,
632
+ '--border': p.border,
633
+ '--text': p.ink1,
634
+ '--text-dim': p.inkMuted,
635
+ });
636
+ const themePalettes = (() => {
335
637
  try {
336
638
  const themes = JSON.parse(fs.readFileSync(path.join(__dirname, 'scripts/themes.json'), 'utf8'));
337
- return Object.fromEntries(themes.map((t) => [t.id, { dark: t.dark.ember, light: t.light.ember }]));
639
+ return Object.fromEntries(themes.map((t) => [t.id, { dark: hubVars(t.dark), light: hubVars(t.light) }]));
338
640
  } catch {
339
641
  // Palette falls back to the --accent in index.html; not worth failing startup over.
340
642
  return {};
@@ -342,8 +644,10 @@ const themeAccents = (() => {
342
644
  })();
343
645
 
344
646
  function appsConfig() {
647
+ const kanban = { name: 'Kanban', url: `http://localhost:${publicPorts.kanban}`, icon: 'columns' };
648
+ if (TERMINAL_TOKEN) kanban.terminalToken = TERMINAL_TOKEN;
345
649
  return {
346
- kanban: { name: 'Kanban', url: `http://localhost:${publicPorts.kanban}`, icon: 'columns' },
650
+ kanban,
347
651
  marketplace: { name: 'Marketplace', url: `http://localhost:${publicPorts.marketplace}`, icon: 'store' },
348
652
  cost: { name: 'Cost', url: `http://localhost:${publicPorts.cost}`, icon: 'dollar-sign' },
349
653
  memory: { name: 'Memory Diagnoser', url: `http://localhost:${publicPorts.memory}`, icon: 'database' },
@@ -351,7 +655,7 @@ function appsConfig() {
351
655
  }
352
656
 
353
657
  app.get('/api/config', (_req, res) => {
354
- res.json({ themeAccents, apps: appsConfig(), activeConfigDir: hubConfig.activeConfigDir, defaultConfigDir });
658
+ res.json({ themePalettes, apps: appsConfig(), activeConfigDir: hubConfig.activeConfigDir, defaultConfigDir });
355
659
  });
356
660
 
357
661
  app.get('/api/config-dirs', (_req, res) => {
@@ -421,7 +725,8 @@ app.post('/api/config-dirs/activate', async (req, res) => {
421
725
  // once its banner has been scraped (see spawnApp).
422
726
  app.get('/api/projects', async (_req, res) => {
423
727
  res.setHeader('Cache-Control', 'no-store');
424
- const kanbanPort = activePool()?.ports.kanban;
728
+ // Waits like the proxy does, so the palette is not the one thing that hard-fails during a restart.
729
+ const kanbanPort = await waitForPort('kanban', Date.now() + API_PORT_WAIT_MS);
425
730
  if (!kanbanPort) {
426
731
  res.status(503).json({ error: 'kanban is starting' });
427
732
  return;
@@ -455,14 +760,18 @@ app.use(express.static(path.join(__dirname, 'public')));
455
760
  const onReady = (actual) => {
456
761
  printBanner(actual);
457
762
  if (process.argv.includes('--open')) {
458
- import('open').then((m) => m.default(`http://localhost:${actual}`));
763
+ import('open').then((m) => m.default(accessUrl(actual)));
459
764
  }
460
765
  };
461
766
 
462
767
  listenWithFallback(app, HUB_PORT, onReady, '');
463
768
 
769
+ function accessUrl(port) {
770
+ return `http://localhost:${port}/?token=${HUB_TOKEN}`;
771
+ }
772
+
464
773
  function printBanner(port) {
465
- console.log(`Claude Code Hub running at http://localhost:${port}`);
774
+ console.log(`Claude Code Hub running at ${accessUrl(port)}`);
466
775
  const warning = net.exposureWarning();
467
776
  if (warning) console.log(warning);
468
777
  console.log('Type "q" or "exit" to stop the server');