@bakapiano/ccsm 0.9.0 → 0.10.1

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 (69) hide show
  1. package/CLAUDE.md +222 -195
  2. package/README.md +77 -79
  3. package/lib/cliSessionWatcher.js +249 -0
  4. package/lib/config.js +101 -24
  5. package/lib/folders.js +96 -0
  6. package/lib/localCliSessions.js +177 -0
  7. package/lib/persistedSessions.js +134 -0
  8. package/lib/webTerminal.js +31 -18
  9. package/lib/workspace.js +26 -4
  10. package/package.json +1 -1
  11. package/public/assets/claude-color.svg +1 -0
  12. package/public/assets/codex-color.svg +1 -0
  13. package/public/assets/copilot-color.svg +1 -0
  14. package/public/css/base.css +22 -5
  15. package/public/css/cards.css +37 -3
  16. package/public/css/feedback.css +127 -43
  17. package/public/css/forms.css +97 -25
  18. package/public/css/layout.css +74 -26
  19. package/public/css/modal.css +40 -26
  20. package/public/css/responsive.css +2 -2
  21. package/public/css/sidebar.css +424 -25
  22. package/public/css/terminals.css +138 -0
  23. package/public/css/tokens.css +28 -12
  24. package/public/css/wco.css +38 -39
  25. package/public/css/widgets.css +1177 -6
  26. package/public/index.html +35 -2
  27. package/public/js/api.js +194 -37
  28. package/public/js/components/AdoptModal.js +171 -0
  29. package/public/js/components/App.js +1 -11
  30. package/public/js/components/DirectoryPicker.js +203 -0
  31. package/public/js/components/EntityFormModal.js +105 -0
  32. package/public/js/components/Modal.js +51 -0
  33. package/public/js/components/OfflineBanner.js +29 -23
  34. package/public/js/components/PageTitleBar.js +13 -0
  35. package/public/js/components/Picker.js +179 -0
  36. package/public/js/components/Popover.js +55 -0
  37. package/public/js/components/Sidebar.js +219 -32
  38. package/public/js/components/TerminalView.js +27 -3
  39. package/public/js/components/useDragSort.js +67 -0
  40. package/public/js/dialog.js +10 -2
  41. package/public/js/icons.js +66 -3
  42. package/public/js/main.js +54 -3
  43. package/public/js/pages/AboutPage.js +80 -0
  44. package/public/js/pages/ConfigurePage.js +429 -207
  45. package/public/js/pages/LaunchPage.js +326 -86
  46. package/public/js/pages/SessionsPage.js +91 -41
  47. package/public/js/state.js +102 -73
  48. package/public/manifest.webmanifest +2 -2
  49. package/scripts/install.js +7 -2
  50. package/server.js +755 -441
  51. package/lib/favorites.js +0 -51
  52. package/lib/focus.js +0 -369
  53. package/lib/labels.js +0 -29
  54. package/lib/launcher.js +0 -219
  55. package/lib/sessions.js +0 -272
  56. package/lib/snapshot.js +0 -141
  57. package/public/js/actions.js +0 -107
  58. package/public/js/components/Fab.js +0 -11
  59. package/public/js/components/FavoritesTable.js +0 -81
  60. package/public/js/components/Footer.js +0 -12
  61. package/public/js/components/NewSessionModal.js +0 -153
  62. package/public/js/components/PageHead.js +0 -33
  63. package/public/js/components/Pagination.js +0 -27
  64. package/public/js/components/RecentTable.js +0 -68
  65. package/public/js/components/SessionsTable.js +0 -71
  66. package/public/js/components/SnapshotPanel.js +0 -77
  67. package/public/js/components/TitleCell.js +0 -40
  68. package/public/js/components/WorkspacesGrid.js +0 -41
  69. 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/ccsm/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,30 +567,38 @@ app.post('/api/sessions/new', async (req, res) => {
313
567
 
314
568
  try {
315
569
  const cfg = await loadConfig();
570
+ const cli = pickCli(cfg, req.body && req.body.cliId);
571
+ if (!cli) return fail('No CLI configured. Add one in Configure → CLIs.');
572
+
316
573
  const explicitRepos = Array.isArray(req.body && req.body.repos);
317
574
  const wantedNames = explicitRepos
318
575
  ? req.body.repos
319
576
  : cfg.repos.filter((r) => r.defaultSelected).map((r) => r.name);
320
-
321
577
  const wantedRepos = cfg.repos.filter((r) => wantedNames.includes(r.name));
322
- // Allow launching with zero repos — caller explicitly passed [] (or no
323
- // defaults exist). The workspace is still created; claude just opens
324
- // in an empty directory.
325
578
  if (wantedRepos.length === 0 && !explicitRepos && wantedNames.length > 0) {
326
579
  return fail('No matching repos found');
327
580
  }
328
581
 
329
582
  let workspace;
330
583
  let created = false;
331
- 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) {
332
599
  const all = await listWorkspaces({ workDir: cfg.workDir, repos: cfg.repos });
333
600
  workspace = all.find((w) => w.name === req.body.workspace);
334
601
  if (!workspace) return fail(`workspace ${req.body.workspace} not found`);
335
- if (workspace.inUse) {
336
- return fail(
337
- `workspace ${workspace.name} is in use by ${workspace.sessionsHere.length} session(s)`
338
- );
339
- }
340
602
  } else {
341
603
  const r = await findOrCreateWorkspace({
342
604
  workDir: cfg.workDir,
@@ -348,87 +610,53 @@ app.post('/api/sessions/new', async (req, res) => {
348
610
  }
349
611
  emit({ type: 'workspace', workspace, created });
350
612
 
351
- 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({
352
616
  workspacePath: workspace.path,
353
617
  repos: wantedRepos,
354
618
  onRepoStart: (repo) =>
355
619
  emit({ type: 'clone-start', repo: repo.name, url: repo.url }),
356
620
  onProgress: (repo, p) =>
357
- emit({
358
- type: 'clone-progress',
359
- repo: repo.name,
360
- phase: p.phase,
361
- percent: p.percent,
362
- current: p.current,
363
- total: p.total,
364
- detail: p.detail,
365
- }),
621
+ emit({ type: 'clone-progress', repo: repo.name, ...p }),
366
622
  onLine: (repo, line) =>
367
623
  emit({ type: 'clone-line', repo: repo.name, line }),
368
624
  onRepoEnd: (repo, result) =>
369
625
  emit({ type: 'clone-end', repo: repo.name, ...result }),
370
626
  });
371
-
372
627
  const failed = cloneResults.filter((r) => !r.ok);
373
- if (failed.length > 0) {
374
- return fail('Some repos failed to clone', { cloneResults });
375
- }
628
+ if (failed.length > 0) return fail('Some repos failed to clone', { cloneResults });
376
629
 
377
630
  const shouldLaunch = req.body && req.body.launch !== false;
378
631
  let launched = null;
379
632
  if (shouldLaunch) {
380
- // mode = 'web' → spawn the claude command as an in-process PTY whose
381
- // stdio is bridged to xterm.js via WebSocket. The session
382
- // lives in webTerminal's pool until killed or claude
383
- // exits. No wt window opens.
384
- // mode = 'wt' (default) → existing behaviour: launch via wt window.
385
- const mode = req.body && req.body.terminal === 'web' ? 'web' : 'wt';
386
-
387
- if (mode === 'web') {
388
- if (!webTerminal.available) {
389
- return fail('node-pty is not installed · web terminal mode unavailable');
390
- }
391
- // Wrap in pwsh so config.claudeCommand can be an alias / function
392
- // defined in the user's profile (e.g. `cc`), same trick wt uses.
393
- const cmd = cfg.claudeCommand || 'claude';
394
- const wrap = (cfg.commandShell || 'pwsh') === 'powershell' ? 'powershell.exe' : 'pwsh.exe';
395
- const entry = webTerminal.spawn({
396
- command: wrap,
397
- 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,
398
646
  cwd: workspace.path,
647
+ sessionId: record.id,
399
648
  meta: { title: workspace.name, workspace: workspace.name, cwd: workspace.path },
400
649
  });
401
- 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 };
402
652
  emit({ type: 'launched', launched });
403
- } else {
404
- const beforeHwnds = await snapshotWindowsOf(
405
- processNameFor(cfg.terminal) || 'WindowsTerminal.exe'
406
- );
407
- launched = launchNewClaude({
408
- cwd: workspace.path,
409
- title: workspace.name,
410
- terminal: cfg.terminal,
411
- claudeCommand: cfg.claudeCommand,
412
- commandShell: cfg.commandShell || 'pwsh',
413
- });
414
- launched = { mode: 'wt', ...launched };
415
- emit({ type: 'launched', launched });
416
- autoFocusAfterLaunch({
417
- terminal: cfg.terminal,
418
- beforeHwnds,
419
- autoFocus: cfg.autoFocusOnLaunch !== false,
420
- });
653
+ } catch (e) {
654
+ await persistedSessions.markExited(record.id, null);
655
+ return fail(`spawn failed: ${e.message}`);
421
656
  }
422
657
  }
423
658
 
424
- emit({
425
- type: 'done',
426
- success: true,
427
- workspace,
428
- created,
429
- cloneResults,
430
- launched,
431
- });
659
+ emit({ type: 'done', success: true, workspace, created, cloneResults, launched });
432
660
  res.end();
433
661
  } catch (e) {
434
662
  console.error('[/api/sessions/new]', e);
@@ -436,130 +664,133 @@ app.post('/api/sessions/new', async (req, res) => {
436
664
  }
437
665
  });
438
666
 
439
- // ---- launch finder session (a claude session in the ccsm data dir pre-pointed at session data) ----
440
- app.post('/api/sessions/finder', asyncH(async (req, res) => {
441
- const cfg = await loadConfig();
442
- const mode = (req.body && req.body.terminal)
443
- || cfg.defaultTerminalMode
444
- || 'wt';
445
- if (mode === 'web') {
446
- if (!webTerminal.available) {
447
- return res.status(400).json({ error: 'node-pty unavailable · cannot launch finder in web terminal' });
448
- }
449
- const cmd = cfg.claudeCommand || 'claude';
450
- const wrap = (cfg.commandShell || 'pwsh') === 'powershell' ? 'powershell.exe' : 'pwsh.exe';
451
- const promptArg = cfg.finderPrompt ? ` '${cfg.finderPrompt.replace(/'/g, "''")}'` : '';
452
- const entry = webTerminal.spawn({
453
- command: wrap,
454
- args: ['-NoExit', '-NoLogo', '-Command', `Set-Location -LiteralPath '${DATA_DIR.replace(/'/g, "''")}'; & '${cmd.replace(/'/g, "''")}'${promptArg}`],
455
- cwd: DATA_DIR,
456
- meta: { title: 'ccsm finder', cwd: DATA_DIR },
457
- });
458
- return res.json({ launched: { mode: 'web', id: entry.id, pid: entry.meta.pid, terminal: 'web' }, cwd: DATA_DIR, prompt: cfg.finderPrompt });
459
- }
460
- const beforeHwnds = await snapshotWindowsOf(processNameFor(cfg.terminal) || 'WindowsTerminal.exe');
461
- const launched = launchNewClaude({
462
- cwd: DATA_DIR,
463
- title: 'ccsm finder',
464
- extraArgs: cfg.finderPrompt ? [cfg.finderPrompt] : [],
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,
473
- });
474
- res.json({ launched: { mode: 'wt', ...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 });
475
684
  }));
476
685
 
477
- // ---- resume single session ----
478
- app.post('/api/sessions/:sessionId/resume', asyncH(async (req, res) => {
479
- const sessionId = req.params.sessionId;
480
- const cwd = req.body && req.body.cwd;
481
- 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
+ }
482
696
  const cfg = await loadConfig();
483
- const mode = (req.body && req.body.terminal)
484
- || cfg.defaultTerminalMode
485
- || 'wt';
486
- if (mode === 'web') {
487
- if (!webTerminal.available) {
488
- return res.status(400).json({ error: 'node-pty unavailable · cannot resume in web terminal' });
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}` });
489
711
  }
490
- const cmd = cfg.claudeCommand || 'claude';
491
- const wrap = (cfg.commandShell || 'pwsh') === 'powershell' ? 'powershell.exe' : 'pwsh.exe';
492
- const entry = webTerminal.spawn({
493
- command: wrap,
494
- args: ['-NoExit', '-NoLogo', '-Command', `Set-Location -LiteralPath '${cwd.replace(/'/g, "''")}'; & '${cmd.replace(/'/g, "''")}' --resume '${sessionId.replace(/'/g, "''")}'`],
495
- cwd,
496
- meta: { title: `resume ${sessionId.slice(0, 8)}`, cwd, sessionId },
497
- });
498
- return res.json({ launched: { mode: 'web', id: entry.id, pid: entry.meta.pid, terminal: 'web' } });
712
+ } catch (e) {
713
+ return res.status(400).json({ error: `cwd not found: ${resolvedCwd}` });
499
714
  }
500
- const beforeHwnds = await snapshotWindowsOf(processNameFor(cfg.terminal) || 'WindowsTerminal.exe');
501
- const launched = launchResume({
502
- cwd,
503
- sessionId,
504
- terminal: cfg.terminal,
505
- claudeCommand: cfg.claudeCommand,
506
- commandShell: cfg.commandShell || "pwsh",
507
- });
508
- autoFocusAfterLaunch({
509
- terminal: cfg.terminal,
510
- beforeHwnds,
511
- autoFocus: cfg.autoFocusOnLaunch !== false,
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,
512
735
  });
513
- res.json({ launched: { mode: 'wt', ...launched } });
736
+ res.json({ session: record, alreadyAdopted: false });
514
737
  }));
515
738
 
516
- // ---- focus the wt window that's already hosting this session ----
517
- app.post('/api/sessions/:sessionId/focus', asyncH(async (req, res) => {
518
- const sessionId = req.params.sessionId;
519
- const sessions = await listSessions();
520
- const s = sessions.find((x) => x.sessionId === sessionId);
521
- 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
+ }
522
748
  const cfg = await loadConfig();
523
- const result = await focusBySession({
524
- pid: s.pid,
525
- sessionId: s.sessionId,
526
- title: s.title,
527
- cwd: s.cwd,
528
- moveToCenter: !!cfg.focusMovesToCenter,
529
- });
530
- 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
+ }
531
767
  }));
532
768
 
533
- // ---- terminal kinds ----
534
- 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
+ }
535
782
 
536
- // ---- capabilities probe · used by the frontend to decide whether to show
537
- // the "open in this page" radio option. node-pty is optional, install-failure
538
- // degrades us to wt-only. ----
783
+ // ---- capabilities probe ----
539
784
  app.get('/api/capabilities', (_req, res) => res.json({
540
785
  webTerminal: webTerminal.available,
541
786
  webTerminalError: webTerminal.available ? null : String(webTerminal.loadError?.message || 'unavailable'),
542
787
  }));
543
788
 
544
- // ---- web terminals · list / kill ----
545
- // (creation happens through /api/sessions/new with terminal:'web'; attach is
546
- // over WebSocket below.)
547
- app.get('/api/sessions/web', (_req, res) => res.json({ terminals: webTerminal.list() }));
548
-
549
- app.delete('/api/sessions/web/:id', (req, res) => {
550
- const ok = webTerminal.kill(req.params.id);
551
- res.json({ killed: ok });
552
- });
553
-
554
789
  // ---- health ----
555
790
  const pkg = require('./package.json');
556
791
  app.get('/api/health', (_req, res) => res.json({ ok: true, pid: process.pid, version: pkg.version, name: pkg.name }));
557
792
 
558
793
  // ---- lifecycle ----
559
- // State shared by /api/spawn-browser (opens another window into this server)
560
- // and the heartbeat watchdog (exits the server if no client has pinged for
561
- // HEARTBEAT_TIMEOUT_MS). Heartbeat is the safety net behind the primary
562
- // "browser child exits → server exits" mechanism wired up after listen.
563
794
  let currentPort = 0;
564
795
  let frontendUrl = '';
565
796
  let lastHeartbeat = Date.now();
@@ -573,41 +804,153 @@ app.post('/api/heartbeat', (_req, res) => {
573
804
  });
574
805
 
575
806
  app.post('/api/spawn-browser', asyncH(async (_req, res) => {
576
- const cfg = await loadConfig();
577
- const mode = cfg.browserMode || (cfg.autoOpenBrowser === false ? 'none' : 'app');
578
- openInBrowser(frontendUrl || `http://localhost:${currentPort}`, mode);
579
- 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 });
580
809
  }));
581
810
 
582
- // Graceful shutdown · the uninstall script and the auto-upgrade path in
583
- // the launcher both call this. We reply first so the caller doesn't see
584
- // a torn connection, then exit on the next tick.
585
811
  app.post('/api/shutdown', (_req, res) => {
586
812
  res.json({ ok: true, bye: 'shutting down' });
587
- // setImmediate so the response flushes before we tear the server down.
588
813
  setImmediate(() => gracefulShutdown('/api/shutdown'));
589
814
  });
590
815
 
591
- // ---- auto-snapshot scheduler ----
592
- let snapshotTimer = null;
593
- async function startSnapshotLoop() {
594
- const cfg = await loadConfig();
595
- const interval = Math.max(5_000, cfg.snapshotIntervalMs || 60_000);
596
- const tick = async () => {
597
- try {
598
- const cfg = await loadConfig();
599
- await saveSnapshot({ keep: cfg.snapshotHistoryKeep });
600
- } catch (e) {
601
- console.error('[snapshot]', e.message);
602
- }
603
- };
604
- snapshotTimer = setInterval(tick, interval);
605
- tick().catch(() => {});
606
- 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;
607
860
  }
608
861
 
609
- // Try the preferred port, then preferred+1..+9, then let the OS pick a free
610
- // 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
+
611
954
  function listenWithFallback(preferred) {
612
955
  return new Promise((resolve, reject) => {
613
956
  const attempt = (port, tries) => {
@@ -616,7 +959,7 @@ function listenWithFallback(preferred) {
616
959
  server.once('error', (err) => {
617
960
  if (err.code !== 'EADDRINUSE') return reject(err);
618
961
  if (tries < 9) attempt(port + 1, tries + 1);
619
- else if (tries === 9) attempt(0, tries + 1); // OS-assigned free port
962
+ else if (tries === 9) attempt(0, tries + 1);
620
963
  else reject(err);
621
964
  });
622
965
  };
@@ -640,39 +983,33 @@ function findAppModeBrowser() {
640
983
  return null;
641
984
  }
642
985
 
643
- function openInBrowser(url, mode) {
644
- 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 };
645
992
  const { spawn } = require('node:child_process');
646
993
  const fs = require('node:fs');
647
-
648
- if (mode === 'app') {
649
- const exe = findAppModeBrowser();
650
- if (exe) {
651
- // Per-ccsm profile dir so we don't get the "already running, --app
652
- // ignored" merge behavior of Edge/Chrome when the user has a normal
653
- // window open. Lives under DATA_DIR so it's tidied with the rest.
654
- const profileDir = path.join(DATA_DIR, 'browser-profile');
655
- fs.mkdirSync(profileDir, { recursive: true });
656
- const child = spawn(
657
- exe,
658
- [
659
- `--app=${url}`,
660
- `--user-data-dir=${profileDir}`,
661
- '--window-size=1500,1100',
662
- '--no-first-run',
663
- '--no-default-browser-check',
664
- ],
665
- { detached: true, stdio: 'ignore' }
666
- );
667
- child.unref();
668
- return { kind: 'app', child };
669
- }
670
- 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 };
671
1011
  }
672
-
673
- // mode === 'tab' (or app-mode fallback). cmd's `start` builtin exits
674
- // immediately after launching the default browser — the child handle
675
- // isn't usable for lifecycle tracking.
1012
+ console.log('[ccsm] no Edge/Chrome found, opening default browser');
676
1013
  const child = spawn('cmd.exe', ['/c', 'start', '', url], {
677
1014
  detached: true,
678
1015
  stdio: 'ignore',
@@ -684,24 +1021,29 @@ function openInBrowser(url, mode) {
684
1021
 
685
1022
  (async () => {
686
1023
  const cfg = await loadConfig();
687
- // CCSM_PORT env var wins over config — handy for running a dev instance
688
- // on a non-default port (e.g. 7778) while a prod ccsm keeps 7777.
689
1024
  const preferredPort = process.env.CCSM_PORT ? Number(process.env.CCSM_PORT) : cfg.port;
690
1025
  const { server, port } = await listenWithFallback(preferredPort);
691
1026
  currentPort = port;
692
1027
 
693
- // WebSocket upgrade for /ws/terminal/:id bridges xterm.js to a PTY
694
- // entry in webTerminal's pool. Only enabled when node-pty loaded; the
695
- // /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
+
696
1041
  if (webTerminal.available) {
697
1042
  let WebSocketServer;
698
1043
  try { ({ WebSocketServer } = require('ws')); } catch {}
699
1044
  if (WebSocketServer) {
700
1045
  const wss = new WebSocketServer({ noServer: true });
701
1046
  server.on('upgrade', (req, socket, head) => {
702
- // Origin check · same allow-list as REST CORS. Browsers always
703
- // send Origin on WebSocket upgrades; missing Origin = non-browser
704
- // client which we tolerate (curl etc).
705
1047
  const origin = req.headers.origin;
706
1048
  if (origin && !ALLOWED_ORIGINS.has(origin) && !/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(origin)) {
707
1049
  socket.destroy();
@@ -716,57 +1058,38 @@ function openInBrowser(url, mode) {
716
1058
  }
717
1059
  }
718
1060
 
719
- // OS signals · run a graceful shutdown (which saves a final snapshot
720
- // and kills PTY children) before exiting.
721
1061
  for (const sig of ['SIGINT', 'SIGTERM']) {
722
1062
  process.on(sig, () => gracefulShutdown(sig));
723
1063
  }
724
- // Last-resort cleanup on sync exit (process.on('exit') can't await
725
- // anything, so it's only a safety net for PTY children).
726
1064
  process.on('exit', () => { try { webTerminal.killAll(); } catch {} });
1065
+
727
1066
  const apiUrl = `http://localhost:${port}`;
728
- // What URL we open in the auto-spawned browser:
729
- // dev → localhost (backend still serves public/ here)
730
- // prod → hosted frontend on GH Pages (backend is API-only)
731
1067
  const FRONTEND_URL = IS_DEV
732
1068
  ? apiUrl
733
- : 'https://bakapiano.github.io/ccsm/v1/';
1069
+ : 'https://bakapiano.github.io/ccsm/';
734
1070
  frontendUrl = FRONTEND_URL;
735
1071
  console.log(`ccsm listening on ${apiUrl}${port !== cfg.port ? ` (requested ${cfg.port}, was taken)` : ''}`);
736
1072
  console.log(`frontend at ${FRONTEND_URL}`);
737
1073
  console.log(`data dir: ${DATA_DIR}`);
738
1074
  console.log(`work dir: ${cfg.workDir}`);
739
- console.log(`terminal: ${cfg.terminal} · ${cfg.claudeCommand}${cfg.terminal === 'wt' ? ` (via ${cfg.commandShell})` : ''}`);
740
- // CCSM_NO_BROWSER=1 (set by the launcher when responding to a ccsm://
741
- // protocol click) suppresses the auto-spawned browser window — the
742
- // caller already has one open and just needs the backend to come up.
743
- const mode = process.env.CCSM_NO_BROWSER === '1'
744
- ? 'none'
745
- : (cfg.browserMode || (cfg.autoOpenBrowser === false ? 'none' : 'app'));
746
- const opened = openInBrowser(FRONTEND_URL, mode);
747
-
748
- // Primary lifecycle: tie this server's lifetime to the chromeless
749
- // browser window. msedge.exe runs with its own --user-data-dir process
750
- // group, so when the user closes the window it actually exits — and
751
- // the spawned child handle we hold here fires 'exit'. Skip if the user
752
- // 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
+
753
1085
  if (opened.kind === 'app' && opened.child && process.env.CCSM_KEEP_ALIVE !== '1') {
754
1086
  const launchedAt = Date.now();
755
1087
  opened.child.on('exit', () => {
756
1088
  const alive = Date.now() - launchedAt;
757
- // Edge --app= often spawns a process that immediately hands its URL
758
- // off to an existing Edge profile process group and exits — our
759
- // child handle dies milliseconds after creation. Treat any exit
760
- // inside the first 5s as a hand-off, not a real close.
761
1089
  if (alive < 5000) {
762
1090
  console.log(`[ccsm] spawned browser child exited in ${alive}ms · handed off to an existing Edge instance, staying alive`);
763
1091
  return;
764
1092
  }
765
- // Defer the kill decision by one full frontend ping cycle (~12s,
766
- // matching the 10s heartbeat cadence below). Any heartbeat that
767
- // arrives AFTER this moment must be from a DIFFERENT client (the
768
- // closing browser couldn't ping after it died) — so a hosted-tab
769
- // user is still around and we should stay alive.
770
1093
  const closedAt = Date.now();
771
1094
  setTimeout(() => {
772
1095
  if (lastHeartbeat > closedAt + 100) {
@@ -779,13 +1102,6 @@ function openInBrowser(url, mode) {
779
1102
  console.log('[ccsm] tied to browser window — close it to stop ccsm');
780
1103
  }
781
1104
 
782
- // Heartbeat watchdog · only activated when launched via bin/ccsm.js
783
- // (CCSM_LAUNCHER=1). Catches cases the primary mechanism misses: the
784
- // browser was killed forcibly, msedge crashed without a clean exit, or
785
- // the user opened the URL in tab-mode in their own browser instead of
786
- // the chromeless app window. We don't kill until we've seen at least
787
- // one heartbeat — that way a freshly-booted server with no client yet
788
- // doesn't suicide.
789
1105
  if (process.env.CCSM_LAUNCHER === '1' && process.env.CCSM_KEEP_ALIVE !== '1') {
790
1106
  setInterval(() => {
791
1107
  if (!heartbeatSeen) return;
@@ -795,8 +1111,6 @@ function openInBrowser(url, mode) {
795
1111
  }, 30_000);
796
1112
  console.log('[ccsm] heartbeat watchdog active');
797
1113
  }
798
-
799
- startSnapshotLoop();
800
1114
  })().catch((err) => {
801
1115
  console.error('startup failed:', err);
802
1116
  process.exit(1);