@bakapiano/ccsm 0.8.4 → 0.10.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.
Files changed (70) hide show
  1. package/CLAUDE.md +222 -195
  2. package/README.md +78 -80
  3. package/bin/ccsm.js +1 -1
  4. package/lib/cliSessionWatcher.js +249 -0
  5. package/lib/config.js +101 -19
  6. package/lib/folders.js +96 -0
  7. package/lib/localCliSessions.js +177 -0
  8. package/lib/persistedSessions.js +134 -0
  9. package/lib/webTerminal.js +48 -13
  10. package/lib/workspace.js +26 -4
  11. package/package.json +4 -4
  12. package/public/assets/claude-color.svg +1 -0
  13. package/public/assets/codex-color.svg +1 -0
  14. package/public/assets/copilot-color.svg +1 -0
  15. package/public/css/base.css +22 -5
  16. package/public/css/cards.css +37 -3
  17. package/public/css/feedback.css +127 -43
  18. package/public/css/forms.css +133 -10
  19. package/public/css/layout.css +79 -26
  20. package/public/css/modal.css +40 -26
  21. package/public/css/responsive.css +2 -2
  22. package/public/css/sidebar.css +456 -20
  23. package/public/css/terminals.css +182 -0
  24. package/public/css/tokens.css +28 -12
  25. package/public/css/wco.css +47 -19
  26. package/public/css/widgets.css +1177 -6
  27. package/public/index.html +39 -4
  28. package/public/js/api.js +194 -37
  29. package/public/js/components/AdoptModal.js +171 -0
  30. package/public/js/components/App.js +1 -11
  31. package/public/js/components/DirectoryPicker.js +203 -0
  32. package/public/js/components/EntityFormModal.js +105 -0
  33. package/public/js/components/Modal.js +51 -0
  34. package/public/js/components/OfflineBanner.js +29 -23
  35. package/public/js/components/PageTitleBar.js +13 -0
  36. package/public/js/components/Picker.js +179 -0
  37. package/public/js/components/Popover.js +55 -0
  38. package/public/js/components/Sidebar.js +244 -26
  39. package/public/js/components/TerminalView.js +192 -2
  40. package/public/js/components/useDragSort.js +67 -0
  41. package/public/js/dialog.js +10 -2
  42. package/public/js/icons.js +66 -3
  43. package/public/js/main.js +54 -3
  44. package/public/js/pages/AboutPage.js +81 -1
  45. package/public/js/pages/ConfigurePage.js +452 -159
  46. package/public/js/pages/LaunchPage.js +328 -76
  47. package/public/js/pages/SessionsPage.js +91 -41
  48. package/public/js/state.js +179 -35
  49. package/public/manifest.webmanifest +2 -2
  50. package/scripts/install.js +1 -1
  51. package/server.js +763 -407
  52. package/lib/favorites.js +0 -51
  53. package/lib/focus.js +0 -369
  54. package/lib/labels.js +0 -29
  55. package/lib/launcher.js +0 -219
  56. package/lib/sessions.js +0 -272
  57. package/lib/snapshot.js +0 -141
  58. package/public/js/actions.js +0 -87
  59. package/public/js/components/Fab.js +0 -11
  60. package/public/js/components/FavoritesTable.js +0 -81
  61. package/public/js/components/Footer.js +0 -12
  62. package/public/js/components/NewSessionModal.js +0 -142
  63. package/public/js/components/PageHead.js +0 -33
  64. package/public/js/components/Pagination.js +0 -27
  65. package/public/js/components/RecentTable.js +0 -68
  66. package/public/js/components/SessionsTable.js +0 -71
  67. package/public/js/components/SnapshotPanel.js +0 -77
  68. package/public/js/components/TitleCell.js +0 -40
  69. package/public/js/components/WorkspacesGrid.js +0 -41
  70. package/public/js/pages/TerminalsPage.js +0 -74
package/server.js CHANGED
@@ -4,75 +4,42 @@
4
4
  const path = require('node:path');
5
5
  const express = require('express');
6
6
 
7
- const { listSessions, listRecentSessions, findSessionMetadata } = require('./lib/sessions');
8
- const { listFavorites, addFavorite, removeFavorite, loadFavorites } = require('./lib/favorites');
9
- const { loadLabels, setLabel, removeLabel } = require('./lib/labels');
10
7
  const { loadConfig, saveConfig, DATA_DIR } = require('./lib/config');
11
- const {
12
- saveSnapshot,
13
- loadLatestSnapshot,
14
- listSnapshotHistory,
15
- loadSnapshotByFile,
16
- restoreSnapshot,
17
- } = require('./lib/snapshot');
18
8
  const {
19
9
  listWorkspaces,
20
10
  findOrCreateWorkspace,
21
11
  ensureReposInWorkspace,
12
+ isInside,
13
+ dirSize,
22
14
  } = require('./lib/workspace');
23
- const {
24
- launchNewClaude,
25
- launchResume,
26
- listTerminalKinds,
27
- processNameFor,
28
- } = require('./lib/launcher');
29
- const {
30
- focusByPid,
31
- focusBySession,
32
- snapshotWindowsOf,
33
- focusNewlyOpenedHwnd,
34
- } = require('./lib/focus');
35
15
  const webTerminal = require('./lib/webTerminal');
36
-
37
- // One unified exit path so every reason-for-shutdown gets the same
38
- // cleanup: final snapshot save (so the next launch can restore current
39
- // state) + PTY children killed. Idempotent — concurrent triggers are no-ops.
16
+ const persistedSessions = require('./lib/persistedSessions');
17
+ const folders = require('./lib/folders');
18
+ const cliSessionWatcher = require('./lib/cliSessionWatcher');
19
+ const localCliSessions = require('./lib/localCliSessions');
20
+
21
+ // One unified exit path: kill PTY children, then exit. v1.0 dropped the
22
+ // snapshot-on-exit behaviour because the new persistedSessions store is
23
+ // the source of truth (and is always on disk, not in memory).
40
24
  let shuttingDown = false;
41
25
  async function gracefulShutdown(reason) {
42
26
  if (shuttingDown) return;
43
27
  shuttingDown = true;
44
28
  console.log(`[ccsm] shutting down · ${reason}`);
45
-
46
- // Final snapshot. Wrap in a race so a wedged disk doesn't hang us
47
- // indefinitely — 2s is generous (typical save is <300ms).
29
+ // Mark all running sessions as exited (best-effort) so the next launch
30
+ // doesn't show stale "running" rows.
48
31
  try {
49
- const cfg = await loadConfig();
50
- await Promise.race([
51
- saveSnapshot({ keep: cfg.snapshotHistoryKeep }),
52
- new Promise((_, reject) => setTimeout(() => reject(new Error('save timeout (2s)')), 2000)),
53
- ]);
54
- console.log('[ccsm] final snapshot saved');
55
- } catch (e) {
56
- console.error('[ccsm] final snapshot skipped:', e.message);
57
- }
58
-
59
- // Kill any in-process PTY children so they don't outlive us.
32
+ const all = await persistedSessions.loadAll();
33
+ for (const s of all) {
34
+ if (s.status === 'running') {
35
+ await persistedSessions.markExited(s.id, null).catch(() => {});
36
+ }
37
+ }
38
+ } catch {}
60
39
  try { webTerminal.killAll(); } catch {}
61
-
62
40
  process.exit(0);
63
41
  }
64
42
 
65
- async function autoFocusAfterLaunch({ terminal, beforeHwnds, autoFocus }) {
66
- if (!autoFocus) return;
67
- try {
68
- const processName = processNameFor(terminal);
69
- if (!processName) return;
70
- await focusNewlyOpenedHwnd(beforeHwnds, processName);
71
- } catch (e) {
72
- console.error('[auto-focus]', e.message);
73
- }
74
- }
75
-
76
43
  const app = express();
77
44
  app.use(express.json({ limit: '1mb' }));
78
45
 
@@ -101,7 +68,7 @@ app.use((req, res, next) => {
101
68
  // so a contributor can iterate without pushing to GH Pages; (b) hot-reload
102
69
  // SSE endpoint that watches public/ for changes. CCSM_NO_DEV=1 disables
103
70
  // both explicitly. In production (npm-installed), backend is API-only —
104
- // frontend lives at https://bakapiano.github.io/cssm/v1/.
71
+ // frontend lives at https://bakapiano.github.io/ccsm/ (router → per-version).
105
72
  const IS_DEV = !__dirname.includes(`${path.sep}node_modules${path.sep}`) && process.env.CCSM_NO_DEV !== '1';
106
73
 
107
74
  if (IS_DEV) {
@@ -118,7 +85,6 @@ if (IS_DEV) {
118
85
  res.flushHeaders();
119
86
  res.write(': connected\n\n');
120
87
  reloadClients.add(res);
121
- // Heartbeat every 25s so intermediate proxies don't kill the stream.
122
88
  const hb = setInterval(() => { try { res.write(': ping\n\n'); } catch {} }, 25000);
123
89
  req.on('close', () => { clearInterval(hb); reloadClients.delete(res); });
124
90
  });
@@ -147,165 +113,453 @@ function asyncH(fn) {
147
113
  };
148
114
  }
149
115
 
150
- // ---- sessions ----
116
+ // ---- helpers ----
151
117
 
152
- app.get('/api/sessions', asyncH(async (_req, res) => {
153
- const sessions = await listSessions();
154
- res.json({ sessions, takenAt: Date.now() });
155
- }));
118
+ function pickCli(cfg, requestedId) {
119
+ const wanted = requestedId || cfg.defaultCliId;
120
+ return cfg.clis.find((c) => c.id === wanted) || cfg.clis[0];
121
+ }
156
122
 
157
- app.get('/api/sessions/recent', asyncH(async (req, res) => {
158
- const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 15));
159
- const offset = Math.max(0, Number(req.query.offset) || 0);
160
- const live = await listSessions();
161
- const excludeIds = new Set(live.map((s) => s.sessionId));
162
- const { recent, total } = await listRecentSessions({ limit, offset, excludeIds });
163
- res.json({ recent, total, limit, offset, takenAt: Date.now() });
164
- }));
123
+ // Resolve how to spawn a CLI command. Windows quirks:
124
+ // v1.1 spawn strategy is now caller-controlled via cli.shell:
125
+ // 'direct' pty.spawn(command, args). Real .exe / absolute paths only.
126
+ // Won't find pwsh aliases / functions.
127
+ // 'pwsh' — wrap in `pwsh.exe -NoLogo -NoExit -Command "& { cmd args }"`.
128
+ // Loads $PROFILE pwsh aliases / functions (`ccp`, `cxp`) work.
129
+ // Falls back to powershell.exe (5.x) if pwsh.exe absent.
130
+ // 'cmd' — wrap in `cmd.exe /d /s /c "cmd args"`. Resolves doskey aliases
131
+ // and PATH-only names without pwsh dependency.
132
+ function resolveCommand(commandRaw, userArgs = [], shell = 'direct') {
133
+ if (!commandRaw) throw new Error('cli.command is empty');
134
+ const cmd = commandRaw.replace(/^\.[\\\/]/, '');
135
+
136
+ if (shell === 'pwsh') {
137
+ // Build a single -Command string so pwsh tokenizes args itself. The
138
+ // `& { ... }` wrapper makes pwsh execute the line as a script block —
139
+ // critical for functions (which aren't visible without invocation).
140
+ const joined = [cmd, ...userArgs.map(quoteForPwsh)].join(' ');
141
+ return {
142
+ exe: 'pwsh.exe',
143
+ prefixArgs: ['-NoLogo', '-NoExit', '-Command', `& { ${joined} }`],
144
+ fallbackExe: 'powershell.exe',
145
+ consumesUserArgs: true,
146
+ };
147
+ }
165
148
 
166
- // ---- favorites ----
167
- // Sessions the user has starred. Stored at $DATA_DIR/favorites.json.
168
- // Frontend usually GETs once at boot and updates optimistically.
169
- app.get('/api/favorites', asyncH(async (_req, res) => {
170
- const favorites = await listFavorites();
171
- res.json({ favorites });
172
- }));
149
+ if (shell === 'cmd') {
150
+ // /d skips AutoRun, /s preserves quoting, /c runs and exits.
151
+ const joined = [cmd, ...userArgs.map(quoteForCmd)].join(' ');
152
+ return {
153
+ exe: process.env.ComSpec || 'cmd.exe',
154
+ prefixArgs: ['/d', '/s', '/c', joined],
155
+ consumesUserArgs: true,
156
+ };
157
+ }
173
158
 
174
- app.post('/api/favorites/:sessionId', asyncH(async (req, res) => {
175
- const sessionId = req.params.sessionId;
176
- let info = req.body && typeof req.body === 'object' ? req.body : {};
177
- // If client didn't supply title/cwd, try to look them up from the live
178
- // session list or from the jsonl files on disk. This way star-from-empty
179
- // (e.g. via API) still produces a usable favorite.
180
- if (!info.cwd || !info.title) {
181
- const live = await listSessions();
182
- const livehit = live.find((s) => s.sessionId === sessionId);
183
- if (livehit) {
184
- info = { cwd: livehit.cwd, title: livehit.title, ...info };
185
- } else {
186
- const meta = await findSessionMetadata(sessionId);
187
- if (meta) info = { cwd: meta.cwd, title: meta.title, gitBranch: meta.gitBranch, ...info };
159
+ // shell === 'direct' bare pty.spawn. Honour .cmd/.bat/.ps1 extensions
160
+ // when an absolute path was provided so they still work without an
161
+ // explicit shell choice.
162
+ if (path.isAbsolute(cmd)) {
163
+ const ext = path.extname(cmd).toLowerCase();
164
+ if (ext === '.cmd' || ext === '.bat') {
165
+ return { exe: process.env.ComSpec || 'cmd.exe', prefixArgs: ['/d', '/s', '/c', cmd], consumesUserArgs: false };
188
166
  }
167
+ if (ext === '.ps1') {
168
+ return { exe: 'powershell.exe', prefixArgs: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', cmd], consumesUserArgs: false };
169
+ }
170
+ return { exe: cmd, prefixArgs: [], consumesUserArgs: false };
189
171
  }
190
- const fav = await addFavorite(sessionId, info);
191
- res.json({ favorite: fav });
192
- }));
193
-
194
- app.delete('/api/favorites/:sessionId', asyncH(async (req, res) => {
195
- const removed = await removeFavorite(req.params.sessionId);
196
- res.json({ removed });
197
- }));
172
+ // Bare name with shell=direct: defer to cmd.exe so Windows resolves
173
+ // against PATH. Same behavior as before — preserves user expectations
174
+ // for `claude` / `codex` configs that don't set shell.
175
+ return { exe: process.env.ComSpec || 'cmd.exe', prefixArgs: ['/d', '/s', '/c', cmd], consumesUserArgs: false };
176
+ }
198
177
 
199
- // ---- labels (rename overrides) ----
200
- // Custom display titles keyed by sessionId. Empty body / empty label is
201
- // treated as a delete.
202
- app.get('/api/labels', asyncH(async (_req, res) => {
203
- const labels = await loadLabels();
204
- res.json({ labels });
205
- }));
178
+ function quoteForPwsh(s) {
179
+ if (s === '' || /[\s'"`$]/.test(s)) return `'${String(s).replace(/'/g, "''")}'`;
180
+ return s;
181
+ }
182
+ function quoteForCmd(s) {
183
+ if (s === '' || /[\s"&|<>^]/.test(s)) return `"${String(s).replace(/"/g, '""')}"`;
184
+ return s;
185
+ }
206
186
 
207
- app.put('/api/labels/:sessionId', asyncH(async (req, res) => {
208
- const label = req.body && req.body.label;
209
- if (!label || !String(label).trim()) {
210
- const removed = await removeLabel(req.params.sessionId);
211
- return res.json({ removed });
187
+ function spawnCliSession({ cli, cwd, sessionId, meta, extraArgs = [] }) {
188
+ if (!webTerminal.available) {
189
+ const e = new Error('node-pty unavailable · cannot spawn web terminal');
190
+ e.code = 'PTY_UNAVAILABLE';
191
+ throw e;
212
192
  }
213
- const saved = await setLabel(req.params.sessionId, label);
214
- res.json({ label: saved });
215
- }));
193
+ // For shell wrappers (pwsh/cmd) we need to bake BOTH cli.args and
194
+ // extraArgs into the single quoted command string — otherwise extraArgs
195
+ // would become args to the shell itself, not the wrapped command.
196
+ // Re-resolve here when extraArgs is present so the quoting is correct.
197
+ const resolved = resolveCommand(
198
+ cli.command,
199
+ [...(cli.args || []), ...extraArgs],
200
+ cli.shell || 'direct',
201
+ );
202
+ const { exe, prefixArgs, fallbackExe, consumesUserArgs } = resolved;
203
+ const args = consumesUserArgs
204
+ ? prefixArgs
205
+ : [...prefixArgs, ...(cli.args || []), ...extraArgs];
206
+ // Merge user-scope PATH from registry into the env we hand the PTY.
207
+ const env = { ...process.env, ...(cli.env || {}) };
208
+ if (mergedUserPath) env.PATH = mergedUserPath;
209
+ const trySpawn = (executable) => webTerminal.spawn({
210
+ id: sessionId,
211
+ command: executable,
212
+ args,
213
+ cwd,
214
+ env,
215
+ meta: { ...meta, cliId: cli.id, cliName: cli.name },
216
+ onData: () => { persistedSessions.touch(sessionId).catch(() => {}); },
217
+ onExit: ({ exitCode }) => {
218
+ stopWatcher(sessionId);
219
+ persistedSessions.markExited(sessionId, exitCode).catch(() => {});
220
+ },
221
+ });
222
+ try {
223
+ const entry = trySpawn(exe);
224
+ maybeWatchCliSessionId({ cli, cwd, ccsmSessionId: sessionId });
225
+ return entry;
226
+ } catch (e) {
227
+ if (fallbackExe && /ENOENT|cannot find|not recognized/i.test(String(e && e.message || e))) {
228
+ const entry = trySpawn(fallbackExe);
229
+ maybeWatchCliSessionId({ cli, cwd, ccsmSessionId: sessionId });
230
+ return entry;
231
+ }
232
+ throw e;
233
+ }
234
+ }
216
235
 
217
- app.delete('/api/labels/:sessionId', asyncH(async (req, res) => {
218
- const removed = await removeLabel(req.params.sessionId);
219
- res.json({ removed });
220
- }));
236
+ // Start a fs-watch on the CLI's transcript directory so we can capture
237
+ // the upstream session UUID for later precise --resume. Only kicks off
238
+ // for CLI types we know how to watch (claude / codex / copilot).
239
+ //
240
+ // If the watcher times out (5 min with no transcript ever written), we
241
+ // assume the user closed the CLI before it persisted anything — so
242
+ // there's nothing to resume to and the ccsm record is dead weight. Drop
243
+ // the persistedSessions row and kill the PTY if it somehow lingers.
244
+ //
245
+ // IMPORTANT: if the record already has a captured cliSessionId (typical
246
+ // for `resume` and for `adopt`-imported records), skip the watcher
247
+ // entirely — there's nothing left to capture, and the timeout-cleanup
248
+ // would otherwise wipe a perfectly good record after 5 minutes of
249
+ // "no new transcript".
250
+ // Active upstream-session-id watchers, keyed by ccsm session id. We hold
251
+ // onto the cleanup fn returned by cliSessionWatcher so we can tear them
252
+ // down when the PTY exits or the record is deleted — a still-running
253
+ // watcher whose ccsm session is gone would otherwise match a *future*
254
+ // session that happens to spawn in the same cwd and stamp the wrong id
255
+ // onto a dead record (or worse, onto a re-created record reusing memory).
256
+ const activeWatchers = new Map(); // ccsmSessionId → cleanupFn
257
+
258
+ function stopWatcher(ccsmSessionId) {
259
+ const cleanup = activeWatchers.get(ccsmSessionId);
260
+ if (!cleanup) return;
261
+ activeWatchers.delete(ccsmSessionId);
262
+ try { cleanup(); } catch {}
263
+ }
264
+
265
+ async function maybeWatchCliSessionId({ cli, cwd, ccsmSessionId }) {
266
+ if (!cli || !['claude', 'codex', 'copilot'].includes(cli.type)) return;
267
+ // If a previous watcher was still alive on this id (e.g. fast restart),
268
+ // tear it down first.
269
+ stopWatcher(ccsmSessionId);
270
+ try {
271
+ const existing = await persistedSessions.get(ccsmSessionId);
272
+ if (existing?.cliSessionId) {
273
+ console.log(`[cliSessionId] skip watcher · ${cli.type} session already known (${existing.cliSessionId})`);
274
+ return;
275
+ }
276
+ } catch {}
277
+ const cleanup = cliSessionWatcher.captureSessionId({
278
+ cliType: cli.type,
279
+ cwd,
280
+ onCapture: (cliSessionId) => {
281
+ activeWatchers.delete(ccsmSessionId);
282
+ persistedSessions.update(ccsmSessionId, { cliSessionId }).catch((e) => {
283
+ console.error('[cliSessionId] save failed:', e.message);
284
+ });
285
+ console.log(`[cliSessionId] captured ${cli.type} session ${cliSessionId} for ccsm ${ccsmSessionId}`);
286
+ },
287
+ onTimeout: () => {
288
+ activeWatchers.delete(ccsmSessionId);
289
+ console.warn(`[cliSessionId] timeout · removing ccsm session ${ccsmSessionId} (no ${cli.type} transcript)`);
290
+ try { webTerminal.kill(ccsmSessionId); } catch {}
291
+ persistedSessions.remove(ccsmSessionId).catch((e) => {
292
+ console.error('[cliSessionId] remove failed:', e.message);
293
+ });
294
+ },
295
+ });
296
+ if (cleanup) activeWatchers.set(ccsmSessionId, cleanup);
297
+ }
298
+
299
+ // Read user PATH from registry once at boot, prepend to process PATH.
300
+ // On platforms other than Windows or if the read fails, fall back to
301
+ // process.env.PATH unchanged.
302
+ let mergedUserPath = null;
303
+ function buildMergedUserPath() {
304
+ if (process.platform !== 'win32') return process.env.PATH;
305
+ try {
306
+ const { spawnSync } = require('node:child_process');
307
+ const r = spawnSync('reg.exe', ['query', 'HKCU\\Environment', '/v', 'PATH'], { encoding: 'utf8', windowsHide: true });
308
+ if (r.status !== 0 || !r.stdout) return process.env.PATH;
309
+ const line = r.stdout.split(/\r?\n/).find((l) => /\bPATH\b/i.test(l) && /REG_(EXPAND_)?SZ/i.test(l));
310
+ if (!line) return process.env.PATH;
311
+ const m = line.match(/REG_(?:EXPAND_)?SZ\s+(.+)$/);
312
+ if (!m) return process.env.PATH;
313
+ // Expand %VAR% references manually (REG_EXPAND_SZ keeps them literal).
314
+ const userPath = m[1].replace(/%([^%]+)%/g, (_, name) => process.env[name] || '');
315
+ const existing = (process.env.PATH || '').split(';').map((s) => s.trim()).filter(Boolean);
316
+ const adds = userPath.split(';').map((s) => s.trim()).filter(Boolean);
317
+ const merged = [];
318
+ const seen = new Set();
319
+ for (const p of [...adds, ...existing]) {
320
+ const k = p.toLowerCase();
321
+ if (seen.has(k)) continue;
322
+ seen.add(k);
323
+ merged.push(p);
324
+ }
325
+ return merged.join(';');
326
+ } catch {
327
+ return process.env.PATH;
328
+ }
329
+ }
330
+ mergedUserPath = buildMergedUserPath();
221
331
 
222
332
  // ---- config ----
223
333
 
334
+ // Per-CLI install probe. Looks up the command on PATH using `where` (win)
335
+ // or `which` (posix). Result is cached forever — restart ccsm after
336
+ // installing/uninstalling a CLI to refresh. Cheap (10ms cold, 0ms cached).
337
+ const cliProbeCache = new Map();
338
+ function probeCli(command) {
339
+ if (!command) return null;
340
+ if (cliProbeCache.has(command)) return cliProbeCache.get(command);
341
+ const { spawnSync } = require('node:child_process');
342
+ let resolvedPath = null;
343
+ try {
344
+ const isWin = process.platform === 'win32';
345
+ const cmd = isWin ? 'where.exe' : 'which';
346
+ const env = { ...process.env };
347
+ if (mergedUserPath) env.PATH = mergedUserPath;
348
+ const r = spawnSync(cmd, [command], { encoding: 'utf8', windowsHide: true, env });
349
+ if (r.status === 0 && r.stdout) {
350
+ resolvedPath = r.stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0] || null;
351
+ }
352
+ } catch {}
353
+ cliProbeCache.set(command, resolvedPath);
354
+ return resolvedPath;
355
+ }
356
+
357
+ function decorateConfigWithProbes(cfg) {
358
+ return {
359
+ ...cfg,
360
+ clis: (cfg.clis || []).map((c) => {
361
+ const path = probeCli(c.command);
362
+ return { ...c, installed: !!path, installPath: path };
363
+ }),
364
+ };
365
+ }
366
+
224
367
  app.get('/api/config', asyncH(async (_req, res) => {
225
- res.json(await loadConfig());
368
+ res.json(decorateConfigWithProbes(await loadConfig()));
226
369
  }));
227
370
 
228
371
  app.put('/api/config', asyncH(async (req, res) => {
229
372
  const cfg = await saveConfig(req.body || {});
230
- res.json(cfg);
373
+ res.json(decorateConfigWithProbes(cfg));
231
374
  }));
232
375
 
233
- // ---- snapshot ----
376
+ // ---- CLIs ----
377
+ // ---- folders ----
234
378
 
235
- app.get('/api/snapshot', asyncH(async (_req, res) => {
236
- const snap = await loadLatestSnapshot();
237
- res.json({ snapshot: snap });
379
+ app.get('/api/folders', asyncH(async (_req, res) => {
380
+ const list = await folders.loadAll();
381
+ list.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
382
+ res.json({ folders: list });
238
383
  }));
239
384
 
240
- app.post('/api/snapshot', asyncH(async (_req, res) => {
241
- const cfg = await loadConfig();
242
- const snap = await saveSnapshot({ keep: cfg.snapshotHistoryKeep });
243
- res.json({ snapshot: snap });
385
+ app.post('/api/folders', asyncH(async (req, res) => {
386
+ const name = req.body && req.body.name;
387
+ if (!name) return res.status(400).json({ error: 'name required' });
388
+ res.json({ folder: await folders.create({ name }) });
244
389
  }));
245
390
 
246
- app.get('/api/snapshot/history', asyncH(async (_req, res) => {
247
- res.json({ history: await listSnapshotHistory() });
391
+ app.put('/api/folders/:id', asyncH(async (req, res) => {
392
+ const updated = await folders.update(req.params.id, req.body || {});
393
+ if (!updated) return res.status(404).json({ error: 'not found' });
394
+ res.json({ folder: updated });
248
395
  }));
249
396
 
250
- app.post('/api/snapshot/restore', asyncH(async (req, res) => {
251
- let snap;
252
- if (req.body && req.body.file) {
253
- snap = await loadSnapshotByFile(req.body.file);
254
- } else {
255
- snap = await loadLatestSnapshot();
397
+ app.delete('/api/folders/:id', asyncH(async (req, res) => {
398
+ // Move all sessions in this folder to Unsorted before delete.
399
+ const all = await persistedSessions.loadAll();
400
+ for (const s of all) {
401
+ if (s.folderId === req.params.id) {
402
+ await persistedSessions.setFolder(s.id, null);
403
+ }
256
404
  }
257
- if (!snap) return res.status(404).json({ error: 'no snapshot to restore' });
258
- const cfg = await loadConfig();
259
- const beforeHwnds = await snapshotWindowsOf(
260
- processNameFor(cfg.terminal) || 'WindowsTerminal.exe'
261
- );
262
- const result = restoreSnapshot(snap, {
263
- terminal: cfg.terminal,
264
- claudeCommand: cfg.claudeCommand,
265
- commandShell: cfg.commandShell || "pwsh",
266
- });
267
- // For N restored windows we just focus the last one to surface restore-happened
268
- // without strobing focus through all N.
269
- autoFocusAfterLaunch({
270
- terminal: cfg.terminal,
271
- beforeHwnds,
272
- autoFocus: cfg.autoFocusOnLaunch !== false,
273
- });
274
- res.json({ restored: result, takenAt: snap.takenAt, count: snap.sessions.length });
405
+ const removed = await folders.remove(req.params.id);
406
+ res.json({ removed });
407
+ }));
408
+
409
+ app.post('/api/folders/reorder', asyncH(async (req, res) => {
410
+ const ids = req.body && req.body.ids;
411
+ if (!Array.isArray(ids)) return res.status(400).json({ error: 'ids array required' });
412
+ const next = await folders.reorder(ids);
413
+ res.json({ folders: next });
414
+ }));
415
+
416
+ // ---- sessions (persisted, ccsm-owned) ----
417
+
418
+ app.get('/api/sessions', asyncH(async (_req, res) => {
419
+ const list = await persistedSessions.loadAll();
420
+ // Cross-check status against live PTY pool so a stale "running" record
421
+ // doesn't survive a server restart.
422
+ const live = new Set(webTerminal.list().filter((t) => !t.exitedAt).map((t) => t.id));
423
+ for (const s of list) {
424
+ if (s.status === 'running' && !live.has(s.id)) {
425
+ s.status = 'exited';
426
+ }
427
+ }
428
+ res.json({ sessions: list, takenAt: Date.now() });
429
+ }));
430
+
431
+ app.put('/api/sessions/:id', asyncH(async (req, res) => {
432
+ const patch = {};
433
+ if (typeof req.body.title === 'string') patch.title = req.body.title;
434
+ if ('folderId' in (req.body || {})) patch.folderId = req.body.folderId || null;
435
+ const updated = await persistedSessions.update(req.params.id, patch);
436
+ if (!updated) return res.status(404).json({ error: 'not found' });
437
+ res.json({ session: updated });
438
+ }));
439
+
440
+ app.delete('/api/sessions/:id', asyncH(async (req, res) => {
441
+ // Kill PTY first if it's still alive, then drop the record.
442
+ stopWatcher(req.params.id);
443
+ try { webTerminal.kill(req.params.id); } catch {}
444
+ const removed = await persistedSessions.remove(req.params.id);
445
+ res.json({ removed });
275
446
  }));
276
447
 
277
448
  // ---- workspaces ----
278
449
 
450
+ // ---- directory browser ----
451
+ // Lets the launch picker walk the filesystem so users can pick any
452
+ // existing directory as the session cwd. Returns the immediate child
453
+ // dirs of `path` (defaults to home), plus a few hardcoded "starts"
454
+ // (home, workDir, drive roots on Windows).
455
+ app.get('/api/browse', asyncH(async (req, res) => {
456
+ const fs = require('node:fs/promises');
457
+ const os = require('node:os');
458
+ const target = req.query.path ? path.resolve(String(req.query.path)) : os.homedir();
459
+ let entries = [];
460
+ let exists = true;
461
+ try {
462
+ const list = await fs.readdir(target, { withFileTypes: true });
463
+ entries = list
464
+ .filter((d) => d.isDirectory() && !d.name.startsWith('.'))
465
+ .map((d) => ({ name: d.name, path: path.join(target, d.name) }))
466
+ .sort((a, b) => a.name.localeCompare(b.name));
467
+ } catch (e) {
468
+ exists = false;
469
+ }
470
+ const parent = path.dirname(target);
471
+ const cfg = await loadConfig();
472
+ const starts = [
473
+ { label: 'Home', path: os.homedir() },
474
+ { label: 'Work dir', path: cfg.workDir },
475
+ ];
476
+ if (process.platform === 'win32') {
477
+ // Best-effort drive enumeration so users on D:\ etc can hop roots.
478
+ for (const letter of ['C', 'D', 'E', 'F', 'G', 'H']) {
479
+ const root = `${letter}:\\`;
480
+ try { await fs.access(root); starts.push({ label: `${letter}:\\`, path: root }); }
481
+ catch {}
482
+ }
483
+ }
484
+ res.json({
485
+ path: target,
486
+ parent: parent === target ? null : parent,
487
+ exists,
488
+ entries,
489
+ starts,
490
+ });
491
+ }));
492
+
279
493
  app.get('/api/workspaces', asyncH(async (_req, res) => {
280
494
  const cfg = await loadConfig();
495
+ // listWorkspaces calls into the old "in use = ~/.claude/sessions cwd
496
+ // matches workspace path" logic; we just want the directory listing
497
+ // now, so pass empty busy paths.
281
498
  const workspaces = await listWorkspaces({
282
499
  workDir: cfg.workDir,
283
500
  repos: cfg.repos,
284
501
  });
502
+ // Recompute inUse based on persistedSessions instead. A workspace is
503
+ // in use iff any RUNNING ccsm session lives at-or-inside it.
504
+ const allSess = await persistedSessions.loadAll();
505
+ const busy = new Set(
506
+ allSess.filter((s) => s.status === 'running').map((s) => path.resolve(s.cwd).toLowerCase())
507
+ );
508
+ for (const w of workspaces) {
509
+ w.inUse = busy.has(path.resolve(w.path).toLowerCase());
510
+ w.sessionsHere = allSess
511
+ .filter((s) => s.status === 'running' && path.resolve(s.cwd).toLowerCase() === path.resolve(w.path).toLowerCase())
512
+ .map((s) => s.id);
513
+ }
514
+ // Compute sizes in parallel. Cheap on the typical workspace
515
+ // (a few repo clones) and the page is opened infrequently.
516
+ await Promise.all(workspaces.map(async (w) => {
517
+ try { w.size = await dirSize(w.path); }
518
+ catch { w.size = null; }
519
+ }));
285
520
  res.json({ workDir: cfg.workDir, repos: cfg.repos, workspaces });
286
521
  }));
287
522
 
523
+ // Delete a workspace directory. Refuses if any RUNNING session lives
524
+ // inside it, or if the resolved path escapes workDir. The name comes
525
+ // from the URL — we resolve it against workDir and verify containment.
526
+ app.delete('/api/workspaces/:name', asyncH(async (req, res) => {
527
+ const fsp = require('node:fs/promises');
528
+ const cfg = await loadConfig();
529
+ const name = String(req.params.name || '');
530
+ // Reject anything that tries to escape via separators / traversal.
531
+ if (!name || /[\\/]|^\.\.$|^\.$/.test(name)) {
532
+ return res.status(400).json({ error: 'invalid workspace name' });
533
+ }
534
+ const target = path.resolve(cfg.workDir, name);
535
+ if (!isInside(target, cfg.workDir) || path.resolve(target) === path.resolve(cfg.workDir)) {
536
+ return res.status(400).json({ error: 'workspace must live under workDir' });
537
+ }
538
+ try {
539
+ const st = await fsp.stat(target);
540
+ if (!st.isDirectory()) return res.status(400).json({ error: 'not a directory' });
541
+ } catch {
542
+ return res.status(404).json({ error: 'workspace not found' });
543
+ }
544
+ const allSess = await persistedSessions.loadAll();
545
+ const inUse = allSess.some((s) =>
546
+ s.status === 'running' && isInside(s.cwd, target)
547
+ );
548
+ if (inUse) return res.status(409).json({ error: 'workspace is in use by a running session' });
549
+ await fsp.rm(target, { recursive: true, force: true });
550
+ res.json({ ok: true });
551
+ }));
552
+
288
553
  // ---- new session ----
289
- // body: { repos: ["repo-a","repo-b"], workspace?: "ws-2" (override), launch?: true }
290
- // Streams NDJSON: one JSON object per line. Event types:
291
- // {type:"workspace", workspace, created}
292
- // {type:"clone-start", repo}
293
- // {type:"clone-progress", repo, phase, percent, current, total, detail}
294
- // {type:"clone-line", repo, line} (raw git line, when no progress)
295
- // {type:"clone-done", repo, action, path}
296
- // {type:"clone-error", repo, error}
297
- // {type:"launched", launched}
298
- // {type:"done", success, error?}
554
+ // body: { cliId?, repos?, workspace?, folderId?, launch?: true }
555
+ // Streams NDJSON: workspace / clone-* / launched / done.
299
556
  app.post('/api/sessions/new', async (req, res) => {
300
557
  res.setHeader('Content-Type', 'application/x-ndjson');
301
558
  res.setHeader('Cache-Control', 'no-cache, no-transform');
302
559
  res.setHeader('X-Accel-Buffering', 'no');
303
- // Disable response compression buffering — flush right away.
304
560
  if (typeof res.flushHeaders === 'function') res.flushHeaders();
305
561
 
306
- const emit = (obj) => {
307
- res.write(JSON.stringify(obj) + '\n');
308
- };
562
+ const emit = (obj) => { res.write(JSON.stringify(obj) + '\n'); };
309
563
  const fail = (msg, extra) => {
310
564
  emit({ type: 'done', success: false, error: msg, ...extra });
311
565
  res.end();
@@ -313,26 +567,38 @@ app.post('/api/sessions/new', async (req, res) => {
313
567
 
314
568
  try {
315
569
  const cfg = await loadConfig();
316
- const wantedNames = Array.isArray(req.body && req.body.repos)
570
+ const cli = pickCli(cfg, req.body && req.body.cliId);
571
+ if (!cli) return fail('No CLI configured. Add one in Configure → CLIs.');
572
+
573
+ const explicitRepos = Array.isArray(req.body && req.body.repos);
574
+ const wantedNames = explicitRepos
317
575
  ? req.body.repos
318
576
  : cfg.repos.filter((r) => r.defaultSelected).map((r) => r.name);
319
-
320
577
  const wantedRepos = cfg.repos.filter((r) => wantedNames.includes(r.name));
321
- if (wantedRepos.length === 0) {
322
- return fail('No repos selected and no defaults available');
578
+ if (wantedRepos.length === 0 && !explicitRepos && wantedNames.length > 0) {
579
+ return fail('No matching repos found');
323
580
  }
324
581
 
325
582
  let workspace;
326
583
  let created = false;
327
- if (req.body && req.body.workspace) {
584
+ // Three cwd modes:
585
+ // 1. body.cwd — user picked an existing directory; skip clone.
586
+ // 2. body.workspace — reuse a named workspace under workDir.
587
+ // 3. (neither) — auto-allocate a fresh ws-N.
588
+ if (req.body && req.body.cwd) {
589
+ const fsmod = require('node:fs/promises');
590
+ const cwd = path.resolve(String(req.body.cwd));
591
+ try {
592
+ const st = await fsmod.stat(cwd);
593
+ if (!st.isDirectory()) return fail(`${cwd} is not a directory`);
594
+ } catch {
595
+ return fail(`directory not found: ${cwd}`);
596
+ }
597
+ workspace = { name: path.basename(cwd) || cwd, path: cwd };
598
+ } else if (req.body && req.body.workspace) {
328
599
  const all = await listWorkspaces({ workDir: cfg.workDir, repos: cfg.repos });
329
600
  workspace = all.find((w) => w.name === req.body.workspace);
330
601
  if (!workspace) return fail(`workspace ${req.body.workspace} not found`);
331
- if (workspace.inUse) {
332
- return fail(
333
- `workspace ${workspace.name} is in use by ${workspace.sessionsHere.length} session(s)`
334
- );
335
- }
336
602
  } else {
337
603
  const r = await findOrCreateWorkspace({
338
604
  workDir: cfg.workDir,
@@ -344,87 +610,53 @@ app.post('/api/sessions/new', async (req, res) => {
344
610
  }
345
611
  emit({ type: 'workspace', workspace, created });
346
612
 
347
- const cloneResults = await ensureReposInWorkspace({
613
+ // Skip clone entirely when user picked an existing directory — we
614
+ // don't want to dump random repos into someone's project.
615
+ const cloneResults = (req.body && req.body.cwd) ? [] : await ensureReposInWorkspace({
348
616
  workspacePath: workspace.path,
349
617
  repos: wantedRepos,
350
618
  onRepoStart: (repo) =>
351
619
  emit({ type: 'clone-start', repo: repo.name, url: repo.url }),
352
620
  onProgress: (repo, p) =>
353
- emit({
354
- type: 'clone-progress',
355
- repo: repo.name,
356
- phase: p.phase,
357
- percent: p.percent,
358
- current: p.current,
359
- total: p.total,
360
- detail: p.detail,
361
- }),
621
+ emit({ type: 'clone-progress', repo: repo.name, ...p }),
362
622
  onLine: (repo, line) =>
363
623
  emit({ type: 'clone-line', repo: repo.name, line }),
364
624
  onRepoEnd: (repo, result) =>
365
625
  emit({ type: 'clone-end', repo: repo.name, ...result }),
366
626
  });
367
-
368
627
  const failed = cloneResults.filter((r) => !r.ok);
369
- if (failed.length > 0) {
370
- return fail('Some repos failed to clone', { cloneResults });
371
- }
628
+ if (failed.length > 0) return fail('Some repos failed to clone', { cloneResults });
372
629
 
373
630
  const shouldLaunch = req.body && req.body.launch !== false;
374
631
  let launched = null;
375
632
  if (shouldLaunch) {
376
- // mode = 'web' → spawn the claude command as an in-process PTY whose
377
- // stdio is bridged to xterm.js via WebSocket. The session
378
- // lives in webTerminal's pool until killed or claude
379
- // exits. No wt window opens.
380
- // mode = 'wt' (default) → existing behaviour: launch via wt window.
381
- const mode = req.body && req.body.terminal === 'web' ? 'web' : 'wt';
382
-
383
- if (mode === 'web') {
384
- if (!webTerminal.available) {
385
- return fail('node-pty is not installed · web terminal mode unavailable');
386
- }
387
- // Wrap in pwsh so config.claudeCommand can be an alias / function
388
- // defined in the user's profile (e.g. `cc`), same trick wt uses.
389
- const cmd = cfg.claudeCommand || 'claude';
390
- const wrap = (cfg.commandShell || 'pwsh') === 'powershell' ? 'powershell.exe' : 'pwsh.exe';
391
- const entry = webTerminal.spawn({
392
- command: wrap,
393
- args: ['-NoExit', '-NoLogo', '-Command', `Set-Location -LiteralPath '${workspace.path.replace(/'/g, "''")}'; & '${cmd.replace(/'/g, "''")}'`],
633
+ // Create the persistedSessions record FIRST so spawnCliSession can
634
+ // use its id as the PTY id (matching ids simplify resume/attach).
635
+ const record = await persistedSessions.create({
636
+ cliId: cli.id,
637
+ cwd: workspace.path,
638
+ workspace: workspace.name,
639
+ repos: wantedRepos.map((r) => r.name),
640
+ folderId: (req.body && req.body.folderId) || null,
641
+ title: '',
642
+ });
643
+ try {
644
+ const entry = spawnCliSession({
645
+ cli,
394
646
  cwd: workspace.path,
647
+ sessionId: record.id,
395
648
  meta: { title: workspace.name, workspace: workspace.name, cwd: workspace.path },
396
649
  });
397
- launched = { mode: 'web', id: entry.id, pid: entry.meta.pid, terminal: 'web' };
650
+ await persistedSessions.markRunning(record.id, entry.meta.pid);
651
+ launched = { id: record.id, pid: entry.meta.pid, cliId: cli.id };
398
652
  emit({ type: 'launched', launched });
399
- } else {
400
- const beforeHwnds = await snapshotWindowsOf(
401
- processNameFor(cfg.terminal) || 'WindowsTerminal.exe'
402
- );
403
- launched = launchNewClaude({
404
- cwd: workspace.path,
405
- title: workspace.name,
406
- terminal: cfg.terminal,
407
- claudeCommand: cfg.claudeCommand,
408
- commandShell: cfg.commandShell || 'pwsh',
409
- });
410
- launched = { mode: 'wt', ...launched };
411
- emit({ type: 'launched', launched });
412
- autoFocusAfterLaunch({
413
- terminal: cfg.terminal,
414
- beforeHwnds,
415
- autoFocus: cfg.autoFocusOnLaunch !== false,
416
- });
653
+ } catch (e) {
654
+ await persistedSessions.markExited(record.id, null);
655
+ return fail(`spawn failed: ${e.message}`);
417
656
  }
418
657
  }
419
658
 
420
- emit({
421
- type: 'done',
422
- success: true,
423
- workspace,
424
- created,
425
- cloneResults,
426
- launched,
427
- });
659
+ emit({ type: 'done', success: true, workspace, created, cloneResults, launched });
428
660
  res.end();
429
661
  } catch (e) {
430
662
  console.error('[/api/sessions/new]', e);
@@ -432,95 +664,133 @@ app.post('/api/sessions/new', async (req, res) => {
432
664
  }
433
665
  });
434
666
 
435
- // ---- launch finder session (a claude session in the ccsm data dir pre-pointed at session data) ----
436
- app.post('/api/sessions/finder', asyncH(async (_req, res) => {
437
- const cfg = await loadConfig();
438
- const beforeHwnds = await snapshotWindowsOf(processNameFor(cfg.terminal) || 'WindowsTerminal.exe');
439
- const launched = launchNewClaude({
440
- cwd: DATA_DIR,
441
- title: 'ccsm finder',
442
- extraArgs: cfg.finderPrompt ? [cfg.finderPrompt] : [],
443
- terminal: cfg.terminal,
444
- claudeCommand: cfg.claudeCommand,
445
- commandShell: cfg.commandShell || 'pwsh',
446
- });
447
- autoFocusAfterLaunch({
448
- terminal: cfg.terminal,
449
- beforeHwnds,
450
- autoFocus: cfg.autoFocusOnLaunch !== false,
451
- });
452
- res.json({ launched, cwd: DATA_DIR, prompt: cfg.finderPrompt });
667
+ // ---- list local CLI sessions discovered on disk (for "adopt") ----
668
+ // Returns sessions found in ~/.claude / ~/.codex / ~/.copilot that
669
+ // aren't yet adopted by ccsm. Frontend uses this in the Import modal.
670
+ app.get('/api/cli-sessions/:cliType', asyncH(async (req, res) => {
671
+ const type = String(req.params.cliType || '').toLowerCase();
672
+ if (!['claude', 'codex', 'copilot'].includes(type)) {
673
+ return res.status(400).json({ error: `unsupported cli type: ${type}` });
674
+ }
675
+ const [discovered, adopted] = await Promise.all([
676
+ localCliSessions.listForType(type),
677
+ persistedSessions.loadAll(),
678
+ ]);
679
+ const adoptedIds = new Set(adopted.map((s) => s.cliSessionId).filter(Boolean));
680
+ const items = discovered
681
+ .map((s) => ({ ...s, adopted: adoptedIds.has(s.cliSessionId) }))
682
+ .sort((a, b) => b.mtime - a.mtime);
683
+ res.json({ sessions: items });
453
684
  }));
454
685
 
455
- // ---- resume single session ----
456
- app.post('/api/sessions/:sessionId/resume', asyncH(async (req, res) => {
457
- const sessionId = req.params.sessionId;
458
- const cwd = req.body && req.body.cwd;
459
- if (!cwd) return res.status(400).json({ error: 'cwd required in body' });
686
+ // ---- adopt: create a ccsm record pointing at an existing CLI session ----
687
+ // Body: { cliId, cliSessionId, cwd, title?, folderId? }
688
+ // Doesn't spawn — the new entry shows up as "exited" in the sidebar;
689
+ // clicking it kicks off the regular resume flow which uses
690
+ // `cli.resumeIdArgs` ('--resume <id>') so the upstream session reattaches.
691
+ app.post('/api/sessions/adopt', asyncH(async (req, res) => {
692
+ const { cliId, cliSessionId, cwd, title, folderId } = req.body || {};
693
+ if (!cliId || !cliSessionId || !cwd) {
694
+ return res.status(400).json({ error: 'cliId, cliSessionId and cwd required' });
695
+ }
460
696
  const cfg = await loadConfig();
461
- const beforeHwnds = await snapshotWindowsOf(processNameFor(cfg.terminal) || 'WindowsTerminal.exe');
462
- const launched = launchResume({
463
- cwd,
464
- sessionId,
465
- terminal: cfg.terminal,
466
- claudeCommand: cfg.claudeCommand,
467
- commandShell: cfg.commandShell || "pwsh",
468
- });
469
- autoFocusAfterLaunch({
470
- terminal: cfg.terminal,
471
- beforeHwnds,
472
- autoFocus: cfg.autoFocusOnLaunch !== false,
697
+ const cli = pickCli(cfg, cliId);
698
+ if (!cli) return res.status(400).json({ error: `CLI ${cliId} not configured` });
699
+
700
+ // Normalize the cwd up front. /api/sessions/new also resolves cwd, and
701
+ // the workspaces "in use" check (GET /api/workspaces) does
702
+ // path.resolve(s.cwd).toLowerCase() — adopted records must match the
703
+ // same shape, otherwise an adopted+running session leaves its
704
+ // workspace falsely marked as free and a fresh launch could collide.
705
+ const resolvedCwd = path.resolve(cwd);
706
+ try {
707
+ const fsmod = require('node:fs/promises');
708
+ const st = await fsmod.stat(resolvedCwd);
709
+ if (!st.isDirectory()) {
710
+ return res.status(400).json({ error: `cwd is not a directory: ${resolvedCwd}` });
711
+ }
712
+ } catch (e) {
713
+ return res.status(400).json({ error: `cwd not found: ${resolvedCwd}` });
714
+ }
715
+
716
+ // Refuse duplicates: if any ccsm record already owns this upstream
717
+ // session id, return it so the caller can jump to it.
718
+ const all = await persistedSessions.loadAll();
719
+ const dup = all.find((s) => s.cliSessionId === cliSessionId);
720
+ if (dup) return res.json({ session: dup, alreadyAdopted: true });
721
+
722
+ const workspace = path.basename(resolvedCwd) || resolvedCwd;
723
+ // Create directly with status='exited' + cliSessionId set, so a
724
+ // concurrent GET /api/sessions can never observe a "running but no
725
+ // PTY" intermediate state.
726
+ const record = await persistedSessions.create({
727
+ cliId,
728
+ cwd: resolvedCwd,
729
+ workspace,
730
+ folderId: folderId || null,
731
+ title: title || '',
732
+ repos: [],
733
+ status: 'exited',
734
+ cliSessionId,
473
735
  });
474
- res.json({ launched });
736
+ res.json({ session: record, alreadyAdopted: false });
475
737
  }));
476
738
 
477
- // ---- focus the wt window that's already hosting this session ----
478
- app.post('/api/sessions/:sessionId/focus', asyncH(async (req, res) => {
479
- const sessionId = req.params.sessionId;
480
- const sessions = await listSessions();
481
- const s = sessions.find((x) => x.sessionId === sessionId);
482
- if (!s) return res.status(404).json({ error: `session ${sessionId} not live` });
739
+ // ---- resume a previous session in the same cwd / cli ----
740
+ app.post('/api/sessions/:id/resume', asyncH(async (req, res) => {
741
+ const record = await persistedSessions.get(req.params.id);
742
+ if (!record) return res.status(404).json({ error: 'session not found' });
743
+ // Already running and attached no-op, just return its id.
744
+ const live = webTerminal.get(record.id);
745
+ if (live && !live.exitedAt) {
746
+ return res.json({ launched: { id: record.id, pid: live.meta.pid, cliId: record.cliId } });
747
+ }
483
748
  const cfg = await loadConfig();
484
- const result = await focusBySession({
485
- pid: s.pid,
486
- sessionId: s.sessionId,
487
- title: s.title,
488
- cwd: s.cwd,
489
- moveToCenter: !!cfg.focusMovesToCenter,
490
- });
491
- res.json({ session: { pid: s.pid, sessionId: s.sessionId, cwd: s.cwd, title: s.title }, ...result });
749
+ const cli = pickCli(cfg, record.cliId);
750
+ if (!cli) return res.status(400).json({ error: `CLI ${record.cliId} no longer configured` });
751
+ try {
752
+ // Prefer precise --resume <cliSessionId> when we have one captured;
753
+ // fall back to cli.resumeArgs (--continue / resume --last) otherwise.
754
+ const extraArgs = buildResumeArgs(cli, record);
755
+ const entry = spawnCliSession({
756
+ cli,
757
+ cwd: record.cwd,
758
+ sessionId: record.id,
759
+ meta: { title: record.title || record.workspace, workspace: record.workspace, cwd: record.cwd },
760
+ extraArgs,
761
+ });
762
+ await persistedSessions.markRunning(record.id, entry.meta.pid);
763
+ res.json({ launched: { id: record.id, pid: entry.meta.pid, cliId: cli.id } });
764
+ } catch (e) {
765
+ res.status(500).json({ error: e.message });
766
+ }
492
767
  }));
493
768
 
494
- // ---- terminal kinds ----
495
- app.get('/api/terminals', (_req, res) => res.json({ terminals: listTerminalKinds() }));
769
+ // Build the args appended on resume:
770
+ // When ccsm has captured the upstream CLI's session UUID and the CLI
771
+ // defines `resumeIdArgs` (e.g. ['--resume', '<id>']), we substitute the
772
+ // <id> placeholder and use those for a precise resume. Otherwise we
773
+ // fall back to `cli.resumeArgs` (e.g. ['--continue']).
774
+ function buildResumeArgs(cli, record) {
775
+ const id = record.cliSessionId;
776
+ const tpl = Array.isArray(cli.resumeIdArgs) ? cli.resumeIdArgs : [];
777
+ if (id && tpl.length > 0) {
778
+ return tpl.map((a) => (typeof a === 'string' ? a.replace(/<id>/g, id) : a));
779
+ }
780
+ return Array.isArray(cli.resumeArgs) ? cli.resumeArgs : [];
781
+ }
496
782
 
497
- // ---- capabilities probe · used by the frontend to decide whether to show
498
- // the "open in this page" radio option. node-pty is optional, install-failure
499
- // degrades us to wt-only. ----
783
+ // ---- capabilities probe ----
500
784
  app.get('/api/capabilities', (_req, res) => res.json({
501
785
  webTerminal: webTerminal.available,
502
786
  webTerminalError: webTerminal.available ? null : String(webTerminal.loadError?.message || 'unavailable'),
503
787
  }));
504
788
 
505
- // ---- web terminals · list / kill ----
506
- // (creation happens through /api/sessions/new with terminal:'web'; attach is
507
- // over WebSocket below.)
508
- app.get('/api/sessions/web', (_req, res) => res.json({ terminals: webTerminal.list() }));
509
-
510
- app.delete('/api/sessions/web/:id', (req, res) => {
511
- const ok = webTerminal.kill(req.params.id);
512
- res.json({ killed: ok });
513
- });
514
-
515
789
  // ---- health ----
516
790
  const pkg = require('./package.json');
517
791
  app.get('/api/health', (_req, res) => res.json({ ok: true, pid: process.pid, version: pkg.version, name: pkg.name }));
518
792
 
519
793
  // ---- lifecycle ----
520
- // State shared by /api/spawn-browser (opens another window into this server)
521
- // and the heartbeat watchdog (exits the server if no client has pinged for
522
- // HEARTBEAT_TIMEOUT_MS). Heartbeat is the safety net behind the primary
523
- // "browser child exits → server exits" mechanism wired up after listen.
524
794
  let currentPort = 0;
525
795
  let frontendUrl = '';
526
796
  let lastHeartbeat = Date.now();
@@ -534,41 +804,153 @@ app.post('/api/heartbeat', (_req, res) => {
534
804
  });
535
805
 
536
806
  app.post('/api/spawn-browser', asyncH(async (_req, res) => {
537
- const cfg = await loadConfig();
538
- const mode = cfg.browserMode || (cfg.autoOpenBrowser === false ? 'none' : 'app');
539
- openInBrowser(frontendUrl || `http://localhost:${currentPort}`, mode);
540
- res.json({ ok: true, mode, url: frontendUrl });
807
+ const opened = openInBrowser(frontendUrl || `http://localhost:${currentPort}`);
808
+ res.json({ ok: true, mode: opened.kind, url: frontendUrl });
541
809
  }));
542
810
 
543
- // Graceful shutdown · the uninstall script and the auto-upgrade path in
544
- // the launcher both call this. We reply first so the caller doesn't see
545
- // a torn connection, then exit on the next tick.
546
811
  app.post('/api/shutdown', (_req, res) => {
547
812
  res.json({ ok: true, bye: 'shutting down' });
548
- // setImmediate so the response flushes before we tear the server down.
549
813
  setImmediate(() => gracefulShutdown('/api/shutdown'));
550
814
  });
551
815
 
552
- // ---- auto-snapshot scheduler ----
553
- let snapshotTimer = null;
554
- async function startSnapshotLoop() {
555
- const cfg = await loadConfig();
556
- const interval = Math.max(5_000, cfg.snapshotIntervalMs || 60_000);
557
- const tick = async () => {
558
- try {
559
- const cfg = await loadConfig();
560
- await saveSnapshot({ keep: cfg.snapshotHistoryKeep });
561
- } catch (e) {
562
- console.error('[snapshot]', e.message);
563
- }
564
- };
565
- snapshotTimer = setInterval(tick, interval);
566
- tick().catch(() => {});
567
- console.log(`[snapshot] auto-saving every ${Math.round(interval / 1000)}s`);
816
+ // ---- version / upgrade ----
817
+ // `/api/version` reports the installed version (= pkg.version) and, if
818
+ // reachable, the latest published on the npm registry. The result is
819
+ // cached for 30 minutes in memory so the AboutPage poll doesn't hit the
820
+ // registry on every render.
821
+ //
822
+ // `/api/upgrade` kicks off `npm i -g @bakapiano/ccsm@latest` as a
823
+ // detached child. When the install completes, the child re-spawns `ccsm`
824
+ // (also detached) so the launcher comes back up on the new version, and
825
+ // the current server gracefulShutdowns. The frontend's OfflineBanner
826
+ // covers the gap; the version router picks up the new version on the
827
+ // next probe.
828
+ const VERSION_CACHE_MS = 30 * 60_000;
829
+ let versionCache = null; // { latest, fetchedAt }
830
+ let upgradeInFlight = false;
831
+
832
+ async function fetchLatestFromNpm() {
833
+ // Node 18+ has a global fetch. Time out the registry call to avoid
834
+ // hanging the response when the user is offline / behind a captive
835
+ // portal.
836
+ const ctrl = new AbortController();
837
+ const t = setTimeout(() => ctrl.abort(), 4000);
838
+ try {
839
+ const r = await fetch('https://registry.npmjs.org/@bakapiano%2Fccsm/latest', {
840
+ headers: { 'Accept': 'application/json' },
841
+ signal: ctrl.signal,
842
+ });
843
+ if (!r.ok) throw new Error(`registry HTTP ${r.status}`);
844
+ const j = await r.json();
845
+ return String(j.version || '');
846
+ } finally {
847
+ clearTimeout(t);
848
+ }
849
+ }
850
+
851
+ function cmpSemver(a, b) {
852
+ const pa = String(a || '').split('.').map(Number);
853
+ const pb = String(b || '').split('.').map(Number);
854
+ for (let i = 0; i < 3; i++) {
855
+ const x = pa[i] || 0, y = pb[i] || 0;
856
+ if (x > y) return 1;
857
+ if (x < y) return -1;
858
+ }
859
+ return 0;
568
860
  }
569
861
 
570
- // Try the preferred port, then preferred+1..+9, then let the OS pick a free
571
- // one. Resolves with the port the server actually bound to.
862
+ app.get('/api/version', asyncH(async (req, res) => {
863
+ const force = String(req.query.refresh || '') === '1';
864
+ const now = Date.now();
865
+ if (!force && versionCache && (now - versionCache.fetchedAt) < VERSION_CACHE_MS) {
866
+ return res.json({
867
+ current: pkg.version,
868
+ latest: versionCache.latest,
869
+ updateAvailable: cmpSemver(versionCache.latest, pkg.version) > 0,
870
+ fetchedAt: versionCache.fetchedAt,
871
+ cached: true,
872
+ });
873
+ }
874
+ try {
875
+ const latest = await fetchLatestFromNpm();
876
+ versionCache = { latest, fetchedAt: now };
877
+ res.json({
878
+ current: pkg.version,
879
+ latest,
880
+ updateAvailable: cmpSemver(latest, pkg.version) > 0,
881
+ fetchedAt: now,
882
+ cached: false,
883
+ });
884
+ } catch (e) {
885
+ // Swallow: surface "unknown" so the UI doesn't keep showing a stale
886
+ // "update available" badge based on a 6-hour-old cached value.
887
+ res.json({
888
+ current: pkg.version,
889
+ latest: null,
890
+ updateAvailable: false,
891
+ fetchedAt: now,
892
+ error: String(e.message || e),
893
+ });
894
+ }
895
+ }));
896
+
897
+ app.post('/api/upgrade', asyncH(async (req, res) => {
898
+ if (upgradeInFlight) {
899
+ return res.status(409).json({ error: 'upgrade already in progress' });
900
+ }
901
+ upgradeInFlight = true;
902
+ const target = String((req.body && req.body.target) || 'latest');
903
+ // Refuse anything that doesn't look like a semver dist-tag or version
904
+ // — defends against `;` etc. winding up in the spawn argv even though
905
+ // we don't shell out.
906
+ if (!/^[a-z0-9.+\-^~]+$/i.test(target)) {
907
+ upgradeInFlight = false;
908
+ return res.status(400).json({ error: `invalid target: ${target}` });
909
+ }
910
+ console.log(`[upgrade] starting npm i -g @bakapiano/ccsm@${target}`);
911
+ res.json({ ok: true, started: true, target });
912
+
913
+ // Defer the actual spawn so the HTTP response flushes first.
914
+ setImmediate(() => {
915
+ const { spawn } = require('node:child_process');
916
+ const npmExe = process.platform === 'win32' ? 'npm.cmd' : 'npm';
917
+ const args = ['i', '-g', `@bakapiano/ccsm@${target}`];
918
+ const child = spawn(npmExe, args, {
919
+ detached: true,
920
+ stdio: 'ignore',
921
+ windowsHide: true,
922
+ shell: false,
923
+ });
924
+ child.on('error', (e) => {
925
+ console.error('[upgrade] npm spawn failed:', e.message);
926
+ upgradeInFlight = false;
927
+ });
928
+ child.on('exit', (code) => {
929
+ console.log(`[upgrade] npm exit ${code}`);
930
+ upgradeInFlight = false;
931
+ if (code !== 0) return;
932
+ // Install succeeded → spawn a fresh ccsm and shut down. The
933
+ // launcher already detaches on its own.
934
+ try {
935
+ const ccsmCmd = process.platform === 'win32' ? 'ccsm.cmd' : 'ccsm';
936
+ const respawn = spawn(ccsmCmd, [], {
937
+ detached: true,
938
+ stdio: 'ignore',
939
+ windowsHide: true,
940
+ shell: false,
941
+ env: { ...process.env, CCSM_NO_BROWSER: '1' },
942
+ });
943
+ respawn.unref();
944
+ } catch (e) {
945
+ console.error('[upgrade] respawn failed:', e.message);
946
+ }
947
+ setTimeout(() => gracefulShutdown('upgrade'), 1500);
948
+ });
949
+ child.unref();
950
+ });
951
+ }));
952
+
953
+
572
954
  function listenWithFallback(preferred) {
573
955
  return new Promise((resolve, reject) => {
574
956
  const attempt = (port, tries) => {
@@ -577,7 +959,7 @@ function listenWithFallback(preferred) {
577
959
  server.once('error', (err) => {
578
960
  if (err.code !== 'EADDRINUSE') return reject(err);
579
961
  if (tries < 9) attempt(port + 1, tries + 1);
580
- else if (tries === 9) attempt(0, tries + 1); // OS-assigned free port
962
+ else if (tries === 9) attempt(0, tries + 1);
581
963
  else reject(err);
582
964
  });
583
965
  };
@@ -601,39 +983,33 @@ function findAppModeBrowser() {
601
983
  return null;
602
984
  }
603
985
 
604
- function openInBrowser(url, mode) {
605
- if (mode === 'none' || process.platform !== 'win32') return { kind: 'none', child: null };
986
+ // Auto-open the frontend in a browser when ccsm boots. Strategy: try a
987
+ // chromeless app window first (Edge/Chrome --app=); if neither is
988
+ // installed, fall back to the OS default browser as a regular tab. On
989
+ // non-Windows we skip — the bundled launcher isn't ported yet.
990
+ function openInBrowser(url) {
991
+ if (process.platform !== 'win32') return { kind: 'none', child: null };
606
992
  const { spawn } = require('node:child_process');
607
993
  const fs = require('node:fs');
608
-
609
- if (mode === 'app') {
610
- const exe = findAppModeBrowser();
611
- if (exe) {
612
- // Per-ccsm profile dir so we don't get the "already running, --app
613
- // ignored" merge behavior of Edge/Chrome when the user has a normal
614
- // window open. Lives under DATA_DIR so it's tidied with the rest.
615
- const profileDir = path.join(DATA_DIR, 'browser-profile');
616
- fs.mkdirSync(profileDir, { recursive: true });
617
- const child = spawn(
618
- exe,
619
- [
620
- `--app=${url}`,
621
- `--user-data-dir=${profileDir}`,
622
- '--window-size=1500,1100',
623
- '--no-first-run',
624
- '--no-default-browser-check',
625
- ],
626
- { detached: true, stdio: 'ignore' }
627
- );
628
- child.unref();
629
- return { kind: 'app', child };
630
- }
631
- console.log('[ccsm] no Edge/Chrome found for app mode, falling back to default browser');
994
+ const exe = findAppModeBrowser();
995
+ if (exe) {
996
+ const profileDir = path.join(DATA_DIR, 'browser-profile');
997
+ fs.mkdirSync(profileDir, { recursive: true });
998
+ const child = spawn(
999
+ exe,
1000
+ [
1001
+ `--app=${url}`,
1002
+ `--user-data-dir=${profileDir}`,
1003
+ '--window-size=1500,1100',
1004
+ '--no-first-run',
1005
+ '--no-default-browser-check',
1006
+ ],
1007
+ { detached: true, stdio: 'ignore' }
1008
+ );
1009
+ child.unref();
1010
+ return { kind: 'app', child };
632
1011
  }
633
-
634
- // mode === 'tab' (or app-mode fallback). cmd's `start` builtin exits
635
- // immediately after launching the default browser — the child handle
636
- // isn't usable for lifecycle tracking.
1012
+ console.log('[ccsm] no Edge/Chrome found, opening default browser');
637
1013
  const child = spawn('cmd.exe', ['/c', 'start', '', url], {
638
1014
  detached: true,
639
1015
  stdio: 'ignore',
@@ -645,21 +1021,29 @@ function openInBrowser(url, mode) {
645
1021
 
646
1022
  (async () => {
647
1023
  const cfg = await loadConfig();
648
- const { server, port } = await listenWithFallback(cfg.port);
1024
+ const preferredPort = process.env.CCSM_PORT ? Number(process.env.CCSM_PORT) : cfg.port;
1025
+ const { server, port } = await listenWithFallback(preferredPort);
649
1026
  currentPort = port;
650
1027
 
651
- // WebSocket upgrade for /ws/terminal/:id bridges xterm.js to a PTY
652
- // entry in webTerminal's pool. Only enabled when node-pty loaded; the
653
- // /api/capabilities endpoint advertises this to the frontend.
1028
+ // On boot, mark any persisted "running" sessions as exited — they
1029
+ // belong to a previous server process whose PTYs are gone.
1030
+ try {
1031
+ const all = await persistedSessions.loadAll();
1032
+ for (const s of all) {
1033
+ if (s.status === 'running') {
1034
+ await persistedSessions.markExited(s.id, null);
1035
+ }
1036
+ }
1037
+ } catch (e) {
1038
+ console.error('[ccsm] could not reconcile persisted sessions:', e.message);
1039
+ }
1040
+
654
1041
  if (webTerminal.available) {
655
1042
  let WebSocketServer;
656
1043
  try { ({ WebSocketServer } = require('ws')); } catch {}
657
1044
  if (WebSocketServer) {
658
1045
  const wss = new WebSocketServer({ noServer: true });
659
1046
  server.on('upgrade', (req, socket, head) => {
660
- // Origin check · same allow-list as REST CORS. Browsers always
661
- // send Origin on WebSocket upgrades; missing Origin = non-browser
662
- // client which we tolerate (curl etc).
663
1047
  const origin = req.headers.origin;
664
1048
  if (origin && !ALLOWED_ORIGINS.has(origin) && !/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(origin)) {
665
1049
  socket.destroy();
@@ -674,57 +1058,38 @@ function openInBrowser(url, mode) {
674
1058
  }
675
1059
  }
676
1060
 
677
- // OS signals · run a graceful shutdown (which saves a final snapshot
678
- // and kills PTY children) before exiting.
679
1061
  for (const sig of ['SIGINT', 'SIGTERM']) {
680
1062
  process.on(sig, () => gracefulShutdown(sig));
681
1063
  }
682
- // Last-resort cleanup on sync exit (process.on('exit') can't await
683
- // anything, so it's only a safety net for PTY children).
684
1064
  process.on('exit', () => { try { webTerminal.killAll(); } catch {} });
1065
+
685
1066
  const apiUrl = `http://localhost:${port}`;
686
- // What URL we open in the auto-spawned browser:
687
- // dev → localhost (backend still serves public/ here)
688
- // prod → hosted frontend on GH Pages (backend is API-only)
689
1067
  const FRONTEND_URL = IS_DEV
690
1068
  ? apiUrl
691
- : 'https://bakapiano.github.io/cssm/v1/';
1069
+ : 'https://bakapiano.github.io/ccsm/';
692
1070
  frontendUrl = FRONTEND_URL;
693
1071
  console.log(`ccsm listening on ${apiUrl}${port !== cfg.port ? ` (requested ${cfg.port}, was taken)` : ''}`);
694
1072
  console.log(`frontend at ${FRONTEND_URL}`);
695
1073
  console.log(`data dir: ${DATA_DIR}`);
696
1074
  console.log(`work dir: ${cfg.workDir}`);
697
- console.log(`terminal: ${cfg.terminal} · ${cfg.claudeCommand}${cfg.terminal === 'wt' ? ` (via ${cfg.commandShell})` : ''}`);
698
- // CCSM_NO_BROWSER=1 (set by the launcher when responding to a ccsm://
699
- // protocol click) suppresses the auto-spawned browser window — the
700
- // caller already has one open and just needs the backend to come up.
701
- const mode = process.env.CCSM_NO_BROWSER === '1'
702
- ? 'none'
703
- : (cfg.browserMode || (cfg.autoOpenBrowser === false ? 'none' : 'app'));
704
- const opened = openInBrowser(FRONTEND_URL, mode);
705
-
706
- // Primary lifecycle: tie this server's lifetime to the chromeless
707
- // browser window. msedge.exe runs with its own --user-data-dir process
708
- // group, so when the user closes the window it actually exits — and
709
- // the spawned child handle we hold here fires 'exit'. Skip if the user
710
- // explicitly asked the server to stay alive (e.g. an automation host).
1075
+ console.log(`clis: ${cfg.clis.map((c) => c.id).join(', ')} (default: ${cfg.defaultCliId})`);
1076
+
1077
+ // CCSM_NO_BROWSER=1 (set by the ccsm:// protocol launcher) suppresses
1078
+ // the auto-open entirely. Otherwise try app-mode (chromeless Edge/Chrome
1079
+ // window); if no such browser is installed, openInBrowser falls back to
1080
+ // the OS default browser on its own.
1081
+ const opened = process.env.CCSM_NO_BROWSER === '1'
1082
+ ? { kind: 'none', child: null }
1083
+ : openInBrowser(FRONTEND_URL);
1084
+
711
1085
  if (opened.kind === 'app' && opened.child && process.env.CCSM_KEEP_ALIVE !== '1') {
712
1086
  const launchedAt = Date.now();
713
1087
  opened.child.on('exit', () => {
714
1088
  const alive = Date.now() - launchedAt;
715
- // Edge --app= often spawns a process that immediately hands its URL
716
- // off to an existing Edge profile process group and exits — our
717
- // child handle dies milliseconds after creation. Treat any exit
718
- // inside the first 5s as a hand-off, not a real close.
719
1089
  if (alive < 5000) {
720
1090
  console.log(`[ccsm] spawned browser child exited in ${alive}ms · handed off to an existing Edge instance, staying alive`);
721
1091
  return;
722
1092
  }
723
- // Defer the kill decision by one full frontend ping cycle (~12s,
724
- // matching the 10s heartbeat cadence below). Any heartbeat that
725
- // arrives AFTER this moment must be from a DIFFERENT client (the
726
- // closing browser couldn't ping after it died) — so a hosted-tab
727
- // user is still around and we should stay alive.
728
1093
  const closedAt = Date.now();
729
1094
  setTimeout(() => {
730
1095
  if (lastHeartbeat > closedAt + 100) {
@@ -737,13 +1102,6 @@ function openInBrowser(url, mode) {
737
1102
  console.log('[ccsm] tied to browser window — close it to stop ccsm');
738
1103
  }
739
1104
 
740
- // Heartbeat watchdog · only activated when launched via bin/ccsm.js
741
- // (CCSM_LAUNCHER=1). Catches cases the primary mechanism misses: the
742
- // browser was killed forcibly, msedge crashed without a clean exit, or
743
- // the user opened the URL in tab-mode in their own browser instead of
744
- // the chromeless app window. We don't kill until we've seen at least
745
- // one heartbeat — that way a freshly-booted server with no client yet
746
- // doesn't suicide.
747
1105
  if (process.env.CCSM_LAUNCHER === '1' && process.env.CCSM_KEEP_ALIVE !== '1') {
748
1106
  setInterval(() => {
749
1107
  if (!heartbeatSeen) return;
@@ -753,8 +1111,6 @@ function openInBrowser(url, mode) {
753
1111
  }, 30_000);
754
1112
  console.log('[ccsm] heartbeat watchdog active');
755
1113
  }
756
-
757
- startSnapshotLoop();
758
1114
  })().catch((err) => {
759
1115
  console.error('startup failed:', err);
760
1116
  process.exit(1);