claude-code-hub 1.1.0 → 1.2.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/lib/ipc.js ADDED
@@ -0,0 +1,34 @@
1
+ 'use strict';
2
+
3
+ let seq = 0;
4
+
5
+ // Resolves with the child's answer to a `type` message over its IPC channel, or null after timeoutMs.
6
+ // The channel does not use TCP, so a loopback connect failure does not stop the answer.
7
+ function ask(child, type, timeoutMs, answerType = type) {
8
+ if (!child?.connected) return Promise.resolve(null);
9
+ const id = ++seq;
10
+ return new Promise((resolve) => {
11
+ const done = (m) => {
12
+ clearTimeout(timer);
13
+ child.off('message', onMessage);
14
+ resolve(m);
15
+ };
16
+ const onMessage = (m) => m?.type === answerType && m.id === id && done(m);
17
+ const timer = setTimeout(() => done(null), timeoutMs);
18
+ child.on('message', onMessage);
19
+ child.send({ type, id }, (err) => err && done(null));
20
+ });
21
+ }
22
+
23
+ // Resolves true when the child answers hub:ping within timeoutMs.
24
+ async function ping(child, timeoutMs) {
25
+ return !!(await ask(child, 'hub:ping', timeoutMs, 'hub:pong'));
26
+ }
27
+
28
+ // An app that does not mount the SDK server does not answer, so it resolves null.
29
+ async function stats(child, timeoutMs) {
30
+ const m = await ask(child, 'hub:stats', timeoutMs);
31
+ return m && { rss: m.rss, cpu: m.cpu };
32
+ }
33
+
34
+ module.exports = { ping, stats };
package/lib/net-guard.js CHANGED
@@ -37,11 +37,14 @@ const PORT_RE = /^[0-9]{1,5}$/;
37
37
  // port dynamic, so only the host is allowlisted.
38
38
  const validPort = (p) => PORT_RE.test(p) && Number(p) >= 1 && Number(p) <= 65535;
39
39
  const KEEP_ALIVE_MS = 65000;
40
+ // A whole IPv4 literal: a prefix test would let a rebinding name like 127.evil.com through.
41
+ const LOOPBACK_V4_RE = /^127(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3}$/;
40
42
 
41
43
  function isLoopbackAddress(host) {
42
44
  if (!host) return false;
43
- const h = String(host).toLowerCase();
44
- return LOOPBACK_HOSTS.has(h) || h.startsWith('127.');
45
+ // An Origin or URL host carries an IPv6 literal in brackets.
46
+ const h = String(host).toLowerCase().replace(/^\[(.*)\]$/, '$1');
47
+ return LOOPBACK_HOSTS.has(h) || LOOPBACK_V4_RE.test(h);
45
48
  }
46
49
 
47
50
  // Hand-parsed rather than via new URL(), which is far more permissive than we
@@ -102,7 +105,7 @@ function getFlag(name) {
102
105
  }
103
106
 
104
107
  /**
105
- * @param {{appName?: string}} [config]
108
+ * @param {{appName?: string, selfFramedPaths?: string[]}} [config]
106
109
  */
107
110
  function createNetGuard(config = {}) {
108
111
  const appName = config.appName || 'This app';
@@ -216,9 +219,18 @@ function createNetGuard(config = {}) {
216
219
  CSP = "frame-ancestors 'none'";
217
220
  }
218
221
 
219
- function frameGuard(_req, res, next) {
222
+ // Alone, these pages may be framed by this app's own port under its loopback names, for a page
223
+ // that frames itself from its other name to get its own renderer process.
224
+ const selfFramed = new Set(config.selfFramedPaths || []);
225
+
226
+ function frameGuard(req, res, next) {
220
227
  res.setHeader('X-Content-Type-Options', 'nosniff');
221
228
  res.setHeader('Referrer-Policy', 'no-referrer');
229
+ if (!hubOrigin && selfFramed.has(req.path)) {
230
+ const port = req.socket.localPort;
231
+ res.setHeader('Content-Security-Policy', `frame-ancestors 'self' http://localhost:${port} http://127.0.0.1:${port}`);
232
+ return next();
233
+ }
222
234
  res.setHeader('Content-Security-Policy', CSP);
223
235
  if (!hubOrigin) res.setHeader('X-Frame-Options', 'DENY');
224
236
  next();
@@ -232,7 +244,10 @@ function createNetGuard(config = {}) {
232
244
  * the OS resolver orders A/AAAA records - binding only 127.0.0.1 breaks hosts
233
245
  * where `localhost` resolves to ::1, and vice versa. Doing it on the *resolved*
234
246
  * port means every existing URL, startup banner and postMessage origin is
235
- * unchanged. Failures are swallowed: it is an optimization, not a requirement.
247
+ * unchanged. A family the host does not have is skipped. A port that another
248
+ * process holds on the other family is treated as busy: the primary closes and
249
+ * emits EADDRINUSE (port 0 picks another random port instead), because that process would answer `localhost:<port>` (and a
250
+ * cross-site frame on it) in our name. onReady waits for the second bind.
236
251
  *
237
252
  * Returns the primary server, so callers keep attaching their own 'error'
238
253
  * handler for the EADDRINUSE fallback.
@@ -259,18 +274,30 @@ function createNetGuard(config = {}) {
259
274
  };
260
275
  const server = makeServer(app);
261
276
  let secondary = null;
277
+ let randomTries = 0;
262
278
 
263
279
  server.on('listening', () => {
264
- actualPort = server.address().port;
265
- if (!EXPOSED) {
266
- const otherFamily = BIND_HOST.includes(':') ? '127.0.0.1' : '::1';
267
- try {
268
- secondary = makeServer(app);
269
- secondary.on('error', () => {}); // that family may not exist on this host
270
- secondary.listen({ host: otherFamily, port: actualPort, ipv6Only: otherFamily === '::1' });
271
- } catch { /* best effort */ }
280
+ const boundPort = server.address().port;
281
+ actualPort = boundPort;
282
+ // Not actualPort: the hub listens several servers through one guard, and another one can bind
283
+ // while this one waits for its second family.
284
+ const ready = () => { if (onReady) onReady(boundPort); };
285
+ if (EXPOSED) return ready();
286
+ const otherFamily = BIND_HOST.includes(':') ? '127.0.0.1' : '::1';
287
+ try {
288
+ secondary = makeServer(app);
289
+ secondary.once('listening', ready);
290
+ secondary.on('error', (err) => {
291
+ if (err.code !== 'EADDRINUSE') return ready();
292
+ // A caller asking for port 0 has no fallback of its own, so it gets another random port here.
293
+ if (port === 0 && ++randomTries < 5) return server.close(() => server.listen(0, BIND_HOST));
294
+ server.close();
295
+ server.emit('error', err);
296
+ });
297
+ secondary.listen({ host: otherFamily, port: boundPort, ipv6Only: otherFamily === '::1' });
298
+ } catch {
299
+ ready();
272
300
  }
273
- if (onReady) onReady(actualPort);
274
301
  });
275
302
  server.on('close', () => { try { if (secondary) secondary.close(); } catch { /* already gone */ } });
276
303
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-hub",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "Unified hub for Claude Code tools — Marketplace + Kanban in one PWA",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -45,10 +45,10 @@
45
45
  },
46
46
  "homepage": "https://github.com/NikiforovAll/claude-code-hub#readme",
47
47
  "dependencies": {
48
- "claude-code-cost": "1.1.0",
49
- "claude-code-kanban": "5.2.0",
50
- "claude-code-marketplace": "1.1.0",
51
- "claude-code-memory-explorer": "1.1.0",
48
+ "claude-code-cost": "1.2.0",
49
+ "claude-code-kanban": "5.4.0",
50
+ "claude-code-marketplace": "1.3.0",
51
+ "claude-code-memory-explorer": "1.1.1",
52
52
  "express": "^4.21.0",
53
53
  "open": "^10.1.0"
54
54
  },
@@ -334,6 +334,8 @@
334
334
  },
335
335
  // For an element that eats keys before the document sees them, like a terminal.
336
336
  forwards,
337
+ // The combos forwards() matches, for a frame that tests keys with ClaudeHub.comboOf on its own.
338
+ forwardCombos: () => (forward ? [...forward] : []),
337
339
  closeGuard(on) {
338
340
  post({ type: 'hub:closeGuard', on: !!on });
339
341
  },
@@ -363,7 +365,7 @@
363
365
  return hub;
364
366
  }
365
367
 
366
- return { connect };
368
+ return { connect, comboOf };
367
369
  }
368
370
 
369
371
  if (typeof module === 'object' && module.exports) module.exports = { createClaudeHub, comboOf };
@@ -5,12 +5,34 @@ const path = require('node:path');
5
5
  // The hub hands this file to each app it spawns (HUB_SDK_SERVER), so it runs the hub's version, not
6
6
  // one the app shipped. An app run alone never loads it.
7
7
 
8
+ // The app's own process only: a process-tree walk needs an OS call that costs more than the number
9
+ // is worth. CPU is sampled over a short window when asked, so nothing runs between asks, and asks
10
+ // that overlap share one sample.
11
+ const STATS_SAMPLE_MS = 250;
12
+ let statsSample = null;
13
+
14
+ function sampleStats() {
15
+ statsSample ??= new Promise((resolve) => {
16
+ const cpu0 = process.cpuUsage();
17
+ const t0 = process.hrtime.bigint();
18
+ setTimeout(() => {
19
+ const cpu = process.cpuUsage(cpu0);
20
+ const elapsedUs = Number(process.hrtime.bigint() - t0) / 1000;
21
+ statsSample = null;
22
+ resolve({ rss: process.memoryUsage.rss(), cpu: Math.round(((cpu.user + cpu.system) / elapsedUs) * 100) });
23
+ }, STATS_SAMPLE_MS);
24
+ });
25
+ return statsSample;
26
+ }
27
+
8
28
  // The hub spawns each app with an IPC channel. A loopback connect can fail while the app is fine,
9
29
  // so before the hub replaces an app it asks over the channel, which does not go through TCP.
10
30
  function answerHub() {
11
31
  if (!process.channel) return;
12
32
  process.on('message', (m) => {
13
33
  if (m?.type === 'hub:ping') process.send({ type: 'hub:pong', id: m.id });
34
+ if (m?.type === 'hub:stats')
35
+ sampleStats().then((s) => process.connected && process.send({ type: 'hub:stats', id: m.id, ...s }));
14
36
  });
15
37
  process.on('disconnect', () => process.exit(0));
16
38
  // Listeners ref the channel. The app's own server keeps it alive, not the hub.
@@ -3,6 +3,15 @@
3
3
  ((root) => {
4
4
  'use strict';
5
5
 
6
+ // A copy of client.js comboOf(); a test checks they match.
7
+ function comboOf(e) {
8
+ const lower = typeof e.key === 'string' ? e.key.toLowerCase() : '';
9
+ const m = /^(?:Key|Digit)([A-Z1-9])$/.exec(e.code || '');
10
+ const key = /^[a-z1-9]$/.test(lower) ? lower : m ? m[1].toLowerCase() : e.key;
11
+ const mods = [e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift', e.metaKey && 'meta'];
12
+ return [...mods, key].filter(Boolean).join('+');
13
+ }
14
+
6
15
  function createClaudeHub(win) {
7
16
  let hub = null;
8
17
  const off = () => {};
@@ -32,6 +41,7 @@
32
41
  },
33
42
  can: (action) => !!standaloneFn(action),
34
43
  forwards: () => false,
44
+ forwardCombos: () => [],
35
45
  closeGuard() {},
36
46
  openExternal(url) {
37
47
  win.open(url, '_blank', 'noopener');
@@ -41,9 +51,9 @@
41
51
  return hub;
42
52
  }
43
53
 
44
- return { connect };
54
+ return { connect, comboOf };
45
55
  }
46
56
 
47
- if (typeof module === 'object' && module.exports) module.exports = { createClaudeHub };
57
+ if (typeof module === 'object' && module.exports) module.exports = { createClaudeHub, comboOf };
48
58
  else root.ClaudeHub = createClaudeHub(root);
49
59
  })(typeof window !== 'undefined' ? window : globalThis);
package/public/app.js CHANGED
@@ -118,10 +118,10 @@ function applyHubTheme() {
118
118
  for (const [name, value] of Object.entries(vars ?? {})) root.style.setProperty(name, value);
119
119
  }
120
120
 
121
- // Claude's on-disk project-directory name. Matches memory/server.js encodeProjectPath. The hub
121
+ // Claude's on-disk project-directory name: every non-alphanumeric character becomes a dash. The hub
122
122
  // owns this transform so cost never has to convert anything; the inverse is lossy and not needed.
123
123
  function encodeProjectPath(p) {
124
- return p.replace(/\\/g, '/').replace(/:/g, '-').replace(/\//g, '-');
124
+ return p.replace(/[^a-zA-Z0-9]/g, '-');
125
125
  }
126
126
 
127
127
  function basename(p) {
@@ -154,8 +154,14 @@ function sendTopic(appId, topic, payload) {
154
154
  if (liveApps.get(appId)?.has(topic)) postTo(appId, { type: 'hub:event', topic, payload });
155
155
  }
156
156
 
157
- function setProject(absPath) {
158
- projectState = { project: absPath, encoded: encodeProjectPath(absPath), name: basename(absPath) };
157
+ // `worktrees` are the linked worktrees the projects provider folded into this repo.
158
+ function setProject(absPath, worktrees = []) {
159
+ projectState = {
160
+ project: absPath,
161
+ encoded: encodeProjectPath(absPath),
162
+ name: basename(absPath),
163
+ worktrees: worktrees.map((p) => ({ path: p, encoded: encodeProjectPath(p) })),
164
+ };
159
165
  for (const id of Object.keys(iframes)) sendProject(id);
160
166
  }
161
167
 
@@ -644,6 +650,8 @@ async function loadProjects() {
644
650
  path: p.path,
645
651
  name: basename(p.path),
646
652
  parent: parentOf(p.path),
653
+ worktrees: p.worktrees ?? [],
654
+ worktreeNames: (p.worktrees ?? []).map((w) => basename(w).toLowerCase()),
647
655
  ts: p.modifiedAt ? Date.parse(p.modifiedAt) : 0,
648
656
  }))
649
657
  .sort((a, b) => b.ts - a.ts);
@@ -676,6 +684,7 @@ function projectRows(projects, q) {
676
684
  const path = p.path.toLowerCase();
677
685
  if (path.split(/[/\\]/).includes(q)) return PATH_RANK;
678
686
  if (path.includes(q)) return PATH_RANK + 1;
687
+ if (p.worktreeNames.some((w) => w.includes(q))) return PATH_RANK + 2;
679
688
  return -1;
680
689
  };
681
690
  let out = projects.map((p) => ({ p, rank: rankOf(p) })).filter((x) => x.rank >= 0);
@@ -744,6 +753,10 @@ function relAge(ts) {
744
753
  return `${Math.round(m / 1440)}d`;
745
754
  }
746
755
 
756
+ async function loadAppStats() {
757
+ palette.appStats = await sendJson('GET', '/api/apps/stats');
758
+ }
759
+
747
760
  async function loadConfigDirs() {
748
761
  palette.configDirs = await sendJson('GET', '/api/config-dirs');
749
762
  }
@@ -940,12 +953,17 @@ const PALETTE_MODES = {
940
953
  hint: 'Enter switch · Ctrl+R restart the app',
941
954
  empty: 'No matching app',
942
955
  rows: (_typed, q) => appRows(q),
956
+ load: loadAppStats,
943
957
  initialSel(rows) {
944
958
  const prev = rows.findIndex((r) => r.id === previousApp);
945
959
  return prev < 0 ? 0 : prev;
946
960
  },
947
961
  fillRow(li, row, q) {
948
962
  li.append(appIcon(row.app), markedSpan('name', row.app.name, q));
963
+ const s = palette.appStats?.[row.id];
964
+ const stats = el('span', 'stats', s ? `${Math.round(s.rss / 1048576)} MB · ${s.cpu}%` : '');
965
+ stats.title = 'Memory and CPU of the app process. Child processes are not counted. 100% is one core.';
966
+ li.append(stats);
949
967
  const key = row.id === activeApp ? 'active' : row.index < 9 ? `Alt+${row.index + 1}` : '';
950
968
  li.append(el('span', 'age', key));
951
969
  li.append(rowButton('palette-restart', '↻', 'r', 'Restart the app (Ctrl+R)', `Restart ${row.app.name}`));
@@ -1060,7 +1078,7 @@ async function commitProject(row) {
1060
1078
  const absPath = row.kind === 'project' ? row.path : await resolveTypedPath(row.path);
1061
1079
  if (!absPath) return;
1062
1080
  closePalette();
1063
- setProject(absPath);
1081
+ setProject(absPath, row.p?.worktrees);
1064
1082
  }
1065
1083
 
1066
1084
  async function sendJson(method, url, body) {
package/public/index.html CHANGED
@@ -264,6 +264,14 @@
264
264
  padding-left: 10px;
265
265
  }
266
266
 
267
+ .palette-row .stats {
268
+ color: var(--text-dim);
269
+ margin-left: auto;
270
+ font-variant-numeric: tabular-nums;
271
+ }
272
+
273
+ .palette-row .stats + .age { margin-left: 0; }
274
+
267
275
  .palette-row.literal {
268
276
  border-top: 1px solid var(--border);
269
277
  color: var(--accent);
package/server.js CHANGED
@@ -12,7 +12,7 @@ const { actionTable } = require('./lib/actions');
12
12
  const { selectApps, loadApp, conflicts } = require('./lib/apps');
13
13
  const { stripCookie } = require('./lib/cookies');
14
14
  const { createNetGuard, KEEP_ALIVE_MS } = require('./lib/net-guard');
15
- const { ping } = require('./lib/ping');
15
+ const { ping, stats } = require('./lib/ipc');
16
16
  const { killChild } = require('./lib/kill-child');
17
17
  const { readRegistry, themeConfig } = require('./lib/themes');
18
18
  const { createUpdateCheck } = require('./lib/update-check');
@@ -926,6 +926,16 @@ app.post('/api/config-dirs/activate', async (req, res) => {
926
926
  res.json({ apps: appsConfig() });
927
927
  });
928
928
 
929
+ const STATS_TIMEOUT_MS = 1000;
930
+ app.get('/api/apps/stats', async (_req, res) => {
931
+ res.setHeader('Cache-Control', 'no-store');
932
+ const pool = activePool();
933
+ const entries = await Promise.all(
934
+ ENABLED_APPS.map(async (a) => [a.id, await stats(pool?.children.get(a.id), STATS_TIMEOUT_MS)]),
935
+ );
936
+ res.json(Object.fromEntries(entries));
937
+ });
938
+
929
939
  // Restarting the terminal provider ends its live terminals, so that takes force: true.
930
940
  const restarting = new Set();
931
941
  app.post('/api/apps/:id/restart', async (req, res) => {
package/lib/ping.js DELETED
@@ -1,23 +0,0 @@
1
- 'use strict';
2
-
3
- let seq = 0;
4
-
5
- // Resolves true when the child answers hub:ping over its IPC channel within timeoutMs. The channel
6
- // does not use TCP, so a loopback connect failure does not stop the answer.
7
- function ping(child, timeoutMs) {
8
- if (!child?.connected) return Promise.resolve(false);
9
- const id = ++seq;
10
- return new Promise((resolve) => {
11
- const done = (ok) => {
12
- clearTimeout(timer);
13
- child.off('message', onMessage);
14
- resolve(ok);
15
- };
16
- const onMessage = (m) => m?.type === 'hub:pong' && m.id === id && done(true);
17
- const timer = setTimeout(() => done(false), timeoutMs);
18
- child.on('message', onMessage);
19
- child.send({ type: 'hub:ping', id }, (err) => err && done(false));
20
- });
21
- }
22
-
23
- module.exports = { ping };