claude-code-hub 0.37.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.37.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.23.0",
41
- "claude-code-kanban": "^4.26.0",
42
- "claude-code-marketplace": "^0.18.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,9 +3,13 @@ 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.
@@ -24,11 +28,19 @@ const palette = {
24
28
  };
25
29
 
26
30
  function loadThemeState() {
31
+ let state;
27
32
  try {
28
- return JSON.parse(localStorage.getItem('hub-theme')) ?? {};
33
+ state = JSON.parse(localStorage.getItem('hub-theme')) ?? {};
29
34
  } catch {
30
- return {};
35
+ state = {};
31
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;
32
44
  }
33
45
 
34
46
  function osTheme() {
@@ -38,7 +50,7 @@ function osTheme() {
38
50
  // Sub-apps disagree on their first-run default (marketplace follows the OS, the rest start dark),
39
51
  // so an unset theme resolves to the OS preference here and reaches every iframe on load.
40
52
  function themeMessage() {
41
- return { type: 'hub:theme', theme: themeState.theme ?? osTheme(), colorTheme: themeState.colorTheme };
53
+ return { type: 'hub:theme', theme: themeState.theme ?? osTheme(), colorTheme: activeColorTheme() };
42
54
  }
43
55
 
44
56
  // {<themeId>: {dark, light}} from /api/config, which derives it from scripts/themes.json — the same
@@ -51,7 +63,7 @@ let themeAccents = {};
51
63
  function applyHubTheme() {
52
64
  const mode = themeState.theme ?? osTheme();
53
65
  document.documentElement.classList.toggle('light', mode === 'light');
54
- const pair = themeAccents[themeState.colorTheme];
66
+ const pair = themeAccents[activeColorTheme()];
55
67
  if (pair) document.documentElement.style.setProperty('--accent', pair[mode]);
56
68
  }
57
69
 
@@ -123,6 +135,7 @@ async function init() {
123
135
  const config = await res.json();
124
136
  setApps(config.apps);
125
137
  defaultConfigDir = config.defaultConfigDir ?? null;
138
+ activeConfigDir = config.activeConfigDir ?? null;
126
139
  applyTitle(config.activeConfigDir);
127
140
  themeAccents = config.themeAccents ?? {};
128
141
  applyHubTheme();
@@ -236,10 +249,13 @@ function listenMessages() {
236
249
  // Legacy senders pass only {theme}; colorTheme is optional and sticky.
237
250
  if (data.theme !== 'light' && data.theme !== 'dark') return;
238
251
  const hasColor = typeof data.colorTheme === 'string' && /^[a-z0-9-]{0,32}$/.test(data.colorTheme);
239
- const changed = data.theme !== themeState.theme || (hasColor && data.colorTheme !== themeState.colorTheme);
252
+ const changed = data.theme !== themeState.theme || (hasColor && data.colorTheme !== activeColorTheme());
240
253
  if (!changed) return;
241
254
  themeState.theme = data.theme;
242
- if (hasColor) themeState.colorTheme = data.colorTheme;
255
+ if (hasColor) {
256
+ themeState.colorTheme = data.colorTheme;
257
+ if (activeConfigDir) themeState.colorThemes[activeConfigDir] = data.colorTheme;
258
+ }
243
259
  localStorage.setItem('hub-theme', JSON.stringify(themeState));
244
260
  applyHubTheme();
245
261
  broadcast(themeMessage(), e.source);
@@ -254,18 +270,30 @@ function handleForwardedKey(d) {
254
270
  const alt = typeof d.alt === 'boolean' ? d.alt : true;
255
271
  const ctrl = typeof d.alt === 'boolean' ? d.ctrl : d.key.startsWith('Arrow');
256
272
  if (!alt || d.shift === true) return;
273
+ const k = bindingKey(d);
257
274
  if (!ctrl) {
258
- if (/^[1-9]$/.test(d.key)) switchByIndex(Number(d.key) - 1);
275
+ if (/^[1-9]$/.test(k)) switchByIndex(Number(k) - 1);
259
276
  return;
260
277
  }
261
- if (d.key === 'ArrowLeft') cycleTab(-1);
262
- else if (d.key === 'ArrowRight') cycleTab(1);
263
- 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]);
264
281
  }
265
282
 
266
283
  // Ctrl+Alt+<letter> bindings. Sub-app shims forward every letter, so this is the one keymap.
267
284
  const PALETTE_KEYS = { p: 'project', w: 'configDir' };
268
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
+
269
297
  function cycleTab(delta) {
270
298
  const ids = Object.keys(apps);
271
299
  const idx = ids.indexOf(activeApp);
@@ -280,11 +308,13 @@ function switchByIndex(idx) {
280
308
 
281
309
  function listenKeys() {
282
310
  document.addEventListener('keydown', (e) => {
283
- // Matches both cases: with Ctrl+Alt held, some layouts report AltGr-shifted characters.
284
- if (e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey && PALETTE_KEYS[e.key.toLowerCase()]) {
285
- e.preventDefault();
286
- togglePalette(PALETTE_KEYS[e.key.toLowerCase()]);
287
- 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
+ }
288
318
  }
289
319
  // While the palette is open the input owns the keyboard — don't let tab shortcuts fire
290
320
  // mid-path (Alt+digit especially, since Windows paths contain digits). Escape still closes:
@@ -297,10 +327,10 @@ function listenKeys() {
297
327
  return;
298
328
  }
299
329
  if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey) {
300
- const digit = parseInt(e.key, 10);
301
- if (digit >= 1 && digit <= 9) {
330
+ const k = bindingKey(e);
331
+ if (/^[1-9]$/.test(k)) {
302
332
  e.preventDefault();
303
- switchByIndex(digit - 1);
333
+ switchByIndex(Number(k) - 1);
304
334
  return;
305
335
  }
306
336
  }
@@ -431,6 +461,10 @@ async function removeConfigDir(dirPath) {
431
461
  console.warn('remove config dir failed:', err.message);
432
462
  return;
433
463
  }
464
+ if (dirPath in themeState.colorThemes) {
465
+ delete themeState.colorThemes[dirPath];
466
+ localStorage.setItem('hub-theme', JSON.stringify(themeState));
467
+ }
434
468
  if (palette.open && palette.mode === 'configDir') renderPalette();
435
469
  }
436
470
 
@@ -644,7 +678,10 @@ async function commitConfigDir(row) {
644
678
  setApps((await sendJson('POST', '/api/config-dirs/activate', { path: target })).apps);
645
679
  // The palette renders from this cache before its refetch lands, and initialSel keys off it.
646
680
  palette.configDirs.active = target;
681
+ activeConfigDir = target;
647
682
  applyTitle(target);
683
+ // Each iframe gets the new dir's color theme from its load handler; this repaints the palette.
684
+ applyHubTheme();
648
685
  reloadIframes();
649
686
  } catch (err) {
650
687
  console.warn('config dir switch failed:', err.message);
@@ -705,8 +742,15 @@ function bindPalette() {
705
742
  palette.sel = Number(li.dataset.idx);
706
743
  commitPalette();
707
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
+ });
708
751
  document.getElementById('palette').addEventListener('mousedown', (e) => {
709
752
  if (e.target.id === 'palette') closePalette();
753
+ else if (e.target !== input) e.preventDefault();
710
754
  });
711
755
  }
712
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.
@@ -138,24 +144,44 @@ function spawnApp(pool, name, cmd, args) {
138
144
  child.stderr.on('data', (d) => process.stderr.write(`[${name}] ${d}`));
139
145
  child.on('exit', (code) => {
140
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];
141
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();
142
165
  });
143
166
 
144
- pool.children.push(child);
167
+ pool.children.set(name, child);
145
168
  return child;
146
169
  }
147
170
 
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
- }
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();
156
177
  }
157
178
  }
158
179
 
180
+ function killPool(pool) {
181
+ pool.retired = true;
182
+ for (const child of pool.children.values()) killChild(child);
183
+ }
184
+
159
185
  function killAll() {
160
186
  for (const pool of pools.values()) killPool(pool);
161
187
  }
@@ -183,10 +209,11 @@ async function ensurePool(dir) {
183
209
  console.log(`Evicting children for ${oldestDir}`);
184
210
  dropPool(oldestDir);
185
211
  }
186
- pool = { dir, children: [], ports: {} };
212
+ pool = { dir, children: new Map(), ports: {}, restarts: {}, retired: false };
187
213
  pools.set(dir, pool);
188
214
  spawnChildren(pool);
189
- 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);
190
217
  return pool.ready;
191
218
  }
192
219
 
@@ -265,33 +292,165 @@ function rewriteOrigin(origin, publicPort, childPort) {
265
292
 
266
293
  const proxyAgent = new http.Agent({ keepAlive: true, maxSockets: 64 });
267
294
 
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);
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) => {
282
394
  const upstream = http.request(
283
395
  { host: '127.0.0.1', port: childPort, method: req.method, path: req.url, headers, agent: proxyAgent },
284
396
  (u) => {
285
397
  res.writeHead(u.statusCode, u.headers);
286
398
  u.pipe(res);
399
+ resolve(null);
287
400
  },
288
401
  );
402
+ onUpstream(upstream);
403
+ capConnect(upstream, name, childPort);
289
404
  upstream.on('error', (err) => {
290
- if (!res.headersSent) res.writeHead(502);
291
- res.end(err.message);
405
+ if (res.headersSent) {
406
+ res.end();
407
+ resolve(null);
408
+ return;
409
+ }
410
+ resolve(err);
292
411
  });
293
- res.on('close', () => upstream.destroy());
294
- 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
+ }
295
454
  };
296
455
  }
297
456
 
@@ -421,7 +580,8 @@ app.post('/api/config-dirs/activate', async (req, res) => {
421
580
  // once its banner has been scraped (see spawnApp).
422
581
  app.get('/api/projects', async (_req, res) => {
423
582
  res.setHeader('Cache-Control', 'no-store');
424
- 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);
425
585
  if (!kanbanPort) {
426
586
  res.status(503).json({ error: 'kanban is starting' });
427
587
  return;