claude-code-hub 1.27.2 → 1.29.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.
@@ -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
@@ -1,5 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ const { HUB_VERSION, checkArgs, printedUrl } = require('./lib/cli');
4
+
5
+ // Before the other requires: -v and -h answer at once, and a bad flag never reaches the running-hub check,
6
+ // which prints the hub URL with its token.
7
+ const cli = checkArgs(process.argv.slice(2));
8
+ if (cli.exit !== undefined) {
9
+ (cli.exit ? console.error : console.log)(cli.out);
10
+ process.exit(cli.exit);
11
+ }
12
+
3
13
  const { spawn } = require('child_process');
4
14
  const crypto = require('crypto');
5
15
  const express = require('express');
@@ -16,7 +26,8 @@ const { createNetGuard, KEEP_ALIVE_MS } = require('./lib/net-guard');
16
26
  const { ping, stats } = require('./lib/ipc');
17
27
  const { killChild } = require('./lib/kill-child');
18
28
  const { createPortHealth } = require('./lib/port-health');
19
- const { readRegistry, themeConfig } = require('./lib/themes');
29
+ const { readRegistry, themeConfig, modThemes } = require('./lib/themes');
30
+ const { resolveMods, modAssets, appModEnv, readSurface, hubModScript } = require('./lib/mods');
20
31
  const { createUpdateCheck } = require('./lib/update-check');
21
32
  const { liveHub, removeRecord, writeRecord } = require('./lib/hub-record');
22
33
  const { HUB_PLUGIN } = require('./lib/install');
@@ -32,7 +43,6 @@ const {
32
43
  MAX_POOL_SIZE,
33
44
  settingValues,
34
45
  } = require('./lib/settings');
35
- const { version: HUB_VERSION } = require('./package.json');
36
46
 
37
47
  function getArg(name) {
38
48
  const idx = process.argv.findIndex((a) => a.startsWith(`--${name}`));
@@ -159,6 +169,9 @@ function resolveHubConfig() {
159
169
  // Hand-edited only; kept verbatim so a rewrite of the file does not drop it.
160
170
  if (saved.terminal && typeof saved.terminal === 'object') config.terminal = saved.terminal;
161
171
  if (Array.isArray(saved.apps)) config.apps = saved.apps;
172
+ if (Array.isArray(saved.addons)) config.addons = saved.addons;
173
+ if (Array.isArray(saved.mods)) config.mods = saved.mods;
174
+ if (typeof saved.modsEnabled === 'boolean') config.modsEnabled = saved.modsEnabled;
162
175
  if (saved.keys !== undefined) config.keys = saved.keys;
163
176
  if (saved.voice && typeof saved.voice === 'object') config.voice = saved.voice;
164
177
  copyLive(saved, config);
@@ -206,6 +219,18 @@ if (!ENABLED_APPS.length) {
206
219
  console.error(`No enabled app could load (${ids}). The reasons are logged above.`);
207
220
  process.exit(1);
208
221
  }
222
+ const SURFACES = {
223
+ hub: readSurface(__dirname),
224
+ ...Object.fromEntries(ENABLED_APPS.map((a) => [a.id, readSurface(a.root)])),
225
+ };
226
+ const MODS_OFF = process.argv.includes('--no-mods') || hubConfig.modsEnabled === false;
227
+ if (MODS_OFF && hubConfig.mods?.length) console.log('mods are off (--no-mods or modsEnabled: false): no mod loads');
228
+ const MODS = resolveMods(hubConfig, path.dirname(HUB_CONFIG_FILE), {
229
+ warn: console.log,
230
+ surfaces: SURFACES,
231
+ off: MODS_OFF,
232
+ });
233
+ for (const m of MODS) console.log(`mod ${m.id} changes ${Object.keys(m.targets).join(', ')}`);
209
234
  const ACTIONS = actionTable(ENABLED_APPS);
210
235
  const { keys: USER_KEYS, lines: keyLines } = userKeys(hubConfig.keys, ENABLED_APPS);
211
236
  for (const line of keyLines) console.log(`${HUB_CONFIG_FILE}: ${line}`);
@@ -299,6 +324,10 @@ function childEnv(pool, name) {
299
324
  ALLOWED_HOSTS: net.ALLOWED_HOSTS,
300
325
  HUB_SDK_SERVER,
301
326
  };
327
+ // The app's own process serves its mod files (the SDK's mount), so they load from the app's origin.
328
+ const mods = appModEnv(MODS, name);
329
+ if (mods) env.HUB_MODS = mods;
330
+ else delete env.HUB_MODS;
302
331
  if (TERMINAL.enabled && name === TERMINAL_APP.id) {
303
332
  env.CCK_TERMINAL = JSON.stringify(TERMINAL);
304
333
  env.CCK_TERMINAL_TOKEN = TERMINAL_TOKEN;
@@ -919,6 +948,7 @@ app.use(express.json());
919
948
  const THEME_REGISTRY = readRegistry();
920
949
  // Read on each /api/config, so an edit applies on the next page load.
921
950
  const USER_THEMES_FILE = path.join(HUB_DIR, 'themes.json');
951
+ const MOD_THEMES = modThemes(THEME_REGISTRY, MODS, console.log);
922
952
 
923
953
  function appsConfig() {
924
954
  return Object.fromEntries(
@@ -958,7 +988,7 @@ app.get('/api/update', async (_req, res) => {
958
988
 
959
989
  app.get('/api/config', (_req, res) => {
960
990
  res.json({
961
- ...themeConfig(THEME_REGISTRY, { userFile: USER_THEMES_FILE }),
991
+ ...themeConfig(THEME_REGISTRY, { userFile: USER_THEMES_FILE, extra: MOD_THEMES }),
962
992
  apps: appsConfig(),
963
993
  actions: ACTIONS,
964
994
  keys: USER_KEYS,
@@ -1328,7 +1358,6 @@ app.get('/api/projects', async (_req, res) => {
1328
1358
  });
1329
1359
 
1330
1360
  // 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
1361
  const voice = hubConfig.voice?.enabled === true ? loadVoice() : null;
1333
1362
 
1334
1363
  function loadVoice() {
@@ -1353,27 +1382,17 @@ const heldOn = (req) => (typeof req.query.app === 'string' ? req.query.app : und
1353
1382
  app.post('/api/voice/text', express.json({ limit: '64kb' }), async (req, res) => {
1354
1383
  if (!voice) return res.status(404).json({ error: 'voice is off' });
1355
1384
  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);
1385
+ res.json(await voice.text(req.body.text, req.body.final === true, heldOn(req)));
1360
1386
  });
1361
1387
 
1362
1388
  app.post('/api/voice/:op', async (req, res) => {
1363
1389
  if (!voice) return res.status(404).json({ error: 'voice is off' });
1364
1390
  const { op } = req.params;
1365
1391
  if (!['start', 'stop', 'dictate', 'cancel'].includes(op)) return res.status(404).json({ error: 'unknown op' });
1366
- const t0 = Date.now();
1367
1392
  const r = await voice[op](heldOn(req));
1368
- if ((op === 'stop' || op === 'dictate') && r.ok) logVoice(r, t0);
1369
1393
  res.status(r.ok ? 200 : 502).json(r);
1370
1394
  });
1371
1395
 
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
1396
  // Live speech: the page sends mic audio while the key is held, about 5 posts a second, and each
1378
1397
  // answer carries the text so far. 501 sends the page back to /api/voice/start and /stop.
1379
1398
  app.post(
@@ -1385,9 +1404,7 @@ app.post(
1385
1404
  if (!['start', 'audio', 'finish', 'cancel'].includes(op)) return res.status(404).json({ error: 'unknown op' });
1386
1405
  if (!voice.stream) return res.status(501).json({ error: 'no live speech' });
1387
1406
  if (op === 'audio' && !Buffer.isBuffer(req.body)) return res.status(400).json({ error: 'audio is required' });
1388
- const t0 = Date.now();
1389
1407
  const r = await voice.stream[op](req.body, heldOn(req));
1390
- if (op === 'finish' && r.ok) logVoice(r, t0, { live: true });
1391
1408
  res.status(r.ok ? 200 : r.unsupported ? 501 : 502).json(r);
1392
1409
  },
1393
1410
  );
@@ -1409,6 +1426,27 @@ app.get('/api/resolve-path', (req, res) => {
1409
1426
 
1410
1427
  const HUB_KEYS_JS = path.join(__dirname, 'packages', 'claude-hub-sdk', 'src', 'keys.js');
1411
1428
  app.get('/sdk/keys.js', (_req, res) => res.sendFile(HUB_KEYS_JS));
1429
+ // Public like the rest of the page's styles. Only the files a manifest lists are served.
1430
+ const HUB_MOD_ASSETS = modAssets(MODS, 'hub');
1431
+ const HUB_MOD_CSS = HUB_MOD_ASSETS.css.map((u) => `@import url("${u}");`).join('\n');
1432
+ const HUB_MOD_JS = hubModScript(HUB_MOD_ASSETS);
1433
+ app.get('/mods/hub.css', (_req, res) => res.type('css').set('Cache-Control', 'no-cache').send(HUB_MOD_CSS));
1434
+ app.get('/mods/hub.js', (_req, res) => res.type('js').set('Cache-Control', 'no-cache').send(HUB_MOD_JS));
1435
+ app.get('/mods/:id/*', (req, res, next) => {
1436
+ const abs = HUB_MOD_ASSETS.files.get(`/${req.params.id}/${req.params[0]}`);
1437
+ if (!abs) return next();
1438
+ res.sendFile(abs, { maxAge: '1y', immutable: true });
1439
+ });
1440
+ // With no hub mod, or under ?nomods, the page leaves out the mod tags: no two extra requests, and
1441
+ // no render-blocking stylesheet that safe mode would paint before app.js removes it.
1442
+ const HUB_PAGE = path.join(__dirname, 'public', 'index.html');
1443
+ const HUB_HAS_MODS = HUB_MOD_ASSETS.css.length > 0 || HUB_MOD_ASSETS.js.length > 0;
1444
+ const MOD_TAGS = ['<link rel="stylesheet" href="/mods/hub.css">', '<script src="/mods/hub.js"></script>'];
1445
+ app.get(['/', '/index.html'], (req, res, next) => {
1446
+ if (HUB_HAS_MODS && req.query.nomods === undefined) return next();
1447
+ const html = MOD_TAGS.reduce((page, tag) => page.replace(tag, ''), fs.readFileSync(HUB_PAGE, 'utf8'));
1448
+ res.type('html').set('Cache-Control', 'no-cache').send(html);
1449
+ });
1412
1450
  app.use(express.static(path.join(__dirname, 'public')));
1413
1451
 
1414
1452
  // Children get HUB_URL at spawn and trust only that origin, so the first pool waits for the port
@@ -1435,7 +1473,7 @@ if (!TRAY_FLAG) {
1435
1473
  listenWithFallback(app, HUB_PORT, onReady, '');
1436
1474
  return;
1437
1475
  }
1438
- console.log(`Claude Code Hub already runs for ${HUB_DIR} at ${accessUrl(running.port)}`);
1476
+ printUrl(`Claude Code Hub already runs for ${HUB_DIR} at`, running.port);
1439
1477
  if (process.argv.includes('--open')) {
1440
1478
  import('open').then((m) => m.default(accessUrl(running.port))).finally(() => process.exit(0));
1441
1479
  } else {
@@ -1448,8 +1486,13 @@ function accessUrl(port) {
1448
1486
  return `http://localhost:${port}/?token=${HUB_TOKEN}`;
1449
1487
  }
1450
1488
 
1489
+ function printUrl(line, port) {
1490
+ const show = process.argv.includes('--show-token') || require('tty').isatty(1);
1491
+ console.log(printedUrl(line, accessUrl(port), { show, tokenFile: HUB_TOKEN_FILE }));
1492
+ }
1493
+
1451
1494
  function printBanner(port) {
1452
- console.log(`Claude Code Hub running at ${accessUrl(port)}`);
1495
+ printUrl('Claude Code Hub running at', port);
1453
1496
  const warning = net.exposureWarning();
1454
1497
  if (warning) console.log(warning);
1455
1498
  console.log('Type "q" or "exit" to stop the server');