claude-code-hub 1.27.1 → 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.
@@ -14,6 +14,7 @@
14
14
 
15
15
  // keys.js comes first in the served file. Required as a module, client.js loads it itself.
16
16
  const { comboOf, keyParts, keySigs } = typeof ClaudeHubKeys === 'object' ? ClaudeHubKeys : require('./keys');
17
+ const { createMods } = typeof ClaudeHubModKit === 'object' ? ClaudeHubModKit : require('./mods');
17
18
 
18
19
  function isVars(v) {
19
20
  return (
@@ -41,11 +42,24 @@
41
42
  : [],
42
43
  );
43
44
 
45
+ // The page's light or dark, for mod CSS: [data-hub-mode="light"]. Cached so it is set before the hub answers.
46
+ const MODE_KEY = 'claude-hub:mode';
47
+
44
48
  function createClaudeHub(win) {
45
49
  const doc = win.document;
46
50
  const framed = win.top !== win;
47
51
  let appliedVars = null;
48
52
  let hub = null;
53
+ const mods = createMods(win);
54
+ const { mod } = mods;
55
+
56
+ function setMode(mode) {
57
+ if (mode !== 'light' && mode !== 'dark') return;
58
+ if (doc.documentElement) doc.documentElement.dataset.hubMode = mode;
59
+ try {
60
+ win.localStorage.setItem(MODE_KEY, mode);
61
+ } catch (_) {}
62
+ }
49
63
 
50
64
  function setVars(vars) {
51
65
  const body = doc.body;
@@ -82,6 +96,9 @@
82
96
  if (framed) {
83
97
  const cache = readCache();
84
98
  if (cache && hubOnTop(cache.origin)) setVars(cache.vars);
99
+ try {
100
+ setMode(win.localStorage.getItem(MODE_KEY));
101
+ } catch (_) {}
85
102
  }
86
103
 
87
104
  function connect({ standalone = {} } = {}) {
@@ -151,6 +168,7 @@
151
168
  if (isVars(p.vars)) adoptVars(p.vars, origin);
152
169
  else if (!waiting()) adoptVars(null);
153
170
  lastTheme = p;
171
+ setMode(p.theme);
154
172
  applyMode(p);
155
173
  emit('theme.changed', p);
156
174
  }
@@ -188,6 +206,11 @@
188
206
  actions: strings(m.actions),
189
207
  keys: comboMap(m.keys),
190
208
  themes: Array.isArray(m.themes) ? m.themes : [],
209
+ voice: Array.isArray(m.voice)
210
+ ? m.voice
211
+ .filter((c) => typeof c?.say === 'string' && typeof c?.help === 'string')
212
+ .map(({ say, help }) => ({ say, help }))
213
+ : [],
191
214
  };
192
215
  forward = strings(m.forward);
193
216
  appOrigins = strings(m.origins);
@@ -351,6 +374,7 @@
351
374
  Promise.all([config, loaded]).then(() => {
352
375
  if (!origin) return;
353
376
  post({ type: 'hub:hello', protocol: [1], subscribes: [...topics.keys()] });
377
+ mods.connectSender(post);
354
378
  setStatus('waiting');
355
379
  // A hub older than protocol v1 never answers. The app then runs as if alone, with no hub calls.
356
380
  win.setTimeout(() => {
@@ -463,6 +487,8 @@
463
487
  while (n < all[0].length - 1 && all.every((p) => p.length - 1 > n && p[n] === all[0][n])) n++;
464
488
  return [...all[0].slice(0, n), all.map((p) => p.slice(n).join('+')).join('/')];
465
489
  },
490
+ // The voice commands this app handles, [{say, help}], for its help. [] with voice off, before welcome, or standalone.
491
+ voiceCommands: () => (welcome ? welcome.voice.map((c) => ({ ...c })) : []),
466
492
  closeGuard(on) {
467
493
  post({ type: 'hub:closeGuard', on: !!on });
468
494
  },
@@ -493,7 +519,7 @@
493
519
  return hub;
494
520
  }
495
521
 
496
- return { connect, comboOf };
522
+ return { connect, comboOf, mod };
497
523
  }
498
524
 
499
525
  if (typeof module === 'object' && module.exports) module.exports = { createClaudeHub, comboOf };
@@ -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
- return `${read('keys.js')}\n${read(file)}`;
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 client = bundle('client.js');
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
- return { connect, comboOf };
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 rows = voiceRowsHere().map((c) => helpRow(c.action, `Hub, ${c.say}`, `<${c.param}>`, c.help));
490
- if (voice.drafts)
491
- rows.push(helpRow('session.paste', 'anything else', '', 'Typed into the session as a draft', 'other'));
492
- document.getElementById('voice-help-rows').replaceChildren(...rows);
493
- document.getElementById('voice-help-title').textContent = `What you can say on ${tabName()}`;
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 === 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
- return apps[id].url + path + (token ? `#t=${token}` : '');
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
- bottom: 16px;
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
- const t0 = Date.now();
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