claude-code-hub 1.0.3 → 1.2.0
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 +34 -0
- package/lib/net-guard.js +41 -14
- package/package.json +5 -5
- package/packages/claude-hub-sdk/src/client.js +3 -1
- package/packages/claude-hub-sdk/src/server.js +22 -0
- package/packages/claude-hub-sdk/src/stub.js +12 -2
- package/public/app.js +62 -9
- package/public/index.html +20 -0
- package/server.js +11 -1
- package/lib/ping.js +0 -23
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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.0
|
|
3
|
+
"version": "1.2.0",
|
|
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.
|
|
49
|
-
"claude-code-kanban": "5.
|
|
50
|
-
"claude-code-marketplace": "1.
|
|
51
|
-
"claude-code-memory-explorer": "1.
|
|
48
|
+
"claude-code-cost": "1.2.0",
|
|
49
|
+
"claude-code-kanban": "5.3.0",
|
|
50
|
+
"claude-code-marketplace": "1.2.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
|
|
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(
|
|
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
|
-
|
|
158
|
-
|
|
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
|
|
|
@@ -229,6 +235,7 @@ async function init() {
|
|
|
229
235
|
listenKeys();
|
|
230
236
|
bindPalette();
|
|
231
237
|
registerSW();
|
|
238
|
+
bindErrorToast();
|
|
232
239
|
showUpdateToast();
|
|
233
240
|
}
|
|
234
241
|
|
|
@@ -273,6 +280,37 @@ async function showUpdateToast() {
|
|
|
273
280
|
arm();
|
|
274
281
|
}
|
|
275
282
|
|
|
283
|
+
const ERROR_TOAST_MS = 8_000;
|
|
284
|
+
let errorToastTimer = 0;
|
|
285
|
+
|
|
286
|
+
function hideErrorToast() {
|
|
287
|
+
clearTimeout(errorToastTimer);
|
|
288
|
+
const toast = document.getElementById('error-toast');
|
|
289
|
+
toast.classList.add('leaving');
|
|
290
|
+
setTimeout(() => (toast.hidden = true), 200);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// A 401 means the hub_token cookie no longer matches the token file. Opening /?token=… sets a new
|
|
294
|
+
// cookie; reloading lands on the locked page, where the token can be pasted instead.
|
|
295
|
+
function showErrorToast(action, err) {
|
|
296
|
+
const unauthorized = err.status === 401;
|
|
297
|
+
document.getElementById('error-title').textContent = unauthorized ? `${action}: hub token rejected` : action;
|
|
298
|
+
document.getElementById('error-detail').textContent = unauthorized
|
|
299
|
+
? `This browser has an old hub token. Open the link the hub printed in the terminal (${location.origin}/?token=…) to renew it.`
|
|
300
|
+
: err.message;
|
|
301
|
+
document.getElementById('error-actions').hidden = !unauthorized;
|
|
302
|
+
const toast = document.getElementById('error-toast');
|
|
303
|
+
toast.classList.remove('leaving');
|
|
304
|
+
toast.hidden = false;
|
|
305
|
+
clearTimeout(errorToastTimer);
|
|
306
|
+
if (!unauthorized) errorToastTimer = setTimeout(hideErrorToast, ERROR_TOAST_MS);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function bindErrorToast() {
|
|
310
|
+
document.getElementById('error-close').addEventListener('click', hideErrorToast);
|
|
311
|
+
document.getElementById('error-reload').addEventListener('click', () => location.reload());
|
|
312
|
+
}
|
|
313
|
+
|
|
276
314
|
// The hub has no visible chrome, so the window/tab title is the only place the active config dir
|
|
277
315
|
// shows. Only a non-default dir is named, and by basename alone — the PWA window prepends the
|
|
278
316
|
// manifest name, so a product name here would read as "Claude Code Hub - .claude-eom · Hub".
|
|
@@ -612,6 +650,8 @@ async function loadProjects() {
|
|
|
612
650
|
path: p.path,
|
|
613
651
|
name: basename(p.path),
|
|
614
652
|
parent: parentOf(p.path),
|
|
653
|
+
worktrees: p.worktrees ?? [],
|
|
654
|
+
worktreeNames: (p.worktrees ?? []).map((w) => basename(w).toLowerCase()),
|
|
615
655
|
ts: p.modifiedAt ? Date.parse(p.modifiedAt) : 0,
|
|
616
656
|
}))
|
|
617
657
|
.sort((a, b) => b.ts - a.ts);
|
|
@@ -644,6 +684,7 @@ function projectRows(projects, q) {
|
|
|
644
684
|
const path = p.path.toLowerCase();
|
|
645
685
|
if (path.split(/[/\\]/).includes(q)) return PATH_RANK;
|
|
646
686
|
if (path.includes(q)) return PATH_RANK + 1;
|
|
687
|
+
if (p.worktreeNames.some((w) => w.includes(q))) return PATH_RANK + 2;
|
|
647
688
|
return -1;
|
|
648
689
|
};
|
|
649
690
|
let out = projects.map((p) => ({ p, rank: rankOf(p) })).filter((x) => x.rank >= 0);
|
|
@@ -712,6 +753,10 @@ function relAge(ts) {
|
|
|
712
753
|
return `${Math.round(m / 1440)}d`;
|
|
713
754
|
}
|
|
714
755
|
|
|
756
|
+
async function loadAppStats() {
|
|
757
|
+
palette.appStats = await sendJson('GET', '/api/apps/stats');
|
|
758
|
+
}
|
|
759
|
+
|
|
715
760
|
async function loadConfigDirs() {
|
|
716
761
|
palette.configDirs = await sendJson('GET', '/api/config-dirs');
|
|
717
762
|
}
|
|
@@ -722,7 +767,7 @@ async function removeConfigDir(dirPath) {
|
|
|
722
767
|
try {
|
|
723
768
|
palette.configDirs = await sendJson('DELETE', '/api/config-dirs', { path: dirPath });
|
|
724
769
|
} catch (err) {
|
|
725
|
-
|
|
770
|
+
showErrorToast('Could not remove config dir', err);
|
|
726
771
|
return;
|
|
727
772
|
}
|
|
728
773
|
if (dirPath in themeState.colorThemes) {
|
|
@@ -908,12 +953,17 @@ const PALETTE_MODES = {
|
|
|
908
953
|
hint: 'Enter switch · Ctrl+R restart the app',
|
|
909
954
|
empty: 'No matching app',
|
|
910
955
|
rows: (_typed, q) => appRows(q),
|
|
956
|
+
load: loadAppStats,
|
|
911
957
|
initialSel(rows) {
|
|
912
958
|
const prev = rows.findIndex((r) => r.id === previousApp);
|
|
913
959
|
return prev < 0 ? 0 : prev;
|
|
914
960
|
},
|
|
915
961
|
fillRow(li, row, q) {
|
|
916
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);
|
|
917
967
|
const key = row.id === activeApp ? 'active' : row.index < 9 ? `Alt+${row.index + 1}` : '';
|
|
918
968
|
li.append(el('span', 'age', key));
|
|
919
969
|
li.append(rowButton('palette-restart', '↻', 'r', 'Restart the app (Ctrl+R)', `Restart ${row.app.name}`));
|
|
@@ -993,7 +1043,10 @@ function openPalette(mode) {
|
|
|
993
1043
|
.then(() => {
|
|
994
1044
|
if (palette.open && palette.mode === mode) renderPalette();
|
|
995
1045
|
})
|
|
996
|
-
.catch((err) =>
|
|
1046
|
+
.catch((err) => {
|
|
1047
|
+
if (err.status === 401) showErrorToast('Could not load the list', err);
|
|
1048
|
+
else console.warn(`${mode} list unavailable:`, err.message);
|
|
1049
|
+
});
|
|
997
1050
|
}
|
|
998
1051
|
|
|
999
1052
|
function closePalette() {
|
|
@@ -1025,7 +1078,7 @@ async function commitProject(row) {
|
|
|
1025
1078
|
const absPath = row.kind === 'project' ? row.path : await resolveTypedPath(row.path);
|
|
1026
1079
|
if (!absPath) return;
|
|
1027
1080
|
closePalette();
|
|
1028
|
-
setProject(absPath);
|
|
1081
|
+
setProject(absPath, row.p?.worktrees);
|
|
1029
1082
|
}
|
|
1030
1083
|
|
|
1031
1084
|
async function sendJson(method, url, body) {
|
|
@@ -1057,7 +1110,7 @@ async function commitConfigDir(row) {
|
|
|
1057
1110
|
try {
|
|
1058
1111
|
target = (await withPaletteBusy(() => sendJson('POST', '/api/config-dirs', { path: row.path }))).path;
|
|
1059
1112
|
} catch (err) {
|
|
1060
|
-
|
|
1113
|
+
showErrorToast('Could not add config dir', err);
|
|
1061
1114
|
return;
|
|
1062
1115
|
}
|
|
1063
1116
|
}
|
|
@@ -1075,7 +1128,7 @@ async function commitConfigDir(row) {
|
|
|
1075
1128
|
applyHubTheme();
|
|
1076
1129
|
reloadIframes();
|
|
1077
1130
|
} catch (err) {
|
|
1078
|
-
|
|
1131
|
+
showErrorToast('Could not switch config dir', err);
|
|
1079
1132
|
hideLoading();
|
|
1080
1133
|
}
|
|
1081
1134
|
}
|
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);
|
|
@@ -376,6 +384,7 @@
|
|
|
376
384
|
}
|
|
377
385
|
|
|
378
386
|
.update-toast-close:hover { color: var(--text); }
|
|
387
|
+
.error-toast { z-index: 25; border-left-color: #e5484d; }
|
|
379
388
|
</style>
|
|
380
389
|
</head>
|
|
381
390
|
<body>
|
|
@@ -448,6 +457,17 @@
|
|
|
448
457
|
<button class="update-toast-close" id="update-close" type="button" aria-label="Close">×</button>
|
|
449
458
|
</div>
|
|
450
459
|
|
|
460
|
+
<div class="update-toast error-toast" id="error-toast" role="alert" hidden>
|
|
461
|
+
<div class="update-toast-body">
|
|
462
|
+
<div class="update-toast-title" id="error-title"></div>
|
|
463
|
+
<div class="update-toast-meta" id="error-detail"></div>
|
|
464
|
+
<div class="update-toast-actions" id="error-actions" hidden>
|
|
465
|
+
<button class="update-toast-skip" id="error-reload" type="button">Paste token instead</button>
|
|
466
|
+
</div>
|
|
467
|
+
</div>
|
|
468
|
+
<button class="update-toast-close" id="error-close" type="button" aria-label="Close">×</button>
|
|
469
|
+
</div>
|
|
470
|
+
|
|
451
471
|
<script src="/app.js"></script>
|
|
452
472
|
</body>
|
|
453
473
|
</html>
|
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/
|
|
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 };
|