claude-code-hub 1.23.0 → 1.25.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/lib/apps.js CHANGED
@@ -81,6 +81,7 @@ function manifestError(m, id, root) {
81
81
  if (entry === path.resolve(root) || !isContained(entry, root)) return 'run.entry is outside the app directory';
82
82
  return (
83
83
  providesError(m.provides) ??
84
+ launcherError(m.launcher) ??
84
85
  publishesError(m.publishes) ??
85
86
  keysError(m.keys) ??
86
87
  pluginError(m.plugin, root) ??
@@ -242,17 +243,23 @@ function publishesError(publishes) {
242
243
  // The hub fetches each path from the provider's own server.
243
244
  const CAPABILITY_PATHS = { projects: 'path', terminal: 'liveWork' };
244
245
 
246
+ const isPath = (p) => typeof p === 'string' && p.startsWith('/');
247
+
245
248
  function providesError(provides) {
246
249
  if (provides === undefined) return null;
247
250
  if (!isObject(provides)) return 'provides is not an object';
248
251
  for (const [cap, key] of Object.entries(CAPABILITY_PATHS)) {
249
252
  if (provides[cap] === undefined) continue;
250
- const p = provides[cap]?.[key];
251
- if (typeof p !== 'string' || !p.startsWith('/')) return `provides.${cap}.${key} is not a path`;
253
+ if (!isPath(provides[cap]?.[key])) return `provides.${cap}.${key} is not a path`;
252
254
  }
253
255
  return null;
254
256
  }
255
257
 
258
+ function launcherError(launcher) {
259
+ if (launcher === undefined) return null;
260
+ return isPath(launcher?.items) ? null : 'launcher.items is not a path';
261
+ }
262
+
256
263
  // The manifest at root and its resolved plugin. With no id, the manifest's own id is checked. Throws with the file and the reason.
257
264
  function readApp(root, id) {
258
265
  const file = path.join(root, 'hub-app.json');
@@ -316,6 +323,7 @@ function loadApp(app, { flagPort, hubRoot = HUB_ROOT, resolve = require.resolve,
316
323
  loading: manifest.loading ?? {},
317
324
  actions: manifest.actions ?? {},
318
325
  provides: manifest.provides ?? {},
326
+ launcher: manifest.launcher ?? {},
319
327
  publishes: manifest.publishes ?? [],
320
328
  keeps: manifest.keys?.keeps ?? [],
321
329
  settings: manifest.settings ?? [],
package/lib/ext.js CHANGED
@@ -17,13 +17,16 @@ const USAGE = `Usage: claude-code-hub ext <command>
17
17
  ext install github:<owner>/<repo>[/<folder>][@<commit or branch>]
18
18
  Clone the extension into the hub dir, add it to the hub and install its plugin
19
19
  ext install <dir> Run the extension in <dir> from the hub and install its plugin
20
- ext install <id> Install the plugin of an app the hub runs, in a config dir
20
+ ext install <id> Install the plugin of an app the hub runs, or put an add-on on the dock, in a config dir
21
21
  ext list List the extensions and the state of their plugins
22
22
  ext update <id> Get the latest code of a GitHub extension, and install its plugin again
23
23
  ext remove <id> Remove the extension's plugin and its entry, and the clone of a GitHub extension
24
24
 
25
+ A Kanban dock add-on (addon.json) installs the same way: in place of a plugin, it goes on the
26
+ Kanban dock of the config dir, for every session.
27
+
25
28
  --hub-dir <path> The hub dir (default CLAUDE_HUB_DIR, else ~/.claude-hub)
26
- --dir <path> The config dir for plugin steps (default CLAUDE_CONFIG_DIR, else ~/.claude)`;
29
+ --dir <path> The config dir for plugin and dock steps (default CLAUDE_CONFIG_DIR, else ~/.claude)`;
27
30
 
28
31
  const RESTART = ' Restart the hub to load the change.';
29
32
  const REGISTRY = 'https://nikiforovall.blog/claude-code-hub/extensions.json';
@@ -43,19 +46,27 @@ function checkRequires(ctx, { id, requires }, config) {
43
46
 
44
47
  const isExt = (e) => typeof e?.id === 'string' && typeof e.source === 'string';
45
48
 
49
+ // [entry, kind] for each entry of the kinds' lists in config.json; extEntries keeps the extensions.
50
+ const allEntries = (config) => Object.values(KINDS).flatMap((kind) => (config[kind.list] ?? []).map((e) => [e, kind]));
51
+ const extEntries = (config) => allEntries(config).filter(([e]) => isExt(e));
52
+
53
+ const withList = (config, kind, change) => ({ ...config, [kind.list]: change(config[kind.list] ?? []) });
54
+
46
55
  function extEntry(config, id) {
47
56
  if (!id) throw new Error(USAGE);
48
- const entry = (config.apps ?? []).find((e) => isExt(e) && e.id === id);
49
- if (!entry) throw new Error(`"${id}" is not an extension. ext list shows them.`);
50
- return entry;
57
+ const found = extEntries(config).find(([e]) => e.id === id);
58
+ if (!found) throw new Error(`"${id}" is not an extension. ext list shows them.`);
59
+ return found;
51
60
  }
52
61
 
53
62
  const isGithub = (entry) => entry.source.startsWith('github:');
54
63
 
55
64
  function managedEntry(config, id, verb) {
56
- const entry = extEntry(config, id);
57
- if (entry.source !== 'local' && !isGithub(entry)) throw new Error(`${id} has source "${entry.source}": only a local or GitHub extension can ${verb}.`);
58
- return entry;
65
+ const found = extEntry(config, id);
66
+ const [entry] = found;
67
+ const { source } = entry;
68
+ if (source !== 'local' && !isGithub(entry)) throw new Error(`${id} has source "${source}": only a local or GitHub extension can ${verb}.`);
69
+ return found;
59
70
  }
60
71
 
61
72
  const rootOf = (ctx, entry) => path.resolve(ctx.hubDir, entry.path);
@@ -108,9 +119,10 @@ function cloneSource(ctx, src) {
108
119
  }
109
120
  }
110
121
 
122
+ // The retries outlast a dock add-on's server, which the board stops a moment after the add-on goes.
111
123
  function removeDir(dir) {
112
124
  try {
113
- fs.rmSync(dir, { recursive: true, force: true, maxRetries: 3 });
125
+ fs.rmSync(dir, { recursive: true, force: true, maxRetries: 10 });
114
126
  return true;
115
127
  } catch (e) {
116
128
  console.log(` Could not delete ${dir} (${e.code ?? e.message}). Delete it by hand.`);
@@ -181,6 +193,72 @@ function pluginStep(ctx, plugin, step, root) {
181
193
  return { install: installPlugin, reinstall: reinstallPlugin, uninstall: uninstallPlugin }[step](args);
182
194
  }
183
195
 
196
+ function readAddon(root, id) {
197
+ const file = path.join(root, 'addon.json');
198
+ let manifest;
199
+ try {
200
+ manifest = JSON.parse(fs.readFileSync(file, 'utf8'));
201
+ } catch (e) {
202
+ throw new Error(`${file}: ${e.code === 'ENOENT' ? 'not found' : e.message}`);
203
+ }
204
+ // Kanban's addon add --manifest checks the rest of the manifest.
205
+ if (manifest?.kind !== 'addon' || !ID_RE.test(manifest.id ?? '')) {
206
+ throw new Error(`${file}: an add-on manifest needs "kind": "addon" and an id of lowercase letters, digits and dashes`);
207
+ }
208
+ if (id && manifest.id !== id) throw new Error(`${file}: its id is ${manifest.id}, not ${id}`);
209
+ return { manifest };
210
+ }
211
+
212
+ // Kanban's CLI writes the dock entry into the config dir's addons.json, and a running board picks it up.
213
+ // The manifest's id keys the entry, so an add again updates it.
214
+ function dockStep(ctx, id, _ext, step, root, config) {
215
+ const kanban = selectApps(config.apps, ctx.hubDir).apps.find((a) => a.id === 'kanban');
216
+ if (!kanban) {
217
+ console.log(' Kanban is off in the hub, so it has no dock to change.');
218
+ return false;
219
+ }
220
+ const configDir = ctx.configDir();
221
+ console.log(`\n Dock add-on ${id}\n Config dir: ${configDir}\n`);
222
+ const [label, cmd] =
223
+ step === 'uninstall'
224
+ ? ['Take it off the dock', `claude-code-kanban addon rm --key ${id} --offline`]
225
+ : ['Put it on the dock', `claude-code-kanban addon add --manifest "${root}" --offline`];
226
+ return report(label, ctx.exec(appCommand(cmd, appRoot(kanban, HUB_ROOT, require.resolve)), claudeEnv(configDir)));
227
+ }
228
+
229
+ // What differs between an app the hub runs (hub-app.json, with a plugin) and a Kanban dock add-on
230
+ // (addon.json, put on the dock). The hub keeps add-ons in `addons`, because it runs every entry of `apps`.
231
+ const KINDS = {
232
+ app: {
233
+ list: 'apps',
234
+ file: 'hub-app.json',
235
+ read: readApp,
236
+ step: (ctx, _id, ext, step, root) => !ext?.plugin || pluginStep(ctx, ext.plugin, step, root),
237
+ stepName: 'Plugin install',
238
+ removeName: 'Plugin removal',
239
+ unread: 'The plugin is not removed, because the manifest does not name it.',
240
+ installed: RESTART,
241
+ removed: RESTART,
242
+ updated: 'Restart it (Ctrl+R in the app launcher) to load app changes, and start a new Claude Code session to load plugin changes.',
243
+ noPlugin: 'no plugin',
244
+ },
245
+ addon: {
246
+ list: 'addons',
247
+ file: 'addon.json',
248
+ read: readAddon,
249
+ step: dockStep,
250
+ stepName: 'Putting it on the dock',
251
+ removeName: 'Taking it off the dock',
252
+ unread: 'It is taken off the dock by its id.',
253
+ installed: ' It is on the Kanban dock of every session in that config dir.',
254
+ removed: ' It is off the Kanban dock of that config dir.',
255
+ updated: 'Restart its server (Ctrl+R on its row in the app launcher) to load the new code.',
256
+ noPlugin: 'dock add-on',
257
+ },
258
+ };
259
+
260
+ const kindOf = (root) => (fs.existsSync(path.join(root, 'addon.json')) ? KINDS.addon : KINDS.app);
261
+
184
262
  // An id the hub runs gets its plugin installed. A folder is added to the hub, with its plugin, unless the
185
263
  // hub already runs it from there; then only the plugin is installed. Any other name is looked up in the registry.
186
264
  function install(ctx, target) {
@@ -190,6 +268,8 @@ function install(ctx, target) {
190
268
  if (/^(npm|https?):/.test(target)) throw new Error('Only a registry name, a github: source or a local folder installs. ext --help shows the forms.');
191
269
  const app = selectApps(config.apps, ctx.hubDir).apps.find((a) => a.id === target);
192
270
  if (app) return installAppPlugin(ctx, app);
271
+ const [addon] = extEntries(config).find(([e, k]) => k === KINDS.addon && e.id === target) ?? [];
272
+ if (addon) return dockStep(ctx, target, null, 'install', rootOf(ctx, addon), config);
193
273
  if (!fs.existsSync(target) && ID_RE.test(target)) return installRegistry(ctx, target, file, config);
194
274
  return installFolder(ctx, target, file, config);
195
275
  }
@@ -226,22 +306,23 @@ function installRegistry(ctx, name, file, config) {
226
306
  }
227
307
 
228
308
  // The entry the hub has for the id when `same` accepts it; throws when the id is built-in or taken.
229
- function claimId(id, config, file, same) {
309
+ function claimId(id, config, file, kind, same) {
230
310
  if (APPS.some((a) => a.id === id)) throw new Error(`Cannot install ${id}: it is the id of a built-in app.`);
231
- const entry = (config.apps ?? []).find((e) => e?.id === id);
232
- if (entry && !(isExt(entry) && same(entry))) throw new Error(`Cannot install ${id}: ${file} already has an entry for it.`);
311
+ const [entry, k] = allEntries(config).find(([e]) => e?.id === id) ?? [];
312
+ const ours = entry && isExt(entry) && k === kind && same(entry);
313
+ if (entry && !ours) throw new Error(`Cannot install ${id}: ${file} already has an entry for it.`);
233
314
  return entry;
234
315
  }
235
316
 
236
- // Installs the plugin, then adds the entry. Returns false with nothing written when the plugin fails.
237
- function addExtension(ctx, root, file, config, fields) {
238
- const ext = readApp(root);
317
+ // Runs the install step, then adds the entry. Returns false with nothing written when the step fails.
318
+ function addExtension(ctx, root, file, config, fields, kind) {
319
+ const ext = kind.read(root);
239
320
  const { id } = ext.manifest;
240
- if (ext.plugin && !pluginStep(ctx, ext.plugin, 'install', root)) {
241
- console.log(`\n Plugin install failed, so ${id} is not added. See the errors above.`);
321
+ if (!kind.step(ctx, id, ext, 'install', root, config)) {
322
+ console.log(`\n ${kind.stepName} failed, so ${id} is not added. See the errors above.`);
242
323
  return false;
243
324
  }
244
- writeConfigFile(file, { ...config, apps: [...(config.apps ?? []), { id, ...fields }] });
325
+ writeConfigFile(file, withList(config, kind, (list) => [...list, { id, ...fields }]));
245
326
  return true;
246
327
  }
247
328
 
@@ -249,16 +330,18 @@ function installGithub(ctx, target, file, config, name) {
249
330
  const src = parseGithub(target);
250
331
  const { dir, sha } = cloneSource(ctx, src);
251
332
  let id;
333
+ let kind;
252
334
  try {
253
- const ext = readApp(path.join(dir, src.subdir));
335
+ const extDir = path.join(dir, src.subdir);
336
+ kind = kindOf(extDir);
337
+ const ext = kind.read(extDir);
254
338
  id = ext.manifest.id;
255
- if (name && id !== name) throw new Error(`The registry lists ${name} at ${src.source}, but its hub-app.json has the id ${id}.`);
256
- const entry = claimId(id, config, file, (e) => e.source === src.source);
339
+ if (name && id !== name) throw new Error(`The registry lists ${name} at ${src.source}, but its ${kind.file} has the id ${id}.`);
340
+ const entry = claimId(id, config, file, kind, (e) => e.source === src.source);
257
341
  if (entry) {
258
342
  removeDir(dir);
259
343
  const root = rootOf(ctx, entry);
260
- const { plugin } = readApp(root, id);
261
- if (plugin && !pluginStep(ctx, plugin, 'install', root)) return false;
344
+ if (!kind.step(ctx, id, kind.read(root, id), 'install', root, config)) return false;
262
345
  console.log(`\n ${id} is already in the hub, at ${short(entry.sha ?? '')}. ext update ${id} gets the latest code.`);
263
346
  return true;
264
347
  }
@@ -276,11 +359,11 @@ function installGithub(ctx, target, file, config, name) {
276
359
  const root = path.join(clone, src.subdir);
277
360
  const rel = path.relative(ctx.hubDir, root).replaceAll('\\', '/');
278
361
  const fields = { path: rel, source: src.source, ...(src.ref && { ref: src.ref }), sha };
279
- if (!installDeps(ctx, root) || !addExtension(ctx, root, file, config, fields)) {
362
+ if (!installDeps(ctx, root) || !addExtension(ctx, root, file, config, fields, kind)) {
280
363
  removeDir(clone);
281
364
  return false;
282
365
  }
283
- console.log(`\n Installed ${id} from ${src.source} at ${short(sha)}.\n${RESTART}`);
366
+ console.log(`\n Installed ${id} from ${src.source} at ${short(sha)}.\n${kind.installed}`);
284
367
  return true;
285
368
  }
286
369
 
@@ -297,37 +380,38 @@ function installFolder(ctx, target, file, config) {
297
380
  } catch {
298
381
  throw new Error(`"${target}" is not an app the hub runs, and no folder has that name.`);
299
382
  }
300
- const ext = readApp(root);
383
+ const kind = kindOf(root);
384
+ const ext = kind.read(root);
301
385
  const { id } = ext.manifest;
302
- const entry = claimId(id, config, file, (e) => rootOf(ctx, e) === root);
386
+ const entry = claimId(id, config, file, kind, (e) => rootOf(ctx, e) === root);
303
387
  checkRequires(ctx, ext.manifest, config);
304
388
  if (entry) {
305
- if (ext.plugin && !pluginStep(ctx, ext.plugin, 'install', root)) {
306
- console.log('\n Plugin install failed. See the errors above.');
389
+ if (!kind.step(ctx, id, ext, 'install', root, config)) {
390
+ console.log(`\n ${kind.stepName} failed. See the errors above.`);
307
391
  return false;
308
392
  }
309
393
  console.log(`\n ${id} is already in the hub.`);
310
394
  return true;
311
395
  }
312
396
  const linked = root.replaceAll('\\', '/');
313
- if (!addExtension(ctx, root, file, config, { path: linked, source: 'local' })) return false;
314
- console.log(`\n Linked ${id} to ${linked}.\n${RESTART}`);
397
+ if (!addExtension(ctx, root, file, config, { path: linked, source: 'local' }, kind)) return false;
398
+ console.log(`\n Linked ${id} to ${linked}.\n${kind.installed}`);
315
399
  return true;
316
400
  }
317
401
 
318
402
  function list(ctx) {
319
403
  const { config } = readConfig(ctx.hubDir);
320
- const entries = (config.apps ?? []).filter(isExt);
404
+ const entries = extEntries(config);
321
405
  if (!entries.length) {
322
406
  console.log(' No extensions.');
323
407
  return true;
324
408
  }
325
409
  const check = pluginChecker(ctx.configDir());
326
- for (const e of entries) {
410
+ for (const [e, kind] of entries) {
327
411
  const where = e.source === 'local' ? `local ${e.path}` : [e.source, e.ref, e.sha && short(e.sha)].filter(Boolean).join(' ');
328
- let plugin = 'no plugin';
412
+ let plugin = kind.noPlugin;
329
413
  try {
330
- const p = readApp(rootOf(ctx, e), e.id).plugin;
414
+ const p = kind.read(rootOf(ctx, e), e.id).plugin;
331
415
  if (p) plugin = `${p.id} ${check(p).state}`;
332
416
  } catch (err) {
333
417
  plugin = err.message;
@@ -340,7 +424,7 @@ function list(ctx) {
340
424
  // A GitHub extension moves to the tip of its ref, or of the default branch, and goes back when the new code fails.
341
425
  function update(ctx, id) {
342
426
  const { file, config } = readConfig(ctx.hubDir);
343
- const entry = managedEntry(config, id, 'update');
427
+ const [entry, kind] = managedEntry(config, id, 'update');
344
428
  const root = rootOf(ctx, entry);
345
429
  let sha;
346
430
  if (isGithub(entry)) {
@@ -349,7 +433,7 @@ function update(ctx, id) {
349
433
  sha = checkout(clone, entry.ref);
350
434
  const back = () => entry.sha && entry.sha !== sha && checkout(clone, entry.sha);
351
435
  try {
352
- checkRequires(ctx, readApp(root, id).manifest, config);
436
+ checkRequires(ctx, kind.read(root, id).manifest, config);
353
437
  } catch (e) {
354
438
  back();
355
439
  throw e;
@@ -359,37 +443,37 @@ function update(ctx, id) {
359
443
  return false;
360
444
  }
361
445
  } else {
362
- checkRequires(ctx, readApp(root, id).manifest, config);
446
+ checkRequires(ctx, kind.read(root, id).manifest, config);
363
447
  }
364
- const { plugin } = readApp(root, id);
365
- if (plugin && !pluginStep(ctx, plugin, 'reinstall', root)) return false;
366
- if (sha && sha !== entry.sha) writeConfigFile(file, { ...config, apps: config.apps.map((e) => (e === entry ? { ...e, sha } : e)) });
448
+ if (!kind.step(ctx, id, kind.read(root, id), 'reinstall', root, config)) return false;
449
+ if (sha && sha !== entry.sha) writeConfigFile(file, withList(config, kind, (list) => list.map((e) => (e === entry ? { ...e, sha } : e))));
367
450
  const at = sha ? ` to ${short(sha)}` : '';
368
- console.log(`\n Updated ${id}${at}. Restart it (Ctrl+R in the app launcher) to load app changes, and start a new Claude Code session to load plugin changes.`);
451
+ console.log(`\n Updated ${id}${at}. ${kind.updated}`);
369
452
  return true;
370
453
  }
371
454
 
372
455
  function remove(ctx, id) {
373
456
  const { file, config } = readConfig(ctx.hubDir);
374
- const entry = managedEntry(config, id, 'be removed');
457
+ const [entry, kind] = managedEntry(config, id, 'be removed');
458
+ const root = rootOf(ctx, entry);
375
459
  let ext = null;
376
460
  try {
377
- ext = readApp(rootOf(ctx, entry), id);
461
+ ext = kind.read(root, id);
378
462
  } catch (e) {
379
- console.log(` ${e.message}\n The plugin is not removed, because the manifest does not name it.`);
463
+ console.log(` ${e.message}\n ${kind.unread}`);
380
464
  }
381
- if (ext?.plugin && !pluginStep(ctx, ext.plugin, 'uninstall')) {
382
- console.log(`\n Plugin removal failed, so ${id} keeps its entry. See the errors above.`);
465
+ if (!kind.step(ctx, id, ext, 'uninstall', root, config)) {
466
+ console.log(`\n ${kind.removeName} failed, so ${id} keeps its entry. See the errors above.`);
383
467
  return false;
384
468
  }
385
469
  const extensions = path.join(ctx.hubDir, 'extensions');
386
470
  const clone = isGithub(entry) ? path.join(extensions, id) : null;
387
- writeConfigFile(file, { ...config, apps: config.apps.filter((e) => e !== entry) });
471
+ writeConfigFile(file, withList(config, kind, (list) => list.filter((e) => e !== entry)));
388
472
  if (clone && isContained(clone, extensions) && fs.existsSync(clone)) {
389
- if (removeDir(clone)) console.log(`\n Removed ${id} and its clone.\n${RESTART}`);
473
+ if (removeDir(clone)) console.log(`\n Removed ${id} and its clone.\n${kind.removed}`);
390
474
  return true;
391
475
  }
392
- console.log(`\n Removed ${id}. ${entry.path} is left in place.\n${RESTART}`);
476
+ console.log(`\n Removed ${id}. ${entry.path} is left in place.\n${kind.removed}`);
393
477
  return true;
394
478
  }
395
479
 
package/lib/themes.json CHANGED
@@ -14,7 +14,7 @@
14
14
  "ink1": "#f0f1f3",
15
15
  "ink2": "#c2c4c9",
16
16
  "ink3": "#9a9da5",
17
- "inkMuted": "#7d808a"
17
+ "inkMuted": "#858891"
18
18
  },
19
19
  "light": {
20
20
  "ember": "#e86f33",
@@ -28,7 +28,7 @@
28
28
  "ink1": "#0a0a0a",
29
29
  "ink2": "#2c2c2c",
30
30
  "ink3": "#565656",
31
- "inkMuted": "#767676"
31
+ "inkMuted": "#666666"
32
32
  }
33
33
  },
34
34
  {
@@ -46,7 +46,7 @@
46
46
  "ink1": "#ebdbb2",
47
47
  "ink2": "#d5c4a1",
48
48
  "ink3": "#bdae93",
49
- "inkMuted": "#928374"
49
+ "inkMuted": "#a69782"
50
50
  },
51
51
  "light": {
52
52
  "ember": "#d65d0e",
@@ -59,8 +59,8 @@
59
59
  "border": "#c9bc9d",
60
60
  "ink1": "#3c3836",
61
61
  "ink2": "#504945",
62
- "ink3": "#665c54",
63
- "inkMuted": "#7c6f64"
62
+ "ink3": "#5f564f",
63
+ "inkMuted": "#70645b"
64
64
  }
65
65
  },
66
66
  {
@@ -78,7 +78,7 @@
78
78
  "ink1": "#cdd6f4",
79
79
  "ink2": "#bac2de",
80
80
  "ink3": "#a6adc8",
81
- "inkMuted": "#7f849c"
81
+ "inkMuted": "#82879f"
82
82
  },
83
83
  "light": {
84
84
  "ember": "#8839ef",
@@ -91,8 +91,8 @@
91
91
  "border": "#bcc0cc",
92
92
  "ink1": "#4c4f69",
93
93
  "ink2": "#5c5f77",
94
- "ink3": "#6c6f85",
95
- "inkMuted": "#8c8fa1"
94
+ "ink3": "#545770",
95
+ "inkMuted": "#63667d"
96
96
  }
97
97
  },
98
98
  {
@@ -109,8 +109,8 @@
109
109
  "border": "#3b4261",
110
110
  "ink1": "#c0caf5",
111
111
  "ink2": "#a9b1d6",
112
- "ink3": "#787c99",
113
- "inkMuted": "#565f89"
112
+ "ink3": "#959cbe",
113
+ "inkMuted": "#838aaf"
114
114
  },
115
115
  "light": {
116
116
  "ember": "#2e7de9",
@@ -123,8 +123,8 @@
123
123
  "border": "#a8aecb",
124
124
  "ink1": "#343b58",
125
125
  "ink2": "#565a6e",
126
- "ink3": "#6c6e75",
127
- "inkMuted": "#848cb5"
126
+ "ink3": "#505466",
127
+ "inkMuted": "#5d627a"
128
128
  }
129
129
  },
130
130
  {
@@ -141,8 +141,8 @@
141
141
  "border": "#586e75",
142
142
  "ink1": "#e2dfd0",
143
143
  "ink2": "#b7c0bf",
144
- "ink3": "#93a1a1",
145
- "inkMuted": "#839496"
144
+ "ink3": "#b7beb7",
145
+ "inkMuted": "#9faaa7"
146
146
  },
147
147
  "light": {
148
148
  "ember": "#2280c2",
@@ -157,8 +157,8 @@
157
157
  "border": "#cdc4ab",
158
158
  "ink1": "#073642",
159
159
  "ink2": "#4f6369",
160
- "ink3": "#586e75",
161
- "inkMuted": "#728589"
160
+ "ink3": "#385861",
161
+ "inkMuted": "#4a666e"
162
162
  }
163
163
  },
164
164
  {
@@ -175,8 +175,8 @@
175
175
  "border": "#565a75",
176
176
  "ink1": "#f8f8f2",
177
177
  "ink2": "#d8d8d2",
178
- "ink3": "#b0b3c5",
179
- "inkMuted": "#6272a4"
178
+ "ink3": "#b2b5c6",
179
+ "inkMuted": "#9aa1bc"
180
180
  },
181
181
  "light": {
182
182
  "ember": "#7c3aed",
@@ -189,8 +189,8 @@
189
189
  "border": "#c9c9d6",
190
190
  "ink1": "#16161e",
191
191
  "ink2": "#34343f",
192
- "ink3": "#5a5a68",
193
- "inkMuted": "#787885"
192
+ "ink3": "#575765",
193
+ "inkMuted": "#666674"
194
194
  }
195
195
  },
196
196
  {
@@ -207,8 +207,8 @@
207
207
  "border": "#4c566a",
208
208
  "ink1": "#eceff4",
209
209
  "ink2": "#d8dee9",
210
- "ink3": "#aeb6c5",
211
- "inkMuted": "#7b88a1"
210
+ "ink3": "#bec4d1",
211
+ "inkMuted": "#a7b0c1"
212
212
  },
213
213
  "light": {
214
214
  "ember": "#5e81ac",
@@ -222,7 +222,7 @@
222
222
  "ink1": "#2e3440",
223
223
  "ink2": "#3b4252",
224
224
  "ink3": "#4c566a",
225
- "inkMuted": "#6b7589"
225
+ "inkMuted": "#5e687c"
226
226
  }
227
227
  },
228
228
  {
@@ -239,8 +239,8 @@
239
239
  "border": "#403d52",
240
240
  "ink1": "#e0def4",
241
241
  "ink2": "#c5c2dd",
242
- "ink3": "#908caa",
243
- "inkMuted": "#6e6a86"
242
+ "ink3": "#a09db9",
243
+ "inkMuted": "#8e8ba7"
244
244
  },
245
245
  "light": {
246
246
  "ember": "#907aa9",
@@ -253,8 +253,8 @@
253
253
  "border": "#cecacd",
254
254
  "ink1": "#575279",
255
255
  "ink2": "#635e87",
256
- "ink3": "#6e6a88",
257
- "inkMuted": "#857f96"
256
+ "ink3": "#5a557b",
257
+ "inkMuted": "#6a6485"
258
258
  }
259
259
  },
260
260
  {
@@ -271,8 +271,8 @@
271
271
  "border": "#475258",
272
272
  "ink1": "#d3c6aa",
273
273
  "ink2": "#bfb6a3",
274
- "ink3": "#9da9a0",
275
- "inkMuted": "#859289"
274
+ "ink3": "#c3bea7",
275
+ "inkMuted": "#a8ab9a"
276
276
  },
277
277
  "light": {
278
278
  "ember": "#6f8352",
@@ -285,8 +285,8 @@
285
285
  "border": "#d3d2bf",
286
286
  "ink1": "#3d484e",
287
287
  "ink2": "#55636b",
288
- "ink3": "#66705f",
289
- "inkMuted": "#7a8577"
288
+ "ink3": "#515b56",
289
+ "inkMuted": "#606a62"
290
290
  }
291
291
  },
292
292
  {
@@ -304,7 +304,7 @@
304
304
  "ink1": "#dcd7ba",
305
305
  "ink2": "#c8c093",
306
306
  "ink3": "#a6a69c",
307
- "inkMuted": "#727169"
307
+ "inkMuted": "#94948a"
308
308
  },
309
309
  "light": {
310
310
  "ember": "#4d699b",
@@ -317,8 +317,8 @@
317
317
  "border": "#d2ccb6",
318
318
  "ink1": "#545464",
319
319
  "ink2": "#66667a",
320
- "ink3": "#716e61",
321
- "inkMuted": "#8a8775"
320
+ "ink3": "#565564",
321
+ "inkMuted": "#666469"
322
322
  }
323
323
  },
324
324
  {
@@ -335,8 +335,8 @@
335
335
  "border": "#4b5263",
336
336
  "ink1": "#d7dae0",
337
337
  "ink2": "#abb2bf",
338
- "ink3": "#8b919e",
339
- "inkMuted": "#5c6370"
338
+ "ink3": "#acb0ba",
339
+ "inkMuted": "#999ea8"
340
340
  },
341
341
  "light": {
342
342
  "ember": "#4078f2",
@@ -349,8 +349,8 @@
349
349
  "border": "#d4d4d6",
350
350
  "ink1": "#383a42",
351
351
  "ink2": "#50525a",
352
- "ink3": "#696c77",
353
- "inkMuted": "#a0a1a7"
352
+ "ink3": "#575963",
353
+ "inkMuted": "#666871"
354
354
  }
355
355
  },
356
356
  {
@@ -367,8 +367,8 @@
367
367
  "border": "#5f7e97",
368
368
  "ink1": "#d6deeb",
369
369
  "ink2": "#b8c5d6",
370
- "ink3": "#8ba3b8",
371
- "inkMuted": "#637777"
370
+ "ink3": "#9bb0c3",
371
+ "inkMuted": "#899eab"
372
372
  },
373
373
  "light": {
374
374
  "ember": "#0c969b",
@@ -381,8 +381,8 @@
381
381
  "border": "#d0d0d0",
382
382
  "ink1": "#403f53",
383
383
  "ink2": "#545167",
384
- "ink3": "#6f6e85",
385
- "inkMuted": "#989fb1"
384
+ "ink3": "#5a596f",
385
+ "inkMuted": "#68687e"
386
386
  }
387
387
  },
388
388
  {
@@ -399,8 +399,8 @@
399
399
  "border": "#5b595c",
400
400
  "ink1": "#fcfcfa",
401
401
  "ink2": "#d9d8d6",
402
- "ink3": "#939293",
403
- "inkMuted": "#727072"
402
+ "ink3": "#bfbfbe",
403
+ "inkMuted": "#ababab"
404
404
  },
405
405
  "light": {
406
406
  "ember": "#c08a00",
@@ -413,8 +413,8 @@
413
413
  "border": "#cdc8c5",
414
414
  "ink1": "#2c292d",
415
415
  "ink2": "#46434a",
416
- "ink3": "#6b686d",
417
- "inkMuted": "#8d8a8d"
416
+ "ink3": "#59565b",
417
+ "inkMuted": "#68656a"
418
418
  }
419
419
  },
420
420
  {
@@ -431,8 +431,8 @@
431
431
  "border": "#444d56",
432
432
  "ink1": "#e1e4e8",
433
433
  "ink2": "#d1d5da",
434
- "ink3": "#959da5",
435
- "inkMuted": "#6a737d"
434
+ "ink3": "#a6adb4",
435
+ "inkMuted": "#929aa2"
436
436
  },
437
437
  "light": {
438
438
  "ember": "#0366d6",
@@ -445,8 +445,8 @@
445
445
  "border": "#d1d5da",
446
446
  "ink1": "#24292e",
447
447
  "ink2": "#444d56",
448
- "ink3": "#586069",
449
- "inkMuted": "#6a737d"
448
+ "ink3": "#575f68",
449
+ "inkMuted": "#666e78"
450
450
  }
451
451
  },
452
452
  {
@@ -463,8 +463,8 @@
463
463
  "border": "#2d3343",
464
464
  "ink1": "#bfbdb6",
465
465
  "ink2": "#a8a6a0",
466
- "ink3": "#8a9199",
467
- "inkMuted": "#6c7380"
466
+ "ink3": "#8d949b",
467
+ "inkMuted": "#7c838d"
468
468
  },
469
469
  "light": {
470
470
  "ember": "#c97612",
@@ -477,8 +477,8 @@
477
477
  "border": "#d8dde2",
478
478
  "ink1": "#3d4149",
479
479
  "ink2": "#5c6166",
480
- "ink3": "#787b80",
481
- "inkMuted": "#8a9199"
480
+ "ink3": "#5a5e64",
481
+ "inkMuted": "#686d74"
482
482
  }
483
483
  },
484
484
  {
@@ -495,8 +495,8 @@
495
495
  "border": "#333333",
496
496
  "ink1": "#dbd7ca",
497
497
  "ink2": "#bfbaaa",
498
- "ink3": "#8f8f85",
499
- "inkMuted": "#758575"
498
+ "ink3": "#99998e",
499
+ "inkMuted": "#7e8a7b"
500
500
  },
501
501
  "light": {
502
502
  "ember": "#1c6b48",
@@ -509,8 +509,8 @@
509
509
  "border": "#d6d6d6",
510
510
  "ink1": "#393a34",
511
511
  "ink2": "#4e4f47",
512
- "ink3": "#6b6d63",
513
- "inkMuted": "#a0ada0"
512
+ "ink3": "#5d5e56",
513
+ "inkMuted": "#6a6d64"
514
514
  }
515
515
  },
516
516
  {
@@ -528,7 +528,7 @@
528
528
  "ink1": "#ffffff",
529
529
  "ink2": "#d8d6e8",
530
530
  "ink3": "#b6b1d8",
531
- "inkMuted": "#848bbd"
531
+ "inkMuted": "#8f94c3"
532
532
  },
533
533
  "light": {
534
534
  "ember": "#c936a6",
@@ -541,8 +541,8 @@
541
541
  "border": "#c5bcdd",
542
542
  "ink1": "#241b2f",
543
543
  "ink2": "#3d3455",
544
- "ink3": "#5c5380",
545
- "inkMuted": "#7d76a3"
544
+ "ink3": "#59507c",
545
+ "inkMuted": "#675f8b"
546
546
  }
547
547
  }
548
548
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-hub",
3
- "version": "1.23.0",
3
+ "version": "1.25.0",
4
4
  "description": "Unified hub for Claude Code tools — Marketplace + Kanban in one PWA",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -46,10 +46,10 @@
46
46
  },
47
47
  "homepage": "https://github.com/NikiforovAll/claude-code-hub#readme",
48
48
  "dependencies": {
49
- "claude-code-cost": "1.5.1",
50
- "claude-code-kanban": "7.19.0",
51
- "claude-code-marketplace": "1.7.0",
52
- "claude-code-memory-explorer": "1.3.3",
49
+ "claude-code-cost": "1.6.0",
50
+ "claude-code-kanban": "7.22.0",
51
+ "claude-code-marketplace": "1.7.1",
52
+ "claude-code-memory-explorer": "1.3.4",
53
53
  "express": "^4.21.0",
54
54
  "open": "^10.1.0"
55
55
  },
package/public/app.js CHANGED
@@ -1029,6 +1029,14 @@ const APP_ICONS = {
1029
1029
  ['path', { d: 'M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3' }],
1030
1030
  ['path', { d: 'M12 12V8' }],
1031
1031
  ],
1032
+ puzzle: [
1033
+ [
1034
+ 'path',
1035
+ {
1036
+ d: 'M15.39 4.39a1 1 0 0 0 1.68-.474 2.5 2.5 0 1 1 3.014 3.015 1 1 0 0 0-.474 1.68l1.683 1.682a2.414 2.414 0 0 1 0 3.414L19.61 15.39a1 1 0 0 1-1.68-.474 2.5 2.5 0 1 0-3.014 3.015 1 1 0 0 1 .474 1.68l-1.683 1.682a2.414 2.414 0 0 1-3.414 0L8.61 19.61a1 1 0 0 0-1.68.474 2.5 2.5 0 1 1-3.014-3.015 1 1 0 0 0 .474-1.68l-1.683-1.682a2.414 2.414 0 0 1 0-3.414L4.39 8.61a1 1 0 0 1 1.68.474 2.5 2.5 0 1 0 3.014-3.015 1 1 0 0 1-.474-1.68l1.683-1.682a2.414 2.414 0 0 1 3.414 0z',
1037
+ },
1038
+ ],
1039
+ ],
1032
1040
  };
1033
1041
 
1034
1042
  const ROW_ICONS = {
@@ -1074,12 +1082,80 @@ function appIcon(app) {
1074
1082
  return box;
1075
1083
  }
1076
1084
 
1085
+ // An app's own rows (`items` in its stats) follow its row and match on their own name.
1077
1086
  function appRows(q) {
1078
1087
  const tabs = tabIds();
1079
- const list = Object.entries(apps).map(([id, a]) => ({ kind: 'app', id, app: a, index: tabs.indexOf(id) }));
1088
+ const list = Object.entries(apps).flatMap(([id, a]) => [
1089
+ { kind: 'app', id, app: a, index: tabs.indexOf(id), name: a.name },
1090
+ ...(palette.appStats?.[id]?.items ?? []).map((item) => ({ kind: 'item', id, app: a, item, name: item.name })),
1091
+ ]);
1080
1092
  if (!q) return list;
1081
- const hit = list.filter((r) => r.app.name.toLowerCase().includes(q) || r.id.includes(q));
1082
- return hit.length ? hit : list.filter((r) => subseq(r.app.name.toLowerCase(), q));
1093
+ const hit = list.filter((r) => r.name.toLowerCase().includes(q) || (r.kind === 'app' && r.id.includes(q)));
1094
+ return hit.length ? hit : list.filter((r) => subseq(r.name.toLowerCase(), q));
1095
+ }
1096
+
1097
+ function fillItemRow(li, row, q) {
1098
+ const { item } = row;
1099
+ li.classList.add('palette-item');
1100
+ li.append(appIcon({ icon: 'puzzle' }), markedSpan('name', item.name, q), el('span', 'url', item.detail));
1101
+ li.append(el('span', 'stats', item.state), el('span', 'age', item.kind || row.app.name));
1102
+ if (item.path)
1103
+ li.append(rowButton('palette-copy', 'copy', 'c', 'Copy the path (Ctrl+C)', `Copy the path of ${item.name}`));
1104
+ for (const op of item.ops) {
1105
+ const { key, glyph, title } = ITEM_OPS[op];
1106
+ li.append(rowButton(`palette-${op}`, glyph, key, `${title} (Ctrl+${key.toUpperCase()})`, `${title} ${item.name}`));
1107
+ }
1108
+ }
1109
+
1110
+ // The ops the hub knows for an app's launcher rows; the app lists which ones each row offers.
1111
+ const ITEM_OPS = {
1112
+ restart: { key: 'r', glyph: 'restart', title: 'Restart', doing: 'Restarting', done: 'Restarted' },
1113
+ remove: { key: 'd', glyph: '×', title: 'Remove', doing: 'Removing', done: 'Removed', confirm: true },
1114
+ };
1115
+
1116
+ const itemKeys = (item) =>
1117
+ [item.path && 'Ctrl+C copy path', ...item.ops.map((op) => `Ctrl+${ITEM_OPS[op].key.toUpperCase()} ${op}`)]
1118
+ .filter(Boolean)
1119
+ .join(' · ');
1120
+
1121
+ const APP_ROW_KEYS = {
1122
+ c: (row) => copyToHint(apps[row.id].url),
1123
+ r: (row) => appOpFromPalette(row.id, 'restart'),
1124
+ s: (row) => toggleFromPalette(row.id),
1125
+ };
1126
+
1127
+ const ITEM_ROW_KEYS = {
1128
+ c: (row) => row.item.path && copyToHint(row.item.path),
1129
+ ...Object.fromEntries(Object.entries(ITEM_OPS).map(([op, { key }]) => [key, (row) => itemOpFromPalette(row, op)])),
1130
+ };
1131
+
1132
+ // An op with `confirm` asks once: the same key again on the same row sends it.
1133
+ async function itemOpFromPalette(row, op) {
1134
+ const { id, item } = row;
1135
+ if (!item.ops.includes(op)) return;
1136
+ const { key, doing, done } = ITEM_OPS[op];
1137
+ const confirm = `item\n${op}\n${id}\n${item.id}`;
1138
+ if (ITEM_OPS[op].confirm && palette.confirmOp !== confirm) {
1139
+ palette.confirmOp = confirm;
1140
+ setPaletteHint(`Press Ctrl+${key.toUpperCase()} again to ${op} ${item.name} from ${row.app.name}.`);
1141
+ return;
1142
+ }
1143
+ palette.confirmOp = null;
1144
+ setPaletteHint(`${doing} ${item.name}…`);
1145
+ let body;
1146
+ try {
1147
+ body = await withPaletteBusy(() =>
1148
+ sendJson('POST', `/api/apps/${encodeURIComponent(id)}/items/${encodeURIComponent(item.id)}/${op}`),
1149
+ );
1150
+ } catch (err) {
1151
+ setPaletteHint(`Could not ${op} ${item.name}: ${err.message}`);
1152
+ return;
1153
+ }
1154
+ setPaletteHint(body?.message ?? `${done} ${item.name}`);
1155
+ if (op === 'remove' && palette.appStats?.[id]?.items) {
1156
+ palette.appStats[id].items = palette.appStats[id].items.filter((i) => i.id !== item.id);
1157
+ } else if (body?.state) item.state = body.state;
1158
+ renderPalette();
1083
1159
  }
1084
1160
 
1085
1161
  // A click on the button runs the mode's rowKeys entry for `key`, the same as Ctrl+<key>.
@@ -1247,7 +1323,11 @@ const PALETTE_MODES = {
1247
1323
  placeholder: 'Search apps...',
1248
1324
  label: 'Switch app',
1249
1325
  get hint() {
1250
- const keys = 'Enter switch · Ctrl+C copy URL · Ctrl+R restart · Ctrl+S start/stop';
1326
+ const row = palette.rows?.[palette.sel];
1327
+ const keys =
1328
+ row?.kind === 'item'
1329
+ ? itemKeys(row.item)
1330
+ : 'Enter switch · Ctrl+C copy URL · Ctrl+R restart · Ctrl+S start/stop';
1251
1331
  if (!hubInfo.version) return keys;
1252
1332
  const plugin = hubInfo.plugin ? [pluginNode(hubInfo.plugin), ' · '] : [];
1253
1333
  return [`Hub ${hubInfo.version} · `, ...plugin, keys];
@@ -1260,6 +1340,10 @@ const PALETTE_MODES = {
1260
1340
  return prev < 0 ? 0 : prev;
1261
1341
  },
1262
1342
  fillRow(li, row, q) {
1343
+ if (row.kind === 'item') {
1344
+ fillItemRow(li, row, q);
1345
+ return;
1346
+ }
1263
1347
  li.append(appIcon(row.app), markedSpan('name', row.app.name, q), el('span', 'url', new URL(row.app.url).host));
1264
1348
  const s = palette.appStats?.[row.id];
1265
1349
  if (s?.version) li.append(el('span', 'ver', s.version));
@@ -1289,11 +1373,9 @@ const PALETTE_MODES = {
1289
1373
  : rowButton('palette-toggle', 'stop', 's', 'Stop the app (Ctrl+S)', `Stop ${row.app.name}`),
1290
1374
  );
1291
1375
  },
1292
- rowKeys: {
1293
- c: (row) => copyToHint(apps[row.id].url),
1294
- r: (row) => appOpFromPalette(row.id, 'restart'),
1295
- s: (row) => toggleFromPalette(row.id),
1296
- },
1376
+ rowKeys: Object.fromEntries(
1377
+ ['c', 'd', 'r', 's'].map((k) => [k, (row) => (row.kind === 'item' ? ITEM_ROW_KEYS : APP_ROW_KEYS)[k]?.(row)]),
1378
+ ),
1297
1379
  commit(row) {
1298
1380
  closePalette();
1299
1381
  switchTab(row.id);
@@ -1552,6 +1634,7 @@ function renderPalette() {
1552
1634
  // sel is null until the first render that has rows: the cached list may be empty until load() lands.
1553
1635
  if (palette.sel === null && rows.length > 0) palette.sel = spec.initialSel?.(rows) ?? 0;
1554
1636
  palette.sel = Math.min(palette.sel, Math.max(0, rows.length - 1));
1637
+ drawPaletteHint();
1555
1638
  if (rows.length === 0) {
1556
1639
  list.replaceChildren(el('li', 'palette-empty', spec.empty));
1557
1640
  return;
@@ -1597,7 +1680,6 @@ function openPalette(mode) {
1597
1680
  input.value = '';
1598
1681
  input.placeholder = spec.placeholder;
1599
1682
  document.querySelector('#palette .palette-box').setAttribute('aria-label', spec.label);
1600
- drawPaletteHint();
1601
1683
  showOverlay('palette', closePalette);
1602
1684
  renderPalette();
1603
1685
  input.focus();
@@ -1605,7 +1687,6 @@ function openPalette(mode) {
1605
1687
  Promise.resolve(spec.load?.())
1606
1688
  .then(() => {
1607
1689
  if (!palette.open || palette.mode !== mode) return;
1608
- drawPaletteHint();
1609
1690
  renderPalette();
1610
1691
  })
1611
1692
  .catch((err) => {
package/public/index.html CHANGED
@@ -252,6 +252,10 @@
252
252
 
253
253
  .palette-row .name { font-weight: 600; }
254
254
 
255
+ .palette-row.palette-item { padding-left: 40px; }
256
+
257
+ .palette-row.palette-item .name { font-weight: 400; }
258
+
255
259
  /* The letters that made this row match. Accent-coloured rather than block-highlighted so it
256
260
  reads inside a bold name without fighting the selected row's bar. */
257
261
  .palette-row mark {
package/server.js CHANGED
@@ -1173,6 +1173,36 @@ async function checkPortHealth() {
1173
1173
  }
1174
1174
 
1175
1175
  const STATS_TIMEOUT_MS = 1000;
1176
+ const ITEM_OPS = ['restart', 'remove'];
1177
+ // A restart can wait 20 s for the old server to free its port and 20 s for the new one to answer.
1178
+ const ITEM_OP_TIMEOUT_MS = 45000;
1179
+
1180
+ // The app's own rows for the launcher (`launcher.items`), or null when it has none or does not answer in time.
1181
+ async function launcherItems(pool, a) {
1182
+ const itemsPath = a.launcher.items;
1183
+ const port = pool?.ports[a.id];
1184
+ if (!itemsPath || !port || pool.stopped.has(a.id)) return null;
1185
+ try {
1186
+ const r = await fetch(`http://127.0.0.1:${port}${itemsPath}`, { signal: AbortSignal.timeout(STATS_TIMEOUT_MS) });
1187
+ if (!r.ok) return null;
1188
+ const { items } = await r.json();
1189
+ if (!Array.isArray(items)) return null;
1190
+ return items
1191
+ .filter((i) => typeof i?.id === 'string' && typeof i.name === 'string')
1192
+ .map((i) => ({
1193
+ id: i.id,
1194
+ name: i.name,
1195
+ kind: typeof i.kind === 'string' ? i.kind : '',
1196
+ detail: typeof i.detail === 'string' ? i.detail : '',
1197
+ state: typeof i.state === 'string' ? i.state : '',
1198
+ path: typeof i.path === 'string' ? i.path : '',
1199
+ ops: Array.isArray(i.ops) ? i.ops.filter((op) => ITEM_OPS.includes(op)) : [],
1200
+ }));
1201
+ } catch {
1202
+ return null;
1203
+ }
1204
+ }
1205
+
1176
1206
  app.get('/api/apps/stats', async (_req, res) => {
1177
1207
  res.setHeader('Cache-Control', 'no-store');
1178
1208
  const pool = activePool();
@@ -1182,10 +1212,12 @@ app.get('/api/apps/stats', async (_req, res) => {
1182
1212
  ENABLED_APPS.map(async (a) => {
1183
1213
  const child = pool?.children.get(a.id);
1184
1214
  const slowMs = (child && portHealth.flagged(child)?.worstMs) ?? null;
1215
+ const [usage, items] = await Promise.all([stats(child, STATS_TIMEOUT_MS), launcherItems(pool, a)]);
1185
1216
  return [
1186
1217
  a.id,
1187
1218
  {
1188
- ...(await stats(child, STATS_TIMEOUT_MS)),
1219
+ ...usage,
1220
+ items,
1189
1221
  slowMs,
1190
1222
  stopped: !!pool?.stopped.has(a.id),
1191
1223
  linked: a.linked,
@@ -1234,6 +1266,33 @@ app.post('/api/apps/:id/:op(restart|start|stop)', async (req, res) => {
1234
1266
  }
1235
1267
  });
1236
1268
 
1269
+ // An op on one of an app's launcher rows, sent to `<items path>/<item id>/<op>` on that app.
1270
+ app.post(`/api/apps/:id/items/:item/:op(${ITEM_OPS.join('|')})`, async (req, res) => {
1271
+ const { id, item, op } = req.params;
1272
+ const target = ENABLED_APPS.find((a) => a.id === id);
1273
+ const itemsPath = target?.launcher.items;
1274
+ if (!itemsPath) {
1275
+ res.status(404).json({ error: 'Unknown app or app with no launcher items' });
1276
+ return;
1277
+ }
1278
+ const port = await waitForPort(id, Date.now() + API_PORT_WAIT_MS);
1279
+ if (!port) {
1280
+ res.status(503).json({ error: `${target.name} ${noPortReason(id).headline}` });
1281
+ return;
1282
+ }
1283
+ console.log(`[${id}] ${op} ${item} asked for by the hub page`);
1284
+ try {
1285
+ const upstream = await fetch(`http://127.0.0.1:${port}${itemsPath}/${encodeURIComponent(item)}/${op}`, {
1286
+ method: 'POST',
1287
+ signal: AbortSignal.timeout(ITEM_OP_TIMEOUT_MS),
1288
+ });
1289
+ const body = await upstream.json().catch(() => ({}));
1290
+ res.status(upstream.status).json(body);
1291
+ } catch (err) {
1292
+ res.status(502).json({ error: `${target.name} unavailable: ${err.message}` });
1293
+ }
1294
+ });
1295
+
1237
1296
  // Project list for the switcher palette, proxied from the app that provides `projects`. The port is
1238
1297
  // read per request because the provider's real port is only known once its banner has been scraped
1239
1298
  // (see spawnApp).