claude-code-hub 1.13.0 → 1.15.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
@@ -24,7 +24,8 @@ const isObject = (x) => typeof x === 'object' && x !== null && !Array.isArray(x)
24
24
  // unlisted ones follow in default order, so an app added in a later release shows up without an edit.
25
25
  // An entry with a `path` runs the app in that folder: a fork in place of a built-in app, or a new app.
26
26
  // A relative `path` is resolved against `base`, the folder of config.json.
27
- function selectApps(entries = [], base = process.cwd()) {
27
+ // `includeOff` keeps the apps with `enabled: false` and gives every app `enabled`.
28
+ function selectApps(entries = [], base = process.cwd(), { includeOff = false } = {}) {
28
29
  const listed = entries.filter((e) => typeof e?.id === 'string');
29
30
  const entryOf = (id) => listed.find((e) => e.id === id);
30
31
  const pathOf = (id) => {
@@ -37,11 +38,12 @@ function selectApps(entries = [], base = process.cwd()) {
37
38
  const order = new Set([...listed.map((e) => e.id), ...APPS.map((a) => a.id)]);
38
39
  const apps = [...order]
39
40
  .map(byId)
40
- .filter((a) => a && !off.has(a.id))
41
+ .filter((a) => a && (includeOff || !off.has(a.id)))
41
42
  .map((a) => {
42
43
  const e = entryOf(a.id);
43
44
  return {
44
45
  ...a,
46
+ ...(includeOff && { enabled: !off.has(a.id) }),
45
47
  port: e?.port,
46
48
  path: pathOf(a.id),
47
49
  hidden: e?.hidden === true,
package/lib/ext.js CHANGED
@@ -6,7 +6,7 @@ const path = require('node:path');
6
6
  const { parseArgs } = require('node:util');
7
7
  const { APPS, HUB_ROOT, ID_RE, selectApps, appRoot, readApp } = require('./apps');
8
8
  const { isContained } = require('./contain');
9
- const { writeConfigFile } = require('./hub-config');
9
+ const { readConfig, writeConfigFile } = require('./hub-config');
10
10
  const { claudeEnv, hasClaude, installPlugin, reinstallPlugin, uninstallPlugin, report, run } = require('./install');
11
11
  const { pluginChecker } = require('./plugin-status');
12
12
  const { isNewer } = require('./update-check');
@@ -28,21 +28,6 @@ const USAGE = `Usage: claude-code-hub ext <command>
28
28
  const RESTART = ' Restart the hub to load the change.';
29
29
  const REGISTRY = 'https://nikiforovall.blog/claude-code-hub/extensions.json';
30
30
 
31
- function readConfig(hubDir) {
32
- const file = path.join(hubDir, 'config.json');
33
- let raw;
34
- try {
35
- raw = fs.readFileSync(file, 'utf8');
36
- } catch (e) {
37
- if (e.code === 'ENOENT') return { file, config: {} };
38
- throw e;
39
- }
40
- try {
41
- return { file, config: JSON.parse(raw) };
42
- } catch (e) {
43
- throw new Error(`${file}: ${e.message}`);
44
- }
45
- }
46
31
 
47
32
  function checkRequires(ctx, { id, requires }, config) {
48
33
  if (!requires) return;
package/lib/hub-config.js CHANGED
@@ -12,4 +12,21 @@ function writeConfigFile(file, config) {
12
12
  fs.writeFileSync(file, serializeConfig(config));
13
13
  }
14
14
 
15
- module.exports = { serializeConfig, writeConfigFile };
15
+ // A missing file reads as {}. Bad JSON throws, so a caller does not write over a hand edit.
16
+ function readConfig(hubDir) {
17
+ const file = path.join(hubDir, 'config.json');
18
+ let raw;
19
+ try {
20
+ raw = fs.readFileSync(file, 'utf8');
21
+ } catch (e) {
22
+ if (e.code === 'ENOENT') return { file, config: {} };
23
+ throw e;
24
+ }
25
+ try {
26
+ return { file, config: JSON.parse(raw) };
27
+ } catch (e) {
28
+ throw new Error(`${file}: ${e.message}`);
29
+ }
30
+ }
31
+
32
+ module.exports = { readConfig, serializeConfig, writeConfigFile };
package/lib/keymap.js CHANGED
@@ -6,6 +6,7 @@ const HUB_ACTIONS = [
6
6
  'hub.projectPicker',
7
7
  'hub.configDirPicker',
8
8
  'hub.appLauncher',
9
+ 'hub.settings',
9
10
  'hub.prevApp',
10
11
  'hub.nextApp',
11
12
  'hub.appByNumber',
@@ -15,7 +16,7 @@ const PATTERN_ACTIONS = new Set(['hub.appByNumber']);
15
16
  const MODS = ['ctrl', 'alt', 'shift', 'meta'];
16
17
 
17
18
  // A combo name as the SDK's comboOf() gives it (protocol section 8), with one modifier at least.
18
- const COMBO_RE = /^(?=(?:ctrl|alt|shift|meta)\+)(?:ctrl\+)?(?:alt\+)?(?:shift\+)?(?:meta\+)?(?:[a-z1-9]|[A-Z][A-Za-z0-9]+)$/;
19
+ const COMBO_RE = /^(?=(?:ctrl|alt|shift|meta)\+)(?:ctrl\+)?(?:alt\+)?(?:shift\+)?(?:meta\+)?(?:[a-z1-9,]|[A-Z][A-Za-z0-9]+)$/;
19
20
 
20
21
  // "Ctrl+Alt+O" → "ctrl+alt+o", the form comboOf() gives. Null when the text cannot name a combo.
21
22
  function normalizeCombo(text) {
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+
3
+ const { selectApps } = require('./apps');
4
+
5
+ const CONFIG_DIR_SWITCH = ['palette', 'swap'];
6
+ const DEFAULT_POOL_SIZE = 3;
7
+ const MAX_POOL_SIZE = 8;
8
+ const APP_STATES = ['shown', 'hidden', 'off'];
9
+
10
+ const validPoolSize = (n) => Number.isInteger(n) && n >= 1 && n <= MAX_POOL_SIZE;
11
+
12
+ function configDirSwitch(saved) {
13
+ return CONFIG_DIR_SWITCH.includes(saved?.configDirSwitch) ? saved.configDirSwitch : 'palette';
14
+ }
15
+
16
+ function poolSize(saved, flag) {
17
+ if (flag !== null && flag !== undefined) return Math.max(1, parseInt(flag, 10) || DEFAULT_POOL_SIZE);
18
+ return validPoolSize(saved?.poolSize) ? saved.poolSize : DEFAULT_POOL_SIZE;
19
+ }
20
+
21
+ // The keys the running hub applies at once, so it keeps them in memory.
22
+ const LIVE_SETTINGS = ['configDirSwitch', 'poolSize'];
23
+
24
+ function copyLive(from, to) {
25
+ for (const k of LIVE_SETTINGS) if (from[k] !== undefined) to[k] = from[k];
26
+ }
27
+
28
+ // Every app the `apps` list can turn on, in tab order, with the state its entry gives it.
29
+ function appSettings(entries = [], base) {
30
+ return selectApps(entries, base, { includeOff: true }).apps.map((a) => ({
31
+ ...a,
32
+ state: !a.enabled ? 'off' : a.hidden ? 'hidden' : 'shown',
33
+ }));
34
+ }
35
+
36
+ // What the running hub would do differently after a restart: the apps it spawns and how.
37
+ function appsShape(apps) {
38
+ return JSON.stringify(apps.filter((a) => a.state !== 'off').map((a) => [a.id, a.state, a.deferredStart]));
39
+ }
40
+
41
+ // `saved` is config.json as on disk. Returns the new config, or {error}. Only the keys the
42
+ // patch names change; an app entry keeps its other fields (port, path, source).
43
+ function applySettings(saved, patch, base) {
44
+ if (!patch || typeof patch !== 'object') return { error: 'body must be an object' };
45
+ const next = { ...saved };
46
+ if (patch.configDirSwitch !== undefined) {
47
+ if (!CONFIG_DIR_SWITCH.includes(patch.configDirSwitch))
48
+ return { error: `configDirSwitch must be one of ${CONFIG_DIR_SWITCH.join(', ')}` };
49
+ next.configDirSwitch = patch.configDirSwitch;
50
+ }
51
+ if (patch.poolSize !== undefined) {
52
+ if (!validPoolSize(patch.poolSize)) return { error: `poolSize must be an integer from 1 to ${MAX_POOL_SIZE}` };
53
+ next.poolSize = patch.poolSize;
54
+ }
55
+ if (patch.app !== undefined) {
56
+ const { id, state, deferredStart } = patch.app ?? {};
57
+ const current = appSettings(saved.apps, base);
58
+ if (!current.some((a) => a.id === id)) return { error: `Unknown app "${id}"` };
59
+ if (state !== undefined && !APP_STATES.includes(state))
60
+ return { error: `state must be one of ${APP_STATES.join(', ')}` };
61
+ if (deferredStart !== undefined && typeof deferredStart !== 'boolean')
62
+ return { error: 'deferredStart must be a boolean' };
63
+ if (state === 'off' && current.every((a) => a.id === id || a.state === 'off'))
64
+ return { error: 'At least one app must stay on' };
65
+ let entries = Array.isArray(saved.apps) ? [...saved.apps] : [];
66
+ // Listed apps come first, so a new entry appended at the end would move its tab. Listing
67
+ // every app in its current order keeps the order.
68
+ if (!entries.some((e) => e?.id === id)) {
69
+ const others = entries.filter((e) => !current.some((a) => a.id === e?.id));
70
+ entries = [...current.map((a) => entries.find((e) => e?.id === a.id) ?? { id: a.id }), ...others];
71
+ }
72
+ const i = entries.findIndex((e) => e?.id === id);
73
+ const entry = { ...entries[i] };
74
+ if (state !== undefined) {
75
+ entry.enabled = state !== 'off';
76
+ entry.hidden = state === 'hidden';
77
+ }
78
+ if (deferredStart !== undefined) entry.deferredStart = deferredStart;
79
+ entries[i] = entry;
80
+ next.apps = entries;
81
+ }
82
+ return { config: next };
83
+ }
84
+
85
+ module.exports = {
86
+ copyLive,
87
+ MAX_POOL_SIZE,
88
+ appSettings,
89
+ appsShape,
90
+ applySettings,
91
+ configDirSwitch,
92
+ poolSize,
93
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-hub",
3
- "version": "1.13.0",
3
+ "version": "1.15.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.3.2",
50
- "claude-code-kanban": "7.9.0",
50
+ "claude-code-kanban": "7.11.0",
51
51
  "claude-code-marketplace": "1.5.2",
52
52
  "claude-code-memory-explorer": "1.3.2",
53
53
  "express": "^4.21.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-hub",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
4
4
  "description": "Claude Code Hub integration: a skill to build on and extend the hub, with links to its docs, the extension mechanism and custom themes",
5
5
  "author": {
6
6
  "name": "NikiforovAll"
@@ -35,13 +35,14 @@ Ctrl+Alt+O opens the project palette, Ctrl+Alt+H and Ctrl+Alt+L move between too
35
35
  | `hub.projectPicker` | `ctrl+alt+p` | `ctrl+alt+p` |
36
36
  | `hub.configDirPicker` | `ctrl+alt+w` | `ctrl+alt+w` |
37
37
  | `hub.appLauncher` | `ctrl+alt+a` | `ctrl+alt+a` |
38
+ | `hub.settings` | `ctrl+,` | `ctrl+,` |
38
39
  | `hub.prevApp` | `ctrl+alt+ArrowLeft` | `ctrl+alt+ArrowLeft` |
39
40
  | `hub.nextApp` | `ctrl+alt+ArrowRight` | `ctrl+alt+ArrowRight` |
40
41
  | `hub.appByNumber` | `alt+{n}` | `ctrl+alt+{n}` |
41
42
 
42
43
  ## Rules
43
44
 
44
- - **Form.** One or more of `ctrl`, `alt`, `shift`, `meta`, then one key, joined by `+`. The key is a letter, a digit 1 to 9, or a `KeyboardEvent.key` name (`ArrowUp`, `F2`, `PageDown`). On macOS `alt` is Option and `meta` is Command. Modifiers and letters take any case and order (`"Alt+Ctrl+O"` reads as `ctrl+alt+o`); a key name keeps its case (`PageDown`).
45
+ - **Form.** One or more of `ctrl`, `alt`, `shift`, `meta`, then one key, joined by `+`. The key is a letter, a digit 1 to 9, `,`, or a `KeyboardEvent.key` name (`ArrowUp`, `F2`, `PageDown`). On macOS `alt` is Option and `meta` is Command. Modifiers and letters take any case and order (`"Alt+Ctrl+O"` reads as `ctrl+alt+o`); a key name keeps its case (`PageDown`).
45
46
  - **`{n}`.** `hub.appByNumber` ends in `+{n}`, and the hub binds it for 1 to 9. It is the only action with `{n}`.
46
47
  - **`null`** leaves the action with no key.
47
48
  - **Taking a default.** A combo that is another action's default moves to the new action, and the other action has no key until it gets one: `"hub.nextApp": "ctrl+alt+a"` leaves the app launcher with no key. Say so to the user.
package/public/app.js CHANGED
@@ -40,6 +40,8 @@ const missed = new Map();
40
40
  const MAX_EVENT_CHARS = 16 * 1024;
41
41
  // The config dir the hub falls back to (~/.claude). Stays out of the window title.
42
42
  let defaultConfigDir = null;
43
+ // What Ctrl+Alt+W does: 'palette' opens the config-dir palette, 'swap' switches to the previous dir.
44
+ let configDirSwitch = 'palette';
43
45
  // The app launcher's footer: the hub version from /api/config and its plugin from /api/apps/stats.
44
46
  const hubInfo = { version: null, plugin: null };
45
47
  // mode: 'project' (Ctrl+Alt+P), 'configDir' (Ctrl+Alt+W) or 'app' (Ctrl+Alt+A). One widget, three row sources.
@@ -47,7 +49,7 @@ const palette = {
47
49
  open: false,
48
50
  mode: 'project',
49
51
  projects: [],
50
- configDirs: { dirs: [], active: null },
52
+ configDirs: { dirs: [], active: null, previous: null },
51
53
  rows: [],
52
54
  sel: 0,
53
55
  query: '',
@@ -68,9 +70,11 @@ function loadThemeState() {
68
70
 
69
71
  // The color theme of the active config dir, falling back to the last one picked anywhere.
70
72
  // An id the registry lacks shows as Ember everywhere, so the hub page and every app agree.
73
+ const DEFAULT_COLOR_THEME = 'ember';
74
+
71
75
  function activeColorTheme() {
72
76
  const id = themeState.colorThemes[activeConfigDir] ?? themeState.colorTheme;
73
- return id && hubThemes.length && !themeVars[id] ? 'ember' : id;
77
+ return id && hubThemes.length && !themeVars[id] ? DEFAULT_COLOR_THEME : id;
74
78
  }
75
79
 
76
80
  function osTheme() {
@@ -85,7 +89,7 @@ function themePayload() {
85
89
 
86
90
  function themeEvent() {
87
91
  const payload = themePayload();
88
- const vars = themeVars[payload.colorTheme ?? 'ember']?.[payload.theme];
92
+ const vars = themeVars[payload.colorTheme ?? DEFAULT_COLOR_THEME]?.[payload.theme];
89
93
  return vars ? { ...payload, vars } : payload;
90
94
  }
91
95
 
@@ -122,8 +126,10 @@ function applyHubTheme() {
122
126
  const mode = themeState.theme ?? osTheme();
123
127
  const root = document.documentElement;
124
128
  root.classList.toggle('light', mode === 'light');
125
- const key = `${activeColorTheme()}/${mode}`;
126
- const core = themeVars[activeColorTheme()]?.[mode];
129
+ // No app has named a theme yet: paint the one every app starts with.
130
+ const theme = activeColorTheme() ?? DEFAULT_COLOR_THEME;
131
+ const key = `${theme}/${mode}`;
132
+ const core = themeVars[theme]?.[mode];
127
133
  let vars = core && Object.fromEntries(Object.entries(HUB_VARS).map(([hub, name]) => [hub, core[name]]));
128
134
  try {
129
135
  if (vars) localStorage.setItem(PALETTE_KEY, JSON.stringify({ key, vars }));
@@ -273,6 +279,7 @@ async function init() {
273
279
  setApps(config.apps);
274
280
  defaultConfigDir = config.defaultConfigDir ?? null;
275
281
  activeConfigDir = config.activeConfigDir ?? null;
282
+ configDirSwitch = config.configDirSwitch ?? 'palette';
276
283
  applyTitle(config.activeConfigDir);
277
284
  themeVars = config.themeVars ?? {};
278
285
  hubThemes = config.themes ?? [];
@@ -285,6 +292,7 @@ async function init() {
285
292
  listenMessages();
286
293
  listenKeys();
287
294
  bindPalette();
295
+ bindSettings();
288
296
  registerSW();
289
297
  bindErrorToast();
290
298
  showUpdateToast();
@@ -710,8 +718,14 @@ function handleForwardedKey(d) {
710
718
  function hubActions() {
711
719
  return [
712
720
  { id: 'hub.projectPicker', combo: 'ctrl+alt+p', run: () => togglePalette('project'), inPalette: true },
713
- { id: 'hub.configDirPicker', combo: 'ctrl+alt+w', run: () => togglePalette('configDir'), inPalette: true },
721
+ {
722
+ id: 'hub.configDirPicker',
723
+ combo: 'ctrl+alt+w',
724
+ run: () => (configDirSwitch === 'swap' ? swapConfigDir() : togglePalette('configDir')),
725
+ inPalette: true,
726
+ },
714
727
  { id: 'hub.appLauncher', combo: 'ctrl+alt+a', run: () => togglePalette('app'), inPalette: true },
728
+ { id: 'hub.settings', combo: 'ctrl+,', run: () => toggleSettings(), inPalette: true },
715
729
  { id: 'hub.prevApp', combo: 'ctrl+alt+ArrowLeft', run: () => cycleTab(-1) },
716
730
  { id: 'hub.nextApp', combo: 'ctrl+alt+ArrowRight', run: () => cycleTab(1) },
717
731
  {
@@ -784,13 +798,14 @@ function listenKeys() {
784
798
  binding.run();
785
799
  return;
786
800
  }
787
- // While the palette is open the input owns the keyboard — don't let tab shortcuts fire
788
- // mid-path (Alt+digit especially, since Windows paths contain digits). Escape still closes:
789
- // bound here as well as on the input so it works wherever focus landed in the hub document.
790
- if (palette.open) {
801
+ // While the palette or the settings modal is open, its input or buttons own the keyboard — don't
802
+ // let tab shortcuts fire mid-path (Alt+digit especially, since Windows paths contain digits).
803
+ // Escape still closes: bound here as well as on the input so it works wherever focus landed in
804
+ // the hub document.
805
+ if (openOverlay) {
791
806
  if (e.key === 'Escape') {
792
807
  e.preventDefault();
793
- closePalette();
808
+ openOverlay.close();
794
809
  }
795
810
  return;
796
811
  }
@@ -1174,9 +1189,11 @@ const PALETTE_MODES = {
1174
1189
  if (looksLikePath(typed) && !dirs.some((d) => d.toLowerCase() === q)) rows.push({ kind: 'literal', path: typed });
1175
1190
  return rows;
1176
1191
  },
1177
- // Enter on the default row must switch, so the cursor lands on the dir before the active one,
1178
- // wrapping. With two dirs the palette then behaves like a toggle.
1192
+ // Enter on the default row must switch, so the cursor lands on the previous dir, else on the
1193
+ // dir before the active one, wrapping. With two dirs the palette then behaves like a toggle.
1179
1194
  initialSel(rows) {
1195
+ const previous = rows.findIndex((r) => r.path === palette.configDirs.previous);
1196
+ if (previous >= 0) return previous;
1180
1197
  const active = rows.findIndex((r) => r.active);
1181
1198
  if (active < 0 || rows.length < 2) return 0;
1182
1199
  return (active - 1 + rows.length) % rows.length;
@@ -1250,6 +1267,225 @@ const PALETTE_MODES = {
1250
1267
  },
1251
1268
  };
1252
1269
 
1270
+ // The hub settings modal (Ctrl+,). Each change is saved at once; data is the last /api/settings answer.
1271
+ const settingsModal = { open: false, data: null, error: null, busy: false };
1272
+
1273
+ const SWITCH_CHOICES = [
1274
+ ['palette', 'Open the list', 'Opens the config-dir list. Pick a dir, or type a path to add one.'],
1275
+ [
1276
+ 'swap',
1277
+ 'Swap to last dir',
1278
+ 'Switches straight to the dir you used before, with no list. With one dir only, it opens the list.',
1279
+ ],
1280
+ ];
1281
+ const SHOW_CHOICES = [
1282
+ ['shown', 'Tab'],
1283
+ ['hidden', 'Launcher only'],
1284
+ ['off', 'Off'],
1285
+ ];
1286
+ const START_CHOICES = [
1287
+ [false, 'With hub'],
1288
+ [true, 'On demand'],
1289
+ ];
1290
+
1291
+ function toggleSettings() {
1292
+ if (settingsModal.open) closeSettings();
1293
+ else openSettings();
1294
+ }
1295
+
1296
+ async function openSettings() {
1297
+ settingsModal.open = true;
1298
+ settingsModal.error = null;
1299
+ showOverlay('settings', closeSettings);
1300
+ renderSettings();
1301
+ document.querySelector('#settings .settings-box').focus();
1302
+ try {
1303
+ settingsModal.data = await sendJson('GET', '/api/settings');
1304
+ } catch (err) {
1305
+ settingsModal.error = err.message;
1306
+ }
1307
+ if (settingsModal.open) renderSettings();
1308
+ }
1309
+
1310
+ function closeSettings() {
1311
+ settingsModal.open = false;
1312
+ hideOverlay('settings');
1313
+ }
1314
+
1315
+ async function saveSetting(patch) {
1316
+ if (settingsModal.busy) return;
1317
+ settingsModal.busy = true;
1318
+ try {
1319
+ settingsModal.data = await sendJson('POST', '/api/settings', patch);
1320
+ settingsModal.error = null;
1321
+ configDirSwitch = settingsModal.data.configDirSwitch;
1322
+ } catch (err) {
1323
+ settingsModal.error = err.message;
1324
+ } finally {
1325
+ settingsModal.busy = false;
1326
+ }
1327
+ if (settingsModal.open) renderSettings();
1328
+ }
1329
+
1330
+ // A row of buttons that acts as one radio group: Tab reaches the checked one, ← and → change it.
1331
+ function segmented(name, choices, current, onPick, disabledOf = () => null) {
1332
+ const group = el('span', 'seg');
1333
+ group.setAttribute('role', 'radiogroup');
1334
+ group.setAttribute('aria-label', name);
1335
+ for (const [value, label] of choices) {
1336
+ const btn = el('button', value === current ? 'on' : '', label);
1337
+ btn.type = 'button';
1338
+ btn.setAttribute('role', 'radio');
1339
+ btn.setAttribute('aria-checked', String(value === current));
1340
+ btn.tabIndex = value === current ? 0 : -1;
1341
+ const why = disabledOf(value);
1342
+ if (why) {
1343
+ btn.disabled = true;
1344
+ btn.title = why;
1345
+ }
1346
+ btn.addEventListener('click', () => value !== current && onPick(value));
1347
+ group.append(btn);
1348
+ }
1349
+ return group;
1350
+ }
1351
+
1352
+ function settingRow(label, control, help) {
1353
+ const row = el('div', 'set-row');
1354
+ row.append(typeof label === 'string' ? el('span', 'set-label', label) : label, control);
1355
+ if (help) row.append(el('span', 'set-help', help));
1356
+ return row;
1357
+ }
1358
+
1359
+ function renderSettings() {
1360
+ const body = document.getElementById('settings-body');
1361
+ const foot = document.getElementById('settings-foot');
1362
+ const s = settingsModal.data;
1363
+ const focusKey = document.activeElement?.closest?.('[data-focus]')?.dataset.focus;
1364
+ if (!s) {
1365
+ body.replaceChildren(el('div', 'set-help', settingsModal.error ?? 'Loading…'));
1366
+ foot.replaceChildren();
1367
+ return;
1368
+ }
1369
+
1370
+ const dirs = el('section', 'set-sec');
1371
+ dirs.append(el('h4', '', 'Config dirs'));
1372
+ const key = actionKeys()['hub.configDirPicker'];
1373
+ const label = el('span', 'set-label');
1374
+ if (key) label.append(kbd(comboLabel(key)), ' does');
1375
+ else label.textContent = 'Config-dir key does';
1376
+ const sw = SWITCH_CHOICES.find(([v]) => v === s.configDirSwitch);
1377
+ const switchSeg = segmented('Config-dir key', SWITCH_CHOICES, s.configDirSwitch, (v) =>
1378
+ saveSetting({ configDirSwitch: v }),
1379
+ );
1380
+ switchSeg.dataset.focus = 'switch';
1381
+ dirs.append(settingRow(label, switchSeg, sw[2]));
1382
+
1383
+ const step = el('span', 'step');
1384
+ const stepBtn = (text, delta, aria) => {
1385
+ const b = el('button', '', text);
1386
+ b.type = 'button';
1387
+ b.setAttribute('aria-label', aria);
1388
+ const n = s.poolSize + delta;
1389
+ b.disabled = s.poolSizeFlag || n < 1 || n > s.maxPoolSize;
1390
+ b.dataset.focus = `pool${delta}`;
1391
+ b.addEventListener('click', () => saveSetting({ poolSize: n }));
1392
+ return b;
1393
+ };
1394
+ step.append(stepBtn('−', -1, 'Fewer'), el('b', '', String(s.poolSize)), stepBtn('+', 1, 'More'), ' dirs');
1395
+ const poolHelp = s.poolSizeFlag
1396
+ ? 'Set by --pool-size for this run.'
1397
+ : s.poolSize === 1
1398
+ ? 'Only the active dir runs its apps. Every switch waits a few seconds while the apps start.'
1399
+ : `The ${s.poolSize} dirs you used last keep their apps running, so going back to them is instant. Each one runs its own copy of every app. Others start again when you open them (a few seconds).`;
1400
+ dirs.append(settingRow('Instant switch-back', step, poolHelp));
1401
+
1402
+ const manage = el('button', '', 'Manage…');
1403
+ manage.type = 'button';
1404
+ manage.dataset.focus = 'manage';
1405
+ manage.addEventListener('click', () => openPalette('configDir'));
1406
+ dirs.append(settingRow('Config dirs', manage));
1407
+
1408
+ const appsSec = el('section', 'set-sec');
1409
+ appsSec.append(el('h4', '', 'Apps'));
1410
+ const table = el('table', 'set-apps');
1411
+ const head = el('tr', '');
1412
+ head.append(el('th', '', 'App'), el('th', '', 'Show'), el('th', '', 'Start'));
1413
+ table.append(head);
1414
+ const onCount = s.apps.filter((a) => a.state !== 'off').length;
1415
+ for (const a of s.apps) {
1416
+ const tr = el('tr', '');
1417
+ const show = segmented(
1418
+ `Show ${a.name}`,
1419
+ SHOW_CHOICES,
1420
+ a.state,
1421
+ (v) => saveSetting({ app: { id: a.id, state: v } }),
1422
+ (v) => (v === 'off' && a.state !== 'off' && onCount === 1 ? 'At least one app must stay on' : null),
1423
+ );
1424
+ show.dataset.focus = `show:${a.id}`;
1425
+ const start = segmented(
1426
+ `Start ${a.name}`,
1427
+ START_CHOICES,
1428
+ a.deferredStart,
1429
+ (v) => saveSetting({ app: { id: a.id, deferredStart: v } }),
1430
+ () => (a.state === 'off' ? 'The app is off' : null),
1431
+ );
1432
+ start.dataset.focus = `start:${a.id}`;
1433
+ const showTd = el('td', '');
1434
+ showTd.append(show);
1435
+ const startTd = el('td', '');
1436
+ startTd.append(start);
1437
+ tr.append(el('td', '', a.name), showTd, startTd);
1438
+ table.append(tr);
1439
+ }
1440
+ appsSec.append(
1441
+ table,
1442
+ el(
1443
+ 'span',
1444
+ 'set-help',
1445
+ '“Launcher only” keeps the app running but gives it no tab or number key; open it from the app launcher. “On demand” starts it the first time you open it.',
1446
+ ),
1447
+ );
1448
+ body.replaceChildren(dirs, appsSec);
1449
+
1450
+ let status = el('span', '', 'Saved as you change it');
1451
+ if (settingsModal.error) status = el('span', 'set-error', settingsModal.error);
1452
+ else if (s.restartNeeded) status = el('span', 'set-restart', 'Restart the hub to apply app changes');
1453
+ const keys = el('span', 'set-keys');
1454
+ keys.append(kbd('Tab'), ' move · ', kbd('←'), kbd('→'), ' change');
1455
+ foot.replaceChildren(status, keys);
1456
+
1457
+ // A save redraws the modal, so put focus back on the control that had it.
1458
+ if (focusKey) {
1459
+ const target = body.querySelector(`[data-focus="${CSS.escape(focusKey)}"]`);
1460
+ // A radio group gets its checked button; a button that became disabled hands focus to its neighbour.
1461
+ const focusable = target?.matches('button:not(:disabled)')
1462
+ ? target
1463
+ : (target?.querySelector('[aria-checked="true"]:not(:disabled)') ??
1464
+ target?.parentElement.querySelector('button:not(:disabled)'));
1465
+ (focusable ?? document.querySelector('#settings .settings-box')).focus();
1466
+ }
1467
+ }
1468
+
1469
+ function kbd(text) {
1470
+ return el('kbd', '', text);
1471
+ }
1472
+
1473
+ function bindSettings() {
1474
+ const overlay = document.getElementById('settings');
1475
+ overlay.addEventListener('mousedown', (e) => {
1476
+ if (e.target === overlay) closeSettings();
1477
+ });
1478
+ overlay.addEventListener('keydown', (e) => {
1479
+ if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
1480
+ const group = e.target.closest('[role="radiogroup"]');
1481
+ if (!group) return;
1482
+ e.preventDefault();
1483
+ const radios = [...group.querySelectorAll('[role="radio"]:not(:disabled)')];
1484
+ const i = radios.indexOf(e.target);
1485
+ radios[i + (e.key === 'ArrowRight' ? 1 : -1)]?.click();
1486
+ });
1487
+ }
1488
+
1253
1489
  function renderPalette() {
1254
1490
  const list = document.getElementById('palette-list');
1255
1491
  const spec = PALETTE_MODES[palette.mode];
@@ -1306,11 +1542,7 @@ function openPalette(mode) {
1306
1542
  input.placeholder = spec.placeholder;
1307
1543
  document.querySelector('#palette .palette-box').setAttribute('aria-label', spec.label);
1308
1544
  drawPaletteHint();
1309
- document.getElementById('palette').hidden = false;
1310
- // Ctrl+Alt+P usually arrives forwarded from a focused iframe. Without inert the sub-app can keep
1311
- // or take focus back, and then Escape is handled inside it — the palette stays open and only the
1312
- // sub-app's own focus visibly changes.
1313
- setIframesInert(true);
1545
+ showOverlay('palette', closePalette);
1314
1546
  renderPalette();
1315
1547
  input.focus();
1316
1548
  // Stale-while-revalidate: the cached list renders instantly, recency refreshes when this lands.
@@ -1328,7 +1560,25 @@ function openPalette(mode) {
1328
1560
 
1329
1561
  function closePalette() {
1330
1562
  palette.open = false;
1331
- document.getElementById('palette').hidden = true;
1563
+ hideOverlay('palette');
1564
+ }
1565
+
1566
+ // The palette and the settings modal: one is open at a time, and Escape closes it.
1567
+ let openOverlay = null;
1568
+
1569
+ function showOverlay(id, close) {
1570
+ if (openOverlay && openOverlay.id !== id) openOverlay.close();
1571
+ openOverlay = { id, close };
1572
+ document.getElementById(id).hidden = false;
1573
+ // Hub keys usually arrive forwarded from a focused iframe. Without inert the sub-app can keep
1574
+ // or take focus back, and then Escape is handled inside it — the overlay stays open and only the
1575
+ // sub-app's own focus visibly changes.
1576
+ setIframesInert(true);
1577
+ }
1578
+
1579
+ function hideOverlay(id) {
1580
+ if (openOverlay?.id === id) openOverlay = null;
1581
+ document.getElementById(id).hidden = true;
1332
1582
  setIframesInert(false);
1333
1583
  iframes[activeApp]?.focus();
1334
1584
  }
@@ -1394,19 +1644,39 @@ async function commitConfigDir(row) {
1394
1644
  closePalette();
1395
1645
  // The server would no-op too, but the client would still reload every iframe.
1396
1646
  if (target === palette.configDirs.active) return;
1647
+ await switchConfigDir(() => sendJson('POST', '/api/config-dirs/activate', { path: target }));
1648
+ }
1649
+
1650
+ // The previous dir, with no palette. With none (one dir only) it opens the palette to add one.
1651
+ let swapping = false;
1652
+ async function swapConfigDir() {
1653
+ if (swapping) return;
1654
+ swapping = true;
1655
+ openOverlay?.close();
1656
+ try {
1657
+ await switchConfigDir(() => sendJson('POST', '/api/config-dirs/swap'));
1658
+ } finally {
1659
+ swapping = false;
1660
+ }
1661
+ }
1662
+
1663
+ async function switchConfigDir(request) {
1397
1664
  showLoading();
1398
1665
  try {
1399
- setApps((await sendJson('POST', '/api/config-dirs/activate', { path: target })).apps);
1666
+ const { apps: list, active, previous } = await request();
1667
+ setApps(list);
1400
1668
  // The palette renders from this cache before its refetch lands, and initialSel keys off it.
1401
- palette.configDirs.active = target;
1402
- activeConfigDir = target;
1403
- applyTitle(target);
1669
+ palette.configDirs.previous = previous;
1670
+ palette.configDirs.active = active;
1671
+ activeConfigDir = active;
1672
+ applyTitle(active);
1404
1673
  // Each iframe gets the new dir's color theme from its load handler; this repaints the palette.
1405
1674
  applyHubTheme();
1406
1675
  reloadIframes();
1407
1676
  } catch (err) {
1408
- showErrorToast('Could not switch config dir', err);
1409
1677
  hideLoading();
1678
+ if (err.status === 409) openPalette('configDir');
1679
+ else showErrorToast('Could not switch config dir', err);
1410
1680
  }
1411
1681
  }
1412
1682
 
package/public/index.html CHANGED
@@ -238,8 +238,15 @@
238
238
  /* Bar over the neutral hover background, no accent tint: the faintest theme accents
239
239
  (monokai light #c08a00, vitesse light #1c6b48) barely register as a tint, but read
240
240
  clearly as a 2px bar. */
241
- .palette-row.selected {
241
+ .palette-row.selected,
242
+ .set-row:focus-within,
243
+ .set-apps tr:focus-within td {
242
244
  background: var(--surface-hover);
245
+ }
246
+
247
+ .palette-row.selected,
248
+ .set-row:focus-within,
249
+ .set-apps tr:focus-within td:first-child {
243
250
  box-shadow: inset 2px 0 0 var(--accent);
244
251
  }
245
252
 
@@ -380,6 +387,118 @@
380
387
 
381
388
  .palette-hint:empty { display: none; }
382
389
 
390
+ /* Built from the palette's parts so the two read as one: same box, row padding, selected-row
391
+ bar (on the row that holds focus) and footer. Controls follow cck's quiet ones: an accent
392
+ tint for the chosen option, not a solid fill. */
393
+ .settings-box {
394
+ max-height: 76vh;
395
+ overflow-y: auto;
396
+ color: var(--text);
397
+ font-size: 13px;
398
+ outline: none;
399
+ }
400
+
401
+ .settings-bar {
402
+ display: flex;
403
+ align-items: center;
404
+ gap: 10px;
405
+ padding: 12px 14px;
406
+ border-bottom: 1px solid var(--border);
407
+ }
408
+
409
+ .settings-bar h3 { font-size: 14px; font-weight: 600; }
410
+ .settings-bar kbd { margin-left: auto; }
411
+
412
+ .set-sec { padding: 6px 0; border-bottom: 1px solid var(--border); }
413
+ .set-sec:last-child { border-bottom: none; }
414
+
415
+ .set-sec h4 {
416
+ padding: 6px 14px 4px;
417
+ font-size: 11px;
418
+ font-weight: 600;
419
+ letter-spacing: 0.05em;
420
+ text-transform: uppercase;
421
+ color: var(--text-dim);
422
+ }
423
+
424
+ .set-row {
425
+ display: grid;
426
+ grid-template-columns: 1fr auto;
427
+ gap: 2px 12px;
428
+ align-items: center;
429
+ padding: 8px 14px;
430
+ }
431
+
432
+
433
+ .set-label { font-weight: 600; }
434
+ .set-help { grid-column: 1 / -1; color: var(--text-dim); font-size: 12px; }
435
+ .set-sec > .set-help { display: block; padding: 6px 14px 8px; }
436
+ #settings-body > .set-help { display: block; padding: 12px 14px; }
437
+
438
+ .settings-box button {
439
+ padding: 2px 8px;
440
+ border: 1px solid var(--border);
441
+ border-radius: 4px;
442
+ background: transparent;
443
+ color: var(--text-dim);
444
+ font: inherit;
445
+ font-size: 12px;
446
+ cursor: pointer;
447
+ }
448
+
449
+ .settings-box button:hover:not(:disabled) { color: var(--text); }
450
+ .settings-box button:disabled { opacity: 0.4; cursor: default; }
451
+
452
+ .settings-box button:focus-visible {
453
+ outline: none;
454
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 45%, transparent);
455
+ }
456
+
457
+ .seg {
458
+ display: inline-flex;
459
+ gap: 2px;
460
+ padding: 2px;
461
+ border: 1px solid var(--border);
462
+ border-radius: 6px;
463
+ white-space: nowrap;
464
+ }
465
+
466
+ .seg button { border-color: transparent; }
467
+
468
+ .seg button.on {
469
+ background: color-mix(in srgb, var(--accent) 18%, transparent);
470
+ color: var(--text);
471
+ font-weight: 600;
472
+ }
473
+
474
+ .step { display: inline-flex; align-items: center; gap: 8px; color: var(--text-dim); }
475
+ .step button { width: 24px; padding: 2px 0; }
476
+ .step b { min-width: 12px; text-align: center; color: var(--text); font-variant-numeric: tabular-nums; }
477
+
478
+ .set-apps { width: 100%; border-collapse: collapse; }
479
+ .set-apps th, .set-apps td { padding: 5px 6px; text-align: left; white-space: nowrap; }
480
+ .set-apps th:first-child, .set-apps td:first-child { padding-left: 14px; width: 100%; }
481
+ .set-apps th:last-child, .set-apps td:last-child { padding-right: 14px; }
482
+ .set-apps th { color: var(--text-dim); font-size: 11px; font-weight: 400; }
483
+ .set-apps td:first-child { font-weight: 600; }
484
+
485
+ .settings-foot { display: flex; gap: 12px; }
486
+
487
+ .settings-foot .set-keys { margin-left: auto; }
488
+ .set-restart { color: var(--warn); }
489
+ .set-restart::before { content: '●'; margin-right: 6px; }
490
+ .set-error { color: #e5484d; }
491
+
492
+ .settings-box kbd {
493
+ padding: 0 4px;
494
+ border: 1px solid var(--border);
495
+ border-bottom-width: 2px;
496
+ border-radius: 4px;
497
+ background: var(--bg);
498
+ font: 11px ui-monospace, SFMono-Regular, Menlo, monospace;
499
+ color: var(--text-dim);
500
+ }
501
+
383
502
  .update-toast {
384
503
  position: fixed;
385
504
  right: 16px;
@@ -495,6 +614,14 @@
495
614
  </div>
496
615
  </div>
497
616
 
617
+ <div class="palette" id="settings" hidden>
618
+ <div class="palette-box settings-box" role="dialog" aria-modal="true" aria-labelledby="settings-title" tabindex="-1">
619
+ <div class="settings-bar"><h3 id="settings-title">Hub settings</h3><kbd>Esc</kbd></div>
620
+ <div id="settings-body"></div>
621
+ <div class="palette-hint settings-foot" id="settings-foot"></div>
622
+ </div>
623
+ </div>
624
+
498
625
  <div class="update-toast" id="update-toast" role="status" hidden>
499
626
  <div class="update-toast-body">
500
627
  <div class="update-toast-title">Claude Code Hub <span id="update-latest"></span> is available</div>
package/server.js CHANGED
@@ -9,7 +9,7 @@ const tcp = require('net');
9
9
  const os = require('os');
10
10
  const path = require('path');
11
11
  const { actionTable } = require('./lib/actions');
12
- const { selectApps, loadApp, conflicts } = require('./lib/apps');
12
+ const { HUB_ROOT, selectApps, appRoot, loadApp, conflicts, readApp } = require('./lib/apps');
13
13
  const { userKeys } = require('./lib/keymap');
14
14
  const { stripCookie } = require('./lib/cookies');
15
15
  const { createNetGuard, KEEP_ALIVE_MS } = require('./lib/net-guard');
@@ -21,7 +21,16 @@ const { createUpdateCheck } = require('./lib/update-check');
21
21
  const { liveHub, removeRecord, writeRecord } = require('./lib/hub-record');
22
22
  const { HUB_PLUGIN } = require('./lib/install');
23
23
  const { pluginChecker } = require('./lib/plugin-status');
24
- const { serializeConfig, writeConfigFile } = require('./lib/hub-config');
24
+ const { readConfig, serializeConfig, writeConfigFile } = require('./lib/hub-config');
25
+ const {
26
+ appSettings,
27
+ appsShape,
28
+ applySettings,
29
+ configDirSwitch,
30
+ poolSize,
31
+ copyLive,
32
+ MAX_POOL_SIZE,
33
+ } = require('./lib/settings');
25
34
  const { version: HUB_VERSION } = require('./package.json');
26
35
 
27
36
  function getArg(name) {
@@ -36,7 +45,7 @@ const DEFAULT_HUB_PORT = 3540;
36
45
  const HUB_PORT = parseInt(getArg('port') || process.env.PORT || DEFAULT_HUB_PORT, 10);
37
46
  let hubPort = HUB_PORT;
38
47
 
39
- const POOL_SIZE = Math.max(1, parseInt(getArg('pool-size') || '3', 10));
48
+ const POOL_SIZE_FLAG = getArg('pool-size');
40
49
  const RESTART_DELAY_MS = 500;
41
50
  // A rate window, not a total: a child may die and recover all session, but one that cannot stay up
42
51
  // stops being restarted. A consecutive counter cannot express that — anything that starts at all
@@ -150,6 +159,9 @@ function resolveHubConfig() {
150
159
  if (saved.terminal && typeof saved.terminal === 'object') config.terminal = saved.terminal;
151
160
  if (Array.isArray(saved.apps)) config.apps = saved.apps;
152
161
  if (saved.keys !== undefined) config.keys = saved.keys;
162
+ copyLive(saved, config);
163
+ const savedPrevious = typeof saved.previousConfigDir === 'string' ? canonical(saved.previousConfigDir) : null;
164
+ if (dirs.includes(savedPrevious) && savedPrevious !== active) config.previousConfigDir = savedPrevious;
153
165
  return { config, raw, fallback };
154
166
  }
155
167
 
@@ -161,12 +173,22 @@ function writeHubConfig(config) {
161
173
  function saveHubConfig() {
162
174
  let saved = hubConfig;
163
175
  try {
164
- saved = JSON.parse(fs.readFileSync(HUB_CONFIG_FILE, 'utf8'));
176
+ saved = readSavedConfig();
165
177
  } catch {}
166
- writeHubConfig({ ...saved, configDirs: hubConfig.configDirs, activeConfigDir: hubConfig.activeConfigDir });
178
+ writeHubConfig({
179
+ ...saved,
180
+ configDirs: hubConfig.configDirs,
181
+ activeConfigDir: hubConfig.activeConfigDir,
182
+ previousConfigDir: hubConfig.previousConfigDir,
183
+ });
184
+ }
185
+
186
+ function readSavedConfig() {
187
+ return readConfig(path.dirname(HUB_CONFIG_FILE)).config;
167
188
  }
168
189
 
169
190
  const hubConfig = loadHubConfig();
191
+ const currentPoolSize = () => poolSize(hubConfig, POOL_SIZE_FLAG);
170
192
 
171
193
  const { apps: selectedApps, unknown: unknownApps } = selectApps(hubConfig.apps, path.dirname(HUB_CONFIG_FILE));
172
194
  const ENABLED_APPS = selectedApps.map((a) => loadApp(a, { flagPort: getArg(`${a.id}-port`) })).filter(Boolean);
@@ -380,7 +402,7 @@ function withTimeout(promise, ms) {
380
402
  }
381
403
 
382
404
  // Returns the live pool for a dir, spawning one when absent and evicting the least recently used
383
- // pool past POOL_SIZE. Resolves once every child has printed its port banner (or given up), so
405
+ // pool past the pool size. Resolves once every child has printed its port banner (or given up), so
384
406
  // the caller can hand the client URLs that are actually live.
385
407
  async function ensurePool(dir) {
386
408
  let pool = pools.get(dir);
@@ -389,7 +411,7 @@ async function ensurePool(dir) {
389
411
  pools.set(dir, pool);
390
412
  return pool.ready;
391
413
  }
392
- if (pools.size >= POOL_SIZE) await evictPools(dir);
414
+ if (pools.size >= currentPoolSize()) await evictPools(dir);
393
415
  pool = pools.get(dir);
394
416
  if (pool) return pool.ready;
395
417
  const stopped = new Set(ENABLED_APPS.filter((a) => a.deferredStart).map((a) => a.id));
@@ -423,10 +445,10 @@ async function evictPools(keepDir) {
423
445
  const counts = await Promise.all(candidates.map(terminalCount));
424
446
  pinned = new Set(candidates.filter((_, i) => counts[i]).map((p) => p.dir));
425
447
  }
426
- while (pools.size >= POOL_SIZE) {
448
+ while (pools.size >= currentPoolSize()) {
427
449
  const victim = [...pools.keys()].find((d) => d !== keepDir && !pinned.has(d));
428
450
  if (!victim) {
429
- console.log(`Pool size ${POOL_SIZE} exceeded: every other pool has live terminals`);
451
+ console.log(`Pool size ${currentPoolSize()} exceeded: every other pool has live terminals`);
430
452
  return;
431
453
  }
432
454
  console.log(`Evicting children for ${victim}`);
@@ -931,13 +953,20 @@ app.get('/api/config', (_req, res) => {
931
953
  keys: USER_KEYS,
932
954
  activeConfigDir: hubConfig.activeConfigDir,
933
955
  defaultConfigDir,
956
+ configDirSwitch: configDirSwitch(hubConfig),
934
957
  version: HUB_VERSION,
935
958
  });
936
959
  });
937
960
 
961
+ // The dir a swap goes to: the one active before the current one, else the other of exactly two.
962
+ function swapTarget() {
963
+ const { configDirs: dirs, activeConfigDir: active, previousConfigDir: previous } = hubConfig;
964
+ return previous ?? (dirs.length === 2 ? dirs.find((d) => d !== active) : null);
965
+ }
966
+
938
967
  app.get('/api/config-dirs', (_req, res) => {
939
968
  res.setHeader('Cache-Control', 'no-store');
940
- res.json({ dirs: hubConfig.configDirs, active: hubConfig.activeConfigDir });
969
+ res.json({ dirs: hubConfig.configDirs, active: hubConfig.activeConfigDir, previous: swapTarget() });
941
970
  });
942
971
 
943
972
  // Normalizes a typed path to its real on-disk form (true casing, native separators) so cck's
@@ -976,11 +1005,23 @@ app.delete('/api/config-dirs', (req, res) => {
976
1005
  return;
977
1006
  }
978
1007
  hubConfig.configDirs = hubConfig.configDirs.filter((d) => d !== target);
1008
+ if (hubConfig.previousConfigDir === target) delete hubConfig.previousConfigDir;
979
1009
  saveHubConfig();
980
1010
  dropPool(target);
981
- res.json({ dirs: hubConfig.configDirs, active: hubConfig.activeConfigDir });
1011
+ res.json({ dirs: hubConfig.configDirs, active: hubConfig.activeConfigDir, previous: swapTarget() });
982
1012
  });
983
1013
 
1014
+ async function activateConfigDir(target) {
1015
+ if (target !== hubConfig.activeConfigDir) {
1016
+ console.log(`Switching CLAUDE_CONFIG_DIR -> ${target}`);
1017
+ await ensurePool(target);
1018
+ hubConfig.previousConfigDir = hubConfig.activeConfigDir;
1019
+ hubConfig.activeConfigDir = target;
1020
+ saveHubConfig();
1021
+ }
1022
+ return { apps: appsConfig(), active: target, previous: swapTarget() };
1023
+ }
1024
+
984
1025
  // The response carries the app URLs so the client reloads every iframe against the new set.
985
1026
  app.post('/api/config-dirs/activate', async (req, res) => {
986
1027
  const target = typeof req.body?.path === 'string' ? req.body.path : '';
@@ -988,13 +1029,79 @@ app.post('/api/config-dirs/activate', async (req, res) => {
988
1029
  res.status(404).json({ error: 'Unknown config dir; add it first' });
989
1030
  return;
990
1031
  }
991
- if (target !== hubConfig.activeConfigDir) {
992
- console.log(`Switching CLAUDE_CONFIG_DIR -> ${target}`);
993
- await ensurePool(target);
994
- hubConfig.activeConfigDir = target;
995
- saveHubConfig();
1032
+ res.json(await activateConfigDir(target));
1033
+ });
1034
+
1035
+ app.post('/api/config-dirs/swap', async (_req, res) => {
1036
+ const target = swapTarget();
1037
+ if (!target) {
1038
+ res.status(409).json({ error: 'No previous config dir' });
1039
+ return;
1040
+ }
1041
+ res.json(await activateConfigDir(target));
1042
+ });
1043
+
1044
+ // Apps changes need a hub restart: the app list is read once at startup.
1045
+ const CONFIG_BASE = path.dirname(HUB_CONFIG_FILE);
1046
+ const RUNNING_APPS = appsShape(appSettings(hubConfig.apps, CONFIG_BASE));
1047
+ const appNames = new Map(ENABLED_APPS.map((a) => [a.id, a.name]));
1048
+
1049
+ // An app that is off has no loaded manifest, so its name is read on first use.
1050
+ function appName(a) {
1051
+ if (!appNames.has(a.id)) {
1052
+ let name = a.id;
1053
+ try {
1054
+ name = readApp(appRoot(a, HUB_ROOT, require.resolve), a.id).manifest.name;
1055
+ } catch {}
1056
+ appNames.set(a.id, name);
1057
+ }
1058
+ return appNames.get(a.id);
1059
+ }
1060
+
1061
+ function settingsView(saved) {
1062
+ const apps = appSettings(Array.isArray(saved.apps) ? saved.apps : [], CONFIG_BASE);
1063
+ return {
1064
+ configDirSwitch: configDirSwitch(hubConfig),
1065
+ poolSize: currentPoolSize(),
1066
+ poolSizeFlag: POOL_SIZE_FLAG !== null,
1067
+ maxPoolSize: MAX_POOL_SIZE,
1068
+ apps: apps.map((a) => ({ id: a.id, state: a.state, deferredStart: a.deferredStart, name: appName(a) })),
1069
+ restartNeeded: appsShape(apps) !== RUNNING_APPS,
1070
+ };
1071
+ }
1072
+
1073
+ function readSettingsConfig(res) {
1074
+ try {
1075
+ return readSavedConfig();
1076
+ } catch (e) {
1077
+ res.status(500).json({ error: e.message });
1078
+ return null;
1079
+ }
1080
+ }
1081
+
1082
+ app.get('/api/settings', (_req, res) => {
1083
+ const saved = readSettingsConfig(res);
1084
+ if (!saved) return;
1085
+ res.setHeader('Cache-Control', 'no-store');
1086
+ res.json(settingsView(saved));
1087
+ });
1088
+
1089
+ // Writes config.json from its copy on disk, so `ext` and hand edits made while the hub runs stay.
1090
+ app.post('/api/settings', (req, res) => {
1091
+ if (req.body?.poolSize !== undefined && POOL_SIZE_FLAG !== null) {
1092
+ res.status(409).json({ error: '--pool-size sets the pool size for this run' });
1093
+ return;
1094
+ }
1095
+ const saved = readSettingsConfig(res);
1096
+ if (!saved) return;
1097
+ const r = applySettings(saved, req.body, CONFIG_BASE);
1098
+ if (r.error) {
1099
+ res.status(400).json({ error: r.error });
1100
+ return;
996
1101
  }
997
- res.json({ apps: appsConfig() });
1102
+ writeHubConfig(r.config);
1103
+ copyLive(r.config, hubConfig);
1104
+ res.json(settingsView(r.config));
998
1105
  });
999
1106
 
1000
1107
  // The stalls hit a few percent of requests, so each round sends several probes per app.