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 +3 -0
- package/package.json +5 -5
- package/public/app.js +231 -67
- package/public/index.html +41 -1
- package/server.js +293 -56
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.
|
|
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.
|
|
41
|
-
"claude-code-kanban": "^4.
|
|
42
|
-
"claude-code-marketplace": "^0.
|
|
43
|
-
"claude-code-memory-explorer": "^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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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()
|
|
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()
|
|
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
|
|
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,
|
|
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
|
|
374
|
-
const
|
|
375
|
-
|
|
376
|
-
|
|
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',
|
|
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
|
-
|
|
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
|
-
|
|
421
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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 (
|
|
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
|
-
|
|
475
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
24
|
-
const
|
|
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
|
|
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:
|
|
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)
|
|
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) =>
|
|
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
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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.
|
|
159
|
-
//
|
|
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:${
|
|
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
|
-
//
|
|
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
|
|
184
|
-
if (
|
|
185
|
-
|
|
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
|
-
|
|
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
|
-
});
|