claude-code-hub 0.36.0 → 0.38.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-hub",
3
- "version": "0.36.0",
3
+ "version": "0.38.0",
4
4
  "description": "Unified hub for Claude Code tools — Marketplace + Kanban in one PWA",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -9,6 +9,8 @@
9
9
  "scripts": {
10
10
  "start": "node server.js",
11
11
  "dev": "node server.js --open",
12
+ "update": "bash scripts/update-submodules.sh",
13
+ "update:check": "bash scripts/update-submodules.sh --check",
12
14
  "lint": "npx @biomejs/biome check public/app.js server.js && npm run check:escaping && npm run check:security-lib",
13
15
  "lint:fix": "npx @biomejs/biome check --fix public/app.js server.js",
14
16
  "prepare": "husky",
@@ -37,10 +39,10 @@
37
39
  },
38
40
  "homepage": "https://github.com/NikiforovAll/claude-code-hub#readme",
39
41
  "dependencies": {
40
- "claude-code-cost": "^0.22.0",
41
- "claude-code-kanban": "^4.25.0",
42
- "claude-code-marketplace": "^0.17.0",
43
- "claude-code-memory-explorer": "^0.14.0",
42
+ "claude-code-cost": "^0.23.1",
43
+ "claude-code-kanban": "^4.27.0",
44
+ "claude-code-marketplace": "^0.19.0",
45
+ "claude-code-memory-explorer": "^0.14.1",
44
46
  "express": "^4.21.0",
45
47
  "open": "^10.1.0"
46
48
  },
package/public/app.js CHANGED
@@ -3,13 +3,19 @@ let activeApp = null;
3
3
  const iframes = {};
4
4
  const loadedApps = new Set();
5
5
  let allowedOrigins = new Set();
6
- // {theme: 'dark'|'light', colorTheme: '<id>'} — survives hub reloads so
7
- // late-loading iframes and fresh sessions get the last chosen theme.
6
+ // {theme: 'dark'|'light', colorTheme: '<id>', colorThemes: {<configDir>: '<id>'}} — survives hub
7
+ // reloads so late-loading iframes and fresh sessions get the last chosen theme. Light/dark is
8
+ // global; the color theme is per config dir, and colorTheme is the last one picked anywhere, which
9
+ // a dir that has never been themed inherits.
8
10
  const themeState = loadThemeState();
11
+ // Keys themeState.colorThemes. Null until /api/config lands, so the first paint uses the fallback.
12
+ let activeConfigDir = null;
9
13
  // {project, encoded, name} — the current project scope. Deliberately NOT persisted, unlike
10
14
  // hub-theme: starting unset means there is nothing to race against each sub-app's own
11
15
  // project self-restore on boot. Once set it never returns to null.
12
16
  let projectState = null;
17
+ // The config dir the hub falls back to (~/.claude). Stays out of the window title.
18
+ let defaultConfigDir = null;
13
19
  // mode: 'project' (Ctrl+Alt+P) or 'configDir' (Ctrl+Alt+W). One widget, two row sources.
14
20
  const palette = {
15
21
  open: false,
@@ -22,11 +28,19 @@ const palette = {
22
28
  };
23
29
 
24
30
  function loadThemeState() {
31
+ let state;
25
32
  try {
26
- return JSON.parse(localStorage.getItem('hub-theme')) ?? {};
33
+ state = JSON.parse(localStorage.getItem('hub-theme')) ?? {};
27
34
  } catch {
28
- return {};
35
+ state = {};
29
36
  }
37
+ if (!state.colorThemes || typeof state.colorThemes !== 'object') state.colorThemes = {};
38
+ return state;
39
+ }
40
+
41
+ // The color theme of the active config dir, falling back to the last one picked anywhere.
42
+ function activeColorTheme() {
43
+ return themeState.colorThemes[activeConfigDir] ?? themeState.colorTheme;
30
44
  }
31
45
 
32
46
  function osTheme() {
@@ -36,7 +50,7 @@ function osTheme() {
36
50
  // Sub-apps disagree on their first-run default (marketplace follows the OS, the rest start dark),
37
51
  // so an unset theme resolves to the OS preference here and reaches every iframe on load.
38
52
  function themeMessage() {
39
- return { type: 'hub:theme', theme: themeState.theme ?? osTheme(), colorTheme: themeState.colorTheme };
53
+ return { type: 'hub:theme', theme: themeState.theme ?? osTheme(), colorTheme: activeColorTheme() };
40
54
  }
41
55
 
42
56
  // {<themeId>: {dark, light}} from /api/config, which derives it from scripts/themes.json — the same
@@ -49,7 +63,7 @@ let themeAccents = {};
49
63
  function applyHubTheme() {
50
64
  const mode = themeState.theme ?? osTheme();
51
65
  document.documentElement.classList.toggle('light', mode === 'light');
52
- const pair = themeAccents[themeState.colorTheme];
66
+ const pair = themeAccents[activeColorTheme()];
53
67
  if (pair) document.documentElement.style.setProperty('--accent', pair[mode]);
54
68
  }
55
69
 
@@ -120,6 +134,9 @@ async function init() {
120
134
  const res = await fetch('/api/config');
121
135
  const config = await res.json();
122
136
  setApps(config.apps);
137
+ defaultConfigDir = config.defaultConfigDir ?? null;
138
+ activeConfigDir = config.activeConfigDir ?? null;
139
+ applyTitle(config.activeConfigDir);
123
140
  themeAccents = config.themeAccents ?? {};
124
141
  applyHubTheme();
125
142
  buildIframes();
@@ -130,6 +147,13 @@ async function init() {
130
147
  registerSW();
131
148
  }
132
149
 
150
+ // The hub has no visible chrome, so the window/tab title is the only place the active config dir
151
+ // shows. Only a non-default dir is named, and by basename alone — the PWA window prepends the
152
+ // manifest name, so a product name here would read as "Claude Code Hub - .claude-eom · Hub".
153
+ function applyTitle(dir) {
154
+ document.title = dir && dir !== defaultConfigDir ? basename(dir) : 'Claude Code Hub';
155
+ }
156
+
133
157
  function setApps(next) {
134
158
  apps = next;
135
159
  allowedOrigins = new Set(Object.values(apps).map((a) => new URL(a.url).origin));
@@ -210,14 +234,28 @@ function listenMessages() {
210
234
  if (data.url) iframes[data.app].src = apps[data.app].url + data.url;
211
235
  } else if (data.type === 'hub:keydown') {
212
236
  handleForwardedKey(data);
237
+ } else if (data.type === 'hub:openExternal') {
238
+ // In the installed PWA window a framed sub-app's own target=_blank opens
239
+ // nothing, so the shims hand external links up to the top frame instead.
240
+ let url;
241
+ try {
242
+ url = new URL(String(data.url));
243
+ } catch (_) {
244
+ return;
245
+ }
246
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return;
247
+ window.open(url.href, '_blank', 'noopener');
213
248
  } else if (data.type === 'hub:theme') {
214
249
  // Legacy senders pass only {theme}; colorTheme is optional and sticky.
215
250
  if (data.theme !== 'light' && data.theme !== 'dark') return;
216
251
  const hasColor = typeof data.colorTheme === 'string' && /^[a-z0-9-]{0,32}$/.test(data.colorTheme);
217
- const changed = data.theme !== themeState.theme || (hasColor && data.colorTheme !== themeState.colorTheme);
252
+ const changed = data.theme !== themeState.theme || (hasColor && data.colorTheme !== activeColorTheme());
218
253
  if (!changed) return;
219
254
  themeState.theme = data.theme;
220
- if (hasColor) themeState.colorTheme = data.colorTheme;
255
+ if (hasColor) {
256
+ themeState.colorTheme = data.colorTheme;
257
+ if (activeConfigDir) themeState.colorThemes[activeConfigDir] = data.colorTheme;
258
+ }
221
259
  localStorage.setItem('hub-theme', JSON.stringify(themeState));
222
260
  applyHubTheme();
223
261
  broadcast(themeMessage(), e.source);
@@ -232,18 +270,30 @@ function handleForwardedKey(d) {
232
270
  const alt = typeof d.alt === 'boolean' ? d.alt : true;
233
271
  const ctrl = typeof d.alt === 'boolean' ? d.ctrl : d.key.startsWith('Arrow');
234
272
  if (!alt || d.shift === true) return;
273
+ const k = bindingKey(d);
235
274
  if (!ctrl) {
236
- if (/^[1-9]$/.test(d.key)) switchByIndex(Number(d.key) - 1);
275
+ if (/^[1-9]$/.test(k)) switchByIndex(Number(k) - 1);
237
276
  return;
238
277
  }
239
- if (d.key === 'ArrowLeft') cycleTab(-1);
240
- else if (d.key === 'ArrowRight') cycleTab(1);
241
- else if (PALETTE_KEYS[d.key.toLowerCase()]) togglePalette(PALETTE_KEYS[d.key.toLowerCase()]);
278
+ if (k === 'ArrowLeft') cycleTab(-1);
279
+ else if (k === 'ArrowRight') cycleTab(1);
280
+ else if (PALETTE_KEYS[k]) togglePalette(PALETTE_KEYS[k]);
242
281
  }
243
282
 
244
283
  // Ctrl+Alt+<letter> bindings. Sub-app shims forward every letter, so this is the one keymap.
245
284
  const PALETTE_KEYS = { p: 'project', w: 'configDir' };
246
285
 
286
+ // macOS composes Option+<key> into a character — Option+1 is '¡', Option+P is 'π' — and holding
287
+ // Control does not undo it, so e.key alone cannot carry these bindings there. e.code is the
288
+ // physical key, which is wrong for non-US layouts, hence only as a fallback. Takes a real
289
+ // KeyboardEvent or a forwarded {key, code} payload; a payload without code degrades to key.
290
+ function bindingKey(e) {
291
+ const lower = typeof e.key === 'string' ? e.key.toLowerCase() : '';
292
+ if (/^[a-z1-9]$/.test(lower)) return lower;
293
+ const m = /^(?:Key|Digit)([A-Z1-9])$/.exec(e.code || '');
294
+ return m ? m[1].toLowerCase() : e.key;
295
+ }
296
+
247
297
  function cycleTab(delta) {
248
298
  const ids = Object.keys(apps);
249
299
  const idx = ids.indexOf(activeApp);
@@ -258,11 +308,13 @@ function switchByIndex(idx) {
258
308
 
259
309
  function listenKeys() {
260
310
  document.addEventListener('keydown', (e) => {
261
- // Matches both cases: with Ctrl+Alt held, some layouts report AltGr-shifted characters.
262
- if (e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey && PALETTE_KEYS[e.key.toLowerCase()]) {
263
- e.preventDefault();
264
- togglePalette(PALETTE_KEYS[e.key.toLowerCase()]);
265
- return;
311
+ if (e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey) {
312
+ const mode = PALETTE_KEYS[bindingKey(e)];
313
+ if (mode) {
314
+ e.preventDefault();
315
+ togglePalette(mode);
316
+ return;
317
+ }
266
318
  }
267
319
  // While the palette is open the input owns the keyboard — don't let tab shortcuts fire
268
320
  // mid-path (Alt+digit especially, since Windows paths contain digits). Escape still closes:
@@ -275,10 +327,10 @@ function listenKeys() {
275
327
  return;
276
328
  }
277
329
  if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey) {
278
- const digit = parseInt(e.key, 10);
279
- if (digit >= 1 && digit <= 9) {
330
+ const k = bindingKey(e);
331
+ if (/^[1-9]$/.test(k)) {
280
332
  e.preventDefault();
281
- switchByIndex(digit - 1);
333
+ switchByIndex(Number(k) - 1);
282
334
  return;
283
335
  }
284
336
  }
@@ -409,6 +461,10 @@ async function removeConfigDir(dirPath) {
409
461
  console.warn('remove config dir failed:', err.message);
410
462
  return;
411
463
  }
464
+ if (dirPath in themeState.colorThemes) {
465
+ delete themeState.colorThemes[dirPath];
466
+ localStorage.setItem('hub-theme', JSON.stringify(themeState));
467
+ }
412
468
  if (palette.open && palette.mode === 'configDir') renderPalette();
413
469
  }
414
470
 
@@ -622,6 +678,10 @@ async function commitConfigDir(row) {
622
678
  setApps((await sendJson('POST', '/api/config-dirs/activate', { path: target })).apps);
623
679
  // The palette renders from this cache before its refetch lands, and initialSel keys off it.
624
680
  palette.configDirs.active = target;
681
+ activeConfigDir = target;
682
+ applyTitle(target);
683
+ // Each iframe gets the new dir's color theme from its load handler; this repaints the palette.
684
+ applyHubTheme();
625
685
  reloadIframes();
626
686
  } catch (err) {
627
687
  console.warn('config dir switch failed:', err.message);
@@ -682,8 +742,15 @@ function bindPalette() {
682
742
  palette.sel = Number(li.dataset.idx);
683
743
  commitPalette();
684
744
  });
745
+ // Vimium eats Escape inside a text input and only blurs it, so the page never sees the key —
746
+ // losing focus is the one signal left. Busy disables the input and Alt+Tab blurs the window;
747
+ // neither is a dismissal.
748
+ input.addEventListener('blur', () => {
749
+ if (palette.open && !input.disabled && document.hasFocus()) closePalette();
750
+ });
685
751
  document.getElementById('palette').addEventListener('mousedown', (e) => {
686
752
  if (e.target.id === 'palette') closePalette();
753
+ else if (e.target !== input) e.preventDefault();
687
754
  });
688
755
  }
689
756
 
package/public/index.html CHANGED
@@ -5,6 +5,13 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <meta name="theme-color" content="#1a1a2e">
7
7
  <link rel="manifest" href="/manifest.json">
8
+ <!-- A manifest alone does not set the favicon: without these, app windows,
9
+ shortcuts and the history list fall back to /favicon.ico and then to whatever
10
+ icon localhost was last seen with, which is a sub-app's. -->
11
+ <link rel="icon" type="image/svg+xml" href="/icons/icon-192.svg">
12
+ <link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png">
13
+ <link rel="icon" type="image/png" sizes="512x512" href="/icons/icon-512.png">
14
+ <link rel="apple-touch-icon" href="/icons/icon-192.png">
8
15
  <title>Claude Code Hub</title>
9
16
  <style>
10
17
  :root {
package/server.js CHANGED
@@ -24,6 +24,12 @@ const MEMORY_PORT = parseInt(getArg('memory-port') || '3544', 10);
24
24
 
25
25
  const POOL_SIZE = Math.max(1, parseInt(getArg('pool-size') || '3', 10));
26
26
  const DEFAULT_PORTS = { marketplace: MARKETPLACE_PORT, kanban: KANBAN_PORT, cost: COST_PORT, memory: MEMORY_PORT };
27
+ const RESTART_DELAY_MS = 500;
28
+ // A rate window, not a total: a child may die and recover all session, but one that cannot stay up
29
+ // stops being restarted. A consecutive counter cannot express that — anything that starts at all
30
+ // clears it, so a child that starts and dies forever restarts forever.
31
+ const MAX_RESTARTS = 5;
32
+ const RESTART_WINDOW_MS = 60_000;
27
33
 
28
34
  // One child set per config dir, kept alive after a switch so switching back is instant. Map
29
35
  // insertion order doubles as LRU: activating a dir re-inserts it at the end.
@@ -38,6 +44,10 @@ function activePool() {
38
44
  // be known before the first spawn, and the hub already has one localStorage key to reason about.
39
45
  const HUB_CONFIG_FILE = path.join(os.homedir(), '.claude-hub', 'config.json');
40
46
 
47
+ // The dir the hub would use with no saved config. Set by loadHubConfig; the client uses it to keep
48
+ // the default dir out of the window title.
49
+ let defaultConfigDir = null;
50
+
41
51
  function expandHome(p) {
42
52
  return p.startsWith('~') ? p.replace('~', os.homedir()) : p;
43
53
  }
@@ -59,6 +69,7 @@ function loadHubConfig() {
59
69
  const fallback = canonical(
60
70
  process.env.CLAUDE_CONFIG_DIR || process.env.CLAUDE_DIR || path.join(os.homedir(), '.claude'),
61
71
  );
72
+ defaultConfigDir = fallback;
62
73
  let raw = '';
63
74
  let saved = {};
64
75
  try {
@@ -133,24 +144,44 @@ function spawnApp(pool, name, cmd, args) {
133
144
  child.stderr.on('data', (d) => process.stderr.write(`[${name}] ${d}`));
134
145
  child.on('exit', (code) => {
135
146
  console.log(`[${name}] exited (code ${code})`);
147
+ // The port dies with the child and the OS may hand it to a stranger, so stop dialing it.
148
+ delete pool.ports[name];
136
149
  markReady();
150
+ if (pool.retired || pool.children.get(name) !== child) return;
151
+ // Nothing else would ever bring this app back: a pool is only rebuilt when its dir is
152
+ // re-activated.
153
+ const recent = (pool.restarts[name] || []).filter((t) => Date.now() - t < RESTART_WINDOW_MS);
154
+ if (recent.length >= MAX_RESTARTS) {
155
+ console.log(`[${name}] gave up after ${MAX_RESTARTS} restarts in ${RESTART_WINDOW_MS / 1000}s`);
156
+ return;
157
+ }
158
+ recent.push(Date.now());
159
+ pool.restarts[name] = recent;
160
+ setTimeout(() => {
161
+ if (pool.retired) return;
162
+ console.log(`[${name}] restarting (${recent.length}/${MAX_RESTARTS})`);
163
+ spawnApp(pool, name, cmd, args);
164
+ }, RESTART_DELAY_MS).unref();
137
165
  });
138
166
 
139
- pool.children.push(child);
167
+ pool.children.set(name, child);
140
168
  return child;
141
169
  }
142
170
 
143
- function killPool(pool) {
144
- for (const child of pool.children) {
145
- if (child.exitCode !== null || child.killed) continue;
146
- if (process.platform === 'win32') {
147
- spawn('taskkill', ['/pid', String(child.pid), '/f', '/t'], { stdio: 'ignore' });
148
- } else {
149
- child.kill();
150
- }
171
+ function killChild(child) {
172
+ if (child.exitCode !== null || child.killed) return;
173
+ if (process.platform === 'win32') {
174
+ spawn('taskkill', ['/pid', String(child.pid), '/f', '/t'], { stdio: 'ignore' });
175
+ } else {
176
+ child.kill();
151
177
  }
152
178
  }
153
179
 
180
+ function killPool(pool) {
181
+ pool.retired = true;
182
+ for (const child of pool.children.values()) killChild(child);
183
+ }
184
+
154
185
  function killAll() {
155
186
  for (const pool of pools.values()) killPool(pool);
156
187
  }
@@ -178,10 +209,11 @@ async function ensurePool(dir) {
178
209
  console.log(`Evicting children for ${oldestDir}`);
179
210
  dropPool(oldestDir);
180
211
  }
181
- pool = { dir, children: [], ports: {} };
212
+ pool = { dir, children: new Map(), ports: {}, restarts: {}, retired: false };
182
213
  pools.set(dir, pool);
183
214
  spawnChildren(pool);
184
- pool.ready = withTimeout(Promise.all(pool.children.map((c) => c.ready)), 20000).then(() => pool);
215
+ const ready = [...pool.children.values()].map((c) => c.ready);
216
+ pool.ready = withTimeout(Promise.all(ready), 20000).then(() => pool);
185
217
  return pool.ready;
186
218
  }
187
219
 
@@ -260,33 +292,165 @@ function rewriteOrigin(origin, publicPort, childPort) {
260
292
 
261
293
  const proxyAgent = new http.Agent({ keepAlive: true, maxSockets: 64 });
262
294
 
263
- // Host is forwarded untouched so the child's hostGuard still sees what the browser sent. Origin is
264
- // the one header the child cannot judge on its own: its guard compares the port to its own
265
- // ephemeral one, so the public-port spelling is mapped to the child's; anything else passes
266
- // through as-is and the child rejects it.
267
- function proxyHandler(name) {
268
- return (req, res) => {
269
- const childPort = activePool()?.ports[name];
270
- if (!childPort) {
271
- res.writeHead(503, { 'Retry-After': '1' });
272
- res.end(`${name} is starting`);
273
- return;
274
- }
275
- const headers = { ...req.headers };
276
- if (headers.origin) headers.origin = rewriteOrigin(headers.origin, publicPorts[name], childPort);
295
+ // Two failures look the same from here and both recover on their own: a pooled socket the child
296
+ // closed under us, and a child that is restarting. The port is re-read per attempt, so a replay
297
+ // reaches the replacement child rather than the ephemeral port that died with the old one.
298
+ const RETRYABLE = new Set(['ECONNRESET', 'EPIPE', 'ECONNABORTED', 'ECONNREFUSED', 'ETIMEDOUT']);
299
+ const MAX_ATTEMPTS = 3;
300
+ const ATTEMPT_DELAY_MS = 250;
301
+ // A connect to a freshly-bound loopback port is sometimes black-holed on Windows (the child shows
302
+ // as LISTENING and answers nothing) and the OS gives up only after ~20s. This caps the connect
303
+ // alone, never the response: a sub-app handler may legitimately take minutes on a cold scan, and
304
+ // capping time-to-first-byte would kill that request, replay it, and then kill a healthy child.
305
+ const CONNECT_TIMEOUT_MS = 3000;
306
+ // A restart costs about a second, so a document load waits it out instead of showing an error.
307
+ const NAV_PORT_WAIT_MS = 10000;
308
+ const API_PORT_WAIT_MS = 2000;
309
+
310
+ const delay = (ms) => new Promise((r) => setTimeout(r, ms).unref());
311
+
312
+ // An iframe document load, as opposed to the sub-app's own fetch/XHR.
313
+ function isNavigation(req) {
314
+ if (req.headers['sec-fetch-mode']) return req.headers['sec-fetch-mode'] === 'navigate';
315
+ return (req.headers.accept || '').includes('text/html');
316
+ }
317
+
318
+ // Races the child's own ready promise rather than polling it, so a restarted app starts serving the
319
+ // moment it prints its port instead of on the next tick.
320
+ async function waitForPort(name, deadline) {
321
+ for (;;) {
322
+ const pool = activePool();
323
+ const port = pool?.ports[name];
324
+ if (port) return port;
325
+ const remaining = deadline - Date.now();
326
+ if (remaining <= 0) return null;
327
+ await Promise.race([pool?.children.get(name)?.ready, delay(Math.min(remaining, 250))]);
328
+ }
329
+ }
330
+
331
+ const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
332
+
333
+ // A document load that fails gets a page that reloads itself — the reload the user would do by
334
+ // hand. The cap is small because the server already waited NAV_PORT_WAIT_MS before serving this,
335
+ // and each reload re-spends that wait. sessionStorage throws when site data is blocked, and a page
336
+ // whose only job is to reload must still reload there.
337
+ function retryPage(name, detail) {
338
+ return `<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(name)}</title>
339
+ <style>:root{color-scheme:light dark}body{font:14px system-ui,sans-serif;margin:0;display:grid;place-items:center;height:100vh}
340
+ div{text-align:center;opacity:.7}code{font-size:12px;opacity:.6}</style>
341
+ </head><body><div><p id="m">${escapeHtml(name)} is restarting…</p><p><code>${escapeHtml(detail)}</code></p></div>
342
+ <script>
343
+ let n = 0;
344
+ const k = 'hub-retry:' + location.pathname;
345
+ try { n = +(sessionStorage.getItem(k) || 0); sessionStorage.setItem(k, n + 1); } catch {}
346
+ if (n < 3) setTimeout(() => location.reload(), 1000);
347
+ else { try { sessionStorage.removeItem(k); } catch {} document.getElementById('m').textContent = 'not responding'; }
348
+ </script></body></html>`;
349
+ }
350
+
351
+ function sendUnavailable(req, res, name, status, detail) {
352
+ if (res.destroyed || res.headersSent) return;
353
+ if (isNavigation(req)) {
354
+ res.writeHead(status, { 'Retry-After': '1', 'Content-Type': 'text/html; charset=utf-8' });
355
+ res.end(retryPage(name, detail));
356
+ return;
357
+ }
358
+ // The sub-app's own callers hand the body to .json(): plain text surfaces as a SyntaxError
359
+ // instead of the reason.
360
+ res.writeHead(status, { 'Retry-After': '1', 'Content-Type': 'application/json' });
361
+ res.end(JSON.stringify({ error: detail }));
362
+ }
363
+
364
+ // A pooled socket the child closed under us is normal and recovers on the next attempt. A port that
365
+ // never completes a connect does not, so the child is replaced and the respawn gives it a new one.
366
+ function replaceChild(name, childPort, err) {
367
+ const pool = activePool();
368
+ const child = pool?.children.get(name);
369
+ if (!child || pool.ports[name] !== childPort) return;
370
+ console.log(`[${name}] unreachable at 127.0.0.1:${childPort} (${err.code}) - replacing it`);
371
+ delete pool.ports[name];
372
+ killChild(child);
373
+ }
374
+
375
+ // Fires only while the socket is still connecting, so a slow handler is never interrupted.
376
+ function capConnect(upstream, name, childPort) {
377
+ upstream.on('socket', (socket) => {
378
+ if (!socket.connecting) return;
379
+ const timer = setTimeout(() => {
380
+ const err = new Error(`no connect to ${name} at 127.0.0.1:${childPort}`);
381
+ err.code = 'ETIMEDOUT';
382
+ upstream.destroy(err);
383
+ }, CONNECT_TIMEOUT_MS);
384
+ const clear = () => clearTimeout(timer);
385
+ socket.once('connect', clear);
386
+ upstream.once('close', clear);
387
+ });
388
+ }
389
+
390
+ // Resolves null once the response is committed (or the client is gone), or the error when nothing
391
+ // has been written yet and the caller may still retry.
392
+ function forward(name, req, res, childPort, headers, replayable, onUpstream) {
393
+ return new Promise((resolve) => {
277
394
  const upstream = http.request(
278
395
  { host: '127.0.0.1', port: childPort, method: req.method, path: req.url, headers, agent: proxyAgent },
279
396
  (u) => {
280
397
  res.writeHead(u.statusCode, u.headers);
281
398
  u.pipe(res);
399
+ resolve(null);
282
400
  },
283
401
  );
402
+ onUpstream(upstream);
403
+ capConnect(upstream, name, childPort);
284
404
  upstream.on('error', (err) => {
285
- if (!res.headersSent) res.writeHead(502);
286
- res.end(err.message);
405
+ if (res.headersSent) {
406
+ res.end();
407
+ resolve(null);
408
+ return;
409
+ }
410
+ resolve(err);
287
411
  });
288
- res.on('close', () => upstream.destroy());
289
- req.pipe(upstream);
412
+ if (replayable) upstream.end();
413
+ else req.pipe(upstream);
414
+ });
415
+ }
416
+
417
+ // Host is forwarded untouched so the child's hostGuard still sees what the browser sent. Origin is
418
+ // the one header the child cannot judge on its own: its guard compares the port to its own
419
+ // ephemeral one, so the public-port spelling is mapped to the child's; anything else passes
420
+ // through as-is and the child rejects it.
421
+ function proxyHandler(name) {
422
+ return async (req, res) => {
423
+ const headers = { ...req.headers };
424
+ // req is consumed by the first attempt, so only a request with no body can be re-sent. Waiting
425
+ // for a port is not a replay — nothing has been sent yet — so every method gets the same wait.
426
+ const replayable = req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS';
427
+ // One deadline for the whole request, not one per attempt: a retry must not re-spend the wait.
428
+ const deadline = Date.now() + (isNavigation(req) ? NAV_PORT_WAIT_MS : API_PORT_WAIT_MS);
429
+ // One listener for the request, however many attempts it takes.
430
+ let current = null;
431
+ res.once('close', () => current?.destroy());
432
+
433
+ for (let attempt = 1; !res.destroyed; attempt++) {
434
+ const childPort = await waitForPort(name, deadline);
435
+ if (!childPort) {
436
+ sendUnavailable(req, res, name, 503, `${name} is starting`);
437
+ return;
438
+ }
439
+ if (req.headers.origin) {
440
+ headers.origin = rewriteOrigin(req.headers.origin, publicPorts[name], childPort);
441
+ }
442
+ const err = await forward(name, req, res, childPort, headers, replayable, (u) => {
443
+ current = u;
444
+ });
445
+ if (!err) return;
446
+ if (!replayable || attempt >= MAX_ATTEMPTS || !RETRYABLE.has(err.code)) {
447
+ // A port that ate every attempt is not coming back; anything less is a transient close.
448
+ if (attempt >= MAX_ATTEMPTS) replaceChild(name, childPort, err);
449
+ sendUnavailable(req, res, name, 502, err.message);
450
+ return;
451
+ }
452
+ await delay(ATTEMPT_DELAY_MS);
453
+ }
290
454
  };
291
455
  }
292
456
 
@@ -346,7 +510,7 @@ function appsConfig() {
346
510
  }
347
511
 
348
512
  app.get('/api/config', (_req, res) => {
349
- res.json({ themeAccents, apps: appsConfig() });
513
+ res.json({ themeAccents, apps: appsConfig(), activeConfigDir: hubConfig.activeConfigDir, defaultConfigDir });
350
514
  });
351
515
 
352
516
  app.get('/api/config-dirs', (_req, res) => {
@@ -416,7 +580,8 @@ app.post('/api/config-dirs/activate', async (req, res) => {
416
580
  // once its banner has been scraped (see spawnApp).
417
581
  app.get('/api/projects', async (_req, res) => {
418
582
  res.setHeader('Cache-Control', 'no-store');
419
- const kanbanPort = activePool()?.ports.kanban;
583
+ // Waits like the proxy does, so the palette is not the one thing that hard-fails during a restart.
584
+ const kanbanPort = await waitForPort('kanban', Date.now() + API_PORT_WAIT_MS);
420
585
  if (!kanbanPort) {
421
586
  res.status(503).json({ error: 'kanban is starting' });
422
587
  return;