claude-code-hub 0.35.0 → 0.37.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/README.md CHANGED
@@ -55,6 +55,8 @@ Without hooks you still get the task board, but no agent activity or live indica
55
55
  | `Ctrl+M` | Open Memory Diagnoser for current session (Kanban) |
56
56
  | `Ctrl+Alt+Right` | Switch to next tool |
57
57
  | `Ctrl+Alt+Left` | Switch to previous tool |
58
+ | `Ctrl+Alt+P` | Switch project |
59
+ | `Ctrl+Alt+W` | Switch Claude config dir (`CLAUDE_CONFIG_DIR`); restarts the sub-apps |
58
60
 
59
61
  ## How It Works
60
62
 
@@ -77,6 +79,7 @@ The hub server spawns both sub-apps as child processes, each on its own port. A
77
79
  --kanban-port <n> Kanban port (default: 3541)
78
80
  --cost-port <n> Cost port (default: 3543)
79
81
  --memory-port <n> Memory Diagnoser port (default: 3544)
82
+ --pool-size <n> Config dirs kept running after a switch (default: 3)
80
83
  --open Auto-open browser
81
84
  ```
82
85
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-hub",
3
- "version": "0.35.0",
3
+ "version": "0.37.0",
4
4
  "description": "Unified hub for Claude Code tools — Marketplace + Kanban in one PWA",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -37,10 +37,10 @@
37
37
  },
38
38
  "homepage": "https://github.com/NikiforovAll/claude-code-hub#readme",
39
39
  "dependencies": {
40
- "claude-code-cost": "^0.20.0",
41
- "claude-code-kanban": "^4.23.2",
42
- "claude-code-marketplace": "^0.15.0",
43
- "claude-code-memory-explorer": "^0.12.0",
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",
44
44
  "express": "^4.21.0",
45
45
  "open": "^10.1.0"
46
46
  },
package/public/app.js CHANGED
@@ -10,7 +10,18 @@ const themeState = loadThemeState();
10
10
  // hub-theme: starting unset means there is nothing to race against each sub-app's own
11
11
  // project self-restore on boot. Once set it never returns to null.
12
12
  let projectState = null;
13
- const palette = { open: false, projects: [], rows: [], sel: 0, query: '' };
13
+ // The config dir the hub falls back to (~/.claude). Stays out of the window title.
14
+ let defaultConfigDir = null;
15
+ // mode: 'project' (Ctrl+Alt+P) or 'configDir' (Ctrl+Alt+W). One widget, two row sources.
16
+ const palette = {
17
+ open: false,
18
+ mode: 'project',
19
+ projects: [],
20
+ configDirs: { dirs: [], active: null },
21
+ rows: [],
22
+ sel: 0,
23
+ query: '',
24
+ };
14
25
 
15
26
  function loadThemeState() {
16
27
  try {
@@ -20,8 +31,14 @@ function loadThemeState() {
20
31
  }
21
32
  }
22
33
 
34
+ function osTheme() {
35
+ return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
36
+ }
37
+
38
+ // Sub-apps disagree on their first-run default (marketplace follows the OS, the rest start dark),
39
+ // so an unset theme resolves to the OS preference here and reaches every iframe on load.
23
40
  function themeMessage() {
24
- return { type: 'hub:theme', theme: themeState.theme, colorTheme: themeState.colorTheme };
41
+ return { type: 'hub:theme', theme: themeState.theme ?? osTheme(), colorTheme: themeState.colorTheme };
25
42
  }
26
43
 
27
44
  // {<themeId>: {dark, light}} from /api/config, which derives it from scripts/themes.json — the same
@@ -32,8 +49,7 @@ let themeAccents = {};
32
49
  // Paints the palette — the hub's only themed surface — in the active theme. The hub relays
33
50
  // hub:theme to the sub-apps; this is what makes it apply to the hub's own chrome too.
34
51
  function applyHubTheme() {
35
- // themeState.theme is unset until a sub-app pushes one, so fall back to the OS preference.
36
- const mode = themeState.theme ?? (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
52
+ const mode = themeState.theme ?? osTheme();
37
53
  document.documentElement.classList.toggle('light', mode === 'light');
38
54
  const pair = themeAccents[themeState.colorTheme];
39
55
  if (pair) document.documentElement.style.setProperty('--accent', pair[mode]);
@@ -105,10 +121,11 @@ async function init() {
105
121
  applyHubTheme();
106
122
  const res = await fetch('/api/config');
107
123
  const config = await res.json();
108
- apps = config.apps;
124
+ setApps(config.apps);
125
+ defaultConfigDir = config.defaultConfigDir ?? null;
126
+ applyTitle(config.activeConfigDir);
109
127
  themeAccents = config.themeAccents ?? {};
110
128
  applyHubTheme();
111
- allowedOrigins = new Set(Object.values(apps).map((a) => new URL(a.url).origin));
112
129
  buildIframes();
113
130
  switchTab(Object.keys(apps)[0]);
114
131
  listenMessages();
@@ -117,6 +134,38 @@ async function init() {
117
134
  registerSW();
118
135
  }
119
136
 
137
+ // The hub has no visible chrome, so the window/tab title is the only place the active config dir
138
+ // shows. Only a non-default dir is named, and by basename alone — the PWA window prepends the
139
+ // manifest name, so a product name here would read as "Claude Code Hub - .claude-eom · Hub".
140
+ function applyTitle(dir) {
141
+ document.title = dir && dir !== defaultConfigDir ? basename(dir) : 'Claude Code Hub';
142
+ }
143
+
144
+ function setApps(next) {
145
+ apps = next;
146
+ allowedOrigins = new Set(Object.values(apps).map((a) => new URL(a.url).origin));
147
+ }
148
+
149
+ // After a config-dir switch every child is a new process, possibly on a new port. Reloading each
150
+ // iframe from scratch also drops the sub-apps' in-memory project state, which belonged to the old
151
+ // dir; the hub's own projectState is cleared for the same reason.
152
+ function reloadIframes() {
153
+ loadedApps.clear();
154
+ projectState = null;
155
+ // Assigning src navigates even when the URL is unchanged.
156
+ for (const [id, iframe] of Object.entries(iframes)) iframe.src = apps[id].url;
157
+ }
158
+
159
+ function showLoading(text = '') {
160
+ document.getElementById('loading-text').textContent = text;
161
+ document.getElementById('loading-overlay').classList.remove('fade-out');
162
+ }
163
+
164
+ function hideLoading() {
165
+ document.getElementById('loading-text').textContent = '';
166
+ document.getElementById('loading-overlay').classList.add('fade-out');
167
+ }
168
+
120
169
  function buildIframes() {
121
170
  const container = document.getElementById('iframe-container');
122
171
  for (const [id, cfg] of Object.entries(apps)) {
@@ -140,30 +189,23 @@ function switchTab(appId) {
140
189
  postActiveTo(id);
141
190
  }
142
191
 
143
- const overlay = document.getElementById('loading-overlay');
144
- if (!loadedApps.has(appId)) {
145
- overlay.classList.remove('fade-out');
146
- } else {
147
- overlay.classList.add('fade-out');
148
- }
192
+ if (loadedApps.has(appId)) hideLoading();
193
+ else showLoading();
149
194
 
150
195
  iframes[appId]?.focus();
151
196
  }
152
197
 
153
198
  function onIframeLoad(appId) {
154
199
  loadedApps.add(appId);
155
- if (appId === activeApp) {
156
- document.getElementById('loading-overlay').classList.add('fade-out');
157
- }
158
- if (themeState.theme || themeState.colorTheme) {
159
- postTo(appId, themeMessage());
160
- }
200
+ if (appId === activeApp) hideLoading();
201
+ postTo(appId, themeMessage());
161
202
  postProjectTo(appId);
162
203
  postActiveTo(appId);
163
204
  // Posted twice: the shims gate their origin check on window.__HUB__, which they populate from
164
205
  // an async /hub-config fetch that resolves after this load event, so the first post can be
165
206
  // dropped. Safe to repeat — every shim's apply is idempotent.
166
207
  setTimeout(() => {
208
+ postTo(appId, themeMessage());
167
209
  postProjectTo(appId);
168
210
  postActiveTo(appId);
169
211
  }, 400);
@@ -179,6 +221,17 @@ function listenMessages() {
179
221
  if (data.url) iframes[data.app].src = apps[data.app].url + data.url;
180
222
  } else if (data.type === 'hub:keydown') {
181
223
  handleForwardedKey(data);
224
+ } else if (data.type === 'hub:openExternal') {
225
+ // In the installed PWA window a framed sub-app's own target=_blank opens
226
+ // nothing, so the shims hand external links up to the top frame instead.
227
+ let url;
228
+ try {
229
+ url = new URL(String(data.url));
230
+ } catch (_) {
231
+ return;
232
+ }
233
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return;
234
+ window.open(url.href, '_blank', 'noopener');
182
235
  } else if (data.type === 'hub:theme') {
183
236
  // Legacy senders pass only {theme}; colorTheme is optional and sticky.
184
237
  if (data.theme !== 'light' && data.theme !== 'dark') return;
@@ -207,9 +260,12 @@ function handleForwardedKey(d) {
207
260
  }
208
261
  if (d.key === 'ArrowLeft') cycleTab(-1);
209
262
  else if (d.key === 'ArrowRight') cycleTab(1);
210
- else if (d.key.toLowerCase() === 'p') togglePalette();
263
+ else if (PALETTE_KEYS[d.key.toLowerCase()]) togglePalette(PALETTE_KEYS[d.key.toLowerCase()]);
211
264
  }
212
265
 
266
+ // Ctrl+Alt+<letter> bindings. Sub-app shims forward every letter, so this is the one keymap.
267
+ const PALETTE_KEYS = { p: 'project', w: 'configDir' };
268
+
213
269
  function cycleTab(delta) {
214
270
  const ids = Object.keys(apps);
215
271
  const idx = ids.indexOf(activeApp);
@@ -225,9 +281,9 @@ function switchByIndex(idx) {
225
281
  function listenKeys() {
226
282
  document.addEventListener('keydown', (e) => {
227
283
  // Matches both cases: with Ctrl+Alt held, some layouts report AltGr-shifted characters.
228
- if (e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey && e.key.toLowerCase() === 'p') {
284
+ if (e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey && PALETTE_KEYS[e.key.toLowerCase()]) {
229
285
  e.preventDefault();
230
- togglePalette();
286
+ togglePalette(PALETTE_KEYS[e.key.toLowerCase()]);
231
287
  return;
232
288
  }
233
289
  // While the palette is open the input owns the keyboard — don't let tab shortcuts fire
@@ -264,12 +320,7 @@ function listenKeys() {
264
320
  }
265
321
 
266
322
  async function loadProjects() {
267
- const res = await fetch('/api/projects');
268
- if (!res.ok) {
269
- const body = await res.json().catch(() => ({}));
270
- throw new Error(body.error || `HTTP ${res.status}`);
271
- }
272
- const list = await res.json();
323
+ const list = await sendJson('GET', '/api/projects');
273
324
  // kanban sorts alphabetically; a picker wants most-recently-touched first.
274
325
  palette.projects = list
275
326
  .map((p) => ({
@@ -294,8 +345,7 @@ function subseq(text, q) {
294
345
  // hits above parent-path hits. Tier ties fall back to the recency order set in loadProjects.
295
346
  // Returns {p, tier} so the renderer can mark the field that actually explains the match instead of
296
347
  // re-deriving it.
297
- function projectRows(projects, query) {
298
- const q = query.trim().toLowerCase();
348
+ function projectRows(projects, q) {
299
349
  if (!q) return projects.slice(0, 100).map((p) => ({ p, tier: 0 }));
300
350
  const tierOf = (p) => {
301
351
  if (p.name.toLowerCase().includes(q)) return 0;
@@ -368,20 +418,101 @@ function relAge(ts) {
368
418
  return `${Math.round(m / 1440)}d`;
369
419
  }
370
420
 
421
+ async function loadConfigDirs() {
422
+ palette.configDirs = await sendJson('GET', '/api/config-dirs');
423
+ }
424
+
425
+ // Removes a dir from the hub's list only; nothing on disk changes. The server refuses the active
426
+ // dir, whose row has no remove button.
427
+ async function removeConfigDir(dirPath) {
428
+ try {
429
+ palette.configDirs = await sendJson('DELETE', '/api/config-dirs', { path: dirPath });
430
+ } catch (err) {
431
+ console.warn('remove config dir failed:', err.message);
432
+ return;
433
+ }
434
+ if (palette.open && palette.mode === 'configDir') renderPalette();
435
+ }
436
+
437
+ // `typed` is the trimmed query, `q` its lowercase form for matching.
438
+ const PALETTE_MODES = {
439
+ project: {
440
+ placeholder: 'Search projects or type a path...',
441
+ label: 'Switch project',
442
+ empty: 'No matching project',
443
+ literalLabel: 'Use literal path',
444
+ load: loadProjects,
445
+ rows(typed, q) {
446
+ const rows = projectRows(palette.projects, q).map((x) => ({
447
+ kind: 'project',
448
+ path: x.p.path,
449
+ p: x.p,
450
+ tier: x.tier,
451
+ }));
452
+ if (looksLikePath(typed)) rows.push({ kind: 'literal', path: typed });
453
+ return rows;
454
+ },
455
+ fillRow(li, row, q) {
456
+ // Only the field that ranked the row is marked — otherwise a one-letter query like "c"
457
+ // would light up the "C:" drive letter on every row. Tier 0 is a basename hit.
458
+ li.append(
459
+ markedSpan('name', row.p.name, row.tier === 0 ? q : ''),
460
+ markedSpan('parent', row.p.parent, row.tier === 0 ? '' : q),
461
+ el('span', 'age', relAge(row.p.ts)),
462
+ );
463
+ },
464
+ commit: commitProject,
465
+ },
466
+ configDir: {
467
+ placeholder: 'Pick a Claude config dir or type a path to add...',
468
+ label: 'Switch config dir',
469
+ hint: 'Enter switch · Ctrl+D remove · type a path to add',
470
+ empty: 'No matching config dir',
471
+ literalLabel: 'Add config dir',
472
+ load: loadConfigDirs,
473
+ rows(typed, q) {
474
+ const { dirs, active } = palette.configDirs;
475
+ const rows = dirs
476
+ .filter((d) => !q || d.toLowerCase().includes(q))
477
+ .map((d) => ({ kind: 'dir', path: d, active: d === active }));
478
+ if (looksLikePath(typed) && !dirs.some((d) => d.toLowerCase() === q)) rows.push({ kind: 'literal', path: typed });
479
+ return rows;
480
+ },
481
+ // Enter on the default row must switch, so the cursor lands on the dir before the active one,
482
+ // wrapping. With two dirs the palette then behaves like a toggle.
483
+ initialSel(rows) {
484
+ const active = rows.findIndex((r) => r.active);
485
+ if (active < 0 || rows.length < 2) return 0;
486
+ return (active - 1 + rows.length) % rows.length;
487
+ },
488
+ fillRow(li, row, q) {
489
+ li.append(markedSpan('name', row.path, q));
490
+ if (row.active) {
491
+ li.append(el('span', 'age', 'active'));
492
+ return;
493
+ }
494
+ const remove = el('button', 'palette-remove', '×');
495
+ remove.type = 'button';
496
+ remove.title = 'Remove from list (Ctrl+D)';
497
+ remove.dataset.remove = row.path;
498
+ li.append(remove);
499
+ },
500
+ commit: commitConfigDir,
501
+ },
502
+ };
503
+
371
504
  function renderPalette() {
372
505
  const list = document.getElementById('palette-list');
373
- const q = palette.query.trim().toLowerCase();
374
- const rows = projectRows(palette.projects, palette.query).map((x) => ({
375
- kind: 'project',
376
- path: x.p.path,
377
- p: x.p,
378
- tier: x.tier,
379
- }));
380
- if (looksLikePath(palette.query)) rows.push({ kind: 'literal', path: palette.query.trim() });
506
+ const spec = PALETTE_MODES[palette.mode];
507
+ const typed = palette.query.trim();
508
+ const q = typed.toLowerCase();
509
+ const rows = spec.rows(typed, q);
381
510
  palette.rows = rows;
511
+ // sel is null until the first render that has rows: the cached list may be empty until load() lands.
512
+ if (palette.sel === null && rows.length > 0) palette.sel = spec.initialSel?.(rows) ?? 0;
382
513
  palette.sel = Math.min(palette.sel, Math.max(0, rows.length - 1));
383
514
  if (rows.length === 0) {
384
- list.replaceChildren(el('li', 'palette-empty', 'No matching project'));
515
+ list.replaceChildren(el('li', 'palette-empty', spec.empty));
385
516
  return;
386
517
  }
387
518
  list.replaceChildren(
@@ -391,17 +522,8 @@ function renderPalette() {
391
522
  `palette-row${row.kind === 'literal' ? ' literal' : ''}${i === palette.sel ? ' selected' : ''}`,
392
523
  );
393
524
  li.dataset.idx = String(i);
394
- if (row.kind === 'literal') {
395
- li.textContent = `Use literal path -- ${row.path}`;
396
- } else {
397
- // Only the field that ranked the row is marked — otherwise a one-letter query like "c"
398
- // would light up the "C:" drive letter on every row. Tier 0 is a basename hit.
399
- li.append(
400
- markedSpan('name', row.p.name, row.tier === 0 ? q : ''),
401
- markedSpan('parent', row.p.parent, row.tier === 0 ? '' : q),
402
- el('span', 'age', relAge(row.p.ts)),
403
- );
404
- }
525
+ if (row.kind === 'literal') li.textContent = `${spec.literalLabel} -- ${row.path}`;
526
+ else spec.fillRow(li, row, q);
405
527
  return li;
406
528
  }),
407
529
  );
@@ -416,17 +538,23 @@ function el(tag, className, text) {
416
538
  return node;
417
539
  }
418
540
 
419
- function togglePalette() {
420
- if (palette.open) closePalette();
421
- else openPalette();
541
+ function togglePalette(mode) {
542
+ // Same shortcut again closes; the other shortcut swaps the open palette to its mode.
543
+ if (palette.open && palette.mode === mode) closePalette();
544
+ else openPalette(mode);
422
545
  }
423
546
 
424
- function openPalette() {
547
+ function openPalette(mode) {
425
548
  const input = document.getElementById('palette-input');
549
+ const spec = PALETTE_MODES[mode];
426
550
  palette.open = true;
551
+ palette.mode = mode;
427
552
  palette.query = '';
428
- palette.sel = 0;
553
+ palette.sel = null;
429
554
  input.value = '';
555
+ input.placeholder = spec.placeholder;
556
+ document.querySelector('#palette .palette-box').setAttribute('aria-label', spec.label);
557
+ document.getElementById('palette-hint').textContent = spec.hint ?? '';
430
558
  document.getElementById('palette').hidden = false;
431
559
  // Ctrl+Alt+P usually arrives forwarded from a focused iframe. Without inert the sub-app can keep
432
560
  // or take focus back, and then Escape is handled inside it — the palette stays open and only the
@@ -435,11 +563,12 @@ function openPalette() {
435
563
  renderPalette();
436
564
  input.focus();
437
565
  // Stale-while-revalidate: the cached list renders instantly, recency refreshes when this lands.
438
- loadProjects()
566
+ spec
567
+ .load()
439
568
  .then(() => {
440
- if (palette.open) renderPalette();
569
+ if (palette.open && palette.mode === mode) renderPalette();
441
570
  })
442
- .catch((err) => console.warn('project list unavailable:', err.message));
571
+ .catch((err) => console.warn(`${mode} list unavailable:`, err.message));
443
572
  }
444
573
 
445
574
  function closePalette() {
@@ -462,7 +591,10 @@ function movePaletteSel(delta) {
462
591
 
463
592
  async function commitPalette() {
464
593
  const row = palette.rows[palette.sel];
465
- if (!row) return;
594
+ if (row) await PALETTE_MODES[palette.mode].commit(row);
595
+ }
596
+
597
+ async function commitProject(row) {
466
598
  // List rows broadcast verbatim: kanban handed us this exact string and matches it with strict
467
599
  // ===. Only a typed path needs normalizing.
468
600
  const absPath = row.kind === 'project' ? row.path : await resolveTypedPath(row.path);
@@ -471,22 +603,65 @@ async function commitPalette() {
471
603
  setProject(absPath);
472
604
  }
473
605
 
474
- // Returns the real on-disk path, or null when it can't be resolved (the palette stays open).
475
- async function resolveTypedPath(typed) {
606
+ async function sendJson(method, url, body) {
607
+ const res = await fetch(url, {
608
+ method,
609
+ headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
610
+ body: body === undefined ? undefined : JSON.stringify(body),
611
+ });
612
+ const data = await res.json().catch(() => ({}));
613
+ if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
614
+ return data;
615
+ }
616
+
617
+ // Disables the palette input for the duration of a request so Enter can't fire twice.
618
+ async function withPaletteBusy(fn) {
476
619
  const input = document.getElementById('palette-input');
477
620
  input.disabled = true;
478
621
  try {
479
- const res = await fetch(`/api/resolve-path?path=${encodeURIComponent(typed)}`);
480
- const data = await res.json().catch(() => ({}));
481
- if (res.ok) return data.path;
482
- console.warn('resolve-path failed:', data.error || res.status);
483
- } catch (err) {
484
- console.warn('resolve-path failed:', err.message);
622
+ return await fn();
485
623
  } finally {
486
624
  input.disabled = false;
487
625
  if (palette.open) input.focus();
488
626
  }
489
- return null;
627
+ }
628
+
629
+ async function commitConfigDir(row) {
630
+ let target = row.path;
631
+ if (row.kind === 'literal') {
632
+ try {
633
+ target = (await withPaletteBusy(() => sendJson('POST', '/api/config-dirs', { path: row.path }))).path;
634
+ } catch (err) {
635
+ console.warn('add config dir failed:', err.message);
636
+ return;
637
+ }
638
+ }
639
+ closePalette();
640
+ // The server would no-op too, but the client would still reload every iframe.
641
+ if (target === palette.configDirs.active) return;
642
+ showLoading(`Switching to ${target}...`);
643
+ try {
644
+ setApps((await sendJson('POST', '/api/config-dirs/activate', { path: target })).apps);
645
+ // The palette renders from this cache before its refetch lands, and initialSel keys off it.
646
+ palette.configDirs.active = target;
647
+ applyTitle(target);
648
+ reloadIframes();
649
+ } catch (err) {
650
+ console.warn('config dir switch failed:', err.message);
651
+ hideLoading();
652
+ }
653
+ }
654
+
655
+ // Returns the real on-disk path, or null when it can't be resolved (the palette stays open).
656
+ function resolveTypedPath(typed) {
657
+ return withPaletteBusy(async () => {
658
+ try {
659
+ return (await sendJson('GET', `/api/resolve-path?path=${encodeURIComponent(typed)}`)).path;
660
+ } catch (err) {
661
+ console.warn('resolve-path failed:', err.message);
662
+ return null;
663
+ }
664
+ });
490
665
  }
491
666
 
492
667
  function bindPalette() {
@@ -510,9 +685,21 @@ function bindPalette() {
510
685
  } else if (e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey)) {
511
686
  e.preventDefault();
512
687
  movePaletteSel(-1);
688
+ } else if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && e.key.toLowerCase() === 'd') {
689
+ // Ctrl+D would otherwise bookmark the page in Chrome and Firefox.
690
+ e.preventDefault();
691
+ const row = palette.rows[palette.sel];
692
+ if (row?.kind === 'dir') removeConfigDir(row.path);
513
693
  }
514
694
  });
515
695
  document.getElementById('palette-list').addEventListener('click', (e) => {
696
+ const remove = e.target.closest('.palette-remove');
697
+ if (remove) {
698
+ e.stopPropagation();
699
+ removeConfigDir(remove.dataset.remove);
700
+ document.getElementById('palette-input').focus();
701
+ return;
702
+ }
516
703
  const li = e.target.closest('.palette-row');
517
704
  if (!li) return;
518
705
  palette.sel = Number(li.dataset.idx);
package/public/index.html CHANGED
@@ -67,6 +67,8 @@
67
67
  position: absolute;
68
68
  top: 0; left: 0; right: 0; bottom: 0;
69
69
  display: flex;
70
+ flex-direction: column;
71
+ gap: 14px;
70
72
  align-items: center;
71
73
  justify-content: center;
72
74
  background: var(--bg);
@@ -90,7 +92,15 @@
90
92
 
91
93
  @keyframes spin { to { transform: rotate(360deg); } }
92
94
 
93
- /* Project switcher palette — the hub's only chrome, summoned with Ctrl+Alt+P. */
95
+ .loading-text {
96
+ color: var(--text-dim);
97
+ font: 13px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
98
+ }
99
+
100
+ .loading-text:empty { display: none; }
101
+
102
+ /* Switcher palette — the hub's only chrome. Ctrl+Alt+P for projects, Ctrl+Alt+W for Claude
103
+ config dirs. */
94
104
  .palette {
95
105
  position: fixed;
96
106
  inset: 0;
@@ -183,12 +193,41 @@
183
193
  padding: 10px 14px;
184
194
  color: var(--text-dim);
185
195
  }
196
+
197
+ /* Mirrors memory's picker: the remove control is hidden until the row is hovered or selected. */
198
+ .palette-row .palette-remove {
199
+ margin-left: auto;
200
+ padding: 0 6px;
201
+ border: none;
202
+ background: transparent;
203
+ color: var(--text-dim);
204
+ font: inherit;
205
+ font-size: 16px;
206
+ line-height: 1;
207
+ cursor: pointer;
208
+ opacity: 0;
209
+ }
210
+
211
+ .palette-row:hover .palette-remove,
212
+ .palette-row.selected .palette-remove { opacity: 1; }
213
+
214
+ .palette-row .palette-remove:hover { color: #e5484d; }
215
+
216
+ .palette-hint {
217
+ padding: 8px 14px;
218
+ border-top: 1px solid var(--border);
219
+ color: var(--text-dim);
220
+ font-size: 12px;
221
+ }
222
+
223
+ .palette-hint:empty { display: none; }
186
224
  </style>
187
225
  </head>
188
226
  <body>
189
227
  <div class="iframe-container" id="iframe-container">
190
228
  <div class="loading-overlay" id="loading-overlay">
191
229
  <div class="spinner"></div>
230
+ <div class="loading-text" id="loading-text"></div>
192
231
  </div>
193
232
  </div>
194
233
 
@@ -197,6 +236,7 @@
197
236
  <input class="palette-input" id="palette-input" type="text" spellcheck="false"
198
237
  autocomplete="off" placeholder="Search projects or type a path...">
199
238
  <ul class="palette-list" id="palette-list" role="listbox"></ul>
239
+ <div class="palette-hint" id="palette-hint"></div>
200
240
  </div>
201
241
  </div>
202
242
 
package/server.js CHANGED
@@ -3,6 +3,8 @@
3
3
  const { spawn } = require('child_process');
4
4
  const express = require('express');
5
5
  const fs = require('fs');
6
+ const http = require('http');
7
+ const os = require('os');
6
8
  const path = require('path');
7
9
  const { createNetGuard } = require('./lib/net-guard');
8
10
 
@@ -20,20 +22,92 @@ const KANBAN_PORT = parseInt(getArg('kanban-port') || '3541', 10);
20
22
  const COST_PORT = parseInt(getArg('cost-port') || '3543', 10);
21
23
  const MEMORY_PORT = parseInt(getArg('memory-port') || '3544', 10);
22
24
 
23
- const children = [];
24
- const actualPorts = { marketplace: MARKETPLACE_PORT, kanban: KANBAN_PORT, cost: COST_PORT, memory: MEMORY_PORT };
25
+ const POOL_SIZE = Math.max(1, parseInt(getArg('pool-size') || '3', 10));
26
+ const DEFAULT_PORTS = { marketplace: MARKETPLACE_PORT, kanban: KANBAN_PORT, cost: COST_PORT, memory: MEMORY_PORT };
27
+
28
+ // One child set per config dir, kept alive after a switch so switching back is instant. Map
29
+ // insertion order doubles as LRU: activating a dir re-inserts it at the end.
30
+ const pools = new Map();
31
+
32
+ function activePool() {
33
+ return pools.get(hubConfig.activeConfigDir);
34
+ }
35
+
36
+ // Every sub-app resolves CLAUDE_CONFIG_DIR once at startup into a module constant, so switching
37
+ // the dir means restarting the children. Kept on the server, not in the browser: the dir has to
38
+ // 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');
40
+
41
+ // The dir the hub would use with no saved config. Set by loadHubConfig; the client uses it to keep
42
+ // the default dir out of the window title.
43
+ let defaultConfigDir = null;
44
+
45
+ function expandHome(p) {
46
+ return p.startsWith('~') ? p.replace('~', os.homedir()) : p;
47
+ }
48
+
49
+ // Real on-disk spelling (true casing, native separators). One dir is one list entry and one child
50
+ // pool whichever way it was typed. Throws when the path does not exist.
51
+ function canonicalDir(p) {
52
+ return fs.realpathSync.native(path.resolve(expandHome(p)));
53
+ }
54
+
55
+ function loadHubConfig() {
56
+ const canonical = (p) => {
57
+ try {
58
+ return canonicalDir(p);
59
+ } catch {
60
+ return expandHome(p);
61
+ }
62
+ };
63
+ const fallback = canonical(
64
+ process.env.CLAUDE_CONFIG_DIR || process.env.CLAUDE_DIR || path.join(os.homedir(), '.claude'),
65
+ );
66
+ defaultConfigDir = fallback;
67
+ let raw = '';
68
+ let saved = {};
69
+ try {
70
+ raw = fs.readFileSync(HUB_CONFIG_FILE, 'utf8');
71
+ saved = JSON.parse(raw);
72
+ } catch {}
73
+ const savedDirs = Array.isArray(saved.configDirs) ? saved.configDirs.filter((d) => typeof d === 'string') : [];
74
+ const dirs = [...new Set(savedDirs.map(canonical))];
75
+ if (!dirs.includes(fallback)) dirs.unshift(fallback);
76
+ const savedActive = typeof saved.activeConfigDir === 'string' ? canonical(saved.activeConfigDir) : null;
77
+ const active = dirs.includes(savedActive) ? savedActive : fallback;
78
+ const config = { configDirs: dirs, activeConfigDir: active };
79
+ // Configs saved before canonicalization can hold one dir under two spellings; persist the merge.
80
+ if (serializeConfig(config) !== raw) writeHubConfig(config);
81
+ return config;
82
+ }
83
+
84
+ function serializeConfig(config) {
85
+ return `${JSON.stringify(config, null, 2)}\n`;
86
+ }
87
+
88
+ function writeHubConfig(config) {
89
+ fs.mkdirSync(path.dirname(HUB_CONFIG_FILE), { recursive: true });
90
+ fs.writeFileSync(HUB_CONFIG_FILE, serializeConfig(config));
91
+ }
92
+
93
+ function saveHubConfig() {
94
+ writeHubConfig(hubConfig);
95
+ }
96
+
97
+ const hubConfig = loadHubConfig();
25
98
 
26
99
  // Created before spawnApp so the same host/allowed-hosts decision reaches the
27
100
  // children. Without that propagation, `--host 0.0.0.0` would expose the hub shell
28
101
  // while every iframe 403'd on its own Host check.
29
102
  const net = createNetGuard({ appName: 'Claude Code Hub' });
30
103
 
31
- function spawnApp(name, cmd, args, envPort) {
104
+ function spawnApp(pool, name, cmd, args) {
32
105
  const child = spawn(cmd, args, {
33
106
  cwd: __dirname,
34
107
  env: {
35
108
  ...process.env,
36
- PORT: String(envPort),
109
+ PORT: '0',
110
+ CLAUDE_CONFIG_DIR: pool.dir,
37
111
  CLAUDE_HUB: '1',
38
112
  HUB_URL: `http://localhost:${HUB_PORT}`,
39
113
  HOST: net.BIND_HOST,
@@ -43,6 +117,10 @@ function spawnApp(name, cmd, args, envPort) {
43
117
  });
44
118
 
45
119
  let stdoutBuf = '';
120
+ let markReady;
121
+ child.ready = new Promise((resolve) => {
122
+ markReady = resolve;
123
+ });
46
124
  child.stdout.on('data', (d) => {
47
125
  stdoutBuf += d.toString();
48
126
  let nl;
@@ -51,19 +129,25 @@ function spawnApp(name, cmd, args, envPort) {
51
129
  stdoutBuf = stdoutBuf.slice(nl + 1);
52
130
  process.stdout.write(`[${name}] ${line}`);
53
131
  const match = line.match(/running at http:\/\/localhost:(\d+)/i);
54
- if (match) actualPorts[name] = parseInt(match[1], 10);
132
+ if (match) {
133
+ pool.ports[name] = parseInt(match[1], 10);
134
+ markReady();
135
+ }
55
136
  }
56
137
  });
57
138
  child.stderr.on('data', (d) => process.stderr.write(`[${name}] ${d}`));
58
- child.on('exit', (code) => console.log(`[${name}] exited (code ${code})`));
139
+ child.on('exit', (code) => {
140
+ console.log(`[${name}] exited (code ${code})`);
141
+ markReady();
142
+ });
59
143
 
60
- children.push(child);
144
+ pool.children.push(child);
61
145
  return child;
62
146
  }
63
147
 
64
- function killAll() {
65
- for (const child of children) {
66
- if (child.killed) continue;
148
+ function killPool(pool) {
149
+ for (const child of pool.children) {
150
+ if (child.exitCode !== null || child.killed) continue;
67
151
  if (process.platform === 'win32') {
68
152
  spawn('taskkill', ['/pid', String(child.pid), '/f', '/t'], { stdio: 'ignore' });
69
153
  } else {
@@ -72,6 +156,47 @@ function killAll() {
72
156
  }
73
157
  }
74
158
 
159
+ function killAll() {
160
+ for (const pool of pools.values()) killPool(pool);
161
+ }
162
+
163
+ function withTimeout(promise, ms) {
164
+ let timer;
165
+ const gate = new Promise((resolve) => {
166
+ timer = setTimeout(resolve, ms);
167
+ });
168
+ return Promise.race([promise, gate]).finally(() => clearTimeout(timer));
169
+ }
170
+
171
+ // Returns the live pool for a dir, spawning one when absent and evicting the least recently used
172
+ // pool past POOL_SIZE. Resolves once every child has printed its port banner (or given up), so
173
+ // the caller can hand the client URLs that are actually live.
174
+ async function ensurePool(dir) {
175
+ let pool = pools.get(dir);
176
+ if (pool) {
177
+ pools.delete(dir);
178
+ pools.set(dir, pool);
179
+ return pool.ready;
180
+ }
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: {} };
187
+ pools.set(dir, pool);
188
+ spawnChildren(pool);
189
+ pool.ready = withTimeout(Promise.all(pool.children.map((c) => c.ready)), 20000).then(() => pool);
190
+ return pool.ready;
191
+ }
192
+
193
+ function dropPool(dir) {
194
+ const pool = pools.get(dir);
195
+ if (!pool) return;
196
+ pools.delete(dir);
197
+ killPool(pool);
198
+ }
199
+
75
200
  function shutdown() {
76
201
  killAll();
77
202
  process.exit(0);
@@ -115,12 +240,86 @@ const memoryPath = resolveApp('memory', 'claude-code-memory-explorer');
115
240
  const HDR_BYTES = 65536;
116
241
  const NODE_HDR = `--max-http-header-size=${HDR_BYTES}`;
117
242
 
118
- spawnApp('marketplace', process.execPath, [NODE_HDR, marketplacePath, `--port=${MARKETPLACE_PORT}`], MARKETPLACE_PORT);
119
- spawnApp('kanban', process.execPath, [NODE_HDR, kanbanPath], KANBAN_PORT);
120
- spawnApp('cost', process.execPath, [NODE_HDR, costPath, `--port=${COST_PORT}`], COST_PORT);
121
- spawnApp('memory', process.execPath, [NODE_HDR, memoryPath, `--port=${MEMORY_PORT}`], MEMORY_PORT);
243
+ // Children bind ephemeral ports; the public ports below belong to the hub's proxies. Browser
244
+ // localStorage is keyed by origin, so the sub-app origin has to stay put across switches or the
245
+ // user loses pins and filters every time the active set changes.
246
+ function spawnChildren(pool) {
247
+ spawnApp(pool, 'marketplace', process.execPath, [NODE_HDR, marketplacePath]);
248
+ spawnApp(pool, 'kanban', process.execPath, [NODE_HDR, kanbanPath]);
249
+ spawnApp(pool, 'cost', process.execPath, [NODE_HDR, costPath]);
250
+ spawnApp(pool, 'memory', process.execPath, [NODE_HDR, memoryPath]);
251
+ }
252
+
253
+ const publicPorts = { ...DEFAULT_PORTS };
254
+
255
+ function rewriteOrigin(origin, publicPort, childPort) {
256
+ try {
257
+ const u = new URL(origin);
258
+ if (Number(u.port) !== publicPort) return origin;
259
+ u.port = String(childPort);
260
+ return u.origin;
261
+ } catch {
262
+ return origin;
263
+ }
264
+ }
265
+
266
+ const proxyAgent = new http.Agent({ keepAlive: true, maxSockets: 64 });
267
+
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);
282
+ const upstream = http.request(
283
+ { host: '127.0.0.1', port: childPort, method: req.method, path: req.url, headers, agent: proxyAgent },
284
+ (u) => {
285
+ res.writeHead(u.statusCode, u.headers);
286
+ u.pipe(res);
287
+ },
288
+ );
289
+ upstream.on('error', (err) => {
290
+ if (!res.headersSent) res.writeHead(502);
291
+ res.end(err.message);
292
+ });
293
+ res.on('close', () => upstream.destroy());
294
+ req.pipe(upstream);
295
+ };
296
+ }
297
+
298
+ function listenWithFallback(handler, port, onReady, label) {
299
+ const server = net.listenLoopback(handler, port, onReady, { maxHeaderSize: HDR_BYTES });
300
+ server.on('error', (err) => {
301
+ if (err.code !== 'EADDRINUSE' && err.code !== 'EACCES') throw err;
302
+ console.log(`${label}port ${port} in use, trying random port...`);
303
+ net.listenLoopback(handler, 0, onReady, { maxHeaderSize: HDR_BYTES });
304
+ });
305
+ return server;
306
+ }
307
+
308
+ for (const [name, port] of Object.entries(DEFAULT_PORTS)) {
309
+ listenWithFallback(
310
+ proxyHandler(name),
311
+ port,
312
+ (actual) => {
313
+ publicPorts[name] = actual;
314
+ },
315
+ `[${name}] `,
316
+ );
317
+ }
318
+
319
+ ensurePool(hubConfig.activeConfigDir);
122
320
 
123
321
  const app = express();
322
+ app.use(express.json());
124
323
 
125
324
  // Mounted before the routes below, which are registered ahead of the first
126
325
  // app.use() and would otherwise bypass the guards entirely.
@@ -142,26 +341,94 @@ const themeAccents = (() => {
142
341
  }
143
342
  })();
144
343
 
344
+ function appsConfig() {
345
+ return {
346
+ kanban: { name: 'Kanban', url: `http://localhost:${publicPorts.kanban}`, icon: 'columns' },
347
+ marketplace: { name: 'Marketplace', url: `http://localhost:${publicPorts.marketplace}`, icon: 'store' },
348
+ cost: { name: 'Cost', url: `http://localhost:${publicPorts.cost}`, icon: 'dollar-sign' },
349
+ memory: { name: 'Memory Diagnoser', url: `http://localhost:${publicPorts.memory}`, icon: 'database' },
350
+ };
351
+ }
352
+
145
353
  app.get('/api/config', (_req, res) => {
146
- res.json({
147
- themeAccents,
148
- apps: {
149
- kanban: { name: 'Kanban', url: `http://localhost:${actualPorts.kanban}`, icon: 'columns' },
150
- marketplace: { name: 'Marketplace', url: `http://localhost:${actualPorts.marketplace}`, icon: 'store' },
151
- cost: { name: 'Cost', url: `http://localhost:${actualPorts.cost}`, icon: 'dollar-sign' },
152
- memory: { name: 'Memory Diagnoser', url: `http://localhost:${actualPorts.memory}`, icon: 'database' },
153
- },
154
- });
354
+ res.json({ themeAccents, apps: appsConfig(), activeConfigDir: hubConfig.activeConfigDir, defaultConfigDir });
355
+ });
356
+
357
+ app.get('/api/config-dirs', (_req, res) => {
358
+ res.setHeader('Cache-Control', 'no-store');
359
+ res.json({ dirs: hubConfig.configDirs, active: hubConfig.activeConfigDir });
360
+ });
361
+
362
+ // Normalizes a typed path to its real on-disk form (true casing, native separators) so cck's
363
+ // strict === project match and cost's abs->encoded transform both hit, and so the config-dir list
364
+ // holds one spelling per dir.
365
+ function resolveDir(input) {
366
+ if (typeof input !== 'string' || !input.trim()) return { error: 'path is required', status: 400 };
367
+ let resolved;
368
+ try {
369
+ resolved = canonicalDir(input.trim());
370
+ } catch {
371
+ return { error: 'Path not found', status: 404 };
372
+ }
373
+ if (!fs.statSync(resolved).isDirectory()) return { error: 'Not a directory', status: 400 };
374
+ return { path: resolved };
375
+ }
376
+
377
+ app.post('/api/config-dirs', (req, res) => {
378
+ const r = resolveDir(req.body?.path);
379
+ if (r.error) {
380
+ res.status(r.status).json({ error: r.error });
381
+ return;
382
+ }
383
+ if (!hubConfig.configDirs.includes(r.path)) {
384
+ hubConfig.configDirs.push(r.path);
385
+ saveHubConfig();
386
+ }
387
+ res.json({ path: r.path });
388
+ });
389
+
390
+ app.delete('/api/config-dirs', (req, res) => {
391
+ const raw = typeof req.body?.path === 'string' ? req.body.path : '';
392
+ const target = resolveDir(raw).path ?? raw;
393
+ if (target === hubConfig.activeConfigDir) {
394
+ res.status(400).json({ error: 'Cannot remove the active config dir' });
395
+ return;
396
+ }
397
+ hubConfig.configDirs = hubConfig.configDirs.filter((d) => d !== target);
398
+ saveHubConfig();
399
+ dropPool(target);
400
+ res.json({ dirs: hubConfig.configDirs, active: hubConfig.activeConfigDir });
401
+ });
402
+
403
+ // The response carries the app URLs so the client reloads every iframe against the new set.
404
+ app.post('/api/config-dirs/activate', async (req, res) => {
405
+ const target = typeof req.body?.path === 'string' ? req.body.path : '';
406
+ if (!hubConfig.configDirs.includes(target)) {
407
+ res.status(404).json({ error: 'Unknown config dir; add it first' });
408
+ return;
409
+ }
410
+ if (target !== hubConfig.activeConfigDir) {
411
+ console.log(`Switching CLAUDE_CONFIG_DIR -> ${target}`);
412
+ await ensurePool(target);
413
+ hubConfig.activeConfigDir = target;
414
+ saveHubConfig();
415
+ }
416
+ res.json({ apps: appsConfig() });
155
417
  });
156
418
 
157
419
  // Project list for the switcher palette, proxied from kanban — it is the only sub-app that
158
- // enumerates projects. actualPorts is read per request because kanban's real port is only
159
- // known once its banner has been scraped (see spawnApp).
420
+ // enumerates projects. The port is read per request because kanban's real port is only known
421
+ // once its banner has been scraped (see spawnApp).
160
422
  app.get('/api/projects', async (_req, res) => {
161
423
  res.setHeader('Cache-Control', 'no-store');
424
+ const kanbanPort = activePool()?.ports.kanban;
425
+ if (!kanbanPort) {
426
+ res.status(503).json({ error: 'kanban is starting' });
427
+ return;
428
+ }
162
429
  try {
163
430
  // 127.0.0.1, not localhost — Node's verbatim DNS ordering tries ::1 first on Windows.
164
- const upstream = await fetch(`http://127.0.0.1:${actualPorts.kanban}/api/projects`, {
431
+ const upstream = await fetch(`http://127.0.0.1:${kanbanPort}/api/projects`, {
165
432
  signal: AbortSignal.timeout(4000),
166
433
  });
167
434
  if (!upstream.ok) {
@@ -175,28 +442,12 @@ app.get('/api/projects', async (_req, res) => {
175
442
  }
176
443
  });
177
444
 
178
- // Normalizes a typed path to its real on-disk form (true casing, native separators) so cck's
179
- // strict === project match and cost's abs->encoded transform both hit. List-picked paths skip
180
- // this: they are byte-exact copies of what kanban reported.
445
+ // List-picked project paths skip this: they are byte-exact copies of what kanban reported.
181
446
  app.get('/api/resolve-path', (req, res) => {
182
447
  res.setHeader('Cache-Control', 'no-store');
183
- const input = typeof req.query.path === 'string' ? req.query.path.trim() : '';
184
- if (!input) {
185
- res.status(400).json({ error: 'path is required' });
186
- return;
187
- }
188
- let resolved;
189
- try {
190
- resolved = fs.realpathSync.native(path.resolve(input));
191
- } catch {
192
- res.status(404).json({ error: 'Path not found' });
193
- return;
194
- }
195
- if (!fs.statSync(resolved).isDirectory()) {
196
- res.status(400).json({ error: 'Not a directory' });
197
- return;
198
- }
199
- res.json({ path: resolved });
448
+ const r = resolveDir(req.query.path);
449
+ if (r.error) res.status(r.status).json({ error: r.error });
450
+ else res.json({ path: r.path });
200
451
  });
201
452
 
202
453
  app.use(express.static(path.join(__dirname, 'public')));
@@ -208,7 +459,7 @@ const onReady = (actual) => {
208
459
  }
209
460
  };
210
461
 
211
- const server = net.listenLoopback(app, HUB_PORT, onReady, { maxHeaderSize: HDR_BYTES });
462
+ listenWithFallback(app, HUB_PORT, onReady, '');
212
463
 
213
464
  function printBanner(port) {
214
465
  console.log(`Claude Code Hub running at http://localhost:${port}`);
@@ -216,12 +467,3 @@ function printBanner(port) {
216
467
  if (warning) console.log(warning);
217
468
  console.log('Type "q" or "exit" to stop the server');
218
469
  }
219
-
220
- server.on('error', (err) => {
221
- if (err.code === 'EADDRINUSE' || err.code === 'EACCES') {
222
- console.log(`Port ${HUB_PORT} in use, trying random port...`);
223
- net.listenLoopback(app, 0, onReady, { maxHeaderSize: HDR_BYTES });
224
- } else {
225
- throw err;
226
- }
227
- });