claude-code-hub 1.3.2 → 1.3.4

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.
@@ -0,0 +1,34 @@
1
+ 'use strict';
2
+
3
+ // Some loopback ports on Windows answer with TCP retransmit delays (0.3 s, 0.9 s, 2 s, 4.5 s ...)
4
+ // while the app behind them is idle; a restart moves the app to a fresh port and the delays go.
5
+ // A probe pairs an HTTP request with an IPC ping sent at the same moment: a slow HTTP answer with a
6
+ // fast pong is the port, both slow is a busy app.
7
+ const SLOW_HTTP_MS = 300;
8
+ const FAST_IPC_MS = 100;
9
+ const STRIKES = 3;
10
+ const WINDOW_MS = 5 * 60_000;
11
+
12
+ // Keyed by the child process: a restart brings a new child on a new port, so its strikes start over.
13
+ function createPortHealth({ now = Date.now } = {}) {
14
+ const strikesOf = new WeakMap();
15
+
16
+ function recent(child) {
17
+ const since = now() - WINDOW_MS;
18
+ return (strikesOf.get(child) ?? []).filter((s) => s.at >= since);
19
+ }
20
+
21
+ function record(child, { httpMs, ipcMs }) {
22
+ if (httpMs < SLOW_HTTP_MS || ipcMs == null || ipcMs >= FAST_IPC_MS) return;
23
+ strikesOf.set(child, [...recent(child), { at: now(), ms: Math.round(httpMs) }]);
24
+ }
25
+
26
+ function flagged(child) {
27
+ const strikes = recent(child);
28
+ return strikes.length < STRIKES ? null : { worstMs: Math.max(...strikes.map((s) => s.ms)) };
29
+ }
30
+
31
+ return { record, flagged };
32
+ }
33
+
34
+ module.exports = { createPortHealth, STRIKES, WINDOW_MS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-hub",
3
- "version": "1.3.2",
3
+ "version": "1.3.4",
4
4
  "description": "Unified hub for Claude Code tools — Marketplace + Kanban in one PWA",
5
5
  "main": "server.js",
6
6
  "bin": {
package/public/app.js CHANGED
@@ -263,8 +263,7 @@ async function showUpdateToast() {
263
263
  let timer = 0;
264
264
  const hide = () => {
265
265
  clearTimeout(timer);
266
- toast.classList.add('leaving');
267
- setTimeout(() => (toast.hidden = true), 200);
266
+ hideToast(toast);
268
267
  };
269
268
  const arm = () => (timer = setTimeout(hide, UPDATE_TOAST_MS));
270
269
  toast.addEventListener('mouseenter', () => clearTimeout(timer));
@@ -276,18 +275,43 @@ async function showUpdateToast() {
276
275
  } catch {}
277
276
  hide();
278
277
  });
279
- toast.hidden = false;
278
+ showToast(toast);
280
279
  arm();
281
280
  }
282
281
 
282
+ function showToast(toast) {
283
+ toast.classList.remove('leaving');
284
+ toast.hidden = false;
285
+ }
286
+
287
+ // A show during the fade cancels the hide.
288
+ function hideToast(toast) {
289
+ toast.classList.add('leaving');
290
+ setTimeout(() => {
291
+ if (toast.classList.contains('leaving')) toast.hidden = true;
292
+ }, 200);
293
+ }
294
+
295
+ // Null on success. A 409 with live terminals asks for force: true on the next call.
296
+ async function requestRestart(id, force) {
297
+ try {
298
+ await sendJson('POST', `/api/apps/${encodeURIComponent(id)}/restart`, { force });
299
+ return null;
300
+ } catch (err) {
301
+ const name = apps[id]?.name ?? id;
302
+ const n = err.status === 409 && err.data?.terminals;
303
+ if (!n) return { message: `${name} did not restart: ${err.message}` };
304
+ const them = n === 1 ? 'it' : 'them';
305
+ return { terminals: n, message: `${name} has ${n} live terminal${n === 1 ? '' : 's'}. A restart ends ${them}.` };
306
+ }
307
+ }
308
+
283
309
  const ERROR_TOAST_MS = 8_000;
284
310
  let errorToastTimer = 0;
285
311
 
286
312
  function hideErrorToast() {
287
313
  clearTimeout(errorToastTimer);
288
- const toast = document.getElementById('error-toast');
289
- toast.classList.add('leaving');
290
- setTimeout(() => (toast.hidden = true), 200);
314
+ hideToast(document.getElementById('error-toast'));
291
315
  }
292
316
 
293
317
  // A 401 means the hub_token cookie no longer matches the token file. Opening /?token=… sets a new
@@ -299,9 +323,7 @@ function showErrorToast(action, err) {
299
323
  ? `This browser has an old hub token. Open the link the hub printed in the terminal (${location.origin}/?token=…) to renew it.`
300
324
  : err.message;
301
325
  document.getElementById('error-actions').hidden = !unauthorized;
302
- const toast = document.getElementById('error-toast');
303
- toast.classList.remove('leaving');
304
- toast.hidden = false;
326
+ showToast(document.getElementById('error-toast'));
305
327
  clearTimeout(errorToastTimer);
306
328
  if (!unauthorized) errorToastTimer = setTimeout(hideErrorToast, ERROR_TOAST_MS);
307
329
  }
@@ -864,20 +886,17 @@ function setPaletteHint(text) {
864
886
 
865
887
  // The terminal provider answers 409 with a terminal count; the second Ctrl+R on the same app sends force.
866
888
  async function restartFromPalette(id) {
867
- const name = apps[id]?.name ?? id;
868
889
  const force = palette.confirmRestart === id;
869
890
  palette.confirmRestart = null;
870
- setPaletteHint(`Restarting ${name}…`);
871
- try {
872
- await withPaletteBusy(() => sendJson('POST', `/api/apps/${encodeURIComponent(id)}/restart`, { force }));
873
- } catch (err) {
874
- if (err.status === 409 && err.data?.terminals) {
875
- palette.confirmRestart = id;
876
- const n = err.data.terminals;
877
- setPaletteHint(
878
- `${name} has ${n} live terminal${n === 1 ? '' : 's'}. A restart ends ${n === 1 ? 'it' : 'them'}. Press Ctrl+R again to restart.`,
879
- );
880
- } else setPaletteHint(`${name} did not restart: ${err.message}`);
891
+ setPaletteHint(`Restarting ${apps[id]?.name ?? id}…`);
892
+ const failed = await withPaletteBusy(() => requestRestart(id, force));
893
+ if (failed?.terminals) {
894
+ palette.confirmRestart = id;
895
+ setPaletteHint(`${failed.message} Press Ctrl+R again to restart.`);
896
+ return;
897
+ }
898
+ if (failed) {
899
+ setPaletteHint(failed.message);
881
900
  return;
882
901
  }
883
902
  closePalette();
@@ -961,7 +980,12 @@ const PALETTE_MODES = {
961
980
  fillRow(li, row, q) {
962
981
  li.append(appIcon(row.app), markedSpan('name', row.app.name, q));
963
982
  const s = palette.appStats?.[row.id];
964
- const stats = el('span', 'stats', s ? `${Math.round(s.rss / 1048576)} MB · ${s.cpu}%` : '');
983
+ if (s?.slowMs) {
984
+ const badge = el('span', 'slow', 'slow port');
985
+ badge.title = `Its port delays requests by up to ${(s.slowMs / 1000).toFixed(1)} s. Restart (Ctrl+R) moves it to a new port.`;
986
+ li.append(badge);
987
+ }
988
+ const stats = el('span', 'stats', s?.rss ? `${Math.round(s.rss / 1048576)} MB · ${s.cpu}%` : '');
965
989
  stats.title = 'Memory and CPU of the app process. Child processes are not counted. 100% is one core.';
966
990
  li.append(stats);
967
991
  const key = row.id === activeApp ? 'active' : row.index < 9 ? `Alt+${row.index + 1}` : '';
package/public/index.html CHANGED
@@ -272,6 +272,15 @@
272
272
 
273
273
  .palette-row .stats + .age { margin-left: 0; }
274
274
 
275
+ .palette-row .slow {
276
+ margin-left: 10px;
277
+ padding: 0 6px;
278
+ border-radius: 4px;
279
+ color: #f5a524;
280
+ border: 1px solid #f5a524;
281
+ font-size: 11px;
282
+ }
283
+
275
284
  .palette-row.literal {
276
285
  border-top: 1px solid var(--border);
277
286
  color: var(--accent);
package/server.js CHANGED
@@ -14,6 +14,7 @@ const { stripCookie } = require('./lib/cookies');
14
14
  const { createNetGuard, KEEP_ALIVE_MS } = require('./lib/net-guard');
15
15
  const { ping, stats } = require('./lib/ipc');
16
16
  const { killChild } = require('./lib/kill-child');
17
+ const { createPortHealth } = require('./lib/port-health');
17
18
  const { readRegistry, themeConfig } = require('./lib/themes');
18
19
  const { createUpdateCheck } = require('./lib/update-check');
19
20
  const { liveHub, removeRecord, writeRecord } = require('./lib/hub-record');
@@ -952,12 +953,53 @@ app.post('/api/config-dirs/activate', async (req, res) => {
952
953
  res.json({ apps: appsConfig() });
953
954
  });
954
955
 
956
+ // The stalls hit a few percent of requests, so each round sends several probes per app.
957
+ const HEALTH_INTERVAL_MS = 30_000;
958
+ const HEALTH_PROBES = 5;
959
+ const HEALTH_TIMEOUT_MS = 5000;
960
+ const portHealth = createPortHealth();
961
+ let healthRunning = false;
962
+
963
+ async function msUntil(promise) {
964
+ const t = performance.now();
965
+ await promise;
966
+ return performance.now() - t;
967
+ }
968
+
969
+ async function probeApp(id) {
970
+ const port = activePool()?.ports[id];
971
+ for (let i = 0; port && i < HEALTH_PROBES; i++) {
972
+ const child = currentChild(id, port);
973
+ if (!child) return;
974
+ const answered = fetch(`http://127.0.0.1:${port}/hub-config`, { signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS) })
975
+ .then((r) => r.arrayBuffer())
976
+ .catch(() => {});
977
+ const pong = ping(child, PING_TIMEOUT_MS);
978
+ const [httpMs, ipcMs, ponged] = await Promise.all([msUntil(answered), msUntil(pong), pong]);
979
+ portHealth.record(child, { httpMs, ipcMs: ponged ? ipcMs : null });
980
+ }
981
+ }
982
+
983
+ async function checkPortHealth() {
984
+ if (healthRunning) return;
985
+ healthRunning = true;
986
+ try {
987
+ await Promise.all(ENABLED_APPS.map((a) => probeApp(a.id)));
988
+ } finally {
989
+ healthRunning = false;
990
+ }
991
+ }
992
+
955
993
  const STATS_TIMEOUT_MS = 1000;
956
994
  app.get('/api/apps/stats', async (_req, res) => {
957
995
  res.setHeader('Cache-Control', 'no-store');
958
996
  const pool = activePool();
959
997
  const entries = await Promise.all(
960
- ENABLED_APPS.map(async (a) => [a.id, await stats(pool?.children.get(a.id), STATS_TIMEOUT_MS)]),
998
+ ENABLED_APPS.map(async (a) => {
999
+ const child = pool?.children.get(a.id);
1000
+ const slowMs = (child && portHealth.flagged(child)?.worstMs) ?? null;
1001
+ return [a.id, { ...(await stats(child, STATS_TIMEOUT_MS)), slowMs }];
1002
+ }),
961
1003
  );
962
1004
  res.json(Object.fromEntries(entries));
963
1005
  });
@@ -1050,6 +1092,7 @@ const onReady = (actual) => {
1050
1092
  writeRecord(HUB_DIR, actual);
1051
1093
  ensurePool(hubConfig.activeConfigDir);
1052
1094
  updateCheck.refresh();
1095
+ setInterval(checkPortHealth, HEALTH_INTERVAL_MS).unref();
1053
1096
  printBanner(actual);
1054
1097
  if (process.argv.includes('--open')) {
1055
1098
  import('open').then((m) => m.default(accessUrl(actual)));