claude-code-hub 0.40.0 → 0.42.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
@@ -99,6 +99,26 @@ The hub can run the tools against more than one Claude config dir (`CLAUDE_CONFI
99
99
  - Each dir gets its own set of the four tools. When you switch, the hub keeps the old set running, so switching back is instant. `--pool-size` sets how many sets stay alive (default 3). When the pool is full, the hub stops the least recently used set, but not a set whose Kanban has open terminals.
100
100
  - The list and the active dir are saved in `~/.claude-hub/config.json`.
101
101
 
102
+ ## Choose the tools
103
+
104
+ By default the hub runs all four tools. To turn a tool off or change the tab order, add an `apps` list to `~/.claude-hub/config.json`:
105
+
106
+ ```json
107
+ {
108
+ "apps": [
109
+ { "id": "kanban" },
110
+ { "id": "cost" },
111
+ { "id": "marketplace", "enabled": false }
112
+ ]
113
+ }
114
+ ```
115
+
116
+ - The ids are `kanban`, `marketplace`, `cost` and `memory`.
117
+ - The tools you list come first, in list order. The tools you do not list follow in the default order.
118
+ - A tool with `"enabled": false` does not start and gets no tab. Links from other tools to it do nothing.
119
+ - With Kanban off, the embedded terminal is off, and the project palette has no project list. You can still type a path.
120
+ - Restart the hub after you edit the file.
121
+
102
122
  ## Embedded terminal
103
123
 
104
124
  Under the hub, Kanban has an embedded terminal, on by default, so you can start or resume Claude Code sessions from the board. While a terminal is attached, the hub asks before it closes the window, because <kbd>Ctrl</kbd>+<kbd>W</kbd> meant for the terminal would close it.
@@ -126,6 +146,8 @@ The hub has no visible UI of its own. Use these shortcuts:
126
146
  | <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>P</kbd> | Project palette: set the project for all tools |
127
147
  | <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>W</kbd> | Config-dir palette: switch, add, or remove a config dir |
128
148
 
149
+ <kbd>Alt</kbd>+<kbd>1</kbd> to <kbd>Alt</kbd>+<kbd>4</kbd> follow the tab order, so they change when you [choose the tools](#choose-the-tools).
150
+
129
151
  In a palette, use <kbd>↑</kbd>/<kbd>↓</kbd> or <kbd>Tab</kbd> to move, <kbd>Enter</kbd> to select, and <kbd>Esc</kbd> to close.
130
152
 
131
153
  The shortcuts also work when focus is inside a tool. Kanban keeps <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>N</kbd> (new session), <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>R</kbd> (resume session), and <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>S</kbd> (previous session) for itself. From Kanban you can also jump to another tool for the selected session:
package/lib/apps.js ADDED
@@ -0,0 +1,23 @@
1
+ 'use strict';
2
+
3
+ // Built-in apps in their default tab order.
4
+ const APPS = [
5
+ { id: 'kanban', dir: 'cck', pkg: 'claude-code-kanban', name: 'Kanban', icon: 'columns', port: 3541 },
6
+ { id: 'marketplace', dir: 'marketplace', pkg: 'claude-code-marketplace', name: 'Marketplace', icon: 'store', port: 3542 },
7
+ { id: 'cost', dir: 'cost', pkg: 'claude-code-cost', name: 'Cost', icon: 'dollar-sign', port: 3543 },
8
+ { id: 'memory', dir: 'memory', pkg: 'claude-code-memory-explorer', name: 'Memory Diagnoser', icon: 'database', port: 3544 },
9
+ ];
10
+
11
+ // `entries` is the hand-edited `apps` list in config.json. Listed apps come first in list order and
12
+ // unlisted ones follow in default order, so an app added in a later release shows up without an edit.
13
+ function selectApps(entries = []) {
14
+ const listed = entries.filter((e) => typeof e?.id === 'string');
15
+ const byId = (id) => APPS.find((a) => a.id === id);
16
+ const unknown = listed.map((e) => e.id).filter((id) => !byId(id));
17
+ const off = new Set(listed.filter((e) => e.enabled === false).map((e) => e.id));
18
+ const order = new Set([...listed.map((e) => e.id), ...APPS.map((a) => a.id)]);
19
+ const apps = [...order].map(byId).filter((a) => a && !off.has(a.id));
20
+ return { apps, unknown };
21
+ }
22
+
23
+ module.exports = { selectApps };
package/lib/cookies.js ADDED
@@ -0,0 +1,13 @@
1
+ 'use strict';
2
+
3
+ // Returns the Cookie header value without the named cookie, or '' when nothing is left.
4
+ function stripCookie(header, name) {
5
+ if (typeof header !== 'string') return '';
6
+ return header
7
+ .split(';')
8
+ .map((part) => part.trim())
9
+ .filter((part) => part && part.split('=', 1)[0].trim() !== name)
10
+ .join('; ');
11
+ }
12
+
13
+ module.exports = { stripCookie };
package/lib/net-guard.js CHANGED
@@ -245,7 +245,7 @@ function createNetGuard(config = {}) {
245
245
  * @param {{maxHeaderSize?: number, onUpgrade?: (req: any, socket: any, head: Buffer) => void}} [opts]
246
246
  */
247
247
  function listenLoopback(app, port, onReady, opts = {}) {
248
- const http = require('http');
248
+ const http = require('node:http');
249
249
  const serverOpts = opts.maxHeaderSize ? { maxHeaderSize: opts.maxHeaderSize } : {};
250
250
  const makeServer = (handler) => {
251
251
  const s = http.createServer(serverOpts, handler);
@@ -0,0 +1,548 @@
1
+ [
2
+ {
3
+ "id": "ember",
4
+ "label": "Ember",
5
+ "dark": {
6
+ "ember": "#e86f33",
7
+ "emberGlow": "#f0a070",
8
+ "emberDim": "rgba(232,111,51,0.25)",
9
+ "field": "#101114",
10
+ "surface": "#16181c",
11
+ "elevated": "#1e2025",
12
+ "hover": "#282a30",
13
+ "border": "#363840",
14
+ "ink1": "#f0f1f3",
15
+ "ink2": "#c2c4c9",
16
+ "ink3": "#9a9da5",
17
+ "inkMuted": "#7d808a"
18
+ },
19
+ "light": {
20
+ "ember": "#e86f33",
21
+ "emberGlow": "#a0501c",
22
+ "emberDim": "rgba(184,90,32,0.18)",
23
+ "field": "#e8e6e3",
24
+ "surface": "#efede9",
25
+ "elevated": "#fbfaf9",
26
+ "hover": "#d2d0cc",
27
+ "border": "#cfcbc4",
28
+ "ink1": "#0a0a0a",
29
+ "ink2": "#2c2c2c",
30
+ "ink3": "#565656",
31
+ "inkMuted": "#767676"
32
+ }
33
+ },
34
+ {
35
+ "id": "gruvbox",
36
+ "label": "Gruvbox",
37
+ "dark": {
38
+ "ember": "#fe8019",
39
+ "emberGlow": "#f9b27c",
40
+ "emberDim": "rgba(254,128,25,0.25)",
41
+ "field": "#1d2021",
42
+ "surface": "#282828",
43
+ "elevated": "#32302f",
44
+ "hover": "#3c3836",
45
+ "border": "#504945",
46
+ "ink1": "#ebdbb2",
47
+ "ink2": "#d5c4a1",
48
+ "ink3": "#bdae93",
49
+ "inkMuted": "#928374"
50
+ },
51
+ "light": {
52
+ "ember": "#d65d0e",
53
+ "emberGlow": "#af3a03",
54
+ "emberDim": "rgba(214,93,14,0.18)",
55
+ "field": "#ede7d5",
56
+ "surface": "#f3eee0",
57
+ "elevated": "#faf7ec",
58
+ "hover": "#e4dcc6",
59
+ "border": "#c9bc9d",
60
+ "ink1": "#3c3836",
61
+ "ink2": "#504945",
62
+ "ink3": "#665c54",
63
+ "inkMuted": "#7c6f64"
64
+ }
65
+ },
66
+ {
67
+ "id": "catppuccin",
68
+ "label": "Catppuccin",
69
+ "dark": {
70
+ "ember": "#cba6f7",
71
+ "emberGlow": "#d8c2fa",
72
+ "emberDim": "rgba(203,166,247,0.25)",
73
+ "field": "#11111b",
74
+ "surface": "#181825",
75
+ "elevated": "#1e1e2e",
76
+ "hover": "#313244",
77
+ "border": "#45475a",
78
+ "ink1": "#cdd6f4",
79
+ "ink2": "#bac2de",
80
+ "ink3": "#a6adc8",
81
+ "inkMuted": "#7f849c"
82
+ },
83
+ "light": {
84
+ "ember": "#8839ef",
85
+ "emberGlow": "#6f2dbd",
86
+ "emberDim": "rgba(136,57,239,0.18)",
87
+ "field": "#e6e9ef",
88
+ "surface": "#eff1f5",
89
+ "elevated": "#ffffff",
90
+ "hover": "#ccd0da",
91
+ "border": "#bcc0cc",
92
+ "ink1": "#4c4f69",
93
+ "ink2": "#5c5f77",
94
+ "ink3": "#6c6f85",
95
+ "inkMuted": "#8c8fa1"
96
+ }
97
+ },
98
+ {
99
+ "id": "tokyo-night",
100
+ "label": "Tokyo Night",
101
+ "dark": {
102
+ "ember": "#7aa2f7",
103
+ "emberGlow": "#9ab8ff",
104
+ "emberDim": "rgba(122,162,247,0.25)",
105
+ "field": "#16161e",
106
+ "surface": "#1a1b26",
107
+ "elevated": "#1f2335",
108
+ "hover": "#292e42",
109
+ "border": "#3b4261",
110
+ "ink1": "#c0caf5",
111
+ "ink2": "#a9b1d6",
112
+ "ink3": "#787c99",
113
+ "inkMuted": "#565f89"
114
+ },
115
+ "light": {
116
+ "ember": "#2e7de9",
117
+ "emberGlow": "#1659c7",
118
+ "emberDim": "rgba(46,125,233,0.18)",
119
+ "field": "#e1e2e7",
120
+ "surface": "#e9eaf0",
121
+ "elevated": "#f7f8fc",
122
+ "hover": "#d0d5e3",
123
+ "border": "#a8aecb",
124
+ "ink1": "#343b58",
125
+ "ink2": "#565a6e",
126
+ "ink3": "#6c6e75",
127
+ "inkMuted": "#848cb5"
128
+ }
129
+ },
130
+ {
131
+ "id": "solarized",
132
+ "label": "Solarized",
133
+ "dark": {
134
+ "ember": "#c96f47",
135
+ "emberGlow": "#d48a6a",
136
+ "emberDim": "rgba(201,111,71,0.25)",
137
+ "field": "#002b36",
138
+ "surface": "#073642",
139
+ "elevated": "#0a4250",
140
+ "hover": "#11505f",
141
+ "border": "#586e75",
142
+ "ink1": "#e2dfd0",
143
+ "ink2": "#b7c0bf",
144
+ "ink3": "#93a1a1",
145
+ "inkMuted": "#839496"
146
+ },
147
+ "light": {
148
+ "ember": "#2280c2",
149
+ "emberGlow": "#1b6aa3",
150
+ "emberDim": "rgba(34,128,194,0.18)",
151
+ "field": "#eee8d5",
152
+ "sidebar": "#e6dfc7",
153
+ "sidebarItem": "#f3ecd6",
154
+ "surface": "#fdf6e3",
155
+ "elevated": "#fffbef",
156
+ "hover": "#efe8d3",
157
+ "border": "#cdc4ab",
158
+ "ink1": "#073642",
159
+ "ink2": "#4f6369",
160
+ "ink3": "#586e75",
161
+ "inkMuted": "#728589"
162
+ }
163
+ },
164
+ {
165
+ "id": "dracula",
166
+ "label": "Dracula",
167
+ "dark": {
168
+ "ember": "#bd93f9",
169
+ "emberGlow": "#d6b5ff",
170
+ "emberDim": "rgba(189,147,249,0.25)",
171
+ "field": "#21222c",
172
+ "surface": "#282a36",
173
+ "elevated": "#343746",
174
+ "hover": "#44475a",
175
+ "border": "#565a75",
176
+ "ink1": "#f8f8f2",
177
+ "ink2": "#d8d8d2",
178
+ "ink3": "#b0b3c5",
179
+ "inkMuted": "#6272a4"
180
+ },
181
+ "light": {
182
+ "ember": "#7c3aed",
183
+ "emberGlow": "#5b21b6",
184
+ "emberDim": "rgba(124,58,237,0.18)",
185
+ "field": "#e8e8ee",
186
+ "surface": "#f0f0f5",
187
+ "elevated": "#fbfbfd",
188
+ "hover": "#dfdfe8",
189
+ "border": "#c9c9d6",
190
+ "ink1": "#16161e",
191
+ "ink2": "#34343f",
192
+ "ink3": "#5a5a68",
193
+ "inkMuted": "#787885"
194
+ }
195
+ },
196
+ {
197
+ "id": "nord",
198
+ "label": "Nord",
199
+ "dark": {
200
+ "ember": "#88c0d0",
201
+ "emberGlow": "#a8d8e8",
202
+ "emberDim": "rgba(136,192,208,0.25)",
203
+ "field": "#272c36",
204
+ "surface": "#2e3440",
205
+ "elevated": "#3b4252",
206
+ "hover": "#434c5e",
207
+ "border": "#4c566a",
208
+ "ink1": "#eceff4",
209
+ "ink2": "#d8dee9",
210
+ "ink3": "#aeb6c5",
211
+ "inkMuted": "#7b88a1"
212
+ },
213
+ "light": {
214
+ "ember": "#5e81ac",
215
+ "emberGlow": "#44688f",
216
+ "emberDim": "rgba(94,129,172,0.18)",
217
+ "field": "#e5e9f0",
218
+ "surface": "#eceff4",
219
+ "elevated": "#f8f9fb",
220
+ "hover": "#d8dee9",
221
+ "border": "#c2c9d6",
222
+ "ink1": "#2e3440",
223
+ "ink2": "#3b4252",
224
+ "ink3": "#4c566a",
225
+ "inkMuted": "#6b7589"
226
+ }
227
+ },
228
+ {
229
+ "id": "rose-pine",
230
+ "label": "Rosé Pine",
231
+ "dark": {
232
+ "ember": "#c4a7e7",
233
+ "emberGlow": "#d9c4f2",
234
+ "emberDim": "rgba(196,167,231,0.25)",
235
+ "field": "#191724",
236
+ "surface": "#1f1d2e",
237
+ "elevated": "#26233a",
238
+ "hover": "#34304e",
239
+ "border": "#403d52",
240
+ "ink1": "#e0def4",
241
+ "ink2": "#c5c2dd",
242
+ "ink3": "#908caa",
243
+ "inkMuted": "#6e6a86"
244
+ },
245
+ "light": {
246
+ "ember": "#907aa9",
247
+ "emberGlow": "#6f598c",
248
+ "emberDim": "rgba(144,122,169,0.18)",
249
+ "field": "#f2e9e1",
250
+ "surface": "#faf4ed",
251
+ "elevated": "#fffaf3",
252
+ "hover": "#ebdfd4",
253
+ "border": "#cecacd",
254
+ "ink1": "#575279",
255
+ "ink2": "#635e87",
256
+ "ink3": "#6e6a88",
257
+ "inkMuted": "#857f96"
258
+ }
259
+ },
260
+ {
261
+ "id": "everforest",
262
+ "label": "Everforest",
263
+ "dark": {
264
+ "ember": "#a7c080",
265
+ "emberGlow": "#c3d6a2",
266
+ "emberDim": "rgba(167,192,128,0.25)",
267
+ "field": "#232a2e",
268
+ "surface": "#2d353b",
269
+ "elevated": "#343f44",
270
+ "hover": "#3d484d",
271
+ "border": "#475258",
272
+ "ink1": "#d3c6aa",
273
+ "ink2": "#bfb6a3",
274
+ "ink3": "#9da9a0",
275
+ "inkMuted": "#859289"
276
+ },
277
+ "light": {
278
+ "ember": "#6f8352",
279
+ "emberGlow": "#56683f",
280
+ "emberDim": "rgba(111,131,82,0.18)",
281
+ "field": "#ebeadb",
282
+ "surface": "#f5f4e8",
283
+ "elevated": "#fbfbf4",
284
+ "hover": "#e5e4d3",
285
+ "border": "#d3d2bf",
286
+ "ink1": "#3d484e",
287
+ "ink2": "#55636b",
288
+ "ink3": "#66705f",
289
+ "inkMuted": "#7a8577"
290
+ }
291
+ },
292
+ {
293
+ "id": "kanagawa",
294
+ "label": "Kanagawa",
295
+ "dark": {
296
+ "ember": "#7e9cd8",
297
+ "emberGlow": "#9fb9ec",
298
+ "emberDim": "rgba(126,156,216,0.25)",
299
+ "field": "#16161d",
300
+ "surface": "#1f1f28",
301
+ "elevated": "#2a2a37",
302
+ "hover": "#363646",
303
+ "border": "#54546d",
304
+ "ink1": "#dcd7ba",
305
+ "ink2": "#c8c093",
306
+ "ink3": "#a6a69c",
307
+ "inkMuted": "#727169"
308
+ },
309
+ "light": {
310
+ "ember": "#4d699b",
311
+ "emberGlow": "#38537f",
312
+ "emberDim": "rgba(77,105,155,0.18)",
313
+ "field": "#e9e5d6",
314
+ "surface": "#f3f0e4",
315
+ "elevated": "#faf8f1",
316
+ "hover": "#e2ddcb",
317
+ "border": "#d2ccb6",
318
+ "ink1": "#545464",
319
+ "ink2": "#66667a",
320
+ "ink3": "#716e61",
321
+ "inkMuted": "#8a8775"
322
+ }
323
+ },
324
+ {
325
+ "id": "one-dark",
326
+ "label": "One Dark",
327
+ "dark": {
328
+ "ember": "#61afef",
329
+ "emberGlow": "#8cc7ff",
330
+ "emberDim": "rgba(97,175,239,0.25)",
331
+ "field": "#21252b",
332
+ "surface": "#282c34",
333
+ "elevated": "#2f343e",
334
+ "hover": "#3e4452",
335
+ "border": "#4b5263",
336
+ "ink1": "#d7dae0",
337
+ "ink2": "#abb2bf",
338
+ "ink3": "#8b919e",
339
+ "inkMuted": "#5c6370"
340
+ },
341
+ "light": {
342
+ "ember": "#4078f2",
343
+ "emberGlow": "#2c5dd4",
344
+ "emberDim": "rgba(64,120,242,0.18)",
345
+ "field": "#eaeaeb",
346
+ "surface": "#fafafa",
347
+ "elevated": "#ffffff",
348
+ "hover": "#e0e0e2",
349
+ "border": "#d4d4d6",
350
+ "ink1": "#383a42",
351
+ "ink2": "#50525a",
352
+ "ink3": "#696c77",
353
+ "inkMuted": "#a0a1a7"
354
+ }
355
+ },
356
+ {
357
+ "id": "night-owl",
358
+ "label": "Night Owl",
359
+ "dark": {
360
+ "ember": "#82aaff",
361
+ "emberGlow": "#a8c4ff",
362
+ "emberDim": "rgba(130,170,255,0.25)",
363
+ "field": "#011627",
364
+ "surface": "#0b2942",
365
+ "elevated": "#13344f",
366
+ "hover": "#1d3b53",
367
+ "border": "#5f7e97",
368
+ "ink1": "#d6deeb",
369
+ "ink2": "#b8c5d6",
370
+ "ink3": "#8ba3b8",
371
+ "inkMuted": "#637777"
372
+ },
373
+ "light": {
374
+ "ember": "#0c969b",
375
+ "emberGlow": "#0a7479",
376
+ "emberDim": "rgba(12,150,155,0.18)",
377
+ "field": "#ededed",
378
+ "surface": "#f6f6f6",
379
+ "elevated": "#fbfbfb",
380
+ "hover": "#e2e2e2",
381
+ "border": "#d0d0d0",
382
+ "ink1": "#403f53",
383
+ "ink2": "#545167",
384
+ "ink3": "#6f6e85",
385
+ "inkMuted": "#989fb1"
386
+ }
387
+ },
388
+ {
389
+ "id": "monokai",
390
+ "label": "Monokai Pro",
391
+ "dark": {
392
+ "ember": "#ffd866",
393
+ "emberGlow": "#ffe6a0",
394
+ "emberDim": "rgba(255,216,102,0.25)",
395
+ "field": "#221f22",
396
+ "surface": "#2d2a2e",
397
+ "elevated": "#403e41",
398
+ "hover": "#4a484b",
399
+ "border": "#5b595c",
400
+ "ink1": "#fcfcfa",
401
+ "ink2": "#d9d8d6",
402
+ "ink3": "#939293",
403
+ "inkMuted": "#727072"
404
+ },
405
+ "light": {
406
+ "ember": "#c08a00",
407
+ "emberGlow": "#856000",
408
+ "emberDim": "rgba(192,138,0,0.18)",
409
+ "field": "#e9e6e4",
410
+ "surface": "#f1efed",
411
+ "elevated": "#fcfbfa",
412
+ "hover": "#e0dcda",
413
+ "border": "#cdc8c5",
414
+ "ink1": "#2c292d",
415
+ "ink2": "#46434a",
416
+ "ink3": "#6b686d",
417
+ "inkMuted": "#8d8a8d"
418
+ }
419
+ },
420
+ {
421
+ "id": "github",
422
+ "label": "GitHub",
423
+ "dark": {
424
+ "ember": "#58a6ff",
425
+ "emberGlow": "#85bdff",
426
+ "emberDim": "rgba(88,166,255,0.25)",
427
+ "field": "#1f2428",
428
+ "surface": "#24292e",
429
+ "elevated": "#2b3138",
430
+ "hover": "#2f363d",
431
+ "border": "#444d56",
432
+ "ink1": "#e1e4e8",
433
+ "ink2": "#d1d5da",
434
+ "ink3": "#959da5",
435
+ "inkMuted": "#6a737d"
436
+ },
437
+ "light": {
438
+ "ember": "#0366d6",
439
+ "emberGlow": "#044289",
440
+ "emberDim": "rgba(3,102,214,0.18)",
441
+ "field": "#f0f2f4",
442
+ "surface": "#f6f8fa",
443
+ "elevated": "#ffffff",
444
+ "hover": "#e8eaed",
445
+ "border": "#d1d5da",
446
+ "ink1": "#24292e",
447
+ "ink2": "#444d56",
448
+ "ink3": "#586069",
449
+ "inkMuted": "#6a737d"
450
+ }
451
+ },
452
+ {
453
+ "id": "ayu",
454
+ "label": "Ayu",
455
+ "dark": {
456
+ "ember": "#e6b450",
457
+ "emberGlow": "#f0cd85",
458
+ "emberDim": "rgba(230,180,80,0.25)",
459
+ "field": "#0d1017",
460
+ "surface": "#10141c",
461
+ "elevated": "#141821",
462
+ "hover": "#1d2330",
463
+ "border": "#2d3343",
464
+ "ink1": "#bfbdb6",
465
+ "ink2": "#a8a6a0",
466
+ "ink3": "#8a9199",
467
+ "inkMuted": "#6c7380"
468
+ },
469
+ "light": {
470
+ "ember": "#c97612",
471
+ "emberGlow": "#a65e0c",
472
+ "emberDim": "rgba(201,118,18,0.18)",
473
+ "field": "#eff1f3",
474
+ "surface": "#f8f9fa",
475
+ "elevated": "#fcfcfc",
476
+ "hover": "#e7eaed",
477
+ "border": "#d8dde2",
478
+ "ink1": "#3d4149",
479
+ "ink2": "#5c6166",
480
+ "ink3": "#787b80",
481
+ "inkMuted": "#8a9199"
482
+ }
483
+ },
484
+ {
485
+ "id": "vitesse",
486
+ "label": "Vitesse",
487
+ "dark": {
488
+ "ember": "#4d9375",
489
+ "emberGlow": "#6fb392",
490
+ "emberDim": "rgba(77,147,117,0.25)",
491
+ "field": "#121212",
492
+ "surface": "#181818",
493
+ "elevated": "#1e1e1e",
494
+ "hover": "#262626",
495
+ "border": "#333333",
496
+ "ink1": "#dbd7ca",
497
+ "ink2": "#bfbaaa",
498
+ "ink3": "#8f8f85",
499
+ "inkMuted": "#758575"
500
+ },
501
+ "light": {
502
+ "ember": "#1c6b48",
503
+ "emberGlow": "#145236",
504
+ "emberDim": "rgba(28,107,72,0.18)",
505
+ "field": "#f0f0f0",
506
+ "surface": "#f7f7f7",
507
+ "elevated": "#ffffff",
508
+ "hover": "#e7e7e7",
509
+ "border": "#d6d6d6",
510
+ "ink1": "#393a34",
511
+ "ink2": "#4e4f47",
512
+ "ink3": "#6b6d63",
513
+ "inkMuted": "#a0ada0"
514
+ }
515
+ },
516
+ {
517
+ "id": "synthwave",
518
+ "label": "Synthwave '84",
519
+ "dark": {
520
+ "ember": "#ff7edb",
521
+ "emberGlow": "#ffa9e7",
522
+ "emberDim": "rgba(255,126,219,0.25)",
523
+ "field": "#241b2f",
524
+ "surface": "#262335",
525
+ "elevated": "#322a47",
526
+ "hover": "#3d3460",
527
+ "border": "#495495",
528
+ "ink1": "#ffffff",
529
+ "ink2": "#d8d6e8",
530
+ "ink3": "#b6b1d8",
531
+ "inkMuted": "#848bbd"
532
+ },
533
+ "light": {
534
+ "ember": "#c936a6",
535
+ "emberGlow": "#9c2381",
536
+ "emberDim": "rgba(201,54,166,0.18)",
537
+ "field": "#e6e2f0",
538
+ "surface": "#efecf6",
539
+ "elevated": "#fbfafd",
540
+ "hover": "#ddd7ec",
541
+ "border": "#c5bcdd",
542
+ "ink1": "#241b2f",
543
+ "ink2": "#3d3455",
544
+ "ink3": "#5c5380",
545
+ "inkMuted": "#7d76a3"
546
+ }
547
+ }
548
+ ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-hub",
3
- "version": "0.40.0",
3
+ "version": "0.42.0",
4
4
  "description": "Unified hub for Claude Code tools — Marketplace + Kanban in one PWA",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -9,13 +9,18 @@
9
9
  "scripts": {
10
10
  "start": "node server.js",
11
11
  "dev": "node server.js --open",
12
+ "test": "node --test test/*.test.js",
12
13
  "update": "bash scripts/update-submodules.sh",
13
14
  "update:check": "bash scripts/update-submodules.sh --check",
14
15
  "lint": "npx @biomejs/biome check public/app.js server.js && npm run check:escaping && npm run check:security-lib",
15
16
  "lint:fix": "npx @biomejs/biome check --fix public/app.js server.js",
16
17
  "prepare": "husky",
17
18
  "check:escaping": "node scripts/check-escaping.mjs",
18
- "check:security-lib": "bash scripts/sync-security-lib.sh --check"
19
+ "check:security-lib": "bash scripts/sync-security-lib.sh --check",
20
+ "perf": "node scripts/perf.mjs run",
21
+ "perf:compare": "node scripts/perf.mjs compare",
22
+ "perf:report": "node scripts/perf.mjs report",
23
+ "perf:world": "node scripts/perf.mjs gen-world"
19
24
  },
20
25
  "repository": {
21
26
  "type": "git",
@@ -39,10 +44,10 @@
39
44
  },
40
45
  "homepage": "https://github.com/NikiforovAll/claude-code-hub#readme",
41
46
  "dependencies": {
42
- "claude-code-cost": "^0.24.0",
43
- "claude-code-kanban": "^4.29.0",
44
- "claude-code-marketplace": "^0.20.0",
45
- "claude-code-memory-explorer": "^0.15.0",
47
+ "claude-code-cost": "^0.26.0",
48
+ "claude-code-kanban": "^4.31.1",
49
+ "claude-code-marketplace": "^0.22.0",
50
+ "claude-code-memory-explorer": "^0.17.0",
46
51
  "express": "^4.21.0",
47
52
  "open": "^10.1.0"
48
53
  },
package/public/app.js CHANGED
@@ -3,7 +3,6 @@ let activeApp = null;
3
3
  const iframes = {};
4
4
  const loadedApps = new Set();
5
5
  const guardedApps = new Set();
6
- let allowedOrigins = new Set();
7
6
  // {theme: 'dark'|'light', colorTheme: '<id>', colorThemes: {<configDir>: '<id>'}} — survives hub
8
7
  // reloads so late-loading iframes and fresh sessions get the last chosen theme. Light/dark is
9
8
  // global; the color theme is per config dir, and colorTheme is the last one picked anywhere, which
@@ -55,7 +54,7 @@ function themeMessage() {
55
54
  }
56
55
 
57
56
  // {<themeId>: {dark, light}}, each a map of CSS variables, from /api/config, which derives it from
58
- // scripts/themes.json — the same registry that generates each sub-app's themes.css. Empty until
57
+ // lib/themes.json — the same registry that generates each sub-app's themes.css. Empty until
59
58
  // config arrives, and empty if the registry is unreadable; then the hub keeps index.html's colors.
60
59
  let themePalettes = {};
61
60
  // The last applied variables, so the loading screen paints in the theme before /api/config lands.
@@ -151,7 +150,7 @@ async function init() {
151
150
  showLoading();
152
151
  const res = await fetch('/api/config');
153
152
  const config = await res.json();
154
- setApps(config.apps);
153
+ apps = config.apps;
155
154
  defaultConfigDir = config.defaultConfigDir ?? null;
156
155
  activeConfigDir = config.activeConfigDir ?? null;
157
156
  applyTitle(config.activeConfigDir);
@@ -172,11 +171,6 @@ function applyTitle(dir) {
172
171
  document.title = dir && dir !== defaultConfigDir ? basename(dir) : 'Claude Code Hub';
173
172
  }
174
173
 
175
- function setApps(next) {
176
- apps = next;
177
- allowedOrigins = new Set(Object.values(apps).map((a) => new URL(a.url).origin));
178
- }
179
-
180
174
  // After a config-dir switch every child is a new process, possibly on a new port. Reloading each
181
175
  // iframe from scratch also drops the sub-apps' in-memory project state, which belonged to the old
182
176
  // dir; the hub's own projectState is cleared for the same reason.
@@ -294,17 +288,17 @@ function onIframeLoad(appId) {
294
288
  loadedApps.add(appId);
295
289
  guardedApps.delete(appId);
296
290
  if (appId === activeApp) hideLoading();
297
- postTo(appId, themeMessage());
298
- postProjectTo(appId);
299
- postActiveTo(appId);
300
- // Posted twice: the shims gate their origin check on window.__HUB__, which they populate from
301
- // an async /hub-config fetch that resolves after this load event, so the first post can be
302
- // dropped. Safe to repeat — every shim's apply is idempotent.
303
- setTimeout(() => {
291
+ const postState = () => {
304
292
  postTo(appId, themeMessage());
305
293
  postProjectTo(appId);
306
294
  postActiveTo(appId);
307
- }, 400);
295
+ postTo(appId, { type: 'hub:keys', keys: Object.keys(bindings()) });
296
+ };
297
+ postState();
298
+ // Posted twice: the shims gate their origin check on window.__HUB__, which they populate from
299
+ // an async /hub-config fetch that resolves after this load event, so the first post can be
300
+ // dropped. Safe to repeat — every shim's apply is idempotent.
301
+ setTimeout(postState, 400);
308
302
  }
309
303
 
310
304
  function listenMessages() {
@@ -312,7 +306,8 @@ function listenMessages() {
312
306
  if (guardedApps.size) e.preventDefault();
313
307
  });
314
308
  window.addEventListener('message', (e) => {
315
- if (!allowedOrigins.has(e.origin)) return;
309
+ const appId = appIdOf(e.source);
310
+ if (!appId || e.origin !== originOf(appId)) return;
316
311
  const data = e.data ?? {};
317
312
  if (data.type === 'hub:navigate') {
318
313
  if (!apps[data.app]) return;
@@ -324,14 +319,10 @@ function listenMessages() {
324
319
  } else if (data.type === 'hub:keydown') {
325
320
  handleForwardedKey(data);
326
321
  } else if (data.type === 'hub:closeGuard') {
327
- const appId = appIdOf(e.source);
328
- if (!appId) return;
329
322
  if (data.on === true) guardedApps.add(appId);
330
323
  else guardedApps.delete(appId);
331
324
  } else if (data.type === 'hub:terminalToken') {
332
325
  // This hub page may have outlived the restart too, so the cached token is not trusted.
333
- const appId = appIdOf(e.source);
334
- if (!appId) return;
335
326
  sendJson('GET', '/api/config')
336
327
  .then((cfg) => {
337
328
  const token = cfg.apps?.[appId]?.terminalToken;
@@ -373,21 +364,40 @@ function handleForwardedKey(d) {
373
364
  // The modifier fields are new. A sub-app running an older service-worker-cached bundle sends
374
365
  // {key} only; it forwards nothing but the pre-existing bindings, and each of those is
375
366
  // unambiguous from the key alone, so normalize instead of dispatching twice.
376
- const alt = typeof d.alt === 'boolean' ? d.alt : true;
377
- const ctrl = typeof d.alt === 'boolean' ? d.ctrl : d.key.startsWith('Arrow');
378
- if (!alt || d.shift === true) return;
379
- const k = bindingKey(d);
380
- if (!ctrl) {
381
- if (/^[1-9]$/.test(k)) switchByIndex(Number(k) - 1);
382
- return;
383
- }
384
- if (k === 'ArrowLeft') cycleTab(-1);
385
- else if (k === 'ArrowRight') cycleTab(1);
386
- else if (PALETTE_KEYS[k]) togglePalette(PALETTE_KEYS[k]);
367
+ const legacy = typeof d.alt !== 'boolean';
368
+ const e = {
369
+ key: d.key,
370
+ code: d.code,
371
+ altKey: legacy || d.alt,
372
+ ctrlKey: legacy ? d.key.startsWith('Arrow') : d.ctrl,
373
+ shiftKey: d.shift === true,
374
+ };
375
+ bindings()[comboOf(e)]?.run();
387
376
  }
388
377
 
389
- // Ctrl+Alt+<letter> bindings. Sub-app shims forward every letter, so this is the one keymap.
390
- const PALETTE_KEYS = { p: 'project', w: 'configDir' };
378
+ // The one keymap. Its combo names are what the hub:keys message lists for the shims.
379
+ // inPalette: the binding still fires while the palette is open.
380
+ function bindings() {
381
+ const map = {
382
+ 'ctrl+alt+p': { run: () => togglePalette('project'), inPalette: true },
383
+ 'ctrl+alt+w': { run: () => togglePalette('configDir'), inPalette: true },
384
+ 'ctrl+alt+ArrowLeft': { run: () => cycleTab(-1) },
385
+ 'ctrl+alt+ArrowRight': { run: () => cycleTab(1) },
386
+ };
387
+ Object.keys(apps)
388
+ .slice(0, 9)
389
+ .forEach((id, i) => {
390
+ map[`alt+${i + 1}`] = { run: () => switchTab(id) };
391
+ });
392
+ return map;
393
+ }
394
+
395
+ // Each shim's hubCombo() is a copy of this: modifiers in ctrl, alt, shift, meta order, joined by
396
+ // '+' to the key as bindingKey() names it.
397
+ function comboOf(e) {
398
+ const mods = [e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift', e.metaKey && 'meta'];
399
+ return [...mods, bindingKey(e)].filter(Boolean).join('+');
400
+ }
391
401
 
392
402
  // macOS composes Option+<key> into a character — Option+1 is '¡', Option+P is 'π' — and holding
393
403
  // Control does not undo it, so e.key alone cannot carry these bindings there. e.code is the
@@ -407,20 +417,13 @@ function cycleTab(delta) {
407
417
  switchTab(next);
408
418
  }
409
419
 
410
- function switchByIndex(idx) {
411
- const ids = Object.keys(apps);
412
- if (idx < ids.length) switchTab(ids[idx]);
413
- }
414
-
415
420
  function listenKeys() {
416
421
  document.addEventListener('keydown', (e) => {
417
- if (e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey) {
418
- const mode = PALETTE_KEYS[bindingKey(e)];
419
- if (mode) {
420
- e.preventDefault();
421
- togglePalette(mode);
422
- return;
423
- }
422
+ const binding = bindings()[comboOf(e)];
423
+ if (binding?.inPalette) {
424
+ e.preventDefault();
425
+ binding.run();
426
+ return;
424
427
  }
425
428
  // While the palette is open the input owns the keyboard — don't let tab shortcuts fire
426
429
  // mid-path (Alt+digit especially, since Windows paths contain digits). Escape still closes:
@@ -432,26 +435,9 @@ function listenKeys() {
432
435
  }
433
436
  return;
434
437
  }
435
- if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey) {
436
- const k = bindingKey(e);
437
- if (/^[1-9]$/.test(k)) {
438
- e.preventDefault();
439
- switchByIndex(Number(k) - 1);
440
- return;
441
- }
442
- }
443
- if (e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey) {
444
- if (e.key === 'ArrowLeft') {
445
- e.preventDefault();
446
- cycleTab(-1);
447
- return;
448
- }
449
- if (e.key === 'ArrowRight') {
450
- e.preventDefault();
451
- cycleTab(1);
452
- return;
453
- }
454
- }
438
+ if (!binding) return;
439
+ e.preventDefault();
440
+ binding.run();
455
441
  });
456
442
  }
457
443
 
@@ -790,7 +776,7 @@ async function commitConfigDir(row) {
790
776
  if (target === palette.configDirs.active) return;
791
777
  showLoading();
792
778
  try {
793
- setApps((await sendJson('POST', '/api/config-dirs/activate', { path: target })).apps);
779
+ apps = (await sendJson('POST', '/api/config-dirs/activate', { path: target })).apps;
794
780
  // The palette renders from this cache before its refetch lands, and initialSel keys off it.
795
781
  palette.configDirs.active = target;
796
782
  activeConfigDir = target;
package/server.js CHANGED
@@ -8,6 +8,8 @@ const http = require('http');
8
8
  const tcp = require('net');
9
9
  const os = require('os');
10
10
  const path = require('path');
11
+ const { selectApps } = require('./lib/apps');
12
+ const { stripCookie } = require('./lib/cookies');
11
13
  const { createNetGuard } = require('./lib/net-guard');
12
14
 
13
15
  function getArg(name) {
@@ -19,13 +21,9 @@ function getArg(name) {
19
21
  }
20
22
 
21
23
  const HUB_PORT = parseInt(getArg('port') || process.env.PORT || '3540', 10);
22
- const MARKETPLACE_PORT = parseInt(getArg('marketplace-port') || '3542', 10);
23
- const KANBAN_PORT = parseInt(getArg('kanban-port') || '3541', 10);
24
- const COST_PORT = parseInt(getArg('cost-port') || '3543', 10);
25
- const MEMORY_PORT = parseInt(getArg('memory-port') || '3544', 10);
24
+ let hubPort = HUB_PORT;
26
25
 
27
26
  const POOL_SIZE = Math.max(1, parseInt(getArg('pool-size') || '3', 10));
28
- const DEFAULT_PORTS = { marketplace: MARKETPLACE_PORT, kanban: KANBAN_PORT, cost: COST_PORT, memory: MEMORY_PORT };
29
27
  const RESTART_DELAY_MS = 500;
30
28
  // A rate window, not a total: a child may die and recover all session, but one that cannot stay up
31
29
  // stops being restarted. A consecutive counter cannot express that — anything that starts at all
@@ -87,6 +85,7 @@ function loadHubConfig() {
87
85
  const config = { configDirs: dirs, activeConfigDir: active };
88
86
  // Hand-edited only; kept verbatim so a rewrite of the file does not drop it.
89
87
  if (saved.terminal && typeof saved.terminal === 'object') config.terminal = saved.terminal;
88
+ if (Array.isArray(saved.apps)) config.apps = saved.apps;
90
89
  // Configs saved before canonicalization can hold one dir under two spellings; persist the merge.
91
90
  if (serializeConfig(config) !== raw) writeHubConfig(config);
92
91
  return config;
@@ -107,6 +106,14 @@ function saveHubConfig() {
107
106
 
108
107
  const hubConfig = loadHubConfig();
109
108
 
109
+ const { apps: ENABLED_APPS, unknown: unknownApps } = selectApps(hubConfig.apps);
110
+ for (const id of unknownApps) console.log(`Unknown app "${id}" in ${HUB_CONFIG_FILE}, ignored`);
111
+ if (!ENABLED_APPS.length) {
112
+ console.error(`Every app is disabled in ${HUB_CONFIG_FILE}. Enable at least one.`);
113
+ process.exit(1);
114
+ }
115
+ const appEnabled = (id) => ENABLED_APPS.some((a) => a.id === id);
116
+
110
117
  // Every keystroke into cck's terminal passes through the upgrade proxy below. Windows only, for the
111
118
  // reasons in cck/lib/priority.js; the children spawned after this still start at normal.
112
119
  if (process.platform === 'win32' && process.env.CCK_PRIORITY_BOOST !== '0') {
@@ -129,7 +136,8 @@ const net = createNetGuard({ appName: 'Claude Code Hub' });
129
136
  // the WebSocket. cck applies the rest of its policy (exposure refusal, Origin, session cap) itself.
130
137
  const TERMINAL = {
131
138
  ...(hubConfig.terminal || {}),
132
- enabled: !process.argv.includes('--disable-terminal') && hubConfig.terminal?.enabled !== false,
139
+ enabled:
140
+ appEnabled('kanban') && !process.argv.includes('--disable-terminal') && hubConfig.terminal?.enabled !== false,
133
141
  };
134
142
  const TERMINAL_TOKEN = TERMINAL.enabled ? crypto.randomBytes(32).toString('hex') : null;
135
143
 
@@ -185,7 +193,7 @@ function childEnv(pool, name) {
185
193
  PORT: '0',
186
194
  CLAUDE_CONFIG_DIR: pool.dir,
187
195
  CLAUDE_HUB: '1',
188
- HUB_URL: `http://localhost:${HUB_PORT}`,
196
+ HUB_URL: `http://localhost:${hubPort}`,
189
197
  HOST: net.BIND_HOST,
190
198
  ALLOWED_HOSTS: net.ALLOWED_HOSTS,
191
199
  };
@@ -367,10 +375,7 @@ function resolveApp(submoduleDir, npmPackage) {
367
375
  return require.resolve(`${npmPackage}/server.js`);
368
376
  }
369
377
 
370
- const marketplacePath = resolveApp('marketplace', 'claude-code-marketplace');
371
- const kanbanPath = resolveApp('cck', 'claude-code-kanban');
372
- const costPath = resolveApp('cost', 'claude-code-cost');
373
- const memoryPath = resolveApp('memory', 'claude-code-memory-explorer');
378
+ const appPaths = Object.fromEntries(ENABLED_APPS.map((a) => [a.id, resolveApp(a.dir, a.pkg)]));
374
379
 
375
380
  // Raise header size limit to 64KB — localhost cookies from sibling apps can pile up and
376
381
  // trip Node's default 16KB limit, breaking iframes with HTTP 431.
@@ -381,13 +386,12 @@ const NODE_HDR = `--max-http-header-size=${HDR_BYTES}`;
381
386
  // localStorage is keyed by origin, so the sub-app origin has to stay put across switches or the
382
387
  // user loses pins and filters every time the active set changes.
383
388
  function spawnChildren(pool) {
384
- spawnApp(pool, 'marketplace', process.execPath, [NODE_HDR, marketplacePath]);
385
- spawnApp(pool, 'kanban', process.execPath, [NODE_HDR, kanbanPath]);
386
- spawnApp(pool, 'cost', process.execPath, [NODE_HDR, costPath]);
387
- spawnApp(pool, 'memory', process.execPath, [NODE_HDR, memoryPath]);
389
+ for (const a of ENABLED_APPS) spawnApp(pool, a.id, process.execPath, [NODE_HDR, appPaths[a.id]]);
388
390
  }
389
391
 
390
- const publicPorts = { ...DEFAULT_PORTS };
392
+ const publicPorts = Object.fromEntries(
393
+ ENABLED_APPS.map((a) => [a.id, parseInt(getArg(`${a.id}-port`) || String(a.port), 10)]),
394
+ );
391
395
 
392
396
  function rewriteOrigin(origin, publicPort, childPort) {
393
397
  try {
@@ -432,6 +436,7 @@ async function waitForPort(name, deadline) {
432
436
  const pool = activePool();
433
437
  const port = pool?.ports[name];
434
438
  if (port) return port;
439
+ if (pool && !pool.children.has(name)) return null;
435
440
  const remaining = deadline - Date.now();
436
441
  if (remaining <= 0) return null;
437
442
  await Promise.race([pool?.children.get(name)?.ready, delay(Math.min(remaining, 250))]);
@@ -527,10 +532,14 @@ function forward(name, req, res, childPort, headers, replayable, onUpstream) {
527
532
  // Host is forwarded untouched so the child's hostGuard still sees what the browser sent. Origin is
528
533
  // the one header the child cannot judge on its own: its guard compares the port to its own
529
534
  // ephemeral one, so the public-port spelling is mapped to the child's; anything else passes
530
- // through as-is and the child rejects it.
535
+ // through as-is and the child rejects it. Cookies on localhost are shared across ports, so the
536
+ // browser sends the hub's own token cookie to every app; it never reaches a child.
531
537
  function proxyHandler(name) {
532
538
  return async (req, res) => {
533
539
  const headers = { ...req.headers };
540
+ const cookie = stripCookie(headers.cookie, TOKEN_COOKIE);
541
+ if (cookie) headers.cookie = cookie;
542
+ else delete headers.cookie;
534
543
  // req is consumed by the first attempt, so only a request with no body can be re-sent. Waiting
535
544
  // for a port is not a replay — nothing has been sent yet — so every method gets the same wait.
536
545
  const replayable = req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS';
@@ -559,6 +568,9 @@ function proxyHandler(name) {
559
568
  sendUnavailable(req, res, name, 502, err.message);
560
569
  return;
561
570
  }
571
+ console.warn(
572
+ `[${name}] ${req.method} ${req.url.split('?')[0]} to 127.0.0.1:${childPort} failed (${err.code}), retrying`,
573
+ );
562
574
  await delay(ATTEMPT_DELAY_MS);
563
575
  }
564
576
  };
@@ -577,24 +589,46 @@ function proxyUpgrade(name) {
577
589
  }
578
590
  const lines = [`${req.method} ${req.url} HTTP/${req.httpVersion}`];
579
591
  for (let i = 0; i < req.rawHeaders.length; i += 2) {
592
+ const header = req.rawHeaders[i].toLowerCase();
580
593
  let value = req.rawHeaders[i + 1];
581
- if (req.rawHeaders[i].toLowerCase() === 'origin') value = rewriteOrigin(value, publicPorts[name], childPort);
594
+ if (header === 'origin') value = rewriteOrigin(value, publicPorts[name], childPort);
595
+ if (header === 'cookie' && !(value = stripCookie(value, TOKEN_COOKIE))) continue;
582
596
  lines.push(`${req.rawHeaders[i]}: ${value}`);
583
597
  }
584
- const upstream = tcp.connect(childPort, '127.0.0.1', () => {
585
- clearTimeout(connectTimer);
586
- upstream.write(`${lines.join('\r\n')}\r\n\r\n`);
587
- if (head?.length) upstream.write(head);
588
- upstream.pipe(socket);
589
- socket.pipe(upstream);
590
- });
591
- const connectTimer = setTimeout(() => upstream.destroy(), CONNECT_TIMEOUT_MS);
592
- upstream.on('error', () => socket.destroy());
593
- socket.on('close', () => upstream.destroy());
594
- upstream.on('close', () => socket.destroy());
598
+ tunnel(name, childPort, `${lines.join('\r\n')}\r\n\r\n`, head, socket, 1);
595
599
  };
596
600
  }
597
601
 
602
+ // A fresh loopback connect to a child sometimes fails at once with ETIMEDOUT on Windows (cause
603
+ // unknown, seen only on a just-started hub), so a connect that never opened is tried once more.
604
+ function tunnel(name, childPort, request, head, socket, retries) {
605
+ let connected = false;
606
+ const upstream = tcp.connect(childPort, '127.0.0.1', () => {
607
+ connected = true;
608
+ clearTimeout(connectTimer);
609
+ upstream.write(request);
610
+ if (head?.length) upstream.write(head);
611
+ upstream.pipe(socket);
612
+ socket.pipe(upstream);
613
+ });
614
+ const connectTimer = setTimeout(() => upstream.destroy(), CONNECT_TIMEOUT_MS);
615
+ const onSocketClose = () => upstream.destroy();
616
+ socket.on('close', onSocketClose);
617
+ upstream.on('error', (err) => {
618
+ if (connected || socket.destroyed || retries < 1) {
619
+ if (!connected) console.warn(`[${name}] upgrade connect to 127.0.0.1:${childPort} failed (${err.code})`);
620
+ socket.destroy();
621
+ return;
622
+ }
623
+ console.warn(`[${name}] upgrade connect to 127.0.0.1:${childPort} failed (${err.code}), retrying`);
624
+ clearTimeout(connectTimer);
625
+ socket.off('close', onSocketClose);
626
+ upstream.removeAllListeners('close');
627
+ tunnel(name, childPort, request, head, socket, retries - 1);
628
+ });
629
+ upstream.on('close', () => socket.destroy());
630
+ }
631
+
598
632
  function listenWithFallback(handler, port, onReady, label, onUpgrade) {
599
633
  const opts = { maxHeaderSize: HDR_BYTES, onUpgrade };
600
634
  const server = net.listenLoopback(handler, port, onReady, opts);
@@ -606,7 +640,7 @@ function listenWithFallback(handler, port, onReady, label, onUpgrade) {
606
640
  return server;
607
641
  }
608
642
 
609
- for (const [name, port] of Object.entries(DEFAULT_PORTS)) {
643
+ for (const [name, port] of Object.entries(publicPorts)) {
610
644
  listenWithFallback(
611
645
  proxyHandler(name),
612
646
  port,
@@ -618,8 +652,6 @@ for (const [name, port] of Object.entries(DEFAULT_PORTS)) {
618
652
  );
619
653
  }
620
654
 
621
- ensurePool(hubConfig.activeConfigDir);
622
-
623
655
  const app = express();
624
656
 
625
657
  // Mounted before the routes below, which are registered ahead of the first
@@ -646,7 +678,7 @@ const hubVars = (p) => ({
646
678
  });
647
679
  const themePalettes = (() => {
648
680
  try {
649
- const themes = JSON.parse(fs.readFileSync(path.join(__dirname, 'scripts/themes.json'), 'utf8'));
681
+ const themes = JSON.parse(fs.readFileSync(path.join(__dirname, 'lib/themes.json'), 'utf8'));
650
682
  return Object.fromEntries(themes.map((t) => [t.id, { dark: hubVars(t.dark), light: hubVars(t.light) }]));
651
683
  } catch {
652
684
  // Palette falls back to the --accent in index.html; not worth failing startup over.
@@ -655,14 +687,13 @@ const themePalettes = (() => {
655
687
  })();
656
688
 
657
689
  function appsConfig() {
658
- const kanban = { name: 'Kanban', url: `http://localhost:${publicPorts.kanban}`, icon: 'columns' };
659
- if (TERMINAL_TOKEN) kanban.terminalToken = TERMINAL_TOKEN;
660
- return {
661
- kanban,
662
- marketplace: { name: 'Marketplace', url: `http://localhost:${publicPorts.marketplace}`, icon: 'store' },
663
- cost: { name: 'Cost', url: `http://localhost:${publicPorts.cost}`, icon: 'dollar-sign' },
664
- memory: { name: 'Memory Diagnoser', url: `http://localhost:${publicPorts.memory}`, icon: 'database' },
665
- };
690
+ return Object.fromEntries(
691
+ ENABLED_APPS.map((a) => {
692
+ const entry = { name: a.name, url: `http://localhost:${publicPorts[a.id]}`, icon: a.icon };
693
+ if (a.id === 'kanban' && TERMINAL_TOKEN) entry.terminalToken = TERMINAL_TOKEN;
694
+ return [a.id, entry];
695
+ }),
696
+ );
666
697
  }
667
698
 
668
699
  app.get('/api/config', (_req, res) => {
@@ -768,7 +799,11 @@ app.get('/api/resolve-path', (req, res) => {
768
799
 
769
800
  app.use(express.static(path.join(__dirname, 'public')));
770
801
 
802
+ // Children get HUB_URL at spawn and trust only that origin, so the first pool waits for the port
803
+ // the hub actually bound: HUB_PORT may be busy and fall back to a random one.
771
804
  const onReady = (actual) => {
805
+ hubPort = actual;
806
+ ensurePool(hubConfig.activeConfigDir);
772
807
  printBanner(actual);
773
808
  if (process.argv.includes('--open')) {
774
809
  import('open').then((m) => m.default(accessUrl(actual)));