claude-code-hub 0.38.0 → 0.39.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
@@ -4,85 +4,164 @@
4
4
  [![license](https://img.shields.io/npm/l/claude-code-hub)](LICENSE)
5
5
  [![npm downloads](https://img.shields.io/npm/dm/claude-code-hub)](https://www.npmjs.com/package/claude-code-hub)
6
6
 
7
- Unified launcher for Claude Code tools — browse plugins in **Marketplace**, track tasks in **Kanban**, monitor costs in **Cost**, and explore memory in **Memory Diagnoser**, all from a single chromeless PWA.
7
+ One window for your Claude Code tools: Kanban, Marketplace, Cost, and Memory Diagnoser, in a single chromeless PWA that you drive from the keyboard.
8
8
 
9
- ## Kanban:
9
+ Website: [nikiforovall.blog/claude-code-hub](https://nikiforovall.blog/claude-code-hub/)
10
10
 
11
- ![Kanban Screenshot](./assets/cck.png)
11
+ <a href="https://youtu.be/9tMiNn1v6bY">
12
+ <picture>
13
+ <source media="(prefers-color-scheme: dark)" srcset="website/public/video/hub-dark.webp">
14
+ <img alt="Claude Code Hub tour video (37 seconds). Opens on YouTube." src="website/public/video/hub-light.webp">
15
+ </picture>
16
+ </a>
12
17
 
13
- ## Marketplace:
14
- ![Marketplace Screenshot](./assets/marketplace.png)
18
+ Watch the tour on YouTube: [light](https://youtu.be/9tMiNn1v6bY), [dark](https://youtu.be/avZk-3qOpWk).
15
19
 
16
- ## Cost:
17
- ![Cost Screenshot](./assets/cost.png)
20
+ ## The tools
18
21
 
19
- ## Memory Diagnoser:
20
- ![Memory Diagnoser Screenshot](./assets/memory.png)
22
+ ### Kanban
21
23
 
22
- ## Quick Start
24
+ See tasks, sessions, and live agent activity as a board. Open a Claude Code session in the embedded terminal.
25
+
26
+ <picture>
27
+ <source media="(prefers-color-scheme: dark)" srcset="assets/kanban-dark.webp">
28
+ <img alt="Kanban board with sessions and tasks" src="assets/kanban-light.webp">
29
+ </picture>
30
+
31
+ ### Marketplace
32
+
33
+ Browse and manage Claude Code marketplace plugins.
34
+
35
+ <picture>
36
+ <source media="(prefers-color-scheme: dark)" srcset="assets/marketplace-dark.webp">
37
+ <img alt="Marketplace with the plugin list" src="assets/marketplace-light.webp">
38
+ </picture>
39
+
40
+ ### Cost
41
+
42
+ See what your Claude Code usage costs, per session and per project.
43
+
44
+ <picture>
45
+ <source media="(prefers-color-scheme: dark)" srcset="assets/cost-dark.webp">
46
+ <img alt="Cost dashboard with usage charts" src="assets/cost-light.webp">
47
+ </picture>
48
+
49
+ ### Memory Diagnoser
50
+
51
+ Explore the memory files that Claude Code loads for a project.
52
+
53
+ <picture>
54
+ <source media="(prefers-color-scheme: dark)" srcset="assets/memory-dark.webp">
55
+ <img alt="Memory Diagnoser with loaded memory files" src="assets/memory-light.webp">
56
+ </picture>
57
+
58
+ ## Quick start
23
59
 
24
60
  ```bash
25
61
  npx claude-code-hub --open
26
62
  ```
27
63
 
28
- ### From Source
64
+ The hub starts on `http://localhost:3540` and opens the browser. To get a window with no browser UI, install the page as an app from the browser menu.
65
+
66
+ ### From source
29
67
 
30
68
  ```bash
31
69
  git clone --recurse-submodules https://github.com/NikiforovAll/claude-code-hub.git
32
70
  cd claude-code-hub
33
- npm install && npm install --prefix marketplace && npm install --prefix cck && npm install --prefix memory
71
+ npm install && npm install --prefix marketplace && npm install --prefix cck && npm install --prefix cost && npm install --prefix memory
34
72
  npm start # http://localhost:3540
35
73
  ```
36
74
 
37
- ## Agent Observability (one-time setup)
75
+ ## Agent observability (one-time setup)
38
76
 
39
- For the full Kanban experience — agent log, live subagent tracking, waiting-for-user indicators, and context window monitoring — install the hooks:
77
+ For the full Kanban view (agent log, live subagent tracking, waiting-for-user indicators, and context window use), install the hooks:
40
78
 
41
79
  ```bash
42
80
  npx claude-code-kanban --install
43
81
  ```
44
82
 
45
- Without hooks you still get the task board, but no agent activity or live indicators. See the [Kanban README](https://github.com/NikiforovAll/claude-task-viewer#getting-started) for details.
83
+ Without the hooks you still get the task board and sessions, but no live agent activity. The hooks and status line are installed per Claude config dir, so run the command again for each dir you use. See the [Kanban docs](https://nikiforovall.blog/claude-code-kanban/) for details.
84
+
85
+ ## Hub token
86
+
87
+ The hub page and its API need a token. Any local account can reach a loopback port, and the API carries the terminal token.
46
88
 
47
- ## Keyboard Shortcuts
89
+ - The token is in `~/.claude-hub/token`. The hub creates it on the first run and keeps it across restarts.
90
+ - The startup banner prints the URL with the token, `http://localhost:3540/?token=…`, and `--open` uses that URL. The hub sets a cookie and then removes the token from the address bar, so later visits and the installed app need no token.
91
+ - Without the token, the page shows a locked screen.
92
+ - To change the token, delete the file and restart the hub.
48
93
 
49
- | Shortcut | Action |
50
- | ---------------- | ----------------------- |
51
- | `Alt+1` | Switch to Kanban |
52
- | `Alt+2` | Switch to Marketplace |
53
- | `Alt+3` | Switch to Cost |
54
- | `Alt+4` | Switch to Memory Diagnoser |
55
- | `Ctrl+M` | Open Memory Diagnoser for current session (Kanban) |
56
- | `Ctrl+Alt+Right` | Switch to next tool |
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 |
94
+ ## Config dirs
60
95
 
61
- ## How It Works
96
+ The hub can run the tools against more than one Claude config dir (`CLAUDE_CONFIG_DIR`), for example a work dir and a personal dir.
62
97
 
63
- The hub server spawns both sub-apps as child processes, each on its own port. A minimal shell page embeds them in iframes and switches visibility on tab change — zero UI chrome, just keyboard shortcuts.
98
+ - Press <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>W</kbd> to open the config-dir palette. Pick a dir to switch to it, or type a path to add a new one. <kbd>Ctrl</kbd>+<kbd>D</kbd> removes the selected dir.
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
+ - The list and the active dir are saved in `~/.claude-hub/config.json`.
64
101
 
65
- ## Included Tools
102
+ ## Embedded terminal
66
103
 
67
- | Tool | Submodule | Default Port |
68
- | ---------------------------------------------------------------------- | -------------- | ------------ |
69
- | [Marketplace](https://github.com/NikiforovAll/claude-code-marketplace) | `marketplace/` | 3542 |
70
- | [Kanban](https://github.com/NikiforovAll/claude-task-viewer) | `cck/` | 3541 |
71
- | [Cost](https://github.com/NikiforovAll/claude-code-cost) | `cost/` | 3543 |
72
- | [Memory Diagnoser](https://github.com/NikiforovAll/claude-code-memory) | `memory/` | 3544 |
104
+ 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.
73
105
 
74
- ## CLI Flags
106
+ To turn the terminal off, start the hub with `--disable-terminal`, or add this to `~/.claude-hub/config.json`:
107
+
108
+ ```json
109
+ {
110
+ "terminal": { "enabled": false }
111
+ }
112
+ ```
113
+
114
+ ## Keyboard shortcuts
115
+
116
+ The hub has no visible UI of its own. Use these shortcuts:
117
+
118
+ | Shortcut | Action |
119
+ | --- | --- |
120
+ | <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>→</kbd> | Next tool |
121
+ | <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>←</kbd> | Previous tool |
122
+ | <kbd>Alt</kbd>+<kbd>1</kbd> | Kanban |
123
+ | <kbd>Alt</kbd>+<kbd>2</kbd> | Marketplace |
124
+ | <kbd>Alt</kbd>+<kbd>3</kbd> | Cost |
125
+ | <kbd>Alt</kbd>+<kbd>4</kbd> | Memory Diagnoser |
126
+ | <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>P</kbd> | Project palette: set the project for all tools |
127
+ | <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>W</kbd> | Config-dir palette: switch, add, or remove a config dir |
128
+
129
+ 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
+
131
+ 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:
132
+
133
+ | Shortcut | Action |
134
+ | --- | --- |
135
+ | <kbd>M</kbd> | Open Marketplace for the session's project |
136
+ | <kbd>$</kbd> | Open Cost for the session |
137
+ | <kbd>Ctrl</kbd>+<kbd>M</kbd> | Open Memory Diagnoser for the session's project |
138
+
139
+ ## CLI flags
75
140
 
76
141
  ```
77
142
  --port <n> Hub port (default: 3540)
78
- --marketplace-port <n> Marketplace port (default: 3542)
79
143
  --kanban-port <n> Kanban port (default: 3541)
144
+ --marketplace-port <n> Marketplace port (default: 3542)
80
145
  --cost-port <n> Cost port (default: 3543)
81
146
  --memory-port <n> Memory Diagnoser port (default: 3544)
82
- --pool-size <n> Config dirs kept running after a switch (default: 3)
83
- --open Auto-open browser
147
+ --pool-size <n> Config-dir sets kept running after a switch (default: 3)
148
+ --disable-terminal Turn off the embedded terminal in Kanban
149
+ --host <addr> Bind address (default: 127.0.0.1)
150
+ --allowed-hosts <list> Extra host names to accept, comma-separated
151
+ --open Open the browser on start
84
152
  ```
85
153
 
154
+ If a port is busy, the hub uses a free port and reports it.
155
+
156
+ ## Included tools
157
+
158
+ | Tool | Docs | Repo | Submodule | Default port |
159
+ | --- | --- | --- | --- | --- |
160
+ | Kanban | [Docs](https://nikiforovall.blog/claude-code-kanban/) | [claude-code-kanban](https://github.com/NikiforovAll/claude-code-kanban) | `cck/` | 3541 |
161
+ | Marketplace | [Docs](https://nikiforovall.blog/claude-code-marketplace/) | [claude-code-marketplace](https://github.com/NikiforovAll/claude-code-marketplace) | `marketplace/` | 3542 |
162
+ | Cost | [Docs](https://nikiforovall.blog/claude-code-cost/) | [claude-code-cost](https://github.com/NikiforovAll/claude-code-cost) | `cost/` | 3543 |
163
+ | Memory Diagnoser | [Docs](https://nikiforovall.blog/claude-code-memory/) | [claude-code-memory](https://github.com/NikiforovAll/claude-code-memory) | `memory/` | 3544 |
164
+
86
165
  ## License
87
166
 
88
167
  MIT
package/lib/net-guard.js CHANGED
@@ -181,6 +181,16 @@ function createNetGuard(config = {}) {
181
181
  return next();
182
182
  }
183
183
 
184
+ // For a WebSocket upgrade, which originGuard must not judge: the handshake is a
185
+ // GET, and WebSocket has no CORS, so any page can open one. Browsers always send
186
+ // Origin on the handshake, so a missing or `null` one is refused, not trusted.
187
+ // Returns null when allowed, or the reason it was refused.
188
+ function upgradeVerdict(req) {
189
+ if (!hostAllowed(parseHostHeader(req.headers.host))) return 'unrecognized Host header';
190
+ if (originVerdict(req.headers.origin) !== true) return 'cross-origin or missing Origin';
191
+ return null;
192
+ }
193
+
184
194
  // Constant for the life of the process, and frameGuard runs ahead of
185
195
  // express.static — so this is built once rather than per asset fetch.
186
196
  //
@@ -229,12 +239,19 @@ function createNetGuard(config = {}) {
229
239
  * @param {any} app express app (or any http request handler)
230
240
  * @param {number} port 0 selects a free port
231
241
  * @param {(port: number) => void} [onReady]
232
- * @param {{maxHeaderSize?: number}} [opts]
242
+ * `onUpgrade` is attached to both servers; a caller that attached it to the
243
+ * returned one alone would drop every WebSocket that arrives over the other family.
244
+ *
245
+ * @param {{maxHeaderSize?: number, onUpgrade?: (req: any, socket: any, head: Buffer) => void}} [opts]
233
246
  */
234
247
  function listenLoopback(app, port, onReady, opts = {}) {
235
248
  const http = require('http');
236
249
  const serverOpts = opts.maxHeaderSize ? { maxHeaderSize: opts.maxHeaderSize } : {};
237
- const makeServer = (handler) => http.createServer(serverOpts, handler);
250
+ const makeServer = (handler) => {
251
+ const s = http.createServer(serverOpts, handler);
252
+ if (opts.onUpgrade) s.on('upgrade', opts.onUpgrade);
253
+ return s;
254
+ };
238
255
  const server = makeServer(app);
239
256
  let secondary = null;
240
257
 
@@ -261,7 +278,7 @@ function createNetGuard(config = {}) {
261
278
  return `WARNING: listening on ${BIND_HOST} - reachable from your network, with no authentication.`;
262
279
  }
263
280
 
264
- return { BIND_HOST, ALLOWED_HOSTS, hostGuard, originGuard, frameGuard, listenLoopback, exposureWarning };
281
+ return { BIND_HOST, ALLOWED_HOSTS, EXPOSED, hostGuard, originGuard, upgradeVerdict, frameGuard, listenLoopback, exposureWarning };
265
282
  }
266
283
 
267
284
  module.exports = { createNetGuard, parseHostHeader, isLoopbackAddress };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-hub",
3
- "version": "0.38.0",
3
+ "version": "0.39.0",
4
4
  "description": "Unified hub for Claude Code tools — Marketplace + Kanban in one PWA",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -39,10 +39,10 @@
39
39
  },
40
40
  "homepage": "https://github.com/NikiforovAll/claude-code-hub#readme",
41
41
  "dependencies": {
42
- "claude-code-cost": "^0.23.1",
43
- "claude-code-kanban": "^4.27.0",
44
- "claude-code-marketplace": "^0.19.0",
45
- "claude-code-memory-explorer": "^0.14.1",
42
+ "claude-code-cost": "^0.24.0",
43
+ "claude-code-kanban": "^4.28.0",
44
+ "claude-code-marketplace": "^0.20.0",
45
+ "claude-code-memory-explorer": "^0.15.0",
46
46
  "express": "^4.21.0",
47
47
  "open": "^10.1.0"
48
48
  },
package/public/app.js CHANGED
@@ -2,6 +2,7 @@ let apps = {};
2
2
  let activeApp = null;
3
3
  const iframes = {};
4
4
  const loadedApps = new Set();
5
+ const guardedApps = new Set();
5
6
  let allowedOrigins = new Set();
6
7
  // {theme: 'dark'|'light', colorTheme: '<id>', colorThemes: {<configDir>: '<id>'}} — survives hub
7
8
  // reloads so late-loading iframes and fresh sessions get the last chosen theme. Light/dark is
@@ -53,18 +54,30 @@ function themeMessage() {
53
54
  return { type: 'hub:theme', theme: themeState.theme ?? osTheme(), colorTheme: activeColorTheme() };
54
55
  }
55
56
 
56
- // {<themeId>: {dark, light}} from /api/config, which derives it from scripts/themes.json — the same
57
- // registry that generates each sub-app's themes.css. Empty until config arrives, and empty if the
58
- // registry is unreadable; either way the palette keeps the --accent from index.html.
59
- let themeAccents = {};
57
+ // {<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
59
+ // config arrives, and empty if the registry is unreadable; then the hub keeps index.html's colors.
60
+ let themePalettes = {};
61
+ // The last applied variables, so the loading screen paints in the theme before /api/config lands.
62
+ const PALETTE_KEY = 'hub-palette';
60
63
 
61
- // Paints the palette — the hub's only themed surface — in the active theme. The hub relays
64
+ // Paints the hub's own surfaces (loading screen, palette) in the active theme. The hub relays
62
65
  // hub:theme to the sub-apps; this is what makes it apply to the hub's own chrome too.
63
66
  function applyHubTheme() {
64
67
  const mode = themeState.theme ?? osTheme();
65
- document.documentElement.classList.toggle('light', mode === 'light');
66
- const pair = themeAccents[activeColorTheme()];
67
- if (pair) document.documentElement.style.setProperty('--accent', pair[mode]);
68
+ const root = document.documentElement;
69
+ root.classList.toggle('light', mode === 'light');
70
+ const key = `${activeColorTheme()}/${mode}`;
71
+ let vars = themePalettes[activeColorTheme()]?.[mode];
72
+ try {
73
+ if (vars) localStorage.setItem(PALETTE_KEY, JSON.stringify({ key, vars }));
74
+ else {
75
+ const cached = JSON.parse(localStorage.getItem(PALETTE_KEY));
76
+ if (cached?.key === key) vars = cached.vars;
77
+ }
78
+ } catch {}
79
+ root.style.cssText = '';
80
+ for (const [name, value] of Object.entries(vars ?? {})) root.style.setProperty(name, value);
68
81
  }
69
82
 
70
83
  // Claude's on-disk project-directory name. Matches memory/server.js encodeProjectPath. The hub
@@ -103,6 +116,10 @@ function postTo(appId, message) {
103
116
  iframes[appId]?.contentWindow?.postMessage(message, originOf(appId));
104
117
  }
105
118
 
119
+ function appIdOf(source) {
120
+ return Object.keys(iframes).find((id) => iframes[id].contentWindow === source);
121
+ }
122
+
106
123
  // exceptWindow lets an echo of a sub-app's own message skip that sub-app.
107
124
  function broadcast(message, exceptWindow) {
108
125
  for (const [id, iframe] of Object.entries(iframes)) {
@@ -131,13 +148,14 @@ async function init() {
131
148
  // Twice: light/dark comes from localStorage and shouldn't wait on the fetch, the accent can't be
132
149
  // resolved until the registry arrives with it.
133
150
  applyHubTheme();
151
+ showLoading();
134
152
  const res = await fetch('/api/config');
135
153
  const config = await res.json();
136
154
  setApps(config.apps);
137
155
  defaultConfigDir = config.defaultConfigDir ?? null;
138
156
  activeConfigDir = config.activeConfigDir ?? null;
139
157
  applyTitle(config.activeConfigDir);
140
- themeAccents = config.themeAccents ?? {};
158
+ themePalettes = config.themePalettes ?? {};
141
159
  applyHubTheme();
142
160
  buildIframes();
143
161
  switchTab(Object.keys(apps)[0]);
@@ -166,27 +184,91 @@ function reloadIframes() {
166
184
  loadedApps.clear();
167
185
  projectState = null;
168
186
  // Assigning src navigates even when the URL is unchanged.
169
- for (const [id, iframe] of Object.entries(iframes)) iframe.src = apps[id].url;
170
- }
187
+ for (const [id, iframe] of Object.entries(iframes)) iframe.src = appSrc(id);
188
+ }
189
+
190
+ // The terminal token rides in the fragment so it never reaches a server log or a Referer.
191
+ function appSrc(id, path = '') {
192
+ const token = apps[id].terminalToken;
193
+ return apps[id].url + path + (token ? `#t=${token}` : '');
194
+ }
195
+
196
+ const LOADING_VERBS = {
197
+ kanban: [
198
+ 'Shuffling cards…',
199
+ 'Herding tasks…',
200
+ 'Waking sessions…',
201
+ 'Sorting the board…',
202
+ 'Pinning sticky notes…',
203
+ 'Moving cards to done…',
204
+ 'Nudging agents…',
205
+ 'Drawing columns…',
206
+ 'Reading the backlog…',
207
+ ],
208
+ cost: [
209
+ 'Counting tokens…',
210
+ 'Tallying the bill…',
211
+ 'Stacking bars…',
212
+ 'Crunching numbers…',
213
+ 'Checking the cache hits…',
214
+ 'Adding up the days…',
215
+ 'Balancing the books…',
216
+ 'Plotting the trend…',
217
+ 'Weighing the models…',
218
+ ],
219
+ memory: [
220
+ 'Recalling memories…',
221
+ 'Walking the tree…',
222
+ 'Dusting off notes…',
223
+ 'Reading CLAUDE.md…',
224
+ 'Following imports…',
225
+ 'Tracing the context…',
226
+ 'Flipping through pages…',
227
+ 'Connecting the dots…',
228
+ 'Checking what sticks…',
229
+ ],
230
+ marketplace: [
231
+ 'Unpacking plugins…',
232
+ 'Polishing skills…',
233
+ 'Dusting off agents…',
234
+ 'Wiring up hooks…',
235
+ 'Fetching manifests…',
236
+ 'Reading SKILL.md files…',
237
+ 'Listing commands…',
238
+ 'Scanning MCP servers…',
239
+ 'Gathering plugins…',
240
+ ],
241
+ };
171
242
 
172
- function showLoading(text = '') {
173
- document.getElementById('loading-text').textContent = text;
243
+ let loadingTimer = null;
244
+
245
+ function showLoading() {
246
+ const verbs = LOADING_VERBS[activeApp] ?? Object.values(LOADING_VERBS).flat();
247
+ const text = document.getElementById('loading-text');
248
+ let i = Math.floor(Math.random() * verbs.length);
249
+ text.textContent = verbs[i];
250
+ clearInterval(loadingTimer);
251
+ loadingTimer = setInterval(() => {
252
+ i = (i + 1 + Math.floor(Math.random() * (verbs.length - 1))) % verbs.length;
253
+ text.textContent = verbs[i];
254
+ }, 1200);
174
255
  document.getElementById('loading-overlay').classList.remove('fade-out');
175
256
  }
176
257
 
177
258
  function hideLoading() {
259
+ clearInterval(loadingTimer);
178
260
  document.getElementById('loading-text').textContent = '';
179
261
  document.getElementById('loading-overlay').classList.add('fade-out');
180
262
  }
181
263
 
182
264
  function buildIframes() {
183
265
  const container = document.getElementById('iframe-container');
184
- for (const [id, cfg] of Object.entries(apps)) {
266
+ for (const id of Object.keys(apps)) {
185
267
  const iframe = document.createElement('iframe');
186
268
  iframe.id = `iframe-${id}`;
187
- iframe.src = cfg.url;
269
+ iframe.src = appSrc(id);
188
270
  iframe.className = 'hidden';
189
- iframe.allow = 'clipboard-write';
271
+ iframe.allow = 'clipboard-write; microphone';
190
272
  iframe.addEventListener('load', () => onIframeLoad(id));
191
273
  container.appendChild(iframe);
192
274
  iframes[id] = iframe;
@@ -210,6 +292,7 @@ function switchTab(appId) {
210
292
 
211
293
  function onIframeLoad(appId) {
212
294
  loadedApps.add(appId);
295
+ guardedApps.delete(appId);
213
296
  if (appId === activeApp) hideLoading();
214
297
  postTo(appId, themeMessage());
215
298
  postProjectTo(appId);
@@ -225,15 +308,38 @@ function onIframeLoad(appId) {
225
308
  }
226
309
 
227
310
  function listenMessages() {
311
+ window.addEventListener('beforeunload', (e) => {
312
+ if (guardedApps.size) e.preventDefault();
313
+ });
228
314
  window.addEventListener('message', (e) => {
229
315
  if (!allowedOrigins.has(e.origin)) return;
230
316
  const data = e.data ?? {};
231
317
  if (data.type === 'hub:navigate') {
232
318
  if (!apps[data.app]) return;
233
319
  switchTab(data.app);
234
- if (data.url) iframes[data.app].src = apps[data.app].url + data.url;
320
+ if (typeof data.url !== 'string' || !data.url) return;
321
+ // A url such as '@evil.example/' turns the app host into userinfo, and the token rides in the fragment.
322
+ const src = appSrc(data.app, data.url);
323
+ if (URL.canParse(src) && new URL(src).origin === originOf(data.app)) iframes[data.app].src = src;
235
324
  } else if (data.type === 'hub:keydown') {
236
325
  handleForwardedKey(data);
326
+ } else if (data.type === 'hub:closeGuard') {
327
+ const appId = appIdOf(e.source);
328
+ if (!appId) return;
329
+ if (data.on === true) guardedApps.add(appId);
330
+ else guardedApps.delete(appId);
331
+ } else if (data.type === 'hub:terminalToken') {
332
+ // 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
+ sendJson('GET', '/api/config')
336
+ .then((cfg) => {
337
+ const token = cfg.apps?.[appId]?.terminalToken;
338
+ if (!token) return;
339
+ apps[appId].terminalToken = token;
340
+ postTo(appId, { type: 'hub:terminalToken', token });
341
+ })
342
+ .catch(() => {});
237
343
  } else if (data.type === 'hub:openExternal') {
238
344
  // In the installed PWA window a framed sub-app's own target=_blank opens
239
345
  // nothing, so the shims hand external links up to the top frame instead.
@@ -372,28 +478,37 @@ function subseq(text, q) {
372
478
  }
373
479
 
374
480
  // Matches the full path so worktrees with opaque basenames stay reachable, but ranks basename
375
- // hits above parent-path hits. Tier ties fall back to the recency order set in loadProjects.
481
+ // hits above parent-path hits, and within those an exact name, then a prefix, then a word start.
482
+ // Rank ties fall back to the recency order set in loadProjects.
376
483
  // Returns {p, tier} so the renderer can mark the field that actually explains the match instead of
377
484
  // re-deriving it.
485
+ const PATH_RANK = 4;
486
+
378
487
  function projectRows(projects, q) {
379
488
  if (!q) return projects.slice(0, 100).map((p) => ({ p, tier: 0 }));
380
- const tierOf = (p) => {
381
- if (p.name.toLowerCase().includes(q)) return 0;
382
- if (p.path.toLowerCase().includes(q)) return 1;
489
+ const rankOf = (p) => {
490
+ const name = p.name.toLowerCase();
491
+ const at = name.indexOf(q);
492
+ if (name === q) return 0;
493
+ if (at === 0) return 1;
494
+ if (at > 0) return /[^a-z0-9]/.test(name[at - 1]) ? 2 : 3;
495
+ const path = p.path.toLowerCase();
496
+ if (path.split(/[/\\]/).includes(q)) return PATH_RANK;
497
+ if (path.includes(q)) return PATH_RANK + 1;
383
498
  return -1;
384
499
  };
385
- let out = projects.map((p) => ({ p, tier: tierOf(p) })).filter((x) => x.tier >= 0);
500
+ let out = projects.map((p) => ({ p, rank: rankOf(p) })).filter((x) => x.rank >= 0);
386
501
  if (out.length === 0) {
387
502
  // Subsequence only as a fallback — it would otherwise swamp real substring matches.
388
503
  out = projects
389
504
  .map((p) => {
390
- const tier = subseq(p.name.toLowerCase(), q) ? 0 : subseq(p.path.toLowerCase(), q) ? 1 : -1;
391
- return { p, tier };
505
+ const rank = subseq(p.name.toLowerCase(), q) ? 0 : subseq(p.path.toLowerCase(), q) ? PATH_RANK : -1;
506
+ return { p, rank };
392
507
  })
393
- .filter((x) => x.tier >= 0);
508
+ .filter((x) => x.rank >= 0);
394
509
  }
395
- out.sort((a, b) => a.tier - b.tier || b.p.ts - a.p.ts);
396
- return out.slice(0, 100);
510
+ out.sort((a, b) => a.rank - b.rank || b.p.ts - a.p.ts);
511
+ return out.slice(0, 100).map(({ p, rank }) => ({ p, tier: rank < PATH_RANK ? 0 : 1 }));
397
512
  }
398
513
 
399
514
  // The [start, end) slices of `lower` that q matched, mirroring the tiers in projectRows: one
@@ -673,7 +788,7 @@ async function commitConfigDir(row) {
673
788
  closePalette();
674
789
  // The server would no-op too, but the client would still reload every iframe.
675
790
  if (target === palette.configDirs.active) return;
676
- showLoading(`Switching to ${target}...`);
791
+ showLoading();
677
792
  try {
678
793
  setApps((await sendJson('POST', '/api/config-dirs/activate', { path: target })).apps);
679
794
  // The palette renders from this cache before its refetch lands, and initialSel keys off it.
package/public/index.html CHANGED
@@ -25,6 +25,7 @@
25
25
  --text: #e8e8ef;
26
26
  --text-dim: #8a8fa3;
27
27
  --shadow: rgba(0, 0, 0, 0.55);
28
+ --ghost-mix: 22%;
28
29
  }
29
30
 
30
31
  /* applyHubTheme() owns light/dark and stamps this class, resolving the pushed hub:theme first
@@ -39,6 +40,7 @@
39
40
  --text: #18181b;
40
41
  --text-dim: #71717a;
41
42
  --shadow: rgba(0, 0, 0, 0.18);
43
+ --ghost-mix: 10%;
42
44
  }
43
45
 
44
46
  * { margin: 0; padding: 0; box-sizing: border-box; }
@@ -78,7 +80,9 @@
78
80
  gap: 14px;
79
81
  align-items: center;
80
82
  justify-content: center;
81
- background: var(--bg);
83
+ background:
84
+ radial-gradient(circle at 50% 50%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 45%),
85
+ var(--bg);
82
86
  z-index: 10;
83
87
  transition: opacity 0.3s ease;
84
88
  }
@@ -88,16 +92,85 @@
88
92
  pointer-events: none;
89
93
  }
90
94
 
91
- .spinner {
92
- width: 28px;
93
- height: 28px;
94
- border: 3px solid var(--border);
95
- border-top-color: var(--accent);
96
- border-radius: 50%;
97
- animation: spin 0.8s linear infinite;
95
+ /* Clawd, drawn from the Claude Code banner's block art; cck's boot splash uses the same one. */
96
+ .clawd {
97
+ width: 64px;
98
+ height: 40px;
99
+ fill: #d97757;
100
+ overflow: visible;
101
+ transform-origin: 50% 100%;
102
+ animation: clawd-jump 0.9s ease-in-out infinite;
103
+ }
104
+
105
+ @keyframes clawd-jump {
106
+ 0%, 100% { transform: translateY(0) scale(1, 1); }
107
+ 15% { transform: translateY(0) scale(1.1, 0.85); }
108
+ 45% { transform: translateY(-14px) scale(0.95, 1.05); }
109
+ 70% { transform: translateY(0) scale(1.08, 0.9); }
110
+ 85% { transform: translateY(0) scale(1, 1); }
98
111
  }
99
112
 
100
- @keyframes spin { to { transform: rotate(360deg); } }
113
+ @media (prefers-reduced-motion: reduce) {
114
+ .clawd { animation: none; }
115
+ }
116
+
117
+ /* Faint outlines of the four apps, one per quadrant, so the wait reads as "the pages are
118
+ coming" rather than a blank screen. */
119
+ .ghosts {
120
+ --ghost: color-mix(in srgb, var(--accent) 60%, var(--text-dim));
121
+ position: absolute;
122
+ inset: 0;
123
+ display: grid;
124
+ grid-template: 1fr 1fr / 1fr 1fr;
125
+ gap: 16px;
126
+ padding: 16px;
127
+ pointer-events: none;
128
+ -webkit-mask-image: radial-gradient(circle at 50% 50%, transparent 0 80px, #000 240px);
129
+ mask-image: radial-gradient(circle at 50% 50%, transparent 0 80px, #000 240px);
130
+ }
131
+
132
+ .ghost {
133
+ display: grid;
134
+ gap: 12px;
135
+ padding: 14px;
136
+ min-height: 0;
137
+ overflow: hidden;
138
+ border: 1px solid color-mix(in srgb, var(--ghost) calc(var(--ghost-mix) * 0.8), transparent);
139
+ border-radius: 10px;
140
+ }
141
+
142
+ .ghost i {
143
+ display: block;
144
+ min-height: 12px;
145
+ border-radius: 6px;
146
+ background: color-mix(in srgb, var(--ghost) var(--ghost-mix), transparent);
147
+ }
148
+
149
+ .ghost .h { height: 18px; width: 45%; }
150
+ .ghost .stack { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
151
+ .ghost .row { display: flex; gap: 16px; }
152
+ .ghost .row > i { flex: 1; }
153
+
154
+ .ghost-kanban { grid-template-columns: 22% repeat(3, 1fr); }
155
+ .ghost-kanban .side i { height: 24px; }
156
+ .ghost-kanban .col i:not(.h) { height: 60px; }
157
+
158
+ .ghost-cost { grid-template-rows: 56px 1fr auto; }
159
+ .ghost-cost .row > i { height: 56px; }
160
+ .ghost-cost .bars { display: flex; align-items: flex-end; gap: 8px; min-height: 0; }
161
+ .ghost-cost .bars i { flex: 1; }
162
+ .ghost-cost .stack i:not(.h) { height: 16px; }
163
+
164
+ .ghost-memory { grid-template-columns: 28% 1fr; }
165
+ .ghost-memory .side i { height: 18px; }
166
+ .ghost-memory .side .in { margin-left: 14px; }
167
+ .ghost-memory .side .in2 { margin-left: 28px; }
168
+ .ghost-memory .doc i:not(.h) { height: 12px; }
169
+
170
+ .ghost-marketplace { grid-template-rows: 32px 1fr; }
171
+ .ghost-marketplace .cards { display: grid; grid-template-columns: repeat(3, 1fr); grid-auto-rows: 1fr; gap: 12px; min-height: 0; }
172
+
173
+ .loading-overlay > :not(.ghosts) { position: relative; }
101
174
 
102
175
  .loading-text {
103
176
  color: var(--text-dim);
@@ -233,7 +306,48 @@
233
306
  <body>
234
307
  <div class="iframe-container" id="iframe-container">
235
308
  <div class="loading-overlay" id="loading-overlay">
236
- <div class="spinner"></div>
309
+ <div class="ghosts" aria-hidden="true">
310
+ <div class="ghost ghost-kanban">
311
+ <div class="stack side"><i class="h"></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
312
+ <div class="stack col"><i class="h"></i><i></i><i></i><i></i></div>
313
+ <div class="stack col"><i class="h"></i><i></i><i></i></div>
314
+ <div class="stack col"><i class="h"></i><i></i><i></i><i></i><i></i></div>
315
+ </div>
316
+ <div class="ghost ghost-cost">
317
+ <div class="row"><i></i><i></i><i></i><i></i></div>
318
+ <div class="bars">
319
+ <i style="height:35%"></i><i style="height:60%"></i><i style="height:45%"></i><i style="height:80%"></i>
320
+ <i style="height:55%"></i><i style="height:30%"></i><i style="height:70%"></i><i style="height:50%"></i>
321
+ <i style="height:65%"></i><i style="height:40%"></i><i style="height:75%"></i><i style="height:58%"></i>
322
+ </div>
323
+ <div class="stack"><i class="h"></i><i></i><i></i><i></i></div>
324
+ </div>
325
+ <div class="ghost ghost-memory">
326
+ <div class="stack side">
327
+ <i class="h"></i><i></i><i class="in"></i><i class="in2"></i><i class="in2"></i><i class="in"></i>
328
+ <i></i><i class="in"></i><i class="in2"></i><i class="in"></i><i></i>
329
+ </div>
330
+ <div class="stack doc">
331
+ <i class="h"></i><i></i><i></i><i style="width:70%"></i><i></i><i style="width:85%"></i><i style="width:40%"></i>
332
+ </div>
333
+ </div>
334
+ <div class="ghost ghost-marketplace">
335
+ <i></i>
336
+ <div class="cards"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
337
+ </div>
338
+ </div>
339
+ <svg class="clawd" viewBox="1 0 16 10" shape-rendering="crispEdges" aria-hidden="true">
340
+ <rect x="3" y="0" width="12" height="2"/>
341
+ <rect x="3" y="2" width="2" height="2"/>
342
+ <rect x="6" y="2" width="6" height="2"/>
343
+ <rect x="13" y="2" width="2" height="2"/>
344
+ <rect x="1" y="4" width="16" height="2"/>
345
+ <rect x="3" y="6" width="12" height="2"/>
346
+ <rect x="4" y="8" width="1" height="2"/>
347
+ <rect x="6" y="8" width="1" height="2"/>
348
+ <rect x="11" y="8" width="1" height="2"/>
349
+ <rect x="13" y="8" width="1" height="2"/>
350
+ </svg>
237
351
  <div class="loading-text" id="loading-text"></div>
238
352
  </div>
239
353
  </div>
@@ -0,0 +1,169 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <link rel="manifest" href="/manifest.json">
7
+ <link rel="icon" type="image/svg+xml" href="/icons/icon-192.svg">
8
+ <link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png">
9
+ <title>Claude Code Hub</title>
10
+ <style>
11
+ :root {
12
+ --bg: #1a1a2e;
13
+ --border: #0f3460;
14
+ --accent: #e86f33;
15
+ --surface: #16213e;
16
+ --surface-hover: #1f2a4a;
17
+ --text: #e8e8ef;
18
+ --text-dim: #8a8fa3;
19
+ --shadow: rgba(0, 0, 0, 0.55);
20
+ }
21
+
22
+ :root.light {
23
+ --bg: #f4f4f5;
24
+ --border: #e4e4e7;
25
+ --surface: #ffffff;
26
+ --surface-hover: #f1f1f4;
27
+ --text: #18181b;
28
+ --text-dim: #71717a;
29
+ --shadow: rgba(0, 0, 0, 0.18);
30
+ }
31
+
32
+ * { margin: 0; padding: 0; box-sizing: border-box; }
33
+
34
+ body {
35
+ min-height: 100vh;
36
+ display: grid;
37
+ place-items: center;
38
+ padding: 16px;
39
+ background:
40
+ radial-gradient(circle at 50% 42%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 45%),
41
+ var(--bg);
42
+ color: var(--text);
43
+ font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
44
+ }
45
+
46
+ main {
47
+ width: min(440px, 100%);
48
+ display: grid;
49
+ justify-items: center;
50
+ gap: 20px;
51
+ }
52
+
53
+ .clawd {
54
+ width: 64px;
55
+ height: 40px;
56
+ fill: #d97757;
57
+ }
58
+
59
+ .card {
60
+ width: 100%;
61
+ display: grid;
62
+ gap: 14px;
63
+ padding: 22px;
64
+ background: var(--surface);
65
+ border: 1px solid var(--border);
66
+ border-radius: 12px;
67
+ box-shadow: 0 16px 48px var(--shadow);
68
+ }
69
+
70
+ h1 { font-size: 16px; font-weight: 600; }
71
+
72
+ p { color: var(--text-dim); font-size: 13px; }
73
+
74
+ code {
75
+ padding: 1px 5px;
76
+ border-radius: 4px;
77
+ background: var(--surface-hover);
78
+ color: var(--text);
79
+ font: inherit;
80
+ }
81
+
82
+ form { display: flex; gap: 8px; }
83
+
84
+ input {
85
+ flex: 1;
86
+ min-width: 0;
87
+ padding: 9px 12px;
88
+ border: 1px solid var(--border);
89
+ border-radius: 8px;
90
+ background: var(--bg);
91
+ color: var(--text);
92
+ font: inherit;
93
+ }
94
+
95
+ button {
96
+ padding: 9px 16px;
97
+ border: none;
98
+ border-radius: 8px;
99
+ background: var(--accent);
100
+ color: #fff;
101
+ font: inherit;
102
+ font-weight: 600;
103
+ cursor: pointer;
104
+ }
105
+
106
+ button:hover { filter: brightness(1.08); }
107
+
108
+ button:focus-visible, input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
109
+
110
+ .error { color: #e5484d; }
111
+
112
+ .error:empty { display: none; }
113
+ </style>
114
+ <script>
115
+ (() => {
116
+ let mode;
117
+ let cached;
118
+ try {
119
+ mode = JSON.parse(localStorage.getItem('hub-theme'))?.theme;
120
+ cached = JSON.parse(localStorage.getItem('hub-palette'));
121
+ } catch {}
122
+ mode ??= matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
123
+ const root = document.documentElement;
124
+ root.classList.toggle('light', mode === 'light');
125
+ if (cached?.key?.endsWith(`/${mode}`)) {
126
+ for (const [name, value] of Object.entries(cached.vars ?? {})) root.style.setProperty(name, value);
127
+ }
128
+ })();
129
+ </script>
130
+ </head>
131
+ <body>
132
+ <main>
133
+ <svg class="clawd" viewBox="1 0 16 10" shape-rendering="crispEdges" aria-hidden="true">
134
+ <rect x="3" y="0" width="12" height="2"/>
135
+ <rect x="3" y="2" width="2" height="2"/>
136
+ <rect x="6" y="2" width="6" height="2"/>
137
+ <rect x="13" y="2" width="2" height="2"/>
138
+ <rect x="1" y="4" width="16" height="2"/>
139
+ <rect x="3" y="6" width="12" height="2"/>
140
+ <rect x="4" y="8" width="1" height="2"/>
141
+ <rect x="6" y="8" width="1" height="2"/>
142
+ <rect x="11" y="8" width="1" height="2"/>
143
+ <rect x="13" y="8" width="1" height="2"/>
144
+ </svg>
145
+ <div class="card">
146
+ <h1>Unlock Claude Code Hub</h1>
147
+ <p>Open the link the hub prints when it starts, or paste the token from <code>~/.claude-hub/token</code>. This browser remembers it.</p>
148
+ <form id="unlock">
149
+ <input id="token" type="password" autocomplete="off" spellcheck="false" placeholder="Access token" aria-label="Access token" required autofocus>
150
+ <button type="submit">Unlock</button>
151
+ </form>
152
+ <p class="error" id="error" role="alert"></p>
153
+ </div>
154
+ </main>
155
+ <script>
156
+ const params = new URLSearchParams(location.search);
157
+ if (params.has('token')) {
158
+ document.getElementById('error').textContent = 'That token does not match. Check it and try again.';
159
+ params.delete('token');
160
+ history.replaceState(null, '', location.pathname + (params.size ? `?${params}` : ''));
161
+ }
162
+ document.getElementById('unlock').addEventListener('submit', (e) => {
163
+ e.preventDefault();
164
+ params.set('token', document.getElementById('token').value.trim());
165
+ location.replace(`${location.pathname}?${params}`);
166
+ });
167
+ </script>
168
+ </body>
169
+ </html>
package/public/sw.js CHANGED
@@ -1,4 +1,4 @@
1
- const CACHE_NAME = 'hub-shell-v1';
1
+ const CACHE_NAME = 'hub-shell-v2';
2
2
  const SHELL_ASSETS = ['/', '/index.html', '/app.js', '/manifest.json'];
3
3
 
4
4
  self.addEventListener('install', (e) => {
@@ -23,6 +23,16 @@ self.addEventListener('fetch', (e) => {
23
23
  return;
24
24
  }
25
25
  e.respondWith(
26
- fetch(e.request).catch(() => caches.match(e.request))
26
+ fetch(e.request)
27
+ .then((res) => {
28
+ // Refresh the copy, or a load while the hub restarts serves the shell from install time.
29
+ if (res.ok && e.request.method === 'GET' && SHELL_ASSETS.includes(new URL(e.request.url).pathname)) {
30
+ const copy = res.clone();
31
+ e.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.put(e.request, copy)));
32
+ }
33
+ return res;
34
+ })
35
+ // respondWith(undefined) throws, so a miss while the server is down must still be a Response.
36
+ .catch(() => caches.match(e.request).then((cached) => cached || Response.error()))
27
37
  );
28
38
  });
package/server.js CHANGED
@@ -1,9 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  const { spawn } = require('child_process');
4
+ const crypto = require('crypto');
4
5
  const express = require('express');
5
6
  const fs = require('fs');
6
7
  const http = require('http');
8
+ const tcp = require('net');
7
9
  const os = require('os');
8
10
  const path = require('path');
9
11
  const { createNetGuard } = require('./lib/net-guard');
@@ -42,7 +44,8 @@ function activePool() {
42
44
  // Every sub-app resolves CLAUDE_CONFIG_DIR once at startup into a module constant, so switching
43
45
  // the dir means restarting the children. Kept on the server, not in the browser: the dir has to
44
46
  // be known before the first spawn, and the hub already has one localStorage key to reason about.
45
- const HUB_CONFIG_FILE = path.join(os.homedir(), '.claude-hub', 'config.json');
47
+ const HUB_DIR = path.join(os.homedir(), '.claude-hub');
48
+ const HUB_CONFIG_FILE = path.join(HUB_DIR, 'config.json');
46
49
 
47
50
  // The dir the hub would use with no saved config. Set by loadHubConfig; the client uses it to keep
48
51
  // the default dir out of the window title.
@@ -82,6 +85,8 @@ function loadHubConfig() {
82
85
  const savedActive = typeof saved.activeConfigDir === 'string' ? canonical(saved.activeConfigDir) : null;
83
86
  const active = dirs.includes(savedActive) ? savedActive : fallback;
84
87
  const config = { configDirs: dirs, activeConfigDir: active };
88
+ // Hand-edited only; kept verbatim so a rewrite of the file does not drop it.
89
+ if (saved.terminal && typeof saved.terminal === 'object') config.terminal = saved.terminal;
85
90
  // Configs saved before canonicalization can hold one dir under two spellings; persist the merge.
86
91
  if (serializeConfig(config) !== raw) writeHubConfig(config);
87
92
  return config;
@@ -107,18 +112,83 @@ const hubConfig = loadHubConfig();
107
112
  // while every iframe 403'd on its own Host check.
108
113
  const net = createNetGuard({ appName: 'Claude Code Hub' });
109
114
 
115
+ // cck's embedded terminal, on by default under the hub (standalone cck keeps it opt-in).
116
+ // `"terminal": {"enabled": false}` or --disable-terminal turns it off. One token per hub launch,
117
+ // shared by every pool: the hub puts it in the kanban iframe's URL fragment and cck checks it on
118
+ // the WebSocket. cck applies the rest of its policy (exposure refusal, Origin, session cap) itself.
119
+ const TERMINAL = {
120
+ ...(hubConfig.terminal || {}),
121
+ enabled: !process.argv.includes('--disable-terminal') && hubConfig.terminal?.enabled !== false,
122
+ };
123
+ const TERMINAL_TOKEN = TERMINAL.enabled ? crypto.randomBytes(32).toString('hex') : null;
124
+
125
+ // Loopback is not a user boundary: any local account can reach the hub port, and /api/config carries
126
+ // the terminal token. Persisted, unlike the terminal token, so an installed PWA's cookie outlives a
127
+ // hub restart.
128
+ const HUB_TOKEN_FILE = path.join(HUB_DIR, 'token');
129
+ const TOKEN_COOKIE = 'hub_token';
130
+ const TOKEN_COOKIE_RE = /(?:^|;\s*)hub_token=([^;]*)/;
131
+ const TOKEN_COOKIE_MAX_AGE_MS = 400 * 24 * 60 * 60 * 1000;
132
+ const LOCKED_PAGE = path.join(__dirname, 'public', 'locked.html');
133
+
134
+ function loadHubToken() {
135
+ try {
136
+ const saved = fs.readFileSync(HUB_TOKEN_FILE, 'utf8').trim();
137
+ if (/^[0-9a-f]{64}$/.test(saved)) return saved;
138
+ } catch {}
139
+ const token = crypto.randomBytes(32).toString('hex');
140
+ fs.mkdirSync(HUB_DIR, { recursive: true, mode: 0o700 });
141
+ fs.writeFileSync(HUB_TOKEN_FILE, `${token}\n`, { mode: 0o600 });
142
+ return token;
143
+ }
144
+
145
+ const HUB_TOKEN = loadHubToken();
146
+ const HUB_TOKEN_BUF = Buffer.from(HUB_TOKEN);
147
+
148
+ function tokenMatches(candidate) {
149
+ if (typeof candidate !== 'string' || candidate.length !== HUB_TOKEN.length) return false;
150
+ return crypto.timingSafeEqual(Buffer.from(candidate), HUB_TOKEN_BUF);
151
+ }
152
+
153
+ // Scripts, styles, icons and the manifest stay public: they hold no secrets, and Chrome fetches the
154
+ // manifest without cookies, so gating it would break PWA install.
155
+ function tokenGuard(req, res, next) {
156
+ const isApi = req.path.startsWith('/api/');
157
+ if (!isApi && req.path !== '/' && req.path !== '/index.html') return next();
158
+ if (tokenMatches(req.query.token)) {
159
+ res.cookie(TOKEN_COOKIE, HUB_TOKEN, { httpOnly: true, sameSite: 'strict', maxAge: TOKEN_COOKIE_MAX_AGE_MS });
160
+ if (isApi) return next();
161
+ const url = new URL(req.originalUrl, 'http://hub');
162
+ url.searchParams.delete('token');
163
+ return res.redirect(302, url.pathname + url.search);
164
+ }
165
+ if (tokenMatches(TOKEN_COOKIE_RE.exec(req.headers.cookie || '')?.[1])) return next();
166
+ res.setHeader('Cache-Control', 'no-store');
167
+ if (isApi) return res.status(401).json({ error: 'hub token required' });
168
+ res.status(401).sendFile(LOCKED_PAGE, { cacheControl: false });
169
+ }
170
+
171
+ function childEnv(pool, name) {
172
+ const env = {
173
+ ...process.env,
174
+ PORT: '0',
175
+ CLAUDE_CONFIG_DIR: pool.dir,
176
+ CLAUDE_HUB: '1',
177
+ HUB_URL: `http://localhost:${HUB_PORT}`,
178
+ HOST: net.BIND_HOST,
179
+ ALLOWED_HOSTS: net.ALLOWED_HOSTS,
180
+ };
181
+ if (name === 'kanban' && TERMINAL.enabled) {
182
+ env.CCK_TERMINAL = JSON.stringify(TERMINAL);
183
+ env.CCK_TERMINAL_TOKEN = TERMINAL_TOKEN;
184
+ }
185
+ return env;
186
+ }
187
+
110
188
  function spawnApp(pool, name, cmd, args) {
111
189
  const child = spawn(cmd, args, {
112
190
  cwd: __dirname,
113
- env: {
114
- ...process.env,
115
- PORT: '0',
116
- CLAUDE_CONFIG_DIR: pool.dir,
117
- CLAUDE_HUB: '1',
118
- HUB_URL: `http://localhost:${HUB_PORT}`,
119
- HOST: net.BIND_HOST,
120
- ALLOWED_HOSTS: net.ALLOWED_HOSTS,
121
- },
191
+ env: childEnv(pool, name),
122
192
  stdio: ['ignore', 'pipe', 'pipe'],
123
193
  });
124
194
 
@@ -204,11 +274,9 @@ async function ensurePool(dir) {
204
274
  pools.set(dir, pool);
205
275
  return pool.ready;
206
276
  }
207
- while (pools.size >= POOL_SIZE) {
208
- const oldestDir = pools.keys().next().value;
209
- console.log(`Evicting children for ${oldestDir}`);
210
- dropPool(oldestDir);
211
- }
277
+ if (pools.size >= POOL_SIZE) await evictPools(dir);
278
+ pool = pools.get(dir);
279
+ if (pool) return pool.ready;
212
280
  pool = { dir, children: new Map(), ports: {}, restarts: {}, retired: false };
213
281
  pools.set(dir, pool);
214
282
  spawnChildren(pool);
@@ -217,6 +285,37 @@ async function ensurePool(dir) {
217
285
  return pool.ready;
218
286
  }
219
287
 
288
+ async function terminalCount(pool) {
289
+ const port = pool.ports.kanban;
290
+ if (!port) return 0;
291
+ try {
292
+ const r = await fetch(`http://127.0.0.1:${port}/api/terminals`, { signal: AbortSignal.timeout(1500) });
293
+ return r.ok ? (await r.json()).sessions?.length || 0 : 0;
294
+ } catch {
295
+ return 0;
296
+ }
297
+ }
298
+
299
+ // A pool whose cck holds live terminals is pinned: killing it would kill running claude sessions.
300
+ // When every candidate is pinned the pool size is exceeded rather than losing work.
301
+ async function evictPools(keepDir) {
302
+ let pinned = new Set();
303
+ if (TERMINAL.enabled) {
304
+ const candidates = [...pools.values()].filter((p) => p.dir !== keepDir);
305
+ const counts = await Promise.all(candidates.map(terminalCount));
306
+ pinned = new Set(candidates.filter((_, i) => counts[i]).map((p) => p.dir));
307
+ }
308
+ while (pools.size >= POOL_SIZE) {
309
+ const victim = [...pools.keys()].find((d) => d !== keepDir && !pinned.has(d));
310
+ if (!victim) {
311
+ console.log(`Pool size ${POOL_SIZE} exceeded: every other pool has live terminals`);
312
+ return;
313
+ }
314
+ console.log(`Evicting children for ${victim}`);
315
+ dropPool(victim);
316
+ }
317
+ }
318
+
220
319
  function dropPool(dir) {
221
320
  const pool = pools.get(dir);
222
321
  if (!pool) return;
@@ -454,12 +553,44 @@ function proxyHandler(name) {
454
553
  };
455
554
  }
456
555
 
457
- function listenWithFallback(handler, port, onReady, label) {
458
- const server = net.listenLoopback(handler, port, onReady, { maxHeaderSize: HDR_BYTES });
556
+ // WebSocket upgrades (cck's terminal) are tunnelled as raw bytes for every app. Host and Origin get
557
+ // the same treatment as proxyHandler; the child runs its own upgrade checks, and a child with no
558
+ // upgrade listener closes the socket.
559
+ function proxyUpgrade(name) {
560
+ return async (req, socket, head) => {
561
+ socket.on('error', () => socket.destroy());
562
+ const childPort = await waitForPort(name, Date.now() + API_PORT_WAIT_MS);
563
+ if (!childPort || socket.destroyed) {
564
+ socket.destroy();
565
+ return;
566
+ }
567
+ const lines = [`${req.method} ${req.url} HTTP/${req.httpVersion}`];
568
+ for (let i = 0; i < req.rawHeaders.length; i += 2) {
569
+ let value = req.rawHeaders[i + 1];
570
+ if (req.rawHeaders[i].toLowerCase() === 'origin') value = rewriteOrigin(value, publicPorts[name], childPort);
571
+ lines.push(`${req.rawHeaders[i]}: ${value}`);
572
+ }
573
+ const upstream = tcp.connect(childPort, '127.0.0.1', () => {
574
+ clearTimeout(connectTimer);
575
+ upstream.write(`${lines.join('\r\n')}\r\n\r\n`);
576
+ if (head?.length) upstream.write(head);
577
+ upstream.pipe(socket);
578
+ socket.pipe(upstream);
579
+ });
580
+ const connectTimer = setTimeout(() => upstream.destroy(), CONNECT_TIMEOUT_MS);
581
+ upstream.on('error', () => socket.destroy());
582
+ socket.on('close', () => upstream.destroy());
583
+ upstream.on('close', () => socket.destroy());
584
+ };
585
+ }
586
+
587
+ function listenWithFallback(handler, port, onReady, label, onUpgrade) {
588
+ const opts = { maxHeaderSize: HDR_BYTES, onUpgrade };
589
+ const server = net.listenLoopback(handler, port, onReady, opts);
459
590
  server.on('error', (err) => {
460
591
  if (err.code !== 'EADDRINUSE' && err.code !== 'EACCES') throw err;
461
592
  console.log(`${label}port ${port} in use, trying random port...`);
462
- net.listenLoopback(handler, 0, onReady, { maxHeaderSize: HDR_BYTES });
593
+ net.listenLoopback(handler, 0, onReady, opts);
463
594
  });
464
595
  return server;
465
596
  }
@@ -472,28 +603,40 @@ for (const [name, port] of Object.entries(DEFAULT_PORTS)) {
472
603
  publicPorts[name] = actual;
473
604
  },
474
605
  `[${name}] `,
606
+ proxyUpgrade(name),
475
607
  );
476
608
  }
477
609
 
478
610
  ensurePool(hubConfig.activeConfigDir);
479
611
 
480
612
  const app = express();
481
- app.use(express.json());
482
613
 
483
614
  // Mounted before the routes below, which are registered ahead of the first
484
615
  // app.use() and would otherwise bypass the guards entirely.
485
616
  app.use(net.hostGuard);
486
617
  app.use(net.frameGuard);
487
618
  app.use(net.originGuard);
619
+ app.use(tokenGuard);
620
+ app.use(express.json());
488
621
 
489
- // The accent hex per color theme, read from the same registry generate-themes.mjs compiles into each
490
- // sub-app's themes.css. Served rather than hand-copied into public/app.js so there is one source of
491
- // truth; the palette is the hub's only themed surface, so only ember (the accent role) is needed.
622
+ // The hub's CSS variables per color theme and mode, read from the same registry generate-themes.mjs
623
+ // compiles into each sub-app's themes.css. Served rather than hand-copied into public/app.js so there
624
+ // is one source of truth. --bg is the sub-apps' --bg-deep, so the loading screen hands off to an
625
+ // iframe without a flash of another color.
492
626
  // Read once — themes.json only changes when the generator is re-run, which restarts the hub anyway.
493
- const themeAccents = (() => {
627
+ const hubVars = (p) => ({
628
+ '--accent': p.ember,
629
+ '--bg': p.field,
630
+ '--surface': p.surface,
631
+ '--surface-hover': p.hover,
632
+ '--border': p.border,
633
+ '--text': p.ink1,
634
+ '--text-dim': p.inkMuted,
635
+ });
636
+ const themePalettes = (() => {
494
637
  try {
495
638
  const themes = JSON.parse(fs.readFileSync(path.join(__dirname, 'scripts/themes.json'), 'utf8'));
496
- return Object.fromEntries(themes.map((t) => [t.id, { dark: t.dark.ember, light: t.light.ember }]));
639
+ return Object.fromEntries(themes.map((t) => [t.id, { dark: hubVars(t.dark), light: hubVars(t.light) }]));
497
640
  } catch {
498
641
  // Palette falls back to the --accent in index.html; not worth failing startup over.
499
642
  return {};
@@ -501,8 +644,10 @@ const themeAccents = (() => {
501
644
  })();
502
645
 
503
646
  function appsConfig() {
647
+ const kanban = { name: 'Kanban', url: `http://localhost:${publicPorts.kanban}`, icon: 'columns' };
648
+ if (TERMINAL_TOKEN) kanban.terminalToken = TERMINAL_TOKEN;
504
649
  return {
505
- kanban: { name: 'Kanban', url: `http://localhost:${publicPorts.kanban}`, icon: 'columns' },
650
+ kanban,
506
651
  marketplace: { name: 'Marketplace', url: `http://localhost:${publicPorts.marketplace}`, icon: 'store' },
507
652
  cost: { name: 'Cost', url: `http://localhost:${publicPorts.cost}`, icon: 'dollar-sign' },
508
653
  memory: { name: 'Memory Diagnoser', url: `http://localhost:${publicPorts.memory}`, icon: 'database' },
@@ -510,7 +655,7 @@ function appsConfig() {
510
655
  }
511
656
 
512
657
  app.get('/api/config', (_req, res) => {
513
- res.json({ themeAccents, apps: appsConfig(), activeConfigDir: hubConfig.activeConfigDir, defaultConfigDir });
658
+ res.json({ themePalettes, apps: appsConfig(), activeConfigDir: hubConfig.activeConfigDir, defaultConfigDir });
514
659
  });
515
660
 
516
661
  app.get('/api/config-dirs', (_req, res) => {
@@ -615,14 +760,18 @@ app.use(express.static(path.join(__dirname, 'public')));
615
760
  const onReady = (actual) => {
616
761
  printBanner(actual);
617
762
  if (process.argv.includes('--open')) {
618
- import('open').then((m) => m.default(`http://localhost:${actual}`));
763
+ import('open').then((m) => m.default(accessUrl(actual)));
619
764
  }
620
765
  };
621
766
 
622
767
  listenWithFallback(app, HUB_PORT, onReady, '');
623
768
 
769
+ function accessUrl(port) {
770
+ return `http://localhost:${port}/?token=${HUB_TOKEN}`;
771
+ }
772
+
624
773
  function printBanner(port) {
625
- console.log(`Claude Code Hub running at http://localhost:${port}`);
774
+ console.log(`Claude Code Hub running at ${accessUrl(port)}`);
626
775
  const warning = net.exposureWarning();
627
776
  if (warning) console.log(warning);
628
777
  console.log('Type "q" or "exit" to stop the server');