claude-code-hub 1.3.2 → 1.3.3

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.3",
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
@@ -236,6 +236,7 @@ async function init() {
236
236
  bindPalette();
237
237
  registerSW();
238
238
  bindErrorToast();
239
+ bindSlowToast();
239
240
  showUpdateToast();
240
241
  }
241
242
 
@@ -263,8 +264,7 @@ async function showUpdateToast() {
263
264
  let timer = 0;
264
265
  const hide = () => {
265
266
  clearTimeout(timer);
266
- toast.classList.add('leaving');
267
- setTimeout(() => (toast.hidden = true), 200);
267
+ hideToast(toast);
268
268
  };
269
269
  const arm = () => (timer = setTimeout(hide, UPDATE_TOAST_MS));
270
270
  toast.addEventListener('mouseenter', () => clearTimeout(timer));
@@ -276,18 +276,110 @@ async function showUpdateToast() {
276
276
  } catch {}
277
277
  hide();
278
278
  });
279
- toast.hidden = false;
279
+ showToast(toast);
280
280
  arm();
281
281
  }
282
282
 
283
+ function showToast(toast) {
284
+ toast.classList.remove('leaving');
285
+ toast.hidden = false;
286
+ }
287
+
288
+ // A show during the fade cancels the hide.
289
+ function hideToast(toast) {
290
+ toast.classList.add('leaving');
291
+ setTimeout(() => {
292
+ if (toast.classList.contains('leaving')) toast.hidden = true;
293
+ }, 200);
294
+ }
295
+
296
+ // The stalls belong to the port, so a dismissal holds while the app stays on it.
297
+ const SLOW_POLL_MS = 30_000;
298
+ const slowDismissed = new Map();
299
+ const slowToast = { id: null, port: 0, confirm: false, busy: false };
300
+
301
+ function hideSlowToast() {
302
+ slowToast.id = null;
303
+ hideToast(document.getElementById('slow-toast'));
304
+ }
305
+
306
+ function renderSlowToast(app) {
307
+ const restart = document.getElementById('slow-restart');
308
+ document.getElementById('slow-title').textContent = `${app.name} answers slowly`;
309
+ document.getElementById('slow-detail').textContent =
310
+ `Its port delays requests by up to ${(app.worstMs / 1000).toFixed(1)} s. A restart moves it to a new port.`;
311
+ restart.textContent = 'Restart';
312
+ restart.disabled = false;
313
+ slowToast.id = app.id;
314
+ slowToast.port = app.port;
315
+ slowToast.confirm = false;
316
+ showToast(document.getElementById('slow-toast'));
317
+ }
318
+
319
+ async function pollSlowApps() {
320
+ if (document.hidden || slowToast.busy) return;
321
+ let slow;
322
+ try {
323
+ ({ slow } = await sendJson('GET', '/api/apps/health'));
324
+ } catch {
325
+ return;
326
+ }
327
+ if (slowToast.id && !slow.some((a) => a.id === slowToast.id)) hideSlowToast();
328
+ const next = slow.find((a) => slowDismissed.get(a.id) !== a.port);
329
+ if (next && next.id !== slowToast.id) renderSlowToast(next);
330
+ }
331
+
332
+ // Null on success. A 409 with live terminals asks for force: true on the next call.
333
+ async function requestRestart(id, force) {
334
+ try {
335
+ await sendJson('POST', `/api/apps/${encodeURIComponent(id)}/restart`, { force });
336
+ return null;
337
+ } catch (err) {
338
+ const name = apps[id]?.name ?? id;
339
+ const n = err.status === 409 && err.data?.terminals;
340
+ if (!n) return { message: `${name} did not restart: ${err.message}` };
341
+ const them = n === 1 ? 'it' : 'them';
342
+ return { terminals: n, message: `${name} has ${n} live terminal${n === 1 ? '' : 's'}. A restart ends ${them}.` };
343
+ }
344
+ }
345
+
346
+ async function restartSlowApp() {
347
+ const { id } = slowToast;
348
+ if (!id) return;
349
+ const restart = document.getElementById('slow-restart');
350
+ slowToast.busy = true;
351
+ restart.disabled = true;
352
+ restart.textContent = 'Restarting…';
353
+ const failed = await requestRestart(id, slowToast.confirm);
354
+ slowToast.busy = false;
355
+ if (!failed) {
356
+ hideSlowToast();
357
+ replaceIframe(id);
358
+ return;
359
+ }
360
+ slowToast.confirm = !!failed.terminals;
361
+ document.getElementById('slow-detail').textContent = failed.message;
362
+ restart.textContent = failed.terminals ? 'Restart anyway' : 'Restart';
363
+ restart.disabled = false;
364
+ }
365
+
366
+ function bindSlowToast() {
367
+ document.getElementById('slow-restart').addEventListener('click', restartSlowApp);
368
+ document.getElementById('slow-close').addEventListener('click', () => {
369
+ if (slowToast.id) slowDismissed.set(slowToast.id, slowToast.port);
370
+ hideSlowToast();
371
+ });
372
+ setInterval(pollSlowApps, SLOW_POLL_MS);
373
+ document.addEventListener('visibilitychange', pollSlowApps);
374
+ pollSlowApps();
375
+ }
376
+
283
377
  const ERROR_TOAST_MS = 8_000;
284
378
  let errorToastTimer = 0;
285
379
 
286
380
  function hideErrorToast() {
287
381
  clearTimeout(errorToastTimer);
288
- const toast = document.getElementById('error-toast');
289
- toast.classList.add('leaving');
290
- setTimeout(() => (toast.hidden = true), 200);
382
+ hideToast(document.getElementById('error-toast'));
291
383
  }
292
384
 
293
385
  // A 401 means the hub_token cookie no longer matches the token file. Opening /?token=… sets a new
@@ -299,9 +391,7 @@ function showErrorToast(action, err) {
299
391
  ? `This browser has an old hub token. Open the link the hub printed in the terminal (${location.origin}/?token=…) to renew it.`
300
392
  : err.message;
301
393
  document.getElementById('error-actions').hidden = !unauthorized;
302
- const toast = document.getElementById('error-toast');
303
- toast.classList.remove('leaving');
304
- toast.hidden = false;
394
+ showToast(document.getElementById('error-toast'));
305
395
  clearTimeout(errorToastTimer);
306
396
  if (!unauthorized) errorToastTimer = setTimeout(hideErrorToast, ERROR_TOAST_MS);
307
397
  }
@@ -864,20 +954,17 @@ function setPaletteHint(text) {
864
954
 
865
955
  // The terminal provider answers 409 with a terminal count; the second Ctrl+R on the same app sends force.
866
956
  async function restartFromPalette(id) {
867
- const name = apps[id]?.name ?? id;
868
957
  const force = palette.confirmRestart === id;
869
958
  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}`);
959
+ setPaletteHint(`Restarting ${apps[id]?.name ?? id}…`);
960
+ const failed = await withPaletteBusy(() => requestRestart(id, force));
961
+ if (failed?.terminals) {
962
+ palette.confirmRestart = id;
963
+ setPaletteHint(`${failed.message} Press Ctrl+R again to restart.`);
964
+ return;
965
+ }
966
+ if (failed) {
967
+ setPaletteHint(failed.message);
881
968
  return;
882
969
  }
883
970
  closePalette();
package/public/index.html CHANGED
@@ -385,6 +385,7 @@
385
385
 
386
386
  .update-toast-close:hover { color: var(--text); }
387
387
  .error-toast { z-index: 25; border-left-color: #e5484d; }
388
+ .slow-toast { z-index: 20; border-left-color: #f5a524; }
388
389
  </style>
389
390
  </head>
390
391
  <body>
@@ -457,6 +458,17 @@
457
458
  <button class="update-toast-close" id="update-close" type="button" aria-label="Close">×</button>
458
459
  </div>
459
460
 
461
+ <div class="update-toast slow-toast" id="slow-toast" role="status" hidden>
462
+ <div class="update-toast-body">
463
+ <div class="update-toast-title" id="slow-title"></div>
464
+ <div class="update-toast-meta" id="slow-detail"></div>
465
+ <div class="update-toast-actions">
466
+ <button class="update-toast-skip" id="slow-restart" type="button">Restart</button>
467
+ </div>
468
+ </div>
469
+ <button class="update-toast-close" id="slow-close" type="button" aria-label="Close">×</button>
470
+ </div>
471
+
460
472
  <div class="update-toast error-toast" id="error-toast" role="alert" hidden>
461
473
  <div class="update-toast-body">
462
474
  <div class="update-toast-title" id="error-title"></div>
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');
@@ -962,6 +963,54 @@ app.get('/api/apps/stats', async (_req, res) => {
962
963
  res.json(Object.fromEntries(entries));
963
964
  });
964
965
 
966
+ // The stalls hit a few percent of requests, so each round sends several probes per app.
967
+ const HEALTH_INTERVAL_MS = 30_000;
968
+ const HEALTH_PROBES = 5;
969
+ const HEALTH_TIMEOUT_MS = 5000;
970
+ const portHealth = createPortHealth();
971
+ let healthRunning = false;
972
+
973
+ async function msUntil(promise) {
974
+ const t = performance.now();
975
+ await promise;
976
+ return performance.now() - t;
977
+ }
978
+
979
+ async function probeApp(id) {
980
+ const port = activePool()?.ports[id];
981
+ for (let i = 0; port && i < HEALTH_PROBES; i++) {
982
+ const child = currentChild(id, port);
983
+ if (!child) return;
984
+ const answered = fetch(`http://127.0.0.1:${port}/hub-config`, { signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS) })
985
+ .then((r) => r.arrayBuffer())
986
+ .catch(() => {});
987
+ const pong = ping(child, PING_TIMEOUT_MS);
988
+ const [httpMs, ipcMs, ponged] = await Promise.all([msUntil(answered), msUntil(pong), pong]);
989
+ portHealth.record(child, { httpMs, ipcMs: ponged ? ipcMs : null });
990
+ }
991
+ }
992
+
993
+ async function checkPortHealth() {
994
+ if (healthRunning) return;
995
+ healthRunning = true;
996
+ try {
997
+ await Promise.all(ENABLED_APPS.map((a) => probeApp(a.id)));
998
+ } finally {
999
+ healthRunning = false;
1000
+ }
1001
+ }
1002
+
1003
+ app.get('/api/apps/health', (_req, res) => {
1004
+ res.setHeader('Cache-Control', 'no-store');
1005
+ const pool = activePool();
1006
+ const slow = [];
1007
+ for (const a of ENABLED_APPS) {
1008
+ const f = pool?.children.has(a.id) && portHealth.flagged(pool.children.get(a.id));
1009
+ if (f) slow.push({ id: a.id, name: a.name, port: pool.ports[a.id], worstMs: f.worstMs });
1010
+ }
1011
+ res.json({ slow });
1012
+ });
1013
+
965
1014
  // Restarting the terminal provider ends its live terminals, so that takes force: true.
966
1015
  const restarting = new Set();
967
1016
  app.post('/api/apps/:id/restart', async (req, res) => {
@@ -1050,6 +1099,7 @@ const onReady = (actual) => {
1050
1099
  writeRecord(HUB_DIR, actual);
1051
1100
  ensurePool(hubConfig.activeConfigDir);
1052
1101
  updateCheck.refresh();
1102
+ setInterval(checkPortHealth, HEALTH_INTERVAL_MS).unref();
1053
1103
  printBanner(actual);
1054
1104
  if (process.argv.includes('--open')) {
1055
1105
  import('open').then((m) => m.default(accessUrl(actual)));