claude-code-hub 1.35.0 → 1.37.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.
@@ -20,7 +20,7 @@
20
20
  "listing": {
21
21
  "tagline": "Hold a key and speak: start a session or send a prompt with no Enter press",
22
22
  "description": "Hold Ctrl+Shift+Space, speak and let go. \"New session, fix the flaky test\" opens the New session dialog with that prompt, and anything else goes to the session on screen as a prompt and is submitted. Kanban's New session microphone uses the same speech to text. Hex does the speech to text on your machine; without it, the browser's speech recognition does it.",
23
- "tags": ["voice", "experimental"],
23
+ "tags": ["voice"],
24
24
  "screenshots": [
25
25
  {
26
26
  "url": "https://nikiforovall.blog/claude-code-hub/shots/extensions/voice-demo.webp",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-hub",
3
- "version": "1.35.0",
3
+ "version": "1.37.0",
4
4
  "description": "Unified hub for Claude Code tools — Marketplace + Kanban in one PWA",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -47,7 +47,7 @@
47
47
  "homepage": "https://github.com/NikiforovAll/claude-code-hub#readme",
48
48
  "dependencies": {
49
49
  "claude-code-cost": "1.6.2",
50
- "claude-code-kanban": "7.32.0",
50
+ "claude-code-kanban": "7.34.0",
51
51
  "claude-code-marketplace": "1.7.3",
52
52
  "claude-code-memory-explorer": "1.3.6",
53
53
  "express": "^4.21.0",
@@ -101,7 +101,7 @@
101
101
  } catch (_) {}
102
102
  }
103
103
 
104
- function connect({ standalone = {} } = {}) {
104
+ function connect({ standalone = {}, plainHostKeys = [] } = {}) {
105
105
  if (hub) return hub;
106
106
  const topics = new Map();
107
107
  const activeFns = new Set();
@@ -275,7 +275,11 @@
275
275
  const m = e.data;
276
276
  if (!framed || e.source !== win.parent || win.parent === win.top) return;
277
277
  if (m?.type !== 'hub:hostKeys' || !Array.isArray(m.keys)) return;
278
- if (!hostKeys) win.parent.postMessage({ type: 'hub:hostReady' }, e.origin);
278
+ if (!hostKeys)
279
+ win.parent.postMessage(
280
+ { type: 'hub:hostReady', ...(plainHostKeys.length && { plainKeys: plainHostKeys }) },
281
+ e.origin,
282
+ );
279
283
  hostOrigin = e.origin;
280
284
  hostKeys = new Set(m.keys.map((k) => keySigs(k)[0]));
281
285
  }
package/public/app.js CHANGED
@@ -249,6 +249,7 @@ function onPublish(appId, { topic, payload }) {
249
249
  ? { ...payload, encoded: encodeProjectPath(payload.project), projectName: basename(payload.project) }
250
250
  : payload;
251
251
  published.set(topic, { from: appId, payload: event });
252
+ if (topic === 'attention.changed') onAttention(appId, event);
252
253
  for (const id of Object.keys(iframes)) {
253
254
  if (id === appId) continue;
254
255
  if (id === activeApp) sendTopic(id, topic, event);
@@ -317,6 +318,7 @@ async function init() {
317
318
  bindSetup();
318
319
  registerSW();
319
320
  bindErrorToast();
321
+ bindAttentionToast();
320
322
  showUpdateToast();
321
323
  if (!config.onboarding) maybeShowSetup();
322
324
  reloadOnHubRestart();
@@ -1010,8 +1012,108 @@ function bindErrorToast() {
1010
1012
  // The hub has no visible chrome, so the window/tab title is the only place the active config dir
1011
1013
  // shows. Only a non-default dir is named, and by basename alone — the PWA window prepends the
1012
1014
  // manifest name, so a product name here would read as "Claude Code Hub - .claude-eom · Hub".
1013
- function applyTitle(dir) {
1014
- document.title = dir && dir !== defaultConfigDir ? basename(dir) : 'Claude Code Hub';
1015
+ function applyTitle(dir = titleDir) {
1016
+ titleDir = dir;
1017
+ const name = dir && dir !== defaultConfigDir ? basename(dir) : 'Claude Code Hub';
1018
+ const n = attentionCount();
1019
+ document.title = n ? `(${n}) ${name}` : name;
1020
+ }
1021
+
1022
+ // attention.changed lists the sessions whose terminal reports blocked. With no tab strip, the
1023
+ // count goes in the window title and the installed app's taskbar badge. A session that newly
1024
+ // blocks while its app is off screen gets a toast, at most once per ATTENTION_GAP_MS each,
1025
+ // and none from the first payload, so a reload does not toast what was already waiting. The
1026
+ // toast goes when its app comes on screen or the session stops waiting.
1027
+ const ATTENTION_KINDS = { permission: 'Permission', question: 'Question', auth: 'Sign-in' };
1028
+ const ATTENTION_ICONS = {
1029
+ permission: [['path', { d: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' }]],
1030
+ question: [
1031
+ ['circle', { cx: 12, cy: 12, r: 10 }],
1032
+ ['path', { d: 'M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3' }],
1033
+ ['path', { d: 'M12 17h.01' }],
1034
+ ],
1035
+ auth: [
1036
+ ['circle', { cx: 7.5, cy: 15.5, r: 5.5 }],
1037
+ ['path', { d: 'm21 2-9.6 9.6' }],
1038
+ ['path', { d: 'm15.5 7.5 3 3L22 7l-3-3' }],
1039
+ ],
1040
+ other: [
1041
+ ['path', { d: 'M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9' }],
1042
+ ['path', { d: 'M10.3 21a1.94 1.94 0 0 0 3.4 0' }],
1043
+ ],
1044
+ };
1045
+ const ATTENTION_TOAST_MS = 8000;
1046
+ const ATTENTION_GAP_MS = 30_000;
1047
+ let titleDir = null;
1048
+ const attention = new Map();
1049
+ const attentionToasted = new Map();
1050
+ let attentionTimer = 0;
1051
+
1052
+ const attentionCount = () => [...attention.values()].reduce((n, list) => n + list.length, 0);
1053
+
1054
+ function onAttention(appId, payload) {
1055
+ const list = Array.isArray(payload?.blocked) ? payload.blocked : [];
1056
+ const blocked = list
1057
+ .filter((b) => isPlainObject(b) && typeof b.id === 'string' && b.id.length <= 64 && typeof b.name === 'string')
1058
+ .slice(0, 20)
1059
+ .map((b) => ({
1060
+ id: b.id,
1061
+ name: b.name.slice(0, 120),
1062
+ kind: Object.hasOwn(ATTENTION_KINDS, b.kind) ? b.kind : null,
1063
+ }));
1064
+ const seeded = attention.has(appId);
1065
+ const before = new Set(attention.get(appId)?.map((b) => b.id));
1066
+ attention.set(appId, blocked);
1067
+ applyTitle();
1068
+ const total = attentionCount();
1069
+ if (total) navigator.setAppBadge?.(total).catch(() => {});
1070
+ else navigator.clearAppBadge?.().catch(() => {});
1071
+ const now = Date.now();
1072
+ for (const [id, at] of attentionToasted) if (now - at > ATTENTION_GAP_MS) attentionToasted.delete(id);
1073
+ const fresh = blocked.find((b) => !before.has(b.id) && !attentionToasted.has(b.id));
1074
+ const toast = document.getElementById('attention-toast');
1075
+ if (toast.dataset.app === appId && !blocked.some((b) => b.id === toast.dataset.session)) hideAttentionToast();
1076
+ if (!seeded || !fresh || appId === activeApp) return;
1077
+ attentionToasted.set(fresh.id, now);
1078
+ showAttentionToast(appId, fresh, blocked.length - 1);
1079
+ }
1080
+
1081
+ function hideAttentionToast() {
1082
+ clearTimeout(attentionTimer);
1083
+ const toast = document.getElementById('attention-toast');
1084
+ if (!toast.hidden) hideToast(toast);
1085
+ }
1086
+
1087
+ function showAttentionToast(appId, b, others) {
1088
+ const toast = document.getElementById('attention-toast');
1089
+ document
1090
+ .getElementById('attention-icon')
1091
+ .replaceChildren(lucideSvg(ATTENTION_ICONS[b.kind] ?? ATTENTION_ICONS.other));
1092
+ document.getElementById('attention-title').textContent = b.name;
1093
+ document.getElementById('attention-kind').textContent = ATTENTION_KINDS[b.kind] ?? 'Needs you';
1094
+ document.getElementById('attention-detail').textContent = `waiting in ${apps[appId]?.name ?? appId}`;
1095
+ document.getElementById('attention-more').textContent = others > 0 ? `+${others} more waiting` : '';
1096
+ toast.dataset.app = appId;
1097
+ toast.dataset.session = b.id;
1098
+ const timer = document.getElementById('attention-timer');
1099
+ timer.style.animation = 'none';
1100
+ void timer.offsetWidth;
1101
+ timer.style.animation = '';
1102
+ clearTimeout(attentionTimer);
1103
+ attentionTimer = setTimeout(hideAttentionToast, ATTENTION_TOAST_MS);
1104
+ showToast(toast);
1105
+ }
1106
+
1107
+ function bindAttentionToast() {
1108
+ const toast = document.getElementById('attention-toast');
1109
+ toast.style.setProperty('--attention-ms', `${ATTENTION_TOAST_MS}ms`);
1110
+ toast.addEventListener('mouseenter', () => clearTimeout(attentionTimer));
1111
+ toast.addEventListener('mouseleave', () => (attentionTimer = setTimeout(hideAttentionToast, ATTENTION_TOAST_MS / 2)));
1112
+ document.getElementById('attention-close').addEventListener('click', hideAttentionToast);
1113
+ document.getElementById('attention-open').addEventListener('click', () => {
1114
+ hideAttentionToast();
1115
+ onInvoke({ id: 'attention', action: 'session.open', params: { session: toast.dataset.session } }, () => {});
1116
+ });
1015
1117
  }
1016
1118
 
1017
1119
  // After a config-dir switch every child is a new process, possibly on a new port. Reloading each
@@ -1111,8 +1213,9 @@ function addIframe(id) {
1111
1213
  document.getElementById('iframe-container').appendChild(iframes[id]);
1112
1214
  }
1113
1215
 
1216
+ // A deferredStart app's first page load starts it, so its frame waits until the tab opens.
1114
1217
  function buildIframes() {
1115
- for (const id of tabIds()) addIframe(id);
1218
+ for (const id of tabIds()) if (!apps[id].deferredStart) addIframe(id);
1116
1219
  }
1117
1220
 
1118
1221
  const TAB_KEY = 'hub-tab';
@@ -1130,6 +1233,7 @@ function switchTab(appId) {
1130
1233
  if (!iframes[appId]) addIframe(appId);
1131
1234
  if (appId !== activeApp) previousApp = activeApp;
1132
1235
  activeApp = appId;
1236
+ if (document.getElementById('attention-toast').dataset.app === appId) hideAttentionToast();
1133
1237
  try {
1134
1238
  localStorage.setItem(TAB_KEY, appId);
1135
1239
  } catch {}
@@ -1656,6 +1760,19 @@ const APP_ICONS = {
1656
1760
  ['path', { d: 'M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3' }],
1657
1761
  ['path', { d: 'M12 12V8' }],
1658
1762
  ],
1763
+ 'notebook-pen': [
1764
+ ['path', { d: 'M13.4 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7.4' }],
1765
+ ['path', { d: 'M2 6h4' }],
1766
+ ['path', { d: 'M2 10h4' }],
1767
+ ['path', { d: 'M2 14h4' }],
1768
+ ['path', { d: 'M2 18h4' }],
1769
+ [
1770
+ 'path',
1771
+ {
1772
+ d: 'M21.378 5.626a1 1 0 1 0-3.004-3.004l-5.01 5.012a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506z',
1773
+ },
1774
+ ],
1775
+ ],
1659
1776
  puzzle: [
1660
1777
  [
1661
1778
  'path',
package/public/index.html CHANGED
@@ -654,6 +654,77 @@
654
654
  }
655
655
 
656
656
  .update-toast[hidden] { display: none; }
657
+ .attention-toast {
658
+ width: min(380px, calc(100vw - 32px));
659
+ padding: 14px 14px 16px;
660
+ gap: 12px;
661
+ overflow: hidden;
662
+ border-left-color: var(--warn);
663
+ animation: attention-in 0.22s ease-out;
664
+ }
665
+ .attention-icon {
666
+ flex: none;
667
+ display: grid;
668
+ place-items: center;
669
+ width: 34px;
670
+ height: 34px;
671
+ border-radius: 50%;
672
+ background: color-mix(in srgb, var(--warn) 16%, transparent);
673
+ color: var(--warn);
674
+ }
675
+ .attention-icon svg { width: 18px; height: 18px; }
676
+ .attention-name {
677
+ font: 600 14px/1.35 system-ui, -apple-system, "Segoe UI", sans-serif;
678
+ overflow: hidden;
679
+ white-space: nowrap;
680
+ text-overflow: ellipsis;
681
+ }
682
+ .attention-meta {
683
+ display: flex;
684
+ align-items: center;
685
+ gap: 6px;
686
+ margin-top: 3px;
687
+ color: var(--text-dim);
688
+ font-size: 12px;
689
+ }
690
+ .attention-kind {
691
+ padding: 0 7px;
692
+ border: 1px solid currentColor;
693
+ border-radius: 9px;
694
+ color: var(--warn);
695
+ font-size: 11px;
696
+ font-weight: 600;
697
+ line-height: 16px;
698
+ }
699
+ .attention-actions { display: flex; align-items: center; gap: 10px; margin-top: 10px; }
700
+ .attention-open {
701
+ padding: 5px 12px;
702
+ border: none;
703
+ border-radius: 6px;
704
+ background: var(--accent);
705
+ color: #fff;
706
+ font: 600 12px system-ui, -apple-system, "Segoe UI", sans-serif;
707
+ cursor: pointer;
708
+ }
709
+ .attention-open:hover { filter: brightness(1.08); }
710
+ .attention-more { color: var(--text-dim); font-size: 12px; }
711
+ .attention-timer {
712
+ position: absolute;
713
+ left: 0;
714
+ right: 0;
715
+ bottom: 0;
716
+ height: 2px;
717
+ background: var(--warn);
718
+ opacity: 0.5;
719
+ transform-origin: left;
720
+ animation: attention-timer var(--attention-ms, 8s) linear forwards;
721
+ }
722
+ .attention-toast:hover .attention-timer { animation-play-state: paused; }
723
+ @keyframes attention-in { from { opacity: 0; transform: translateY(8px); } }
724
+ @keyframes attention-timer { to { transform: scaleX(0); } }
725
+ @media (prefers-reduced-motion: reduce) {
726
+ .attention-toast, .attention-timer { animation: none; }
727
+ }
657
728
  .voice-pill {
658
729
  position: fixed;
659
730
  left: 50%;
@@ -873,6 +944,20 @@
873
944
  <button class="update-toast-close" id="update-close" type="button" aria-label="Close">×</button>
874
945
  </div>
875
946
 
947
+ <div class="update-toast attention-toast" id="attention-toast" role="status" hidden>
948
+ <span class="attention-icon" id="attention-icon" aria-hidden="true"></span>
949
+ <div class="update-toast-body">
950
+ <div class="attention-name" id="attention-title"></div>
951
+ <div class="attention-meta"><span class="attention-kind" id="attention-kind"></span><span id="attention-detail"></span></div>
952
+ <div class="attention-actions">
953
+ <button class="attention-open" id="attention-open" type="button">Open session</button>
954
+ <span class="attention-more" id="attention-more"></span>
955
+ </div>
956
+ </div>
957
+ <button class="update-toast-close" id="attention-close" type="button" aria-label="Close">×</button>
958
+ <span class="attention-timer" id="attention-timer"></span>
959
+ </div>
960
+
876
961
  <div class="update-toast error-toast" id="error-toast" role="alert" hidden>
877
962
  <div class="update-toast-body">
878
963
  <div class="update-toast-title" id="error-title"></div>
package/server.js CHANGED
@@ -330,6 +330,11 @@ function childEnv(pool, name) {
330
330
  const mods = appModEnv(MODS, name);
331
331
  if (mods) env.HUB_MODS = mods;
332
332
  else delete env.HUB_MODS;
333
+ // A setting the app's server needs before any page connects, such as cck's terminal host for
334
+ // the terminals it restores at start. A change later reaches the app through its pages.
335
+ const settings = appValues.get(name);
336
+ if (settings) env.HUB_APP_SETTINGS = JSON.stringify(settings);
337
+ else delete env.HUB_APP_SETTINGS;
333
338
  if (TERMINAL.enabled && name === TERMINAL_APP.id) {
334
339
  env.CCK_TERMINAL = JSON.stringify(TERMINAL);
335
340
  env.CCK_TERMINAL_TOKEN = TERMINAL_TOKEN;
@@ -397,6 +402,7 @@ async function restartApp(pool, app) {
397
402
  const { id } = app;
398
403
  pool.restarts[id] = [];
399
404
  pool.stopped.delete(id);
405
+ pool.deferred.delete(id);
400
406
  await killApp(pool, id);
401
407
  if (pool.retired) return false;
402
408
  const child = spawnOne(pool, app);
@@ -406,9 +412,21 @@ async function restartApp(pool, app) {
406
412
 
407
413
  async function stopApp(pool, { id }) {
408
414
  pool.stopped.add(id);
415
+ pool.deferred.delete(id);
409
416
  await killApp(pool, id);
410
417
  }
411
418
 
419
+ // A deferredStart app that nothing has started yet starts on its first page load: its tab, a pane,
420
+ // an action. One the user stopped stays stopped.
421
+ function startOnDemand(name) {
422
+ const pool = activePool();
423
+ if (!pool?.deferred.delete(name)) return;
424
+ console.log(`[${name}] starting on first open`);
425
+ pool.stopped.delete(name);
426
+ const target = ENABLED_APPS.find((a) => a.id === name);
427
+ spawnOne(pool, target);
428
+ }
429
+
412
430
  // Out of pool.children before the kill, so the exit handler does not restart it.
413
431
  async function killApp(pool, id) {
414
432
  const old = pool.children.get(id);
@@ -448,7 +466,7 @@ async function ensurePool(dir) {
448
466
  pool = pools.get(dir);
449
467
  if (pool) return pool.ready;
450
468
  const stopped = new Set(ENABLED_APPS.filter((a) => a.deferredStart).map((a) => a.id));
451
- pool = { dir, children: new Map(), ports: {}, restarts: {}, stopped, retired: false };
469
+ pool = { dir, children: new Map(), ports: {}, restarts: {}, stopped, deferred: new Set(stopped), retired: false };
452
470
  pools.set(dir, pool);
453
471
  spawnChildren(pool);
454
472
  const ready = [...pool.children.values()].map((c) => c.ready);
@@ -819,6 +837,7 @@ function proxyHandler(name) {
819
837
  if (body === undefined) return;
820
838
  // One deadline for the whole request, not one per attempt: a retry must not re-spend the wait.
821
839
  const budget = { deadline: Date.now() + (isNavigation(req) ? NAV_PORT_WAIT_MS : API_PORT_WAIT_MS) };
840
+ if (isNavigation(req)) startOnDemand(name);
822
841
  // One listener for the request, however many attempts it takes.
823
842
  let current = null;
824
843
  res.once('close', () => current?.destroy());
@@ -963,6 +982,7 @@ function appsConfig() {
963
982
  publishes: a.publishes,
964
983
  };
965
984
  if (a.hidden) entry.hidden = true;
985
+ if (a.deferredStart) entry.deferredStart = true;
966
986
  if (a.settings.length) entry.settings = appValues.get(a.id);
967
987
  if (TERMINAL_TOKEN && a.id === TERMINAL_APP.id) entry.terminalToken = TERMINAL_TOKEN;
968
988
  return [a.id, entry];
@@ -999,7 +1019,7 @@ app.get('/api/config', (_req, res) => {
999
1019
  configDirSwitch: configDirSwitch(hubConfig),
1000
1020
  voice: voice && { provider: voice.provider, auto: voice.auto, commands: voice.commands },
1001
1021
  version: HUB_VERSION,
1002
- onboarding: hubConfig.onboarding ?? null,
1022
+ onboarding: process.env.CLAUDE_HUB_NO_ONBOARDING ? 'skipped' : (hubConfig.onboarding ?? null),
1003
1023
  });
1004
1024
  });
1005
1025