claude-code-hub 1.11.0 → 1.13.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/server.js CHANGED
@@ -21,6 +21,7 @@ const { createUpdateCheck } = require('./lib/update-check');
21
21
  const { liveHub, removeRecord, writeRecord } = require('./lib/hub-record');
22
22
  const { HUB_PLUGIN } = require('./lib/install');
23
23
  const { pluginChecker } = require('./lib/plugin-status');
24
+ const { serializeConfig, writeConfigFile } = require('./lib/hub-config');
24
25
  const { version: HUB_VERSION } = require('./package.json');
25
26
 
26
27
  function getArg(name) {
@@ -70,19 +71,33 @@ if (TRAY_FLAG) {
70
71
  }).then((ok) => process.exit(ok ? 0 : 1));
71
72
  }
72
73
 
73
- if (process.argv.includes('--install') || process.argv.includes('--uninstall')) {
74
- const { runInstall, runUninstall } = require('./lib/install');
74
+ function cliConfigDir() {
75
75
  const dir = getArg('dir') || process.env.CLAUDE_CONFIG_DIR || '~/.claude';
76
- let configDir;
77
76
  try {
78
- configDir = canonicalDir(dir);
77
+ return canonicalDir(dir);
79
78
  } catch {
80
79
  console.error(`Config dir not found: ${dir}`);
81
80
  process.exit(1);
82
81
  }
83
- const ok = process.argv.includes('--install')
84
- ? runInstall({ hubDir: HUB_DIR, configDir })
85
- : runUninstall({ configDir });
82
+ }
83
+
84
+ if (process.argv[2] === 'ext') {
85
+ const { runExt } = require('./lib/ext');
86
+ const ok = runExt(process.argv.slice(3), { hubDir: HUB_DIR, configDir: cliConfigDir, hubVersion: HUB_VERSION });
87
+ process.exit(ok ? 0 : 1);
88
+ }
89
+
90
+ if (process.argv.includes('--install') || process.argv.includes('--uninstall')) {
91
+ const { runInstall, runUninstall } = require('./lib/install');
92
+ const configDir = cliConfigDir();
93
+ let ok;
94
+ if (process.argv.includes('--install')) {
95
+ const { installBuiltInPlugins } = require('./lib/ext');
96
+ const hubOk = runInstall({ hubDir: HUB_DIR, configDir });
97
+ ok = installBuiltInPlugins({ hubDir: HUB_DIR, configDir: () => configDir }) && hubOk;
98
+ } else {
99
+ ok = runUninstall({ configDir });
100
+ }
86
101
  process.exit(ok ? 0 : 1);
87
102
  }
88
103
 
@@ -138,17 +153,17 @@ function resolveHubConfig() {
138
153
  return { config, raw, fallback };
139
154
  }
140
155
 
141
- function serializeConfig(config) {
142
- return `${JSON.stringify(config, null, 2)}\n`;
143
- }
144
-
145
156
  function writeHubConfig(config) {
146
- fs.mkdirSync(path.dirname(HUB_CONFIG_FILE), { recursive: true });
147
- fs.writeFileSync(HUB_CONFIG_FILE, serializeConfig(config));
157
+ writeConfigFile(HUB_CONFIG_FILE, config);
148
158
  }
149
159
 
160
+ // `ext` and hand edits change the other keys while the hub runs, so a save keeps the file's copy of them.
150
161
  function saveHubConfig() {
151
- writeHubConfig(hubConfig);
162
+ let saved = hubConfig;
163
+ try {
164
+ saved = JSON.parse(fs.readFileSync(HUB_CONFIG_FILE, 'utf8'));
165
+ } catch {}
166
+ writeHubConfig({ ...saved, configDirs: hubConfig.configDirs, activeConfigDir: hubConfig.activeConfigDir });
152
167
  }
153
168
 
154
169
  const hubConfig = loadHubConfig();
@@ -255,6 +270,7 @@ function childEnv(pool, name) {
255
270
  CLAUDE_CONFIG_DIR: pool.dir,
256
271
  CLAUDE_HUB: '1',
257
272
  HUB_URL: `http://localhost:${hubPort}`,
273
+ CLAUDE_HUB_DIR: HUB_DIR,
258
274
  HOST: net.BIND_HOST,
259
275
  ALLOWED_HOSTS: net.ALLOWED_HOSTS,
260
276
  HUB_SDK_SERVER,
@@ -324,17 +340,28 @@ function spawnApp(pool, name, cmd, args) {
324
340
  // Asked for by the user, so it does not count toward MAX_RESTARTS and it brings back an app that gave up.
325
341
  async function restartApp(pool, app) {
326
342
  const { id } = app;
327
- const old = pool.children.get(id);
328
- pool.children.delete(id);
329
- delete pool.ports[id];
330
343
  pool.restarts[id] = [];
331
- if (old) await killChild(old);
344
+ pool.stopped.delete(id);
345
+ await killApp(pool, id);
332
346
  if (pool.retired) return false;
333
347
  const child = spawnOne(pool, app);
334
348
  await withTimeout(child.ready, 20000);
335
349
  return !!pool.ports[id];
336
350
  }
337
351
 
352
+ async function stopApp(pool, { id }) {
353
+ pool.stopped.add(id);
354
+ await killApp(pool, id);
355
+ }
356
+
357
+ // Out of pool.children before the kill, so the exit handler does not restart it.
358
+ async function killApp(pool, id) {
359
+ const old = pool.children.get(id);
360
+ pool.children.delete(id);
361
+ delete pool.ports[id];
362
+ if (old) await killChild(old);
363
+ }
364
+
338
365
  function killPool(pool) {
339
366
  pool.retired = true;
340
367
  return Promise.all([...pool.children.values()].map((child) => killChild(child)));
@@ -365,7 +392,8 @@ async function ensurePool(dir) {
365
392
  if (pools.size >= POOL_SIZE) await evictPools(dir);
366
393
  pool = pools.get(dir);
367
394
  if (pool) return pool.ready;
368
- pool = { dir, children: new Map(), ports: {}, restarts: {}, retired: false };
395
+ const stopped = new Set(ENABLED_APPS.filter((a) => a.deferredStart).map((a) => a.id));
396
+ pool = { dir, children: new Map(), ports: {}, restarts: {}, stopped, retired: false };
369
397
  pools.set(dir, pool);
370
398
  spawnChildren(pool);
371
399
  const ready = [...pool.children.values()].map((c) => c.ready);
@@ -457,7 +485,7 @@ function spawnOne(pool, app) {
457
485
  }
458
486
 
459
487
  function spawnChildren(pool) {
460
- for (const a of ENABLED_APPS) spawnOne(pool, a);
488
+ for (const a of ENABLED_APPS) if (!pool.stopped.has(a.id)) spawnOne(pool, a);
461
489
  }
462
490
 
463
491
  const publicPorts = Object.fromEntries(ENABLED_APPS.map((a) => [a.id, a.port]));
@@ -507,7 +535,7 @@ async function waitForPort(name, deadline) {
507
535
  const pool = activePool();
508
536
  const port = pool?.ports[name];
509
537
  if (port) return port;
510
- if (pool?.children.get(name)?.gaveUp) return null;
538
+ if (pool?.children.get(name)?.gaveUp || pool?.stopped.has(name)) return null;
511
539
  const remaining = deadline - Date.now();
512
540
  if (remaining <= 0) return null;
513
541
  await Promise.race([pool?.children.get(name)?.ready, delay(Math.min(remaining, 250))]);
@@ -515,10 +543,14 @@ async function waitForPort(name, deadline) {
515
543
  }
516
544
 
517
545
  function noPortReason(name) {
518
- const child = activePool()?.children.get(name);
546
+ const pool = activePool();
547
+ if (pool?.stopped.has(name)) {
548
+ return { headline: 'is stopped', detail: `Start ${name} from the app launcher`, final: true };
549
+ }
550
+ const child = pool?.children.get(name);
519
551
  if (child?.gaveUp) {
520
552
  const detail = `${name} exited after ${MAX_RESTARTS} restarts in ${RESTART_WINDOW_MS / 1000}s. The hub stopped restarting it`;
521
- return { headline: 'stopped', detail };
553
+ return { headline: 'stopped', detail, final: true };
522
554
  }
523
555
  return { headline: 'is starting', detail: `${name} is starting` };
524
556
  }
@@ -529,25 +561,29 @@ const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt
529
561
  // hand. The cap is small because the server already waited NAV_PORT_WAIT_MS before serving this,
530
562
  // and each reload re-spends that wait. sessionStorage throws when site data is blocked, and a page
531
563
  // whose only job is to reload must still reload there.
532
- function retryPage(name, headline, detail) {
533
- return `<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(name)}</title>
534
- <style>:root{color-scheme:light dark}body{font:14px system-ui,sans-serif;margin:0;display:grid;place-items:center;height:100vh}
535
- div{text-align:center;opacity:.7}code{font-size:12px;opacity:.6}</style>
536
- </head><body><div><p id="m">${escapeHtml(name)} ${escapeHtml(headline)}…</p><p><code>${escapeHtml(detail)}</code></p></div>
537
- <script>
564
+ const RETRY_SCRIPT = `<script>
538
565
  let n = 0;
539
566
  const k = 'hub-retry:' + location.pathname;
540
567
  try { n = +(sessionStorage.getItem(k) || 0); sessionStorage.setItem(k, n + 1); } catch {}
541
568
  if (n < 3) setTimeout(() => location.reload(), 1000);
542
569
  else { try { sessionStorage.removeItem(k); } catch {} document.getElementById('m').textContent = 'not responding'; }
543
- </script></body></html>`;
570
+ </script>`;
571
+
572
+ // A final page (an app stopped by the user or given up on) does not reload: only the user brings that app back.
573
+ function retryPage(name, { headline, detail, final }) {
574
+ return `<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(name)}</title>
575
+ <style>:root{color-scheme:light dark}body{font:14px system-ui,sans-serif;margin:0;display:grid;place-items:center;height:100vh}
576
+ div{text-align:center;opacity:.7}code{font-size:12px;opacity:.6}</style>
577
+ </head><body><div><p id="m">${escapeHtml(name)} ${escapeHtml(headline)}${final ? '' : '…'}</p><p><code>${escapeHtml(detail)}</code></p></div>
578
+ ${final ? '' : RETRY_SCRIPT}</body></html>`;
544
579
  }
545
580
 
546
- function sendUnavailable(req, res, name, status, detail, headline) {
581
+ function sendUnavailable(req, res, name, status, reason) {
547
582
  if (res.destroyed || res.headersSent) return;
583
+ const { detail } = reason;
548
584
  if (isNavigation(req)) {
549
585
  res.writeHead(status, { 'Retry-After': '1', 'Content-Type': 'text/html; charset=utf-8' });
550
- res.end(retryPage(name, headline, detail));
586
+ res.end(retryPage(name, reason));
551
587
  return;
552
588
  }
553
589
  // The sub-app's own callers hand the body to .json(): plain text surfaces as a SyntaxError
@@ -735,8 +771,7 @@ function proxyHandler(name) {
735
771
  for (let attempt = 1; !res.destroyed; attempt++) {
736
772
  const childPort = await waitForPort(name, budget.deadline);
737
773
  if (!childPort) {
738
- const { headline, detail } = noPortReason(name);
739
- sendUnavailable(req, res, name, 503, detail, headline);
774
+ sendUnavailable(req, res, name, 503, noPortReason(name));
740
775
  return;
741
776
  }
742
777
  if (req.headers.origin) {
@@ -747,12 +782,12 @@ function proxyHandler(name) {
747
782
  });
748
783
  if (!err) return;
749
784
  if (body === null || !RETRYABLE.has(err.code)) {
750
- sendUnavailable(req, res, name, 502, err.message, 'did not respond');
785
+ sendUnavailable(req, res, name, 502, { headline: 'did not respond', detail: err.message });
751
786
  return;
752
787
  }
753
788
  const next = await afterConnectFailure(name, childPort, err, attempt, budget);
754
789
  if (!next.wait) {
755
- sendUnavailable(req, res, name, 503, next.detail, next.headline);
790
+ sendUnavailable(req, res, name, 503, next);
756
791
  return;
757
792
  }
758
793
  await delay(next.wait);
@@ -871,6 +906,7 @@ function appsConfig() {
871
906
  loading: a.loading,
872
907
  publishes: a.publishes,
873
908
  };
909
+ if (a.hidden) entry.hidden = true;
874
910
  if (TERMINAL_TOKEN && a.id === TERMINAL_APP.id) entry.terminalToken = TERMINAL_TOKEN;
875
911
  return [a.id, entry];
876
912
  }),
@@ -1010,18 +1046,26 @@ app.get('/api/apps/stats', async (_req, res) => {
1010
1046
  const slowMs = (child && portHealth.flagged(child)?.worstMs) ?? null;
1011
1047
  return [
1012
1048
  a.id,
1013
- { ...(await stats(child, STATS_TIMEOUT_MS)), slowMs, version: a.version, plugin: plugin(a.plugin) },
1049
+ {
1050
+ ...(await stats(child, STATS_TIMEOUT_MS)),
1051
+ slowMs,
1052
+ stopped: !!pool?.stopped.has(a.id),
1053
+ linked: a.linked,
1054
+ version: a.version,
1055
+ plugin: plugin(a.plugin),
1056
+ },
1014
1057
  ];
1015
1058
  }),
1016
1059
  );
1017
1060
  res.json({ hub: { plugin: plugin(HUB_PLUGIN) }, apps: Object.fromEntries(entries) });
1018
1061
  });
1019
1062
 
1020
- // Restarting the terminal provider ends its live terminals, so that takes force: true.
1063
+ // Restarting or stopping the terminal provider ends its live terminals, so that takes force: true.
1064
+ // Start and restart are the same call: both kill the app's process if it has one and spawn it.
1021
1065
  const restarting = new Set();
1022
- app.post('/api/apps/:id/restart', async (req, res) => {
1066
+ app.post('/api/apps/:id/:op(restart|start|stop)', async (req, res) => {
1023
1067
  const pool = activePool();
1024
- const { id } = req.params;
1068
+ const { id, op } = req.params;
1025
1069
  const target = ENABLED_APPS.find((a) => a.id === id);
1026
1070
  if (!pool || !target) {
1027
1071
  res.status(404).json({ error: 'Unknown app' });
@@ -1029,7 +1073,7 @@ app.post('/api/apps/:id/restart', async (req, res) => {
1029
1073
  }
1030
1074
  const key = `${pool.dir}\n${id}`;
1031
1075
  if (restarting.has(key)) {
1032
- res.status(409).json({ error: `${target.name} is already restarting` });
1076
+ res.status(409).json({ error: `${target.name} is already restarting or stopping` });
1033
1077
  return;
1034
1078
  }
1035
1079
  restarting.add(key);
@@ -1041,8 +1085,11 @@ app.post('/api/apps/:id/restart', async (req, res) => {
1041
1085
  return;
1042
1086
  }
1043
1087
  }
1044
- console.log(`[${id}] restart asked for by the hub page`);
1045
- if (await restartApp(pool, target)) res.json({ ok: true });
1088
+ console.log(`[${id}] ${op} asked for by the hub page`);
1089
+ if (op === 'stop') {
1090
+ await stopApp(pool, target);
1091
+ res.json({ ok: true });
1092
+ } else if (await restartApp(pool, target)) res.json({ ok: true });
1046
1093
  else res.status(503).json({ error: `${target.name} did not start` });
1047
1094
  } finally {
1048
1095
  restarting.delete(key);