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