claude-code-hub 0.38.0 → 0.39.0-rc.1
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 +110 -40
- package/lib/net-guard.js +20 -3
- package/package.json +5 -5
- package/public/app.js +143 -28
- package/public/index.html +124 -10
- package/public/locked.html +169 -0
- package/public/sw.js +12 -2
- package/server.js +177 -28
package/README.md
CHANGED
|
@@ -4,85 +4,155 @@
|
|
|
4
4
|
[](LICENSE)
|
|
5
5
|
[](https://www.npmjs.com/package/claude-code-hub)
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
|
|
9
|
+
Website: [nikiforovall.blog/claude-code-hub](https://nikiforovall.blog/claude-code-hub/)
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
## The tools
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-

|
|
13
|
+
### Kanban
|
|
15
14
|
|
|
16
|
-
|
|
17
|
-

|
|
15
|
+
See tasks, sessions, and live agent activity as a board. Open a Claude Code session in the embedded terminal.
|
|
18
16
|
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
<picture>
|
|
18
|
+
<source media="(prefers-color-scheme: dark)" srcset="assets/kanban-dark.webp">
|
|
19
|
+
<img alt="Kanban board with sessions and tasks" src="assets/kanban-light.webp">
|
|
20
|
+
</picture>
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
### Marketplace
|
|
23
|
+
|
|
24
|
+
Browse and manage Claude Code marketplace plugins.
|
|
25
|
+
|
|
26
|
+
<picture>
|
|
27
|
+
<source media="(prefers-color-scheme: dark)" srcset="assets/marketplace-dark.webp">
|
|
28
|
+
<img alt="Marketplace with the plugin list" src="assets/marketplace-light.webp">
|
|
29
|
+
</picture>
|
|
30
|
+
|
|
31
|
+
### Cost
|
|
32
|
+
|
|
33
|
+
See what your Claude Code usage costs, per session and per project.
|
|
34
|
+
|
|
35
|
+
<picture>
|
|
36
|
+
<source media="(prefers-color-scheme: dark)" srcset="assets/cost-dark.webp">
|
|
37
|
+
<img alt="Cost dashboard with usage charts" src="assets/cost-light.webp">
|
|
38
|
+
</picture>
|
|
39
|
+
|
|
40
|
+
### Memory Diagnoser
|
|
41
|
+
|
|
42
|
+
Explore the memory files that Claude Code loads for a project.
|
|
43
|
+
|
|
44
|
+
<picture>
|
|
45
|
+
<source media="(prefers-color-scheme: dark)" srcset="assets/memory-dark.webp">
|
|
46
|
+
<img alt="Memory Diagnoser with loaded memory files" src="assets/memory-light.webp">
|
|
47
|
+
</picture>
|
|
48
|
+
|
|
49
|
+
## Quick start
|
|
23
50
|
|
|
24
51
|
```bash
|
|
25
52
|
npx claude-code-hub --open
|
|
26
53
|
```
|
|
27
54
|
|
|
28
|
-
|
|
55
|
+
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.
|
|
56
|
+
|
|
57
|
+
### From source
|
|
29
58
|
|
|
30
59
|
```bash
|
|
31
60
|
git clone --recurse-submodules https://github.com/NikiforovAll/claude-code-hub.git
|
|
32
61
|
cd claude-code-hub
|
|
33
|
-
npm install && npm install --prefix marketplace && npm install --prefix cck && npm install --prefix memory
|
|
62
|
+
npm install && npm install --prefix marketplace && npm install --prefix cck && npm install --prefix cost && npm install --prefix memory
|
|
34
63
|
npm start # http://localhost:3540
|
|
35
64
|
```
|
|
36
65
|
|
|
37
|
-
## Agent
|
|
66
|
+
## Agent observability (one-time setup)
|
|
38
67
|
|
|
39
|
-
For the full Kanban
|
|
68
|
+
For the full Kanban view (agent log, live subagent tracking, waiting-for-user indicators, and context window use), install the hooks:
|
|
40
69
|
|
|
41
70
|
```bash
|
|
42
71
|
npx claude-code-kanban --install
|
|
43
72
|
```
|
|
44
73
|
|
|
45
|
-
Without hooks you still get the task board, but no agent activity
|
|
74
|
+
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.
|
|
75
|
+
|
|
76
|
+
## Hub token
|
|
77
|
+
|
|
78
|
+
The hub page and its API need a token. Any local account can reach a loopback port, and the API carries the terminal token.
|
|
79
|
+
|
|
80
|
+
- The token is in `~/.claude-hub/token`. The hub creates it on the first run and keeps it across restarts.
|
|
81
|
+
- 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.
|
|
82
|
+
- Without the token, the page shows a locked screen.
|
|
83
|
+
- To change the token, delete the file and restart the hub.
|
|
46
84
|
|
|
47
|
-
##
|
|
85
|
+
## Config dirs
|
|
48
86
|
|
|
49
|
-
|
|
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 |
|
|
87
|
+
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.
|
|
60
88
|
|
|
61
|
-
|
|
89
|
+
- 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.
|
|
90
|
+
- 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.
|
|
91
|
+
- The list and the active dir are saved in `~/.claude-hub/config.json`.
|
|
62
92
|
|
|
63
|
-
|
|
93
|
+
## Embedded terminal
|
|
64
94
|
|
|
65
|
-
|
|
95
|
+
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.
|
|
66
96
|
|
|
67
|
-
|
|
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 |
|
|
97
|
+
To turn the terminal off, start the hub with `--disable-terminal`, or add this to `~/.claude-hub/config.json`:
|
|
73
98
|
|
|
74
|
-
|
|
99
|
+
```json
|
|
100
|
+
{
|
|
101
|
+
"terminal": { "enabled": false }
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Keyboard shortcuts
|
|
106
|
+
|
|
107
|
+
The hub has no visible UI of its own. Use these shortcuts:
|
|
108
|
+
|
|
109
|
+
| Shortcut | Action |
|
|
110
|
+
| --- | --- |
|
|
111
|
+
| <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>→</kbd> | Next tool |
|
|
112
|
+
| <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>←</kbd> | Previous tool |
|
|
113
|
+
| <kbd>Alt</kbd>+<kbd>1</kbd> | Kanban |
|
|
114
|
+
| <kbd>Alt</kbd>+<kbd>2</kbd> | Marketplace |
|
|
115
|
+
| <kbd>Alt</kbd>+<kbd>3</kbd> | Cost |
|
|
116
|
+
| <kbd>Alt</kbd>+<kbd>4</kbd> | Memory Diagnoser |
|
|
117
|
+
| <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>P</kbd> | Project palette: set the project for all tools |
|
|
118
|
+
| <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>W</kbd> | Config-dir palette: switch, add, or remove a config dir |
|
|
119
|
+
|
|
120
|
+
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.
|
|
121
|
+
|
|
122
|
+
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:
|
|
123
|
+
|
|
124
|
+
| Shortcut | Action |
|
|
125
|
+
| --- | --- |
|
|
126
|
+
| <kbd>M</kbd> | Open Marketplace for the session's project |
|
|
127
|
+
| <kbd>$</kbd> | Open Cost for the session |
|
|
128
|
+
| <kbd>Ctrl</kbd>+<kbd>M</kbd> | Open Memory Diagnoser for the session's project |
|
|
129
|
+
|
|
130
|
+
## CLI flags
|
|
75
131
|
|
|
76
132
|
```
|
|
77
133
|
--port <n> Hub port (default: 3540)
|
|
78
|
-
--marketplace-port <n> Marketplace port (default: 3542)
|
|
79
134
|
--kanban-port <n> Kanban port (default: 3541)
|
|
135
|
+
--marketplace-port <n> Marketplace port (default: 3542)
|
|
80
136
|
--cost-port <n> Cost port (default: 3543)
|
|
81
137
|
--memory-port <n> Memory Diagnoser port (default: 3544)
|
|
82
|
-
--pool-size <n> Config
|
|
83
|
-
--
|
|
138
|
+
--pool-size <n> Config-dir sets kept running after a switch (default: 3)
|
|
139
|
+
--disable-terminal Turn off the embedded terminal in Kanban
|
|
140
|
+
--host <addr> Bind address (default: 127.0.0.1)
|
|
141
|
+
--allowed-hosts <list> Extra host names to accept, comma-separated
|
|
142
|
+
--open Open the browser on start
|
|
84
143
|
```
|
|
85
144
|
|
|
145
|
+
If a port is busy, the hub uses a free port and reports it.
|
|
146
|
+
|
|
147
|
+
## Included tools
|
|
148
|
+
|
|
149
|
+
| Tool | Docs | Repo | Submodule | Default port |
|
|
150
|
+
| --- | --- | --- | --- | --- |
|
|
151
|
+
| Kanban | [Docs](https://nikiforovall.blog/claude-code-kanban/) | [claude-code-kanban](https://github.com/NikiforovAll/claude-code-kanban) | `cck/` | 3541 |
|
|
152
|
+
| Marketplace | [Docs](https://nikiforovall.blog/claude-code-marketplace/) | [claude-code-marketplace](https://github.com/NikiforovAll/claude-code-marketplace) | `marketplace/` | 3542 |
|
|
153
|
+
| Cost | [Docs](https://nikiforovall.blog/claude-code-cost/) | [claude-code-cost](https://github.com/NikiforovAll/claude-code-cost) | `cost/` | 3543 |
|
|
154
|
+
| Memory Diagnoser | [Docs](https://nikiforovall.blog/claude-code-memory/) | [claude-code-memory](https://github.com/NikiforovAll/claude-code-memory) | `memory/` | 3544 |
|
|
155
|
+
|
|
86
156
|
## License
|
|
87
157
|
|
|
88
158
|
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
|
-
*
|
|
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) =>
|
|
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.
|
|
3
|
+
"version": "0.39.0-rc.1",
|
|
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.
|
|
43
|
-
"claude-code-kanban": "^4.
|
|
44
|
-
"claude-code-marketplace": "^0.
|
|
45
|
-
"claude-code-memory-explorer": "^0.
|
|
42
|
+
"claude-code-cost": "^0.24.0-rc.1",
|
|
43
|
+
"claude-code-kanban": "^4.28.0-rc.1",
|
|
44
|
+
"claude-code-marketplace": "^0.20.0-rc.1",
|
|
45
|
+
"claude-code-memory-explorer": "^0.15.0-rc.1",
|
|
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
|
|
57
|
-
// registry that generates each sub-app's themes.css. Empty until
|
|
58
|
-
// registry is unreadable;
|
|
59
|
-
let
|
|
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
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
173
|
-
|
|
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
|
|
266
|
+
for (const id of Object.keys(apps)) {
|
|
185
267
|
const iframe = document.createElement('iframe');
|
|
186
268
|
iframe.id = `iframe-${id}`;
|
|
187
|
-
iframe.src =
|
|
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
|
|
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
|
|
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
|
|
381
|
-
|
|
382
|
-
|
|
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,
|
|
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
|
|
391
|
-
return { p,
|
|
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.
|
|
508
|
+
.filter((x) => x.rank >= 0);
|
|
394
509
|
}
|
|
395
|
-
out.sort((a, b) => a.
|
|
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(
|
|
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:
|
|
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
|
-
.
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
@
|
|
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="
|
|
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-
|
|
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)
|
|
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
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
458
|
-
|
|
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,
|
|
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
|
|
490
|
-
// sub-app's themes.css. Served rather than hand-copied into public/app.js so there
|
|
491
|
-
// truth
|
|
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
|
|
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
|
|
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
|
|
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({
|
|
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(
|
|
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
|
|
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');
|