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 +22 -0
- package/lib/apps.js +23 -0
- package/lib/cookies.js +13 -0
- package/lib/net-guard.js +1 -1
- package/lib/themes.json +548 -0
- package/package.json +11 -6
- package/public/app.js +53 -67
- package/server.js +76 -41
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);
|
package/lib/themes.json
ADDED
|
@@ -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.
|
|
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.
|
|
43
|
-
"claude-code-kanban": "^4.
|
|
44
|
-
"claude-code-marketplace": "^0.
|
|
45
|
-
"claude-code-memory-explorer": "^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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
}
|
|
384
|
-
|
|
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
|
-
//
|
|
390
|
-
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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 (
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:${
|
|
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
|
|
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,
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
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(
|
|
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, '
|
|
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
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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)));
|