claude-code-hub 1.27.2 → 1.28.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/README.md +1 -0
- package/extensions/xmas/hub.css +33 -0
- package/extensions/xmas/hub.js +199 -0
- package/extensions/xmas/kanban.css +141 -0
- package/extensions/xmas/kanban.js +57 -0
- package/extensions/xmas/mod.json +57 -0
- package/lib/apps.js +1 -0
- package/lib/ext.js +117 -8
- package/lib/mods.js +156 -0
- package/lib/themes.js +24 -8
- package/lib/voice/grammar.js +31 -4
- package/mod-surface.json +10 -0
- package/package.json +4 -2
- package/packages/claude-hub-sdk/src/client.js +27 -1
- package/packages/claude-hub-sdk/src/mods.js +128 -0
- package/packages/claude-hub-sdk/src/server.js +26 -3
- package/packages/claude-hub-sdk/src/stub.js +3 -1
- package/public/app.js +59 -7
- package/public/index.html +11 -1
- package/server.js +45 -16
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Claude Code Hub SDK: the runtime of hub mods, shared by client.js and the hub page. mount() and
|
|
2
|
+
// sdk:sync put it in front of client.js; the hub page gets it inside /mods/hub.js.
|
|
3
|
+
// var, not const: the hub page and an app page each load it as a classic script.
|
|
4
|
+
var ClaudeHubModKit = (() => {
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
// "<mod id>:<name>". The id must be an installed mod's, so two mods cannot share a topic.
|
|
8
|
+
const TOPIC_RE = /^([a-z0-9-]+):[\w.-]+$/;
|
|
9
|
+
|
|
10
|
+
// The page's mod list is in win.ClaudeHubMods, ahead of this file. Each CSS file loads after the
|
|
11
|
+
// page's own styles; each JS file is a deferred script, so it runs after a deferred app.js, in
|
|
12
|
+
// list order. ?nomods in the page URL loads none: the safe mode for a mod that breaks the page.
|
|
13
|
+
function createMods(win) {
|
|
14
|
+
const doc = win.document;
|
|
15
|
+
const off = /[?&]nomods(?:[=&]|$)/.test(win.location?.search ?? '');
|
|
16
|
+
// Only an app's own tab, or the page on its own. A page nested in an app's page (cck's terminal,
|
|
17
|
+
// a pane) is not what the mod was written for, and ?nomods reaches only the tabs.
|
|
18
|
+
const nested = win.parent !== win.top;
|
|
19
|
+
const assets = off || nested ? null : win.ClaudeHubMods;
|
|
20
|
+
const list = assets?.mods ?? [];
|
|
21
|
+
const ids = new Set(list.map((m) => m.id));
|
|
22
|
+
const listeners = new Map();
|
|
23
|
+
const wrappedNames = new Set();
|
|
24
|
+
const outbox = new Map();
|
|
25
|
+
let sender = null;
|
|
26
|
+
const report = (what, err) => win.console?.error?.(`[hub mod] ${what}:`, err);
|
|
27
|
+
|
|
28
|
+
function load() {
|
|
29
|
+
if (!assets || !doc) return;
|
|
30
|
+
const js = assets.js ?? [];
|
|
31
|
+
// Mod code runs with the page's rights. connect-src stops fetch, XHR, WebSocket and beacons to
|
|
32
|
+
// other hosts; it does not stop image or script loads or navigation, so it guards against a
|
|
33
|
+
// careless mod, not a hostile one. Consent at install is the real boundary. A wider policy
|
|
34
|
+
// (script-src, img-src) would break the apps' own inline scripts and remote images.
|
|
35
|
+
if (js.length) {
|
|
36
|
+
doc.head.appendChild(
|
|
37
|
+
Object.assign(doc.createElement('meta'), {
|
|
38
|
+
httpEquiv: 'Content-Security-Policy',
|
|
39
|
+
content: "connect-src 'self'",
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
const tags = [
|
|
44
|
+
...(assets.css ?? []).map((href) => Object.assign(doc.createElement('link'), { rel: 'stylesheet', href })),
|
|
45
|
+
...js.map((src) => Object.assign(doc.createElement('script'), { src, defer: true })),
|
|
46
|
+
];
|
|
47
|
+
// document.write keeps a parser-inserted script deferred and in order; appendChild needs async off for that.
|
|
48
|
+
if (!tags.length) return;
|
|
49
|
+
if (doc.readyState === 'loading') doc.write(tags.map((t) => t.outerHTML).join(''));
|
|
50
|
+
else
|
|
51
|
+
for (const t of tags) {
|
|
52
|
+
if (t.tagName === 'SCRIPT') t.async = false;
|
|
53
|
+
(t.tagName === 'LINK' ? doc.head : doc.body).appendChild(t);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// fn(original, ...args). When fn throws, the wrapper returns what original returned, and runs
|
|
58
|
+
// original first if fn had not called it yet, so a broken mod leaves the app as it was.
|
|
59
|
+
function wrap(name, fn) {
|
|
60
|
+
const original = win[name];
|
|
61
|
+
if (typeof original !== 'function') {
|
|
62
|
+
report(`wrap(${name})`, new Error('the app has no such global function'));
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
if (wrappedNames.has(name))
|
|
66
|
+
win.console?.warn?.(`[hub mod] wrap(${name}): two mods wrap it; the later one runs outside the earlier`);
|
|
67
|
+
wrappedNames.add(name);
|
|
68
|
+
win[name] = function wrapped(...args) {
|
|
69
|
+
let called = false;
|
|
70
|
+
let result;
|
|
71
|
+
const next = (...a) => {
|
|
72
|
+
called = true;
|
|
73
|
+
result = original.apply(this, a.length ? a : args);
|
|
74
|
+
return result;
|
|
75
|
+
};
|
|
76
|
+
try {
|
|
77
|
+
return fn.call(this, next, ...args);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
report(`wrap(${name})`, err);
|
|
80
|
+
return called ? result : original.apply(this, args);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function on(event, fn) {
|
|
87
|
+
if (!listeners.has(event)) listeners.set(event, new Set());
|
|
88
|
+
listeners.get(event).add(fn);
|
|
89
|
+
return () => listeners.get(event)?.delete(fn);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function emit(event, detail) {
|
|
93
|
+
for (const fn of listeners.get(event) ?? []) {
|
|
94
|
+
try {
|
|
95
|
+
fn(detail);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
report(`on(${event})`, err);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// To the same mod's code on the hub page, as mod.on(topic) there with {app, data}. Before the
|
|
103
|
+
// hub answers, only the latest data per topic waits.
|
|
104
|
+
function send(topic, data) {
|
|
105
|
+
const m = TOPIC_RE.exec(typeof topic === 'string' ? topic : '');
|
|
106
|
+
if (!m || !ids.has(m[1])) {
|
|
107
|
+
report(`send(${topic})`, new Error('the topic must be "<mod id>:<name>", with the id of an installed mod'));
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
if (sender) sender({ type: 'hub:mod', topic, data });
|
|
111
|
+
else outbox.set(topic, data);
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function connectSender(fn) {
|
|
116
|
+
sender = fn;
|
|
117
|
+
for (const [topic, data] of outbox) fn({ type: 'hub:mod', topic, data });
|
|
118
|
+
outbox.clear();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
load();
|
|
122
|
+
return { mod: { list, wrap, on, emit, send }, connectSender };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { createMods, TOPIC_RE };
|
|
126
|
+
})();
|
|
127
|
+
|
|
128
|
+
if (typeof module === 'object' && module.exports) module.exports = ClaudeHubModKit;
|
|
@@ -40,17 +40,40 @@ function answerHub() {
|
|
|
40
40
|
process.channel.unref();
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
// What an app page loads as /vendor/claude-hub-sdk.js: keys.js, then client.js or stub.js.
|
|
43
|
+
// What an app page loads as /vendor/claude-hub-sdk.js: keys.js, then client.js with mods.js ahead of it, or stub.js.
|
|
44
44
|
function bundle(file) {
|
|
45
45
|
const read = (f) => fs.readFileSync(path.join(__dirname, f), 'utf8');
|
|
46
|
-
|
|
46
|
+
const parts = file === 'client.js' ? ['keys.js', 'mods.js', file] : ['keys.js', file];
|
|
47
|
+
return parts.map(read).join('\n');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// HUB_MODS, fixed for the process's life: {assets, files}. `assets` is the page's mod list as the
|
|
51
|
+
// client reads it; `files` maps each /mods path to the file on disk, and only those are served.
|
|
52
|
+
// The hub builds both (lib/mods.js appModEnv), so the URL format is not repeated here.
|
|
53
|
+
function readMods() {
|
|
54
|
+
try {
|
|
55
|
+
const { assets, files } = JSON.parse(process.env.HUB_MODS || '{}');
|
|
56
|
+
if (assets && typeof assets === 'object' && files && typeof files === 'object')
|
|
57
|
+
return { assets, files: new Map(Object.entries(files)) };
|
|
58
|
+
} catch {}
|
|
59
|
+
return { assets: null, files: new Map() };
|
|
47
60
|
}
|
|
48
61
|
|
|
49
62
|
// Mount before express.static: the hub's client must win over the app's stub in public/vendor.
|
|
50
63
|
function mount(app) {
|
|
51
64
|
answerHub();
|
|
52
|
-
const
|
|
65
|
+
const { assets, files } = readMods();
|
|
66
|
+
const prefix = assets ? `self.ClaudeHubMods = ${JSON.stringify(assets)};\n` : '';
|
|
67
|
+
const client = prefix + bundle('client.js');
|
|
53
68
|
app.get('/vendor/claude-hub-sdk.js', (_req, res) => res.type('js').send(client));
|
|
69
|
+
if (files.size) {
|
|
70
|
+
// app.use, not a route pattern, so it works on each app's Express version.
|
|
71
|
+
app.use('/mods', (req, res, next) => {
|
|
72
|
+
const abs = files.get(req.path);
|
|
73
|
+
if (!abs || (req.method !== 'GET' && req.method !== 'HEAD')) return next();
|
|
74
|
+
res.sendFile(abs, { maxAge: '1y', immutable: true });
|
|
75
|
+
});
|
|
76
|
+
}
|
|
54
77
|
app.get('/hub-config', (_req, res) => {
|
|
55
78
|
res.json({ enabled: !!process.env.CLAUDE_HUB, url: process.env.HUB_URL || null });
|
|
56
79
|
});
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
forwards: () => false,
|
|
41
41
|
forwardCombos: () => [],
|
|
42
42
|
keyLabel: () => null,
|
|
43
|
+
voiceCommands: () => [],
|
|
43
44
|
closeGuard() {},
|
|
44
45
|
openExternal(url) {
|
|
45
46
|
win.open(url, '_blank', 'noopener');
|
|
@@ -49,7 +50,8 @@
|
|
|
49
50
|
return hub;
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
|
|
53
|
+
const mod = { list: [], wrap: () => false, on: () => off, emit() {}, send: () => false };
|
|
54
|
+
return { connect, comboOf, mod };
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
if (typeof module === 'object' && module.exports) module.exports = { createClaudeHub, comboOf };
|
package/public/app.js
CHANGED
|
@@ -126,6 +126,9 @@ const HUB_VARS = {
|
|
|
126
126
|
};
|
|
127
127
|
// The last applied variables, so the loading screen paints in the theme before /api/config lands.
|
|
128
128
|
const PALETTE_KEY = 'hub-palette';
|
|
129
|
+
// /?nomods: the safe mode for a mod that breaks a page. The hub page and every app frame load no mod.
|
|
130
|
+
const NO_MODS = new URLSearchParams(location.search).has('nomods');
|
|
131
|
+
if (NO_MODS) document.querySelector('link[href="/mods/hub.css"]')?.remove();
|
|
129
132
|
|
|
130
133
|
// Paints the hub's own surfaces (loading screen, palette) in the active theme. The hub relays
|
|
131
134
|
// hub:theme to the sub-apps; this is what makes it apply to the hub's own chrome too.
|
|
@@ -133,6 +136,7 @@ function applyHubTheme() {
|
|
|
133
136
|
const mode = themeState.theme ?? osTheme();
|
|
134
137
|
const root = document.documentElement;
|
|
135
138
|
root.classList.toggle('light', mode === 'light');
|
|
139
|
+
root.dataset.hubMode = mode;
|
|
136
140
|
// No app has named a theme yet: paint the one every app starts with.
|
|
137
141
|
const theme = activeColorTheme() ?? DEFAULT_COLOR_THEME;
|
|
138
142
|
const key = `${theme}/${mode}`;
|
|
@@ -482,15 +486,51 @@ const handledHere = (action) => actions[action]?.app === activeApp;
|
|
|
482
486
|
const voiceRowsHere = () => voiceCommands.filter((c) => handledHere(c.action));
|
|
483
487
|
const tabName = () => apps[activeApp]?.name ?? 'this tab';
|
|
484
488
|
|
|
489
|
+
// The voice.context of the app on screen: {title, rows: {action: false to hide the row, or a help text}}.
|
|
490
|
+
function voiceContextHere() {
|
|
491
|
+
const last = published.get('voice.context');
|
|
492
|
+
return last?.from === activeApp && isPlainObject(last.payload?.rows) ? last.payload : null;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// With a title, the rows the app marks come first and the rest fold into one "Also" line.
|
|
496
|
+
function voiceHelpRows(here, ctx) {
|
|
497
|
+
const marks = ctx?.rows ?? {};
|
|
498
|
+
const shown = here.filter((c) => marks[c.action] !== false);
|
|
499
|
+
const row = (c) =>
|
|
500
|
+
helpRow(
|
|
501
|
+
c.action,
|
|
502
|
+
`Hub, ${c.say}`,
|
|
503
|
+
c.param ? `<${c.param}>` : '',
|
|
504
|
+
typeof marks[c.action] === 'string' ? marks[c.action] : c.help,
|
|
505
|
+
);
|
|
506
|
+
if (typeof ctx?.title !== 'string') {
|
|
507
|
+
const draft = helpRow('session.paste', 'anything else', '', 'Typed into the session as a draft', 'other');
|
|
508
|
+
return [...shown.map(row), ...(voice.drafts ? [draft] : [])];
|
|
509
|
+
}
|
|
510
|
+
const folded = [
|
|
511
|
+
...shown.filter((c) => !marks[c.action]),
|
|
512
|
+
...(voice.drafts ? [{ action: 'session.paste', say: 'a draft' }] : []),
|
|
513
|
+
];
|
|
514
|
+
const fold = helpRow(
|
|
515
|
+
folded.map((c) => c.action).join(' '),
|
|
516
|
+
`Also: ${folded.map((c) => c.say).join(', ')}`,
|
|
517
|
+
'',
|
|
518
|
+
'',
|
|
519
|
+
'other fold',
|
|
520
|
+
);
|
|
521
|
+
return [...shown.filter((c) => marks[c.action]).map(row), ...(folded.length ? [fold] : [])];
|
|
522
|
+
}
|
|
523
|
+
|
|
485
524
|
// Open for the whole hold: what the user can say on this tab, with the row the words match lit.
|
|
486
525
|
function showVoiceHelp() {
|
|
487
526
|
const panel = document.getElementById('voice-help');
|
|
488
527
|
clearTimeout(voice.helpTimer);
|
|
489
|
-
const
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
document.getElementById('voice-help-
|
|
493
|
-
|
|
528
|
+
const ctx = voiceContextHere();
|
|
529
|
+
const asks = typeof ctx?.title === 'string';
|
|
530
|
+
document.getElementById('voice-help-rows').replaceChildren(...voiceHelpRows(voiceRowsHere(), ctx));
|
|
531
|
+
const title = document.getElementById('voice-help-title');
|
|
532
|
+
title.replaceChildren(el('span', '', asks ? ctx.title : `What you can say on ${tabName()}`));
|
|
533
|
+
title.classList.toggle('asks', asks);
|
|
494
534
|
document.getElementById('voice-help-tip').textContent = voice.drafts
|
|
495
535
|
? 'Enter during the hold sends · Backspace cancels'
|
|
496
536
|
: 'Backspace cancels';
|
|
@@ -506,7 +546,7 @@ function hideVoiceHelp(afterMs = 0) {
|
|
|
506
546
|
|
|
507
547
|
function markVoiceHelp(action) {
|
|
508
548
|
for (const row of document.getElementById('voice-help-rows').children) {
|
|
509
|
-
row.classList.toggle('on', row.dataset.action
|
|
549
|
+
row.classList.toggle('on', row.dataset.action.split(' ').includes(action));
|
|
510
550
|
}
|
|
511
551
|
}
|
|
512
552
|
|
|
@@ -967,7 +1007,13 @@ function loadApp(id, src, { link = false } = {}) {
|
|
|
967
1007
|
// The terminal token rides in the fragment so it never reaches a server log or a Referer.
|
|
968
1008
|
function appSrc(id, path = '') {
|
|
969
1009
|
const token = apps[id].terminalToken;
|
|
970
|
-
|
|
1010
|
+
let src = apps[id].url + path;
|
|
1011
|
+
const url = NO_MODS && URL.parse(src);
|
|
1012
|
+
if (url) {
|
|
1013
|
+
url.searchParams.set('nomods', '1');
|
|
1014
|
+
src = url.href;
|
|
1015
|
+
}
|
|
1016
|
+
return src + (token ? `#t=${token}` : '');
|
|
971
1017
|
}
|
|
972
1018
|
|
|
973
1019
|
// From each app's manifest. Before /api/config lands there are none, and the first line is generic.
|
|
@@ -1072,6 +1118,9 @@ function welcomeFor(data, appId) {
|
|
|
1072
1118
|
actions: [...Object.keys(actions), ...(hubDictates() ? [DICTATE] : [])],
|
|
1073
1119
|
origins: Object.keys(apps).map(originOf),
|
|
1074
1120
|
settings: apps[appId]?.settings ?? {},
|
|
1121
|
+
voice: voiceOn
|
|
1122
|
+
? voiceCommands.filter((c) => actions[c.action]?.app === appId).map(({ say, help }) => ({ say, help }))
|
|
1123
|
+
: [],
|
|
1075
1124
|
};
|
|
1076
1125
|
}
|
|
1077
1126
|
|
|
@@ -1187,6 +1236,9 @@ function listenMessages() {
|
|
|
1187
1236
|
openExternal(data);
|
|
1188
1237
|
} else if (data.type === 'hub:theme') {
|
|
1189
1238
|
onThemeMessage(data);
|
|
1239
|
+
} else if (data.type === 'hub:mod' && typeof data.topic === 'string') {
|
|
1240
|
+
// From a mod's code in the app (mod.send) to the same mod on this page, which /mods/hub.js loads.
|
|
1241
|
+
window.ClaudeHub?.mod?.emit(data.topic, { app: appId, data: data.data });
|
|
1190
1242
|
}
|
|
1191
1243
|
});
|
|
1192
1244
|
}
|
package/public/index.html
CHANGED
|
@@ -544,9 +544,11 @@
|
|
|
544
544
|
.voice-help {
|
|
545
545
|
position: fixed;
|
|
546
546
|
right: 16px;
|
|
547
|
-
|
|
547
|
+
top: 50%;
|
|
548
|
+
translate: 0 -50%;
|
|
548
549
|
z-index: 30;
|
|
549
550
|
width: min(360px, calc(100vw - 32px));
|
|
551
|
+
max-height: calc(100vh - 32px);
|
|
550
552
|
overflow: hidden;
|
|
551
553
|
background: var(--surface);
|
|
552
554
|
border: 1px solid var(--border);
|
|
@@ -591,6 +593,12 @@
|
|
|
591
593
|
.voice-help-row.other .say { color: var(--text); }
|
|
592
594
|
.voice-help-row .say i { color: var(--text-dim); font-style: normal; }
|
|
593
595
|
.voice-help-row .what { color: var(--text-dim); font-size: 12px; }
|
|
596
|
+
.voice-help-row .what:empty { display: none; }
|
|
597
|
+
.voice-help-row.fold { padding-block: 6px; }
|
|
598
|
+
.voice-help-row.fold .say { color: var(--text-dim); font-size: 12px; }
|
|
599
|
+
.voice-help-title::before { flex-shrink: 0; }
|
|
600
|
+
.voice-help-title > span { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
601
|
+
.voice-help-title.asks { color: var(--warn); }
|
|
594
602
|
.voice-help-tip { padding: 8px 14px; background: var(--bg); border-top: 1px solid var(--border); color: var(--text-dim); font-size: 12px; }
|
|
595
603
|
@media (prefers-reduced-motion: reduce) {
|
|
596
604
|
.voice-help, .voice-help-title::before { animation: none; }
|
|
@@ -630,6 +638,7 @@
|
|
|
630
638
|
.update-toast-close:hover { color: var(--text); }
|
|
631
639
|
.error-toast { z-index: 25; border-left-color: #e5484d; }
|
|
632
640
|
</style>
|
|
641
|
+
<link rel="stylesheet" href="/mods/hub.css">
|
|
633
642
|
</head>
|
|
634
643
|
<body>
|
|
635
644
|
<div class="iframe-container" id="iframe-container">
|
|
@@ -740,5 +749,6 @@
|
|
|
740
749
|
|
|
741
750
|
<script src="/sdk/keys.js"></script>
|
|
742
751
|
<script src="/app.js"></script>
|
|
752
|
+
<script src="/mods/hub.js"></script>
|
|
743
753
|
</body>
|
|
744
754
|
</html>
|
package/server.js
CHANGED
|
@@ -16,7 +16,8 @@ const { createNetGuard, KEEP_ALIVE_MS } = require('./lib/net-guard');
|
|
|
16
16
|
const { ping, stats } = require('./lib/ipc');
|
|
17
17
|
const { killChild } = require('./lib/kill-child');
|
|
18
18
|
const { createPortHealth } = require('./lib/port-health');
|
|
19
|
-
const { readRegistry, themeConfig } = require('./lib/themes');
|
|
19
|
+
const { readRegistry, themeConfig, modThemes } = require('./lib/themes');
|
|
20
|
+
const { resolveMods, modAssets, appModEnv, readSurface, hubModScript } = require('./lib/mods');
|
|
20
21
|
const { createUpdateCheck } = require('./lib/update-check');
|
|
21
22
|
const { liveHub, removeRecord, writeRecord } = require('./lib/hub-record');
|
|
22
23
|
const { HUB_PLUGIN } = require('./lib/install');
|
|
@@ -159,6 +160,9 @@ function resolveHubConfig() {
|
|
|
159
160
|
// Hand-edited only; kept verbatim so a rewrite of the file does not drop it.
|
|
160
161
|
if (saved.terminal && typeof saved.terminal === 'object') config.terminal = saved.terminal;
|
|
161
162
|
if (Array.isArray(saved.apps)) config.apps = saved.apps;
|
|
163
|
+
if (Array.isArray(saved.addons)) config.addons = saved.addons;
|
|
164
|
+
if (Array.isArray(saved.mods)) config.mods = saved.mods;
|
|
165
|
+
if (typeof saved.modsEnabled === 'boolean') config.modsEnabled = saved.modsEnabled;
|
|
162
166
|
if (saved.keys !== undefined) config.keys = saved.keys;
|
|
163
167
|
if (saved.voice && typeof saved.voice === 'object') config.voice = saved.voice;
|
|
164
168
|
copyLive(saved, config);
|
|
@@ -206,6 +210,18 @@ if (!ENABLED_APPS.length) {
|
|
|
206
210
|
console.error(`No enabled app could load (${ids}). The reasons are logged above.`);
|
|
207
211
|
process.exit(1);
|
|
208
212
|
}
|
|
213
|
+
const SURFACES = {
|
|
214
|
+
hub: readSurface(__dirname),
|
|
215
|
+
...Object.fromEntries(ENABLED_APPS.map((a) => [a.id, readSurface(a.root)])),
|
|
216
|
+
};
|
|
217
|
+
const MODS_OFF = process.argv.includes('--no-mods') || hubConfig.modsEnabled === false;
|
|
218
|
+
if (MODS_OFF && hubConfig.mods?.length) console.log('mods are off (--no-mods or modsEnabled: false): no mod loads');
|
|
219
|
+
const MODS = resolveMods(hubConfig, path.dirname(HUB_CONFIG_FILE), {
|
|
220
|
+
warn: console.log,
|
|
221
|
+
surfaces: SURFACES,
|
|
222
|
+
off: MODS_OFF,
|
|
223
|
+
});
|
|
224
|
+
for (const m of MODS) console.log(`mod ${m.id} changes ${Object.keys(m.targets).join(', ')}`);
|
|
209
225
|
const ACTIONS = actionTable(ENABLED_APPS);
|
|
210
226
|
const { keys: USER_KEYS, lines: keyLines } = userKeys(hubConfig.keys, ENABLED_APPS);
|
|
211
227
|
for (const line of keyLines) console.log(`${HUB_CONFIG_FILE}: ${line}`);
|
|
@@ -299,6 +315,10 @@ function childEnv(pool, name) {
|
|
|
299
315
|
ALLOWED_HOSTS: net.ALLOWED_HOSTS,
|
|
300
316
|
HUB_SDK_SERVER,
|
|
301
317
|
};
|
|
318
|
+
// The app's own process serves its mod files (the SDK's mount), so they load from the app's origin.
|
|
319
|
+
const mods = appModEnv(MODS, name);
|
|
320
|
+
if (mods) env.HUB_MODS = mods;
|
|
321
|
+
else delete env.HUB_MODS;
|
|
302
322
|
if (TERMINAL.enabled && name === TERMINAL_APP.id) {
|
|
303
323
|
env.CCK_TERMINAL = JSON.stringify(TERMINAL);
|
|
304
324
|
env.CCK_TERMINAL_TOKEN = TERMINAL_TOKEN;
|
|
@@ -919,6 +939,7 @@ app.use(express.json());
|
|
|
919
939
|
const THEME_REGISTRY = readRegistry();
|
|
920
940
|
// Read on each /api/config, so an edit applies on the next page load.
|
|
921
941
|
const USER_THEMES_FILE = path.join(HUB_DIR, 'themes.json');
|
|
942
|
+
const MOD_THEMES = modThemes(THEME_REGISTRY, MODS, console.log);
|
|
922
943
|
|
|
923
944
|
function appsConfig() {
|
|
924
945
|
return Object.fromEntries(
|
|
@@ -958,7 +979,7 @@ app.get('/api/update', async (_req, res) => {
|
|
|
958
979
|
|
|
959
980
|
app.get('/api/config', (_req, res) => {
|
|
960
981
|
res.json({
|
|
961
|
-
...themeConfig(THEME_REGISTRY, { userFile: USER_THEMES_FILE }),
|
|
982
|
+
...themeConfig(THEME_REGISTRY, { userFile: USER_THEMES_FILE, extra: MOD_THEMES }),
|
|
962
983
|
apps: appsConfig(),
|
|
963
984
|
actions: ACTIONS,
|
|
964
985
|
keys: USER_KEYS,
|
|
@@ -1328,7 +1349,6 @@ app.get('/api/projects', async (_req, res) => {
|
|
|
1328
1349
|
});
|
|
1329
1350
|
|
|
1330
1351
|
// Voice control, off unless config.json has voice.enabled; lib/voice loads only then.
|
|
1331
|
-
const VOICE_LOG = path.join(HUB_DIR, 'voice.jsonl');
|
|
1332
1352
|
const voice = hubConfig.voice?.enabled === true ? loadVoice() : null;
|
|
1333
1353
|
|
|
1334
1354
|
function loadVoice() {
|
|
@@ -1353,27 +1373,17 @@ const heldOn = (req) => (typeof req.query.app === 'string' ? req.query.app : und
|
|
|
1353
1373
|
app.post('/api/voice/text', express.json({ limit: '64kb' }), async (req, res) => {
|
|
1354
1374
|
if (!voice) return res.status(404).json({ error: 'voice is off' });
|
|
1355
1375
|
if (typeof req.body?.text !== 'string') return res.status(400).json({ error: 'text is required' });
|
|
1356
|
-
|
|
1357
|
-
const r = await voice.text(req.body.text, req.body.final === true, heldOn(req));
|
|
1358
|
-
if (req.body.final === true) logVoice(r, t0, { provider: 'browser' });
|
|
1359
|
-
res.json(r);
|
|
1376
|
+
res.json(await voice.text(req.body.text, req.body.final === true, heldOn(req)));
|
|
1360
1377
|
});
|
|
1361
1378
|
|
|
1362
1379
|
app.post('/api/voice/:op', async (req, res) => {
|
|
1363
1380
|
if (!voice) return res.status(404).json({ error: 'voice is off' });
|
|
1364
1381
|
const { op } = req.params;
|
|
1365
1382
|
if (!['start', 'stop', 'dictate', 'cancel'].includes(op)) return res.status(404).json({ error: 'unknown op' });
|
|
1366
|
-
const t0 = Date.now();
|
|
1367
1383
|
const r = await voice[op](heldOn(req));
|
|
1368
|
-
if ((op === 'stop' || op === 'dictate') && r.ok) logVoice(r, t0);
|
|
1369
1384
|
res.status(r.ok ? 200 : 502).json(r);
|
|
1370
1385
|
});
|
|
1371
1386
|
|
|
1372
|
-
function logVoice(r, t0, extra) {
|
|
1373
|
-
const entry = { at: new Date().toISOString(), text: r.text, command: r.command, ms: Date.now() - t0, ...extra };
|
|
1374
|
-
fs.appendFile(VOICE_LOG, `${JSON.stringify(entry)}\n`, () => {});
|
|
1375
|
-
}
|
|
1376
|
-
|
|
1377
1387
|
// Live speech: the page sends mic audio while the key is held, about 5 posts a second, and each
|
|
1378
1388
|
// answer carries the text so far. 501 sends the page back to /api/voice/start and /stop.
|
|
1379
1389
|
app.post(
|
|
@@ -1385,9 +1395,7 @@ app.post(
|
|
|
1385
1395
|
if (!['start', 'audio', 'finish', 'cancel'].includes(op)) return res.status(404).json({ error: 'unknown op' });
|
|
1386
1396
|
if (!voice.stream) return res.status(501).json({ error: 'no live speech' });
|
|
1387
1397
|
if (op === 'audio' && !Buffer.isBuffer(req.body)) return res.status(400).json({ error: 'audio is required' });
|
|
1388
|
-
const t0 = Date.now();
|
|
1389
1398
|
const r = await voice.stream[op](req.body, heldOn(req));
|
|
1390
|
-
if (op === 'finish' && r.ok) logVoice(r, t0, { live: true });
|
|
1391
1399
|
res.status(r.ok ? 200 : r.unsupported ? 501 : 502).json(r);
|
|
1392
1400
|
},
|
|
1393
1401
|
);
|
|
@@ -1409,6 +1417,27 @@ app.get('/api/resolve-path', (req, res) => {
|
|
|
1409
1417
|
|
|
1410
1418
|
const HUB_KEYS_JS = path.join(__dirname, 'packages', 'claude-hub-sdk', 'src', 'keys.js');
|
|
1411
1419
|
app.get('/sdk/keys.js', (_req, res) => res.sendFile(HUB_KEYS_JS));
|
|
1420
|
+
// Public like the rest of the page's styles. Only the files a manifest lists are served.
|
|
1421
|
+
const HUB_MOD_ASSETS = modAssets(MODS, 'hub');
|
|
1422
|
+
const HUB_MOD_CSS = HUB_MOD_ASSETS.css.map((u) => `@import url("${u}");`).join('\n');
|
|
1423
|
+
const HUB_MOD_JS = hubModScript(HUB_MOD_ASSETS);
|
|
1424
|
+
app.get('/mods/hub.css', (_req, res) => res.type('css').set('Cache-Control', 'no-cache').send(HUB_MOD_CSS));
|
|
1425
|
+
app.get('/mods/hub.js', (_req, res) => res.type('js').set('Cache-Control', 'no-cache').send(HUB_MOD_JS));
|
|
1426
|
+
app.get('/mods/:id/*', (req, res, next) => {
|
|
1427
|
+
const abs = HUB_MOD_ASSETS.files.get(`/${req.params.id}/${req.params[0]}`);
|
|
1428
|
+
if (!abs) return next();
|
|
1429
|
+
res.sendFile(abs, { maxAge: '1y', immutable: true });
|
|
1430
|
+
});
|
|
1431
|
+
// With no hub mod, or under ?nomods, the page leaves out the mod tags: no two extra requests, and
|
|
1432
|
+
// no render-blocking stylesheet that safe mode would paint before app.js removes it.
|
|
1433
|
+
const HUB_PAGE = path.join(__dirname, 'public', 'index.html');
|
|
1434
|
+
const HUB_HAS_MODS = HUB_MOD_ASSETS.css.length > 0 || HUB_MOD_ASSETS.js.length > 0;
|
|
1435
|
+
const MOD_TAGS = ['<link rel="stylesheet" href="/mods/hub.css">', '<script src="/mods/hub.js"></script>'];
|
|
1436
|
+
app.get(['/', '/index.html'], (req, res, next) => {
|
|
1437
|
+
if (HUB_HAS_MODS && req.query.nomods === undefined) return next();
|
|
1438
|
+
const html = MOD_TAGS.reduce((page, tag) => page.replace(tag, ''), fs.readFileSync(HUB_PAGE, 'utf8'));
|
|
1439
|
+
res.type('html').set('Cache-Control', 'no-cache').send(html);
|
|
1440
|
+
});
|
|
1412
1441
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
1413
1442
|
|
|
1414
1443
|
// Children get HUB_URL at spawn and trust only that origin, so the first pool waits for the port
|