claude-code-hub 0.41.0 → 1.0.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/public/app.js CHANGED
@@ -1,9 +1,16 @@
1
1
  let apps = {};
2
2
  let activeApp = null;
3
+ // The app launcher's cursor starts here, so Ctrl+Alt+A then Enter goes back to the last tab.
4
+ let previousApp = null;
3
5
  const iframes = {};
4
6
  const loadedApps = new Set();
5
7
  const guardedApps = new Set();
6
- let allowedOrigins = new Set();
8
+ // App id → the topics its hub:hello subscribes to. An app is live from its hello until the next
9
+ // iframe load: e.source is the same WindowProxy across reloads, so a new document starts unknown.
10
+ const liveApps = new Map();
11
+ // Apps whose next document the hub loads from an action URL.
12
+ const linkLoads = new Set();
13
+ const HUB_PROTOCOLS = [1];
7
14
  // {theme: 'dark'|'light', colorTheme: '<id>', colorThemes: {<configDir>: '<id>'}} — survives hub
8
15
  // reloads so late-loading iframes and fresh sessions get the last chosen theme. Light/dark is
9
16
  // global; the color theme is per config dir, and colorTheme is the last one picked anywhere, which
@@ -15,9 +22,14 @@ let activeConfigDir = null;
15
22
  // hub-theme: starting unset means there is nothing to race against each sub-app's own
16
23
  // project self-restore on boot. Once set it never returns to null.
17
24
  let projectState = null;
25
+ // Topic → {from, payload}: the last value of each topic an app published. Cleared with projectState.
26
+ const published = new Map();
27
+ // App id → the published topics it missed while hidden. It gets their last value when it comes on screen.
28
+ const missed = new Map();
29
+ const MAX_EVENT_CHARS = 16 * 1024;
18
30
  // The config dir the hub falls back to (~/.claude). Stays out of the window title.
19
31
  let defaultConfigDir = null;
20
- // mode: 'project' (Ctrl+Alt+P) or 'configDir' (Ctrl+Alt+W). One widget, two row sources.
32
+ // mode: 'project' (Ctrl+Alt+P), 'configDir' (Ctrl+Alt+W) or 'app' (Ctrl+Alt+A). One widget, three row sources.
21
33
  const palette = {
22
34
  open: false,
23
35
  mode: 'project',
@@ -26,6 +38,7 @@ const palette = {
26
38
  rows: [],
27
39
  sel: 0,
28
40
  query: '',
41
+ confirmRestart: null,
29
42
  };
30
43
 
31
44
  function loadThemeState() {
@@ -40,8 +53,10 @@ function loadThemeState() {
40
53
  }
41
54
 
42
55
  // The color theme of the active config dir, falling back to the last one picked anywhere.
56
+ // An id the registry lacks shows as Ember everywhere, so the hub page and every app agree.
43
57
  function activeColorTheme() {
44
- return themeState.colorThemes[activeConfigDir] ?? themeState.colorTheme;
58
+ const id = themeState.colorThemes[activeConfigDir] ?? themeState.colorTheme;
59
+ return id && hubThemes.length && !themeVars[id] ? 'ember' : id;
45
60
  }
46
61
 
47
62
  function osTheme() {
@@ -50,14 +65,36 @@ function osTheme() {
50
65
 
51
66
  // Sub-apps disagree on their first-run default (marketplace follows the OS, the rest start dark),
52
67
  // so an unset theme resolves to the OS preference here and reaches every iframe on load.
53
- function themeMessage() {
54
- return { type: 'hub:theme', theme: themeState.theme ?? osTheme(), colorTheme: activeColorTheme() };
68
+ function themePayload() {
69
+ return { theme: themeState.theme ?? osTheme(), colorTheme: activeColorTheme() };
55
70
  }
56
71
 
57
- // {<themeId>: {dark, light}}, each a map of CSS variables, from /api/config, which derives it from
58
- // scripts/themes.json — the same registry that generates each sub-app's themes.css. Empty until
59
- // config arrives, and empty if the registry is unreadable; then the hub keeps index.html's colors.
60
- let themePalettes = {};
72
+ function sendTheme(appId) {
73
+ const payload = themePayload();
74
+ const vars = themeVars[payload.colorTheme ?? 'ember']?.[payload.theme];
75
+ sendTopic(appId, 'theme.changed', vars ? { ...payload, vars } : payload);
76
+ }
77
+
78
+ // {<themeId>: {dark, light}}, each a map of the apps' core CSS variables, from /api/config, which
79
+ // derives it from lib/themes.json — the same registry that generates each sub-app's themes.css.
80
+ // Empty until config arrives, and empty if the registry is unreadable; then the hub keeps
81
+ // index.html's colors.
82
+ let themeVars = {};
83
+ // The picker list that welcome carries.
84
+ let hubThemes = [];
85
+ // {<action>: {app, params, url, mode}} from the manifests, one handler each, from /api/config.
86
+ let actions = {};
87
+ // The hub page's names for the core variables. --bg is the apps' --bg-deep, so the loading screen
88
+ // hands off to an iframe without a flash of another color.
89
+ const HUB_VARS = {
90
+ '--accent': '--accent',
91
+ '--bg': '--bg-deep',
92
+ '--surface': '--bg-surface',
93
+ '--surface-hover': '--bg-hover',
94
+ '--border': '--border',
95
+ '--text': '--text-primary',
96
+ '--text-dim': '--text-muted',
97
+ };
61
98
  // The last applied variables, so the loading screen paints in the theme before /api/config lands.
62
99
  const PALETTE_KEY = 'hub-palette';
63
100
 
@@ -68,7 +105,8 @@ function applyHubTheme() {
68
105
  const root = document.documentElement;
69
106
  root.classList.toggle('light', mode === 'light');
70
107
  const key = `${activeColorTheme()}/${mode}`;
71
- let vars = themePalettes[activeColorTheme()]?.[mode];
108
+ const core = themeVars[activeColorTheme()]?.[mode];
109
+ let vars = core && Object.fromEntries(Object.entries(HUB_VARS).map(([hub, name]) => [hub, core[name]]));
72
110
  try {
73
111
  if (vars) localStorage.setItem(PALETTE_KEY, JSON.stringify({ key, vars }));
74
112
  else {
@@ -95,15 +133,6 @@ function parentOf(p) {
95
133
  return i <= 0 ? '' : p.slice(0, i);
96
134
  }
97
135
 
98
- function projectMessage() {
99
- return {
100
- type: 'hub:project',
101
- project: projectState.project,
102
- encoded: projectState.encoded,
103
- name: projectState.name,
104
- };
105
- }
106
-
107
136
  function originOf(appId) {
108
137
  return new URL(apps[appId].url).origin;
109
138
  }
@@ -111,7 +140,7 @@ function originOf(appId) {
111
140
  function postTo(appId, message) {
112
141
  // Before the iframe commits its src its contentWindow is still on the hub's own
113
142
  // origin, so a send addressed to the sub-app origin is refused and logged. The
114
- // load handler posts the current state anyway, so skipping here loses nothing.
143
+ // app's hello brings the current state anyway, so skipping here loses nothing.
115
144
  if (!loadedApps.has(appId)) return;
116
145
  iframes[appId]?.contentWindow?.postMessage(message, originOf(appId));
117
146
  }
@@ -120,28 +149,62 @@ function appIdOf(source) {
120
149
  return Object.keys(iframes).find((id) => iframes[id].contentWindow === source);
121
150
  }
122
151
 
123
- // exceptWindow lets an echo of a sub-app's own message skip that sub-app.
124
- function broadcast(message, exceptWindow) {
125
- for (const [id, iframe] of Object.entries(iframes)) {
126
- if (exceptWindow && iframe.contentWindow === exceptWindow) continue;
127
- postTo(id, message);
128
- }
152
+ // The last value lives in themeState and projectState, so a replay after welcome is a send.
153
+ function sendTopic(appId, topic, payload) {
154
+ if (liveApps.get(appId)?.has(topic)) postTo(appId, { type: 'hub:event', topic, payload });
129
155
  }
130
156
 
131
157
  function setProject(absPath) {
132
158
  projectState = { project: absPath, encoded: encodeProjectPath(absPath), name: basename(absPath) };
133
- broadcast(projectMessage());
159
+ for (const id of Object.keys(iframes)) sendProject(id);
134
160
  }
135
161
 
136
- function postProjectTo(appId) {
162
+ function sendProject(appId) {
137
163
  if (!projectState) return;
138
- postTo(appId, projectMessage());
164
+ sendTopic(appId, 'project.changed', projectState);
165
+ }
166
+
167
+ // Protocol §10. A payload with a string `project` gets the hub's encoded form and short name, as in project.changed.
168
+ function onPublish(appId, { topic, payload }) {
169
+ if (!apps[appId].publishes.includes(topic)) return;
170
+ if (payload !== null && !isPlainObject(payload)) return;
171
+ try {
172
+ if (JSON.stringify(payload).length > MAX_EVENT_CHARS) return;
173
+ } catch {
174
+ return;
175
+ }
176
+ const event =
177
+ typeof payload?.project === 'string'
178
+ ? { ...payload, encoded: encodeProjectPath(payload.project), projectName: basename(payload.project) }
179
+ : payload;
180
+ published.set(topic, { from: appId, payload: event });
181
+ for (const id of Object.keys(iframes)) {
182
+ if (id === appId) continue;
183
+ if (id === activeApp) sendTopic(id, topic, event);
184
+ else if (liveApps.get(id)?.has(topic)) missed.set(id, (missed.get(id) ?? new Set()).add(topic));
185
+ }
186
+ }
187
+
188
+ function sendPublished(appId) {
189
+ missed.delete(appId);
190
+ for (const [topic, { from, payload }] of published) if (from !== appId) sendTopic(appId, topic, payload);
191
+ }
192
+
193
+ function sendMissed(appId) {
194
+ for (const topic of missed.get(appId) ?? []) {
195
+ const last = published.get(topic);
196
+ if (last && last.from !== appId) sendTopic(appId, topic, last.payload);
197
+ }
198
+ missed.delete(appId);
139
199
  }
140
200
 
141
201
  // Sub-apps can't detect this themselves: inactive iframes are display:none, and a nested
142
202
  // document's visibilityState still follows the top-level tab. Used for polling/auto-refresh.
143
203
  function postActiveTo(appId) {
144
- postTo(appId, { type: 'hub:active', active: appId === activeApp });
204
+ if (!liveApps.has(appId)) return;
205
+ const active = appId === activeApp;
206
+ if (active) sendMissed(appId);
207
+ postTo(appId, { type: 'hub:active', active });
145
208
  }
146
209
 
147
210
  async function init() {
@@ -151,14 +214,17 @@ async function init() {
151
214
  showLoading();
152
215
  const res = await fetch('/api/config');
153
216
  const config = await res.json();
154
- setApps(config.apps);
217
+ apps = config.apps;
155
218
  defaultConfigDir = config.defaultConfigDir ?? null;
156
219
  activeConfigDir = config.activeConfigDir ?? null;
157
220
  applyTitle(config.activeConfigDir);
158
- themePalettes = config.themePalettes ?? {};
221
+ themeVars = config.themeVars ?? {};
222
+ hubThemes = config.themes ?? [];
223
+ actions = config.actions ?? {};
159
224
  applyHubTheme();
160
225
  buildIframes();
161
- switchTab(Object.keys(apps)[0]);
226
+ const saved = storedTab();
227
+ switchTab(apps[saved] ? saved : Object.keys(apps)[0]);
162
228
  listenMessages();
163
229
  listenKeys();
164
230
  bindPalette();
@@ -172,19 +238,39 @@ function applyTitle(dir) {
172
238
  document.title = dir && dir !== defaultConfigDir ? basename(dir) : 'Claude Code Hub';
173
239
  }
174
240
 
175
- function setApps(next) {
176
- apps = next;
177
- allowedOrigins = new Set(Object.values(apps).map((a) => new URL(a.url).origin));
178
- }
179
-
180
241
  // After a config-dir switch every child is a new process, possibly on a new port. Reloading each
181
242
  // iframe from scratch also drops the sub-apps' in-memory project state, which belonged to the old
182
243
  // dir; the hub's own projectState is cleared for the same reason.
183
244
  function reloadIframes() {
184
245
  loadedApps.clear();
185
246
  projectState = null;
186
- // Assigning src navigates even when the URL is unchanged.
187
- for (const [id, iframe] of Object.entries(iframes)) iframe.src = appSrc(id);
247
+ published.clear();
248
+ missed.clear();
249
+ // A new element, because assigning src is only a fragment navigation when the app's URL
250
+ // differs from the new one by the #t= fragment alone, and the old dir's document stays.
251
+ for (const id of Object.keys(iframes)) replaceIframe(id);
252
+ }
253
+
254
+ function replaceIframe(id) {
255
+ const old = iframes[id];
256
+ liveApps.delete(id);
257
+ linkLoads.delete(id);
258
+ loadedApps.delete(id);
259
+ const fresh = makeIframe(id);
260
+ fresh.className = old.className;
261
+ fresh.inert = old.inert;
262
+ const focused = document.activeElement === old;
263
+ old.replaceWith(fresh);
264
+ iframes[id] = fresh;
265
+ if (focused) fresh.focus();
266
+ }
267
+
268
+ // The old document stays live until the new one fires load, and a hub:action sent to it in between is lost.
269
+ function loadApp(id, src, { link = false } = {}) {
270
+ liveApps.delete(id);
271
+ if (link) linkLoads.add(id);
272
+ else linkLoads.delete(id);
273
+ iframes[id].src = src;
188
274
  }
189
275
 
190
276
  // The terminal token rides in the fragment so it never reaches a server log or a Referer.
@@ -193,57 +279,17 @@ function appSrc(id, path = '') {
193
279
  return apps[id].url + path + (token ? `#t=${token}` : '');
194
280
  }
195
281
 
196
- const LOADING_VERBS = {
197
- kanban: [
198
- 'Shuffling cards…',
199
- 'Herding tasks…',
200
- 'Waking sessions…',
201
- 'Sorting the board…',
202
- 'Pinning sticky notes…',
203
- 'Moving cards to done…',
204
- 'Nudging agents…',
205
- 'Drawing columns…',
206
- 'Reading the backlog…',
207
- ],
208
- cost: [
209
- 'Counting tokens…',
210
- 'Tallying the bill…',
211
- 'Stacking bars…',
212
- 'Crunching numbers…',
213
- 'Checking the cache hits…',
214
- 'Adding up the days…',
215
- 'Balancing the books…',
216
- 'Plotting the trend…',
217
- 'Weighing the models…',
218
- ],
219
- memory: [
220
- 'Recalling memories…',
221
- 'Walking the tree…',
222
- 'Dusting off notes…',
223
- 'Reading CLAUDE.md…',
224
- 'Following imports…',
225
- 'Tracing the context…',
226
- 'Flipping through pages…',
227
- 'Connecting the dots…',
228
- 'Checking what sticks…',
229
- ],
230
- marketplace: [
231
- 'Unpacking plugins…',
232
- 'Polishing skills…',
233
- 'Dusting off agents…',
234
- 'Wiring up hooks…',
235
- 'Fetching manifests…',
236
- 'Reading SKILL.md files…',
237
- 'Listing commands…',
238
- 'Scanning MCP servers…',
239
- 'Gathering plugins…',
240
- ],
241
- };
282
+ // From each app's manifest. Before /api/config lands there are none, and the first line is generic.
283
+ function loadingVerbs() {
284
+ const own = apps[activeApp]?.loading?.verbs ?? [];
285
+ const verbs = own.length ? own : Object.values(apps).flatMap((a) => a.loading?.verbs ?? []);
286
+ return verbs.length ? verbs : ['Loading…'];
287
+ }
242
288
 
243
289
  let loadingTimer = null;
244
290
 
245
291
  function showLoading() {
246
- const verbs = LOADING_VERBS[activeApp] ?? Object.values(LOADING_VERBS).flat();
292
+ const verbs = loadingVerbs();
247
293
  const text = document.getElementById('loading-text');
248
294
  let i = Math.floor(Math.random() * verbs.length);
249
295
  text.textContent = verbs[i];
@@ -261,23 +307,41 @@ function hideLoading() {
261
307
  document.getElementById('loading-overlay').classList.add('fade-out');
262
308
  }
263
309
 
310
+ function makeIframe(id) {
311
+ const iframe = document.createElement('iframe');
312
+ iframe.id = `iframe-${id}`;
313
+ iframe.src = appSrc(id);
314
+ iframe.className = 'hidden';
315
+ iframe.allow = 'clipboard-write; microphone';
316
+ iframe.addEventListener('load', () => onIframeLoad(id));
317
+ return iframe;
318
+ }
319
+
264
320
  function buildIframes() {
265
321
  const container = document.getElementById('iframe-container');
266
322
  for (const id of Object.keys(apps)) {
267
- const iframe = document.createElement('iframe');
268
- iframe.id = `iframe-${id}`;
269
- iframe.src = appSrc(id);
270
- iframe.className = 'hidden';
271
- iframe.allow = 'clipboard-write; microphone';
272
- iframe.addEventListener('load', () => onIframeLoad(id));
273
- container.appendChild(iframe);
274
- iframes[id] = iframe;
323
+ iframes[id] = makeIframe(id);
324
+ container.appendChild(iframes[id]);
325
+ }
326
+ }
327
+
328
+ const TAB_KEY = 'hub-tab';
329
+
330
+ function storedTab() {
331
+ try {
332
+ return localStorage.getItem(TAB_KEY);
333
+ } catch {
334
+ return null;
275
335
  }
276
336
  }
277
337
 
278
338
  function switchTab(appId) {
279
339
  if (!apps[appId]) return;
340
+ if (appId !== activeApp) previousApp = activeApp;
280
341
  activeApp = appId;
342
+ try {
343
+ localStorage.setItem(TAB_KEY, appId);
344
+ } catch {}
281
345
 
282
346
  for (const [id, iframe] of Object.entries(iframes)) {
283
347
  iframe.classList.toggle('hidden', id !== appId);
@@ -291,20 +355,73 @@ function switchTab(appId) {
291
355
  }
292
356
 
293
357
  function onIframeLoad(appId) {
358
+ liveApps.delete(appId);
294
359
  loadedApps.add(appId);
295
360
  guardedApps.delete(appId);
296
361
  if (appId === activeApp) hideLoading();
297
- const postState = () => {
298
- postTo(appId, themeMessage());
299
- postProjectTo(appId);
300
- postActiveTo(appId);
301
- postTo(appId, { type: 'hub:keys', keys: Object.keys(bindings()) });
302
- };
303
- postState();
304
- // Posted twice: the shims gate their origin check on window.__HUB__, which they populate from
305
- // an async /hub-config fetch that resolves after this load event, so the first post can be
306
- // dropped. Safe to repeat — every shim's apply is idempotent.
307
- setTimeout(postState, 400);
362
+ }
363
+
364
+ function onHello(appId, data) {
365
+ const common = Array.isArray(data.protocol) ? HUB_PROTOCOLS.filter((v) => data.protocol.includes(v)) : [];
366
+ if (!common.length) return;
367
+ liveApps.set(appId, new Set(Array.isArray(data.subscribes) ? data.subscribes : []));
368
+ postTo(appId, {
369
+ type: 'hub:welcome',
370
+ protocol: Math.max(...common),
371
+ forward: Object.keys(bindings()),
372
+ themes: hubThemes,
373
+ actions: Object.keys(actions),
374
+ });
375
+ sendTheme(appId);
376
+ // A document the hub loaded from an action URL got its project from that URL. The hub's own
377
+ // project would undo the link, so it goes out only with the next change.
378
+ if (!linkLoads.delete(appId)) sendProject(appId);
379
+ sendPublished(appId);
380
+ postActiveTo(appId);
381
+ }
382
+
383
+ // A url such as '@evil.example/' turns the app host into userinfo, and the token rides in the fragment.
384
+ function navigateApp(appId, url) {
385
+ const src = appSrc(appId, url);
386
+ if (!URL.canParse(src) || new URL(src).origin !== originOf(appId)) return;
387
+ loadApp(appId, src, { link: true });
388
+ }
389
+
390
+ function isPlainObject(v) {
391
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
392
+ }
393
+
394
+ function badParams(declared, params) {
395
+ if (!isPlainObject(params)) return true;
396
+ const given = Object.entries(params);
397
+ if (given.some(([name, value]) => !Object.hasOwn(declared, name) || typeof value !== 'string')) return true;
398
+ return Object.entries(declared).some(([name, type]) => type === 'string' && !Object.hasOwn(params, name));
399
+ }
400
+
401
+ // Null when the template names a param the call left out.
402
+ function fillUrl(template, params) {
403
+ const slot = /\{([^}]+)\}/g;
404
+ if ([...template.matchAll(slot)].some(([, name]) => !Object.hasOwn(params, name))) return null;
405
+ return template.replace(slot, (_, name) => encodeURIComponent(params[name]));
406
+ }
407
+
408
+ // Protocol §7. The result does not wait for the handler: the hub queues nothing.
409
+ function onInvoke(appId, { id, action, params = {} }) {
410
+ if (typeof id !== 'string') return;
411
+ const reply = (result) => postTo(appId, { type: 'hub:result', id, ...result });
412
+ const spec = typeof action === 'string' && Object.hasOwn(actions, action) ? actions[action] : null;
413
+ if (!spec) return reply({ ok: false, reason: 'unhandled' });
414
+ if (badParams(spec.params, params)) return reply({ ok: false, reason: 'bad-params' });
415
+ const { app: handler, mode, url: template } = spec;
416
+ switchTab(handler);
417
+ if (mode === 'message' && liveApps.has(handler)) {
418
+ postTo(handler, { type: 'hub:action', id, action, params });
419
+ } else {
420
+ // Setting src reloads the app, so a guarded app only gets the switch.
421
+ const url = template && fillUrl(template, params);
422
+ if (url && !guardedApps.has(handler)) navigateApp(handler, url);
423
+ }
424
+ reply({ ok: true, handledBy: handler });
308
425
  }
309
426
 
310
427
  function listenMessages() {
@@ -312,26 +429,22 @@ function listenMessages() {
312
429
  if (guardedApps.size) e.preventDefault();
313
430
  });
314
431
  window.addEventListener('message', (e) => {
315
- if (!allowedOrigins.has(e.origin)) return;
432
+ const appId = appIdOf(e.source);
433
+ if (!appId || e.origin !== originOf(appId)) return;
316
434
  const data = e.data ?? {};
317
- if (data.type === 'hub:navigate') {
318
- if (!apps[data.app]) return;
319
- switchTab(data.app);
320
- if (typeof data.url !== 'string' || !data.url) return;
321
- // A url such as '@evil.example/' turns the app host into userinfo, and the token rides in the fragment.
322
- const src = appSrc(data.app, data.url);
323
- if (URL.canParse(src) && new URL(src).origin === originOf(data.app)) iframes[data.app].src = src;
435
+ if (data.type === 'hub:hello') {
436
+ onHello(appId, data);
437
+ } else if (data.type === 'hub:invoke') {
438
+ onInvoke(appId, data);
439
+ } else if (data.type === 'hub:publish') {
440
+ onPublish(appId, data);
324
441
  } else if (data.type === 'hub:keydown') {
325
442
  handleForwardedKey(data);
326
443
  } else if (data.type === 'hub:closeGuard') {
327
- const appId = appIdOf(e.source);
328
- if (!appId) return;
329
444
  if (data.on === true) guardedApps.add(appId);
330
445
  else guardedApps.delete(appId);
331
446
  } else if (data.type === 'hub:terminalToken') {
332
447
  // This hub page may have outlived the restart too, so the cached token is not trusted.
333
- const appId = appIdOf(e.source);
334
- if (!appId) return;
335
448
  sendJson('GET', '/api/config')
336
449
  .then((cfg) => {
337
450
  const token = cfg.apps?.[appId]?.terminalToken;
@@ -342,7 +455,7 @@ function listenMessages() {
342
455
  .catch(() => {});
343
456
  } else if (data.type === 'hub:openExternal') {
344
457
  // In the installed PWA window a framed sub-app's own target=_blank opens
345
- // nothing, so the shims hand external links up to the top frame instead.
458
+ // nothing, so the SDK hands external links up to the top frame instead.
346
459
  let url;
347
460
  try {
348
461
  url = new URL(String(data.url));
@@ -352,7 +465,7 @@ function listenMessages() {
352
465
  if (url.protocol !== 'http:' && url.protocol !== 'https:') return;
353
466
  window.open(url.href, '_blank', 'noopener');
354
467
  } else if (data.type === 'hub:theme') {
355
- // Legacy senders pass only {theme}; colorTheme is optional and sticky.
468
+ // colorTheme is optional and sticky.
356
469
  if (data.theme !== 'light' && data.theme !== 'dark') return;
357
470
  const hasColor = typeof data.colorTheme === 'string' && /^[a-z0-9-]{0,32}$/.test(data.colorTheme);
358
471
  const changed = data.theme !== themeState.theme || (hasColor && data.colorTheme !== activeColorTheme());
@@ -364,32 +477,31 @@ function listenMessages() {
364
477
  }
365
478
  localStorage.setItem('hub-theme', JSON.stringify(themeState));
366
479
  applyHubTheme();
367
- broadcast(themeMessage(), e.source);
480
+ // The sender gets the echo too, for the vars.
481
+ for (const id of Object.keys(iframes)) sendTheme(id);
368
482
  }
369
483
  });
370
484
  }
371
485
 
372
486
  function handleForwardedKey(d) {
373
- // The modifier fields are new. A sub-app running an older service-worker-cached bundle sends
374
- // {key} only; it forwards nothing but the pre-existing bindings, and each of those is
375
- // unambiguous from the key alone, so normalize instead of dispatching twice.
376
- const legacy = typeof d.alt !== 'boolean';
377
487
  const e = {
378
488
  key: d.key,
379
489
  code: d.code,
380
- altKey: legacy || d.alt,
381
- ctrlKey: legacy ? d.key.startsWith('Arrow') : d.ctrl,
490
+ ctrlKey: d.ctrl === true,
491
+ altKey: d.alt === true,
382
492
  shiftKey: d.shift === true,
493
+ metaKey: d.meta === true,
383
494
  };
384
495
  bindings()[comboOf(e)]?.run();
385
496
  }
386
497
 
387
- // The one keymap. Its combo names are what the hub:keys message lists for the shims.
498
+ // The one keymap. Its combo names are what welcome.forward lists for the apps.
388
499
  // inPalette: the binding still fires while the palette is open.
389
500
  function bindings() {
390
501
  const map = {
391
502
  'ctrl+alt+p': { run: () => togglePalette('project'), inPalette: true },
392
503
  'ctrl+alt+w': { run: () => togglePalette('configDir'), inPalette: true },
504
+ 'ctrl+alt+a': { run: () => togglePalette('app'), inPalette: true },
393
505
  'ctrl+alt+ArrowLeft': { run: () => cycleTab(-1) },
394
506
  'ctrl+alt+ArrowRight': { run: () => cycleTab(1) },
395
507
  };
@@ -401,7 +513,7 @@ function bindings() {
401
513
  return map;
402
514
  }
403
515
 
404
- // Each shim's hubCombo() is a copy of this: modifiers in ctrl, alt, shift, meta order, joined by
516
+ // The SDK's comboOf() is a copy of this: modifiers in ctrl, alt, shift, meta order, joined by
405
517
  // '+' to the key as bindingKey() names it.
406
518
  function comboOf(e) {
407
519
  const mods = [e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift', e.metaKey && 'meta'];
@@ -578,6 +690,114 @@ async function removeConfigDir(dirPath) {
578
690
  if (palette.open && palette.mode === 'configDir') renderPalette();
579
691
  }
580
692
 
693
+ // Lucide icons (ISC license) for the names the built-in apps and the Inspector use. Any other
694
+ // manifest icon shows the app's first letter.
695
+ const APP_ICONS = {
696
+ columns: [
697
+ ['rect', { width: 18, height: 18, x: 3, y: 3, rx: 2 }],
698
+ ['path', { d: 'M12 3v18' }],
699
+ ],
700
+ store: [
701
+ ['path', { d: 'm2 7 4.41-4.41A2 2 0 0 1 7.83 2h8.34a2 2 0 0 1 1.42.59L22 7' }],
702
+ ['path', { d: 'M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8' }],
703
+ ['path', { d: 'M15 22v-4a2 2 0 0 0-2-2h-2a2 2 0 0 0-2 2v4' }],
704
+ ['path', { d: 'M2 7h20' }],
705
+ [
706
+ 'path',
707
+ {
708
+ d: 'M22 7v3a2 2 0 0 1-2 2a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 16 12a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 12 12a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 8 12a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 4 12a2 2 0 0 1-2-2V7',
709
+ },
710
+ ],
711
+ ],
712
+ 'dollar-sign': [
713
+ ['line', { x1: 12, x2: 12, y1: 2, y2: 22 }],
714
+ ['path', { d: 'M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6' }],
715
+ ],
716
+ database: [
717
+ ['ellipse', { cx: 12, cy: 5, rx: 9, ry: 3 }],
718
+ ['path', { d: 'M3 5V19A9 3 0 0 0 21 19V5' }],
719
+ ['path', { d: 'M3 12A9 3 0 0 0 21 12' }],
720
+ ],
721
+ 'scan-search': [
722
+ ['path', { d: 'M3 7V5a2 2 0 0 1 2-2h2' }],
723
+ ['path', { d: 'M17 3h2a2 2 0 0 1 2 2v2' }],
724
+ ['path', { d: 'M21 17v2a2 2 0 0 1-2 2h-2' }],
725
+ ['path', { d: 'M7 21H5a2 2 0 0 1-2-2v-2' }],
726
+ ['circle', { cx: 12, cy: 12, r: 3 }],
727
+ ['path', { d: 'm16 16-1.9-1.9' }],
728
+ ],
729
+ };
730
+
731
+ const SVG_NS = 'http://www.w3.org/2000/svg';
732
+
733
+ function svgNode(tag, attrs) {
734
+ const node = document.createElementNS(SVG_NS, tag);
735
+ for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, String(v));
736
+ return node;
737
+ }
738
+
739
+ function appIcon(app) {
740
+ const shapes = APP_ICONS[app.icon];
741
+ if (!shapes) return el('span', 'palette-icon letter', (app.name || '?').slice(0, 1).toUpperCase());
742
+ const svg = svgNode('svg', {
743
+ viewBox: '0 0 24 24',
744
+ fill: 'none',
745
+ stroke: 'currentColor',
746
+ 'stroke-width': 2,
747
+ 'stroke-linecap': 'round',
748
+ 'stroke-linejoin': 'round',
749
+ 'aria-hidden': 'true',
750
+ });
751
+ svg.append(...shapes.map(([tag, attrs]) => svgNode(tag, attrs)));
752
+ const box = el('span', 'palette-icon');
753
+ box.append(svg);
754
+ return box;
755
+ }
756
+
757
+ function appRows(q) {
758
+ const list = Object.entries(apps).map(([id, a], i) => ({ kind: 'app', id, app: a, index: i }));
759
+ if (!q) return list;
760
+ const hit = list.filter((r) => r.app.name.toLowerCase().includes(q) || r.id.includes(q));
761
+ return hit.length ? hit : list.filter((r) => subseq(r.app.name.toLowerCase(), q));
762
+ }
763
+
764
+ // A click on the button runs the mode's rowKeys entry for `key`, the same as Ctrl+<key>.
765
+ function rowButton(className, glyph, key, title, label) {
766
+ const btn = el('button', className, glyph);
767
+ btn.type = 'button';
768
+ btn.title = title;
769
+ btn.setAttribute('aria-label', label);
770
+ btn.dataset.rowKey = key;
771
+ return btn;
772
+ }
773
+
774
+ function setPaletteHint(text) {
775
+ document.getElementById('palette-hint').textContent = text;
776
+ }
777
+
778
+ // The terminal provider answers 409 with a terminal count; the second Ctrl+R on the same app sends force.
779
+ async function restartFromPalette(id) {
780
+ const name = apps[id]?.name ?? id;
781
+ const force = palette.confirmRestart === id;
782
+ palette.confirmRestart = null;
783
+ setPaletteHint(`Restarting ${name}…`);
784
+ try {
785
+ await withPaletteBusy(() => sendJson('POST', `/api/apps/${encodeURIComponent(id)}/restart`, { force }));
786
+ } catch (err) {
787
+ if (err.status === 409 && err.data?.terminals) {
788
+ palette.confirmRestart = id;
789
+ const n = err.data.terminals;
790
+ setPaletteHint(
791
+ `${name} has ${n} live terminal${n === 1 ? '' : 's'}. A restart ends ${n === 1 ? 'it' : 'them'}. Press Ctrl+R again to restart.`,
792
+ );
793
+ } else setPaletteHint(`${name} did not restart: ${err.message}`);
794
+ return;
795
+ }
796
+ closePalette();
797
+ replaceIframe(id);
798
+ switchTab(id);
799
+ }
800
+
581
801
  // `typed` is the trimmed query, `q` its lowercase form for matching.
582
802
  const PALETTE_MODES = {
583
803
  project: {
@@ -635,14 +855,33 @@ const PALETTE_MODES = {
635
855
  li.append(el('span', 'age', 'active'));
636
856
  return;
637
857
  }
638
- const remove = el('button', 'palette-remove', '×');
639
- remove.type = 'button';
640
- remove.title = 'Remove from list (Ctrl+D)';
641
- remove.dataset.remove = row.path;
642
- li.append(remove);
858
+ li.append(rowButton('palette-remove', '×', 'd', 'Remove from list (Ctrl+D)', `Remove ${row.path}`));
643
859
  },
860
+ rowKeys: { d: (row) => removeConfigDir(row.path) },
644
861
  commit: commitConfigDir,
645
862
  },
863
+ app: {
864
+ placeholder: 'Search apps...',
865
+ label: 'Switch app',
866
+ hint: 'Enter switch · Ctrl+R restart the app',
867
+ empty: 'No matching app',
868
+ rows: (_typed, q) => appRows(q),
869
+ initialSel(rows) {
870
+ const prev = rows.findIndex((r) => r.id === previousApp);
871
+ return prev < 0 ? 0 : prev;
872
+ },
873
+ fillRow(li, row, q) {
874
+ li.append(appIcon(row.app), markedSpan('name', row.app.name, q));
875
+ const key = row.id === activeApp ? 'active' : row.index < 9 ? `Alt+${row.index + 1}` : '';
876
+ li.append(el('span', 'age', key));
877
+ li.append(rowButton('palette-restart', '↻', 'r', 'Restart the app (Ctrl+R)', `Restart ${row.app.name}`));
878
+ },
879
+ rowKeys: { r: (row) => restartFromPalette(row.id) },
880
+ commit(row) {
881
+ closePalette();
882
+ switchTab(row.id);
883
+ },
884
+ },
646
885
  };
647
886
 
648
887
  function renderPalette() {
@@ -695,10 +934,11 @@ function openPalette(mode) {
695
934
  palette.mode = mode;
696
935
  palette.query = '';
697
936
  palette.sel = null;
937
+ palette.confirmRestart = null;
698
938
  input.value = '';
699
939
  input.placeholder = spec.placeholder;
700
940
  document.querySelector('#palette .palette-box').setAttribute('aria-label', spec.label);
701
- document.getElementById('palette-hint').textContent = spec.hint ?? '';
941
+ setPaletteHint(spec.hint ?? '');
702
942
  document.getElementById('palette').hidden = false;
703
943
  // Ctrl+Alt+P usually arrives forwarded from a focused iframe. Without inert the sub-app can keep
704
944
  // or take focus back, and then Escape is handled inside it — the palette stays open and only the
@@ -707,8 +947,7 @@ function openPalette(mode) {
707
947
  renderPalette();
708
948
  input.focus();
709
949
  // Stale-while-revalidate: the cached list renders instantly, recency refreshes when this lands.
710
- spec
711
- .load()
950
+ Promise.resolve(spec.load?.())
712
951
  .then(() => {
713
952
  if (palette.open && palette.mode === mode) renderPalette();
714
953
  })
@@ -754,7 +993,7 @@ async function sendJson(method, url, body) {
754
993
  body: body === undefined ? undefined : JSON.stringify(body),
755
994
  });
756
995
  const data = await res.json().catch(() => ({}));
757
- if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
996
+ if (!res.ok) throw Object.assign(new Error(data.error || `HTTP ${res.status}`), { status: res.status, data });
758
997
  return data;
759
998
  }
760
999
 
@@ -785,7 +1024,7 @@ async function commitConfigDir(row) {
785
1024
  if (target === palette.configDirs.active) return;
786
1025
  showLoading();
787
1026
  try {
788
- setApps((await sendJson('POST', '/api/config-dirs/activate', { path: target })).apps);
1027
+ apps = (await sendJson('POST', '/api/config-dirs/activate', { path: target })).apps;
789
1028
  // The palette renders from this cache before its refetch lands, and initialSel keys off it.
790
1029
  palette.configDirs.active = target;
791
1030
  activeConfigDir = target;
@@ -832,22 +1071,24 @@ function bindPalette() {
832
1071
  } else if (e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey)) {
833
1072
  e.preventDefault();
834
1073
  movePaletteSel(-1);
835
- } else if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && e.key.toLowerCase() === 'd') {
1074
+ } else if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && ['d', 'r'].includes(e.key.toLowerCase())) {
836
1075
  // Ctrl+D would otherwise bookmark the page in Chrome and Firefox.
1076
+ // Ctrl+R would otherwise reload the hub page.
837
1077
  e.preventDefault();
1078
+ const run = PALETTE_MODES[palette.mode].rowKeys?.[e.key.toLowerCase()];
838
1079
  const row = palette.rows[palette.sel];
839
- if (row?.kind === 'dir') removeConfigDir(row.path);
1080
+ if (run && row && row.kind !== 'literal') run(row);
840
1081
  }
841
1082
  });
842
1083
  document.getElementById('palette-list').addEventListener('click', (e) => {
843
- const remove = e.target.closest('.palette-remove');
844
- if (remove) {
1084
+ const btn = e.target.closest('[data-row-key]');
1085
+ const li = e.target.closest('.palette-row');
1086
+ if (btn) {
845
1087
  e.stopPropagation();
846
- removeConfigDir(remove.dataset.remove);
1088
+ PALETTE_MODES[palette.mode].rowKeys[btn.dataset.rowKey](palette.rows[Number(li.dataset.idx)]);
847
1089
  document.getElementById('palette-input').focus();
848
1090
  return;
849
1091
  }
850
- const li = e.target.closest('.palette-row');
851
1092
  if (!li) return;
852
1093
  palette.sel = Number(li.dataset.idx);
853
1094
  commitPalette();