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.
- package/lib/port-health.js +34 -0
- package/package.json +1 -1
- package/public/app.js +46 -22
- package/public/index.html +9 -0
- package/server.js +44 -1
|
@@ -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
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
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
|
-
|
|
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) =>
|
|
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)));
|