claude-code-hub 0.34.0 → 0.36.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.34.0",
3
+ "version": "0.36.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.22.0",
41
+ "claude-code-kanban": "^4.25.0",
42
+ "claude-code-marketplace": "^0.17.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,16 @@ 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
+ // mode: 'project' (Ctrl+Alt+P) or 'configDir' (Ctrl+Alt+W). One widget, two row sources.
14
+ const palette = {
15
+ open: false,
16
+ mode: 'project',
17
+ projects: [],
18
+ configDirs: { dirs: [], active: null },
19
+ rows: [],
20
+ sel: 0,
21
+ query: '',
22
+ };
14
23
 
15
24
  function loadThemeState() {
16
25
  try {
@@ -20,8 +29,14 @@ function loadThemeState() {
20
29
  }
21
30
  }
22
31
 
32
+ function osTheme() {
33
+ return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
34
+ }
35
+
36
+ // Sub-apps disagree on their first-run default (marketplace follows the OS, the rest start dark),
37
+ // so an unset theme resolves to the OS preference here and reaches every iframe on load.
23
38
  function themeMessage() {
24
- return { type: 'hub:theme', theme: themeState.theme, colorTheme: themeState.colorTheme };
39
+ return { type: 'hub:theme', theme: themeState.theme ?? osTheme(), colorTheme: themeState.colorTheme };
25
40
  }
26
41
 
27
42
  // {<themeId>: {dark, light}} from /api/config, which derives it from scripts/themes.json — the same
@@ -32,8 +47,7 @@ let themeAccents = {};
32
47
  // Paints the palette — the hub's only themed surface — in the active theme. The hub relays
33
48
  // hub:theme to the sub-apps; this is what makes it apply to the hub's own chrome too.
34
49
  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');
50
+ const mode = themeState.theme ?? osTheme();
37
51
  document.documentElement.classList.toggle('light', mode === 'light');
38
52
  const pair = themeAccents[themeState.colorTheme];
39
53
  if (pair) document.documentElement.style.setProperty('--accent', pair[mode]);
@@ -105,10 +119,9 @@ async function init() {
105
119
  applyHubTheme();
106
120
  const res = await fetch('/api/config');
107
121
  const config = await res.json();
108
- apps = config.apps;
122
+ setApps(config.apps);
109
123
  themeAccents = config.themeAccents ?? {};
110
124
  applyHubTheme();
111
- allowedOrigins = new Set(Object.values(apps).map((a) => new URL(a.url).origin));
112
125
  buildIframes();
113
126
  switchTab(Object.keys(apps)[0]);
114
127
  listenMessages();
@@ -117,6 +130,31 @@ async function init() {
117
130
  registerSW();
118
131
  }
119
132
 
133
+ function setApps(next) {
134
+ apps = next;
135
+ allowedOrigins = new Set(Object.values(apps).map((a) => new URL(a.url).origin));
136
+ }
137
+
138
+ // After a config-dir switch every child is a new process, possibly on a new port. Reloading each
139
+ // iframe from scratch also drops the sub-apps' in-memory project state, which belonged to the old
140
+ // dir; the hub's own projectState is cleared for the same reason.
141
+ function reloadIframes() {
142
+ loadedApps.clear();
143
+ projectState = null;
144
+ // Assigning src navigates even when the URL is unchanged.
145
+ for (const [id, iframe] of Object.entries(iframes)) iframe.src = apps[id].url;
146
+ }
147
+
148
+ function showLoading(text = '') {
149
+ document.getElementById('loading-text').textContent = text;
150
+ document.getElementById('loading-overlay').classList.remove('fade-out');
151
+ }
152
+
153
+ function hideLoading() {
154
+ document.getElementById('loading-text').textContent = '';
155
+ document.getElementById('loading-overlay').classList.add('fade-out');
156
+ }
157
+
120
158
  function buildIframes() {
121
159
  const container = document.getElementById('iframe-container');
122
160
  for (const [id, cfg] of Object.entries(apps)) {
@@ -140,30 +178,23 @@ function switchTab(appId) {
140
178
  postActiveTo(id);
141
179
  }
142
180
 
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
- }
181
+ if (loadedApps.has(appId)) hideLoading();
182
+ else showLoading();
149
183
 
150
184
  iframes[appId]?.focus();
151
185
  }
152
186
 
153
187
  function onIframeLoad(appId) {
154
188
  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
- }
189
+ if (appId === activeApp) hideLoading();
190
+ postTo(appId, themeMessage());
161
191
  postProjectTo(appId);
162
192
  postActiveTo(appId);
163
193
  // Posted twice: the shims gate their origin check on window.__HUB__, which they populate from
164
194
  // an async /hub-config fetch that resolves after this load event, so the first post can be
165
195
  // dropped. Safe to repeat — every shim's apply is idempotent.
166
196
  setTimeout(() => {
197
+ postTo(appId, themeMessage());
167
198
  postProjectTo(appId);
168
199
  postActiveTo(appId);
169
200
  }, 400);
@@ -207,9 +238,12 @@ function handleForwardedKey(d) {
207
238
  }
208
239
  if (d.key === 'ArrowLeft') cycleTab(-1);
209
240
  else if (d.key === 'ArrowRight') cycleTab(1);
210
- else if (d.key.toLowerCase() === 'p') togglePalette();
241
+ else if (PALETTE_KEYS[d.key.toLowerCase()]) togglePalette(PALETTE_KEYS[d.key.toLowerCase()]);
211
242
  }
212
243
 
244
+ // Ctrl+Alt+<letter> bindings. Sub-app shims forward every letter, so this is the one keymap.
245
+ const PALETTE_KEYS = { p: 'project', w: 'configDir' };
246
+
213
247
  function cycleTab(delta) {
214
248
  const ids = Object.keys(apps);
215
249
  const idx = ids.indexOf(activeApp);
@@ -225,9 +259,9 @@ function switchByIndex(idx) {
225
259
  function listenKeys() {
226
260
  document.addEventListener('keydown', (e) => {
227
261
  // 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') {
262
+ if (e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey && PALETTE_KEYS[e.key.toLowerCase()]) {
229
263
  e.preventDefault();
230
- togglePalette();
264
+ togglePalette(PALETTE_KEYS[e.key.toLowerCase()]);
231
265
  return;
232
266
  }
233
267
  // While the palette is open the input owns the keyboard — don't let tab shortcuts fire
@@ -264,12 +298,7 @@ function listenKeys() {
264
298
  }
265
299
 
266
300
  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();
301
+ const list = await sendJson('GET', '/api/projects');
273
302
  // kanban sorts alphabetically; a picker wants most-recently-touched first.
274
303
  palette.projects = list
275
304
  .map((p) => ({
@@ -294,8 +323,7 @@ function subseq(text, q) {
294
323
  // hits above parent-path hits. Tier ties fall back to the recency order set in loadProjects.
295
324
  // Returns {p, tier} so the renderer can mark the field that actually explains the match instead of
296
325
  // re-deriving it.
297
- function projectRows(projects, query) {
298
- const q = query.trim().toLowerCase();
326
+ function projectRows(projects, q) {
299
327
  if (!q) return projects.slice(0, 100).map((p) => ({ p, tier: 0 }));
300
328
  const tierOf = (p) => {
301
329
  if (p.name.toLowerCase().includes(q)) return 0;
@@ -368,20 +396,101 @@ function relAge(ts) {
368
396
  return `${Math.round(m / 1440)}d`;
369
397
  }
370
398
 
399
+ async function loadConfigDirs() {
400
+ palette.configDirs = await sendJson('GET', '/api/config-dirs');
401
+ }
402
+
403
+ // Removes a dir from the hub's list only; nothing on disk changes. The server refuses the active
404
+ // dir, whose row has no remove button.
405
+ async function removeConfigDir(dirPath) {
406
+ try {
407
+ palette.configDirs = await sendJson('DELETE', '/api/config-dirs', { path: dirPath });
408
+ } catch (err) {
409
+ console.warn('remove config dir failed:', err.message);
410
+ return;
411
+ }
412
+ if (palette.open && palette.mode === 'configDir') renderPalette();
413
+ }
414
+
415
+ // `typed` is the trimmed query, `q` its lowercase form for matching.
416
+ const PALETTE_MODES = {
417
+ project: {
418
+ placeholder: 'Search projects or type a path...',
419
+ label: 'Switch project',
420
+ empty: 'No matching project',
421
+ literalLabel: 'Use literal path',
422
+ load: loadProjects,
423
+ rows(typed, q) {
424
+ const rows = projectRows(palette.projects, q).map((x) => ({
425
+ kind: 'project',
426
+ path: x.p.path,
427
+ p: x.p,
428
+ tier: x.tier,
429
+ }));
430
+ if (looksLikePath(typed)) rows.push({ kind: 'literal', path: typed });
431
+ return rows;
432
+ },
433
+ fillRow(li, row, q) {
434
+ // Only the field that ranked the row is marked — otherwise a one-letter query like "c"
435
+ // would light up the "C:" drive letter on every row. Tier 0 is a basename hit.
436
+ li.append(
437
+ markedSpan('name', row.p.name, row.tier === 0 ? q : ''),
438
+ markedSpan('parent', row.p.parent, row.tier === 0 ? '' : q),
439
+ el('span', 'age', relAge(row.p.ts)),
440
+ );
441
+ },
442
+ commit: commitProject,
443
+ },
444
+ configDir: {
445
+ placeholder: 'Pick a Claude config dir or type a path to add...',
446
+ label: 'Switch config dir',
447
+ hint: 'Enter switch · Ctrl+D remove · type a path to add',
448
+ empty: 'No matching config dir',
449
+ literalLabel: 'Add config dir',
450
+ load: loadConfigDirs,
451
+ rows(typed, q) {
452
+ const { dirs, active } = palette.configDirs;
453
+ const rows = dirs
454
+ .filter((d) => !q || d.toLowerCase().includes(q))
455
+ .map((d) => ({ kind: 'dir', path: d, active: d === active }));
456
+ if (looksLikePath(typed) && !dirs.some((d) => d.toLowerCase() === q)) rows.push({ kind: 'literal', path: typed });
457
+ return rows;
458
+ },
459
+ // Enter on the default row must switch, so the cursor lands on the dir before the active one,
460
+ // wrapping. With two dirs the palette then behaves like a toggle.
461
+ initialSel(rows) {
462
+ const active = rows.findIndex((r) => r.active);
463
+ if (active < 0 || rows.length < 2) return 0;
464
+ return (active - 1 + rows.length) % rows.length;
465
+ },
466
+ fillRow(li, row, q) {
467
+ li.append(markedSpan('name', row.path, q));
468
+ if (row.active) {
469
+ li.append(el('span', 'age', 'active'));
470
+ return;
471
+ }
472
+ const remove = el('button', 'palette-remove', '×');
473
+ remove.type = 'button';
474
+ remove.title = 'Remove from list (Ctrl+D)';
475
+ remove.dataset.remove = row.path;
476
+ li.append(remove);
477
+ },
478
+ commit: commitConfigDir,
479
+ },
480
+ };
481
+
371
482
  function renderPalette() {
372
483
  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() });
484
+ const spec = PALETTE_MODES[palette.mode];
485
+ const typed = palette.query.trim();
486
+ const q = typed.toLowerCase();
487
+ const rows = spec.rows(typed, q);
381
488
  palette.rows = rows;
489
+ // sel is null until the first render that has rows: the cached list may be empty until load() lands.
490
+ if (palette.sel === null && rows.length > 0) palette.sel = spec.initialSel?.(rows) ?? 0;
382
491
  palette.sel = Math.min(palette.sel, Math.max(0, rows.length - 1));
383
492
  if (rows.length === 0) {
384
- list.replaceChildren(el('li', 'palette-empty', 'No matching project'));
493
+ list.replaceChildren(el('li', 'palette-empty', spec.empty));
385
494
  return;
386
495
  }
387
496
  list.replaceChildren(
@@ -391,17 +500,8 @@ function renderPalette() {
391
500
  `palette-row${row.kind === 'literal' ? ' literal' : ''}${i === palette.sel ? ' selected' : ''}`,
392
501
  );
393
502
  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
- }
503
+ if (row.kind === 'literal') li.textContent = `${spec.literalLabel} -- ${row.path}`;
504
+ else spec.fillRow(li, row, q);
405
505
  return li;
406
506
  }),
407
507
  );
@@ -416,17 +516,23 @@ function el(tag, className, text) {
416
516
  return node;
417
517
  }
418
518
 
419
- function togglePalette() {
420
- if (palette.open) closePalette();
421
- else openPalette();
519
+ function togglePalette(mode) {
520
+ // Same shortcut again closes; the other shortcut swaps the open palette to its mode.
521
+ if (palette.open && palette.mode === mode) closePalette();
522
+ else openPalette(mode);
422
523
  }
423
524
 
424
- function openPalette() {
525
+ function openPalette(mode) {
425
526
  const input = document.getElementById('palette-input');
527
+ const spec = PALETTE_MODES[mode];
426
528
  palette.open = true;
529
+ palette.mode = mode;
427
530
  palette.query = '';
428
- palette.sel = 0;
531
+ palette.sel = null;
429
532
  input.value = '';
533
+ input.placeholder = spec.placeholder;
534
+ document.querySelector('#palette .palette-box').setAttribute('aria-label', spec.label);
535
+ document.getElementById('palette-hint').textContent = spec.hint ?? '';
430
536
  document.getElementById('palette').hidden = false;
431
537
  // Ctrl+Alt+P usually arrives forwarded from a focused iframe. Without inert the sub-app can keep
432
538
  // or take focus back, and then Escape is handled inside it — the palette stays open and only the
@@ -435,11 +541,12 @@ function openPalette() {
435
541
  renderPalette();
436
542
  input.focus();
437
543
  // Stale-while-revalidate: the cached list renders instantly, recency refreshes when this lands.
438
- loadProjects()
544
+ spec
545
+ .load()
439
546
  .then(() => {
440
- if (palette.open) renderPalette();
547
+ if (palette.open && palette.mode === mode) renderPalette();
441
548
  })
442
- .catch((err) => console.warn('project list unavailable:', err.message));
549
+ .catch((err) => console.warn(`${mode} list unavailable:`, err.message));
443
550
  }
444
551
 
445
552
  function closePalette() {
@@ -462,7 +569,10 @@ function movePaletteSel(delta) {
462
569
 
463
570
  async function commitPalette() {
464
571
  const row = palette.rows[palette.sel];
465
- if (!row) return;
572
+ if (row) await PALETTE_MODES[palette.mode].commit(row);
573
+ }
574
+
575
+ async function commitProject(row) {
466
576
  // List rows broadcast verbatim: kanban handed us this exact string and matches it with strict
467
577
  // ===. Only a typed path needs normalizing.
468
578
  const absPath = row.kind === 'project' ? row.path : await resolveTypedPath(row.path);
@@ -471,22 +581,64 @@ async function commitPalette() {
471
581
  setProject(absPath);
472
582
  }
473
583
 
474
- // Returns the real on-disk path, or null when it can't be resolved (the palette stays open).
475
- async function resolveTypedPath(typed) {
584
+ async function sendJson(method, url, body) {
585
+ const res = await fetch(url, {
586
+ method,
587
+ headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
588
+ body: body === undefined ? undefined : JSON.stringify(body),
589
+ });
590
+ const data = await res.json().catch(() => ({}));
591
+ if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
592
+ return data;
593
+ }
594
+
595
+ // Disables the palette input for the duration of a request so Enter can't fire twice.
596
+ async function withPaletteBusy(fn) {
476
597
  const input = document.getElementById('palette-input');
477
598
  input.disabled = true;
478
599
  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);
600
+ return await fn();
485
601
  } finally {
486
602
  input.disabled = false;
487
603
  if (palette.open) input.focus();
488
604
  }
489
- return null;
605
+ }
606
+
607
+ async function commitConfigDir(row) {
608
+ let target = row.path;
609
+ if (row.kind === 'literal') {
610
+ try {
611
+ target = (await withPaletteBusy(() => sendJson('POST', '/api/config-dirs', { path: row.path }))).path;
612
+ } catch (err) {
613
+ console.warn('add config dir failed:', err.message);
614
+ return;
615
+ }
616
+ }
617
+ closePalette();
618
+ // The server would no-op too, but the client would still reload every iframe.
619
+ if (target === palette.configDirs.active) return;
620
+ showLoading(`Switching to ${target}...`);
621
+ try {
622
+ setApps((await sendJson('POST', '/api/config-dirs/activate', { path: target })).apps);
623
+ // The palette renders from this cache before its refetch lands, and initialSel keys off it.
624
+ palette.configDirs.active = target;
625
+ reloadIframes();
626
+ } catch (err) {
627
+ console.warn('config dir switch failed:', err.message);
628
+ hideLoading();
629
+ }
630
+ }
631
+
632
+ // Returns the real on-disk path, or null when it can't be resolved (the palette stays open).
633
+ function resolveTypedPath(typed) {
634
+ return withPaletteBusy(async () => {
635
+ try {
636
+ return (await sendJson('GET', `/api/resolve-path?path=${encodeURIComponent(typed)}`)).path;
637
+ } catch (err) {
638
+ console.warn('resolve-path failed:', err.message);
639
+ return null;
640
+ }
641
+ });
490
642
  }
491
643
 
492
644
  function bindPalette() {
@@ -510,9 +662,21 @@ function bindPalette() {
510
662
  } else if (e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey)) {
511
663
  e.preventDefault();
512
664
  movePaletteSel(-1);
665
+ } else if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && e.key.toLowerCase() === 'd') {
666
+ // Ctrl+D would otherwise bookmark the page in Chrome and Firefox.
667
+ e.preventDefault();
668
+ const row = palette.rows[palette.sel];
669
+ if (row?.kind === 'dir') removeConfigDir(row.path);
513
670
  }
514
671
  });
515
672
  document.getElementById('palette-list').addEventListener('click', (e) => {
673
+ const remove = e.target.closest('.palette-remove');
674
+ if (remove) {
675
+ e.stopPropagation();
676
+ removeConfigDir(remove.dataset.remove);
677
+ document.getElementById('palette-input').focus();
678
+ return;
679
+ }
516
680
  const li = e.target.closest('.palette-row');
517
681
  if (!li) return;
518
682
  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,87 @@ 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
+ function expandHome(p) {
42
+ return p.startsWith('~') ? p.replace('~', os.homedir()) : p;
43
+ }
44
+
45
+ // Real on-disk spelling (true casing, native separators). One dir is one list entry and one child
46
+ // pool whichever way it was typed. Throws when the path does not exist.
47
+ function canonicalDir(p) {
48
+ return fs.realpathSync.native(path.resolve(expandHome(p)));
49
+ }
50
+
51
+ function loadHubConfig() {
52
+ const canonical = (p) => {
53
+ try {
54
+ return canonicalDir(p);
55
+ } catch {
56
+ return expandHome(p);
57
+ }
58
+ };
59
+ const fallback = canonical(
60
+ process.env.CLAUDE_CONFIG_DIR || process.env.CLAUDE_DIR || path.join(os.homedir(), '.claude'),
61
+ );
62
+ let raw = '';
63
+ let saved = {};
64
+ try {
65
+ raw = fs.readFileSync(HUB_CONFIG_FILE, 'utf8');
66
+ saved = JSON.parse(raw);
67
+ } catch {}
68
+ const savedDirs = Array.isArray(saved.configDirs) ? saved.configDirs.filter((d) => typeof d === 'string') : [];
69
+ const dirs = [...new Set(savedDirs.map(canonical))];
70
+ if (!dirs.includes(fallback)) dirs.unshift(fallback);
71
+ const savedActive = typeof saved.activeConfigDir === 'string' ? canonical(saved.activeConfigDir) : null;
72
+ const active = dirs.includes(savedActive) ? savedActive : fallback;
73
+ const config = { configDirs: dirs, activeConfigDir: active };
74
+ // Configs saved before canonicalization can hold one dir under two spellings; persist the merge.
75
+ if (serializeConfig(config) !== raw) writeHubConfig(config);
76
+ return config;
77
+ }
78
+
79
+ function serializeConfig(config) {
80
+ return `${JSON.stringify(config, null, 2)}\n`;
81
+ }
82
+
83
+ function writeHubConfig(config) {
84
+ fs.mkdirSync(path.dirname(HUB_CONFIG_FILE), { recursive: true });
85
+ fs.writeFileSync(HUB_CONFIG_FILE, serializeConfig(config));
86
+ }
87
+
88
+ function saveHubConfig() {
89
+ writeHubConfig(hubConfig);
90
+ }
91
+
92
+ const hubConfig = loadHubConfig();
25
93
 
26
94
  // Created before spawnApp so the same host/allowed-hosts decision reaches the
27
95
  // children. Without that propagation, `--host 0.0.0.0` would expose the hub shell
28
96
  // while every iframe 403'd on its own Host check.
29
97
  const net = createNetGuard({ appName: 'Claude Code Hub' });
30
98
 
31
- function spawnApp(name, cmd, args, envPort) {
99
+ function spawnApp(pool, name, cmd, args) {
32
100
  const child = spawn(cmd, args, {
33
101
  cwd: __dirname,
34
102
  env: {
35
103
  ...process.env,
36
- PORT: String(envPort),
104
+ PORT: '0',
105
+ CLAUDE_CONFIG_DIR: pool.dir,
37
106
  CLAUDE_HUB: '1',
38
107
  HUB_URL: `http://localhost:${HUB_PORT}`,
39
108
  HOST: net.BIND_HOST,
@@ -43,6 +112,10 @@ function spawnApp(name, cmd, args, envPort) {
43
112
  });
44
113
 
45
114
  let stdoutBuf = '';
115
+ let markReady;
116
+ child.ready = new Promise((resolve) => {
117
+ markReady = resolve;
118
+ });
46
119
  child.stdout.on('data', (d) => {
47
120
  stdoutBuf += d.toString();
48
121
  let nl;
@@ -51,19 +124,25 @@ function spawnApp(name, cmd, args, envPort) {
51
124
  stdoutBuf = stdoutBuf.slice(nl + 1);
52
125
  process.stdout.write(`[${name}] ${line}`);
53
126
  const match = line.match(/running at http:\/\/localhost:(\d+)/i);
54
- if (match) actualPorts[name] = parseInt(match[1], 10);
127
+ if (match) {
128
+ pool.ports[name] = parseInt(match[1], 10);
129
+ markReady();
130
+ }
55
131
  }
56
132
  });
57
133
  child.stderr.on('data', (d) => process.stderr.write(`[${name}] ${d}`));
58
- child.on('exit', (code) => console.log(`[${name}] exited (code ${code})`));
134
+ child.on('exit', (code) => {
135
+ console.log(`[${name}] exited (code ${code})`);
136
+ markReady();
137
+ });
59
138
 
60
- children.push(child);
139
+ pool.children.push(child);
61
140
  return child;
62
141
  }
63
142
 
64
- function killAll() {
65
- for (const child of children) {
66
- if (child.killed) continue;
143
+ function killPool(pool) {
144
+ for (const child of pool.children) {
145
+ if (child.exitCode !== null || child.killed) continue;
67
146
  if (process.platform === 'win32') {
68
147
  spawn('taskkill', ['/pid', String(child.pid), '/f', '/t'], { stdio: 'ignore' });
69
148
  } else {
@@ -72,6 +151,47 @@ function killAll() {
72
151
  }
73
152
  }
74
153
 
154
+ function killAll() {
155
+ for (const pool of pools.values()) killPool(pool);
156
+ }
157
+
158
+ function withTimeout(promise, ms) {
159
+ let timer;
160
+ const gate = new Promise((resolve) => {
161
+ timer = setTimeout(resolve, ms);
162
+ });
163
+ return Promise.race([promise, gate]).finally(() => clearTimeout(timer));
164
+ }
165
+
166
+ // Returns the live pool for a dir, spawning one when absent and evicting the least recently used
167
+ // pool past POOL_SIZE. Resolves once every child has printed its port banner (or given up), so
168
+ // the caller can hand the client URLs that are actually live.
169
+ async function ensurePool(dir) {
170
+ let pool = pools.get(dir);
171
+ if (pool) {
172
+ pools.delete(dir);
173
+ pools.set(dir, pool);
174
+ return pool.ready;
175
+ }
176
+ while (pools.size >= POOL_SIZE) {
177
+ const oldestDir = pools.keys().next().value;
178
+ console.log(`Evicting children for ${oldestDir}`);
179
+ dropPool(oldestDir);
180
+ }
181
+ pool = { dir, children: [], ports: {} };
182
+ pools.set(dir, pool);
183
+ spawnChildren(pool);
184
+ pool.ready = withTimeout(Promise.all(pool.children.map((c) => c.ready)), 20000).then(() => pool);
185
+ return pool.ready;
186
+ }
187
+
188
+ function dropPool(dir) {
189
+ const pool = pools.get(dir);
190
+ if (!pool) return;
191
+ pools.delete(dir);
192
+ killPool(pool);
193
+ }
194
+
75
195
  function shutdown() {
76
196
  killAll();
77
197
  process.exit(0);
@@ -115,12 +235,86 @@ const memoryPath = resolveApp('memory', 'claude-code-memory-explorer');
115
235
  const HDR_BYTES = 65536;
116
236
  const NODE_HDR = `--max-http-header-size=${HDR_BYTES}`;
117
237
 
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);
238
+ // Children bind ephemeral ports; the public ports below belong to the hub's proxies. Browser
239
+ // localStorage is keyed by origin, so the sub-app origin has to stay put across switches or the
240
+ // user loses pins and filters every time the active set changes.
241
+ function spawnChildren(pool) {
242
+ spawnApp(pool, 'marketplace', process.execPath, [NODE_HDR, marketplacePath]);
243
+ spawnApp(pool, 'kanban', process.execPath, [NODE_HDR, kanbanPath]);
244
+ spawnApp(pool, 'cost', process.execPath, [NODE_HDR, costPath]);
245
+ spawnApp(pool, 'memory', process.execPath, [NODE_HDR, memoryPath]);
246
+ }
247
+
248
+ const publicPorts = { ...DEFAULT_PORTS };
249
+
250
+ function rewriteOrigin(origin, publicPort, childPort) {
251
+ try {
252
+ const u = new URL(origin);
253
+ if (Number(u.port) !== publicPort) return origin;
254
+ u.port = String(childPort);
255
+ return u.origin;
256
+ } catch {
257
+ return origin;
258
+ }
259
+ }
260
+
261
+ const proxyAgent = new http.Agent({ keepAlive: true, maxSockets: 64 });
262
+
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);
277
+ const upstream = http.request(
278
+ { host: '127.0.0.1', port: childPort, method: req.method, path: req.url, headers, agent: proxyAgent },
279
+ (u) => {
280
+ res.writeHead(u.statusCode, u.headers);
281
+ u.pipe(res);
282
+ },
283
+ );
284
+ upstream.on('error', (err) => {
285
+ if (!res.headersSent) res.writeHead(502);
286
+ res.end(err.message);
287
+ });
288
+ res.on('close', () => upstream.destroy());
289
+ req.pipe(upstream);
290
+ };
291
+ }
292
+
293
+ function listenWithFallback(handler, port, onReady, label) {
294
+ const server = net.listenLoopback(handler, port, onReady, { maxHeaderSize: HDR_BYTES });
295
+ server.on('error', (err) => {
296
+ if (err.code !== 'EADDRINUSE' && err.code !== 'EACCES') throw err;
297
+ console.log(`${label}port ${port} in use, trying random port...`);
298
+ net.listenLoopback(handler, 0, onReady, { maxHeaderSize: HDR_BYTES });
299
+ });
300
+ return server;
301
+ }
302
+
303
+ for (const [name, port] of Object.entries(DEFAULT_PORTS)) {
304
+ listenWithFallback(
305
+ proxyHandler(name),
306
+ port,
307
+ (actual) => {
308
+ publicPorts[name] = actual;
309
+ },
310
+ `[${name}] `,
311
+ );
312
+ }
313
+
314
+ ensurePool(hubConfig.activeConfigDir);
122
315
 
123
316
  const app = express();
317
+ app.use(express.json());
124
318
 
125
319
  // Mounted before the routes below, which are registered ahead of the first
126
320
  // app.use() and would otherwise bypass the guards entirely.
@@ -142,26 +336,94 @@ const themeAccents = (() => {
142
336
  }
143
337
  })();
144
338
 
339
+ function appsConfig() {
340
+ return {
341
+ kanban: { name: 'Kanban', url: `http://localhost:${publicPorts.kanban}`, icon: 'columns' },
342
+ marketplace: { name: 'Marketplace', url: `http://localhost:${publicPorts.marketplace}`, icon: 'store' },
343
+ cost: { name: 'Cost', url: `http://localhost:${publicPorts.cost}`, icon: 'dollar-sign' },
344
+ memory: { name: 'Memory Diagnoser', url: `http://localhost:${publicPorts.memory}`, icon: 'database' },
345
+ };
346
+ }
347
+
145
348
  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
- });
349
+ res.json({ themeAccents, apps: appsConfig() });
350
+ });
351
+
352
+ app.get('/api/config-dirs', (_req, res) => {
353
+ res.setHeader('Cache-Control', 'no-store');
354
+ res.json({ dirs: hubConfig.configDirs, active: hubConfig.activeConfigDir });
355
+ });
356
+
357
+ // Normalizes a typed path to its real on-disk form (true casing, native separators) so cck's
358
+ // strict === project match and cost's abs->encoded transform both hit, and so the config-dir list
359
+ // holds one spelling per dir.
360
+ function resolveDir(input) {
361
+ if (typeof input !== 'string' || !input.trim()) return { error: 'path is required', status: 400 };
362
+ let resolved;
363
+ try {
364
+ resolved = canonicalDir(input.trim());
365
+ } catch {
366
+ return { error: 'Path not found', status: 404 };
367
+ }
368
+ if (!fs.statSync(resolved).isDirectory()) return { error: 'Not a directory', status: 400 };
369
+ return { path: resolved };
370
+ }
371
+
372
+ app.post('/api/config-dirs', (req, res) => {
373
+ const r = resolveDir(req.body?.path);
374
+ if (r.error) {
375
+ res.status(r.status).json({ error: r.error });
376
+ return;
377
+ }
378
+ if (!hubConfig.configDirs.includes(r.path)) {
379
+ hubConfig.configDirs.push(r.path);
380
+ saveHubConfig();
381
+ }
382
+ res.json({ path: r.path });
383
+ });
384
+
385
+ app.delete('/api/config-dirs', (req, res) => {
386
+ const raw = typeof req.body?.path === 'string' ? req.body.path : '';
387
+ const target = resolveDir(raw).path ?? raw;
388
+ if (target === hubConfig.activeConfigDir) {
389
+ res.status(400).json({ error: 'Cannot remove the active config dir' });
390
+ return;
391
+ }
392
+ hubConfig.configDirs = hubConfig.configDirs.filter((d) => d !== target);
393
+ saveHubConfig();
394
+ dropPool(target);
395
+ res.json({ dirs: hubConfig.configDirs, active: hubConfig.activeConfigDir });
396
+ });
397
+
398
+ // The response carries the app URLs so the client reloads every iframe against the new set.
399
+ app.post('/api/config-dirs/activate', async (req, res) => {
400
+ const target = typeof req.body?.path === 'string' ? req.body.path : '';
401
+ if (!hubConfig.configDirs.includes(target)) {
402
+ res.status(404).json({ error: 'Unknown config dir; add it first' });
403
+ return;
404
+ }
405
+ if (target !== hubConfig.activeConfigDir) {
406
+ console.log(`Switching CLAUDE_CONFIG_DIR -> ${target}`);
407
+ await ensurePool(target);
408
+ hubConfig.activeConfigDir = target;
409
+ saveHubConfig();
410
+ }
411
+ res.json({ apps: appsConfig() });
155
412
  });
156
413
 
157
414
  // 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).
415
+ // enumerates projects. The port is read per request because kanban's real port is only known
416
+ // once its banner has been scraped (see spawnApp).
160
417
  app.get('/api/projects', async (_req, res) => {
161
418
  res.setHeader('Cache-Control', 'no-store');
419
+ const kanbanPort = activePool()?.ports.kanban;
420
+ if (!kanbanPort) {
421
+ res.status(503).json({ error: 'kanban is starting' });
422
+ return;
423
+ }
162
424
  try {
163
425
  // 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`, {
426
+ const upstream = await fetch(`http://127.0.0.1:${kanbanPort}/api/projects`, {
165
427
  signal: AbortSignal.timeout(4000),
166
428
  });
167
429
  if (!upstream.ok) {
@@ -175,28 +437,12 @@ app.get('/api/projects', async (_req, res) => {
175
437
  }
176
438
  });
177
439
 
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.
440
+ // List-picked project paths skip this: they are byte-exact copies of what kanban reported.
181
441
  app.get('/api/resolve-path', (req, res) => {
182
442
  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 });
443
+ const r = resolveDir(req.query.path);
444
+ if (r.error) res.status(r.status).json({ error: r.error });
445
+ else res.json({ path: r.path });
200
446
  });
201
447
 
202
448
  app.use(express.static(path.join(__dirname, 'public')));
@@ -208,7 +454,7 @@ const onReady = (actual) => {
208
454
  }
209
455
  };
210
456
 
211
- const server = net.listenLoopback(app, HUB_PORT, onReady, { maxHeaderSize: HDR_BYTES });
457
+ listenWithFallback(app, HUB_PORT, onReady, '');
212
458
 
213
459
  function printBanner(port) {
214
460
  console.log(`Claude Code Hub running at http://localhost:${port}`);
@@ -216,12 +462,3 @@ function printBanner(port) {
216
462
  if (warning) console.log(warning);
217
463
  console.log('Type "q" or "exit" to stop the server');
218
464
  }
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
- });