@bakapiano/ccsm 0.14.0 → 0.15.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 (53) hide show
  1. package/CLAUDE.md +474 -475
  2. package/README.md +189 -190
  3. package/bin/ccsm.js +194 -194
  4. package/lib/cliActivity.js +118 -0
  5. package/lib/codexSeed.js +147 -0
  6. package/lib/config.js +205 -188
  7. package/lib/folders.js +105 -105
  8. package/lib/localCliSessions.js +489 -489
  9. package/lib/persistedSessions.js +144 -142
  10. package/lib/webTerminal.js +224 -224
  11. package/lib/workspace.js +230 -230
  12. package/package.json +57 -57
  13. package/public/css/base.css +99 -99
  14. package/public/css/cards.css +183 -183
  15. package/public/css/feedback.css +303 -303
  16. package/public/css/forms.css +405 -405
  17. package/public/css/layout.css +160 -160
  18. package/public/css/modal.css +190 -190
  19. package/public/css/responsive.css +10 -10
  20. package/public/css/sidebar.css +613 -608
  21. package/public/css/terminals.css +294 -294
  22. package/public/css/tokens.css +81 -81
  23. package/public/css/wco.css +98 -98
  24. package/public/css/widgets.css +1628 -1628
  25. package/public/index.html +111 -105
  26. package/public/js/api.js +296 -280
  27. package/public/js/components/AdoptModal.js +343 -343
  28. package/public/js/components/App.js +35 -35
  29. package/public/js/components/DirectoryPicker.js +203 -203
  30. package/public/js/components/EntityFormModal.js +141 -141
  31. package/public/js/components/Modal.js +51 -51
  32. package/public/js/components/OfflineBanner.js +93 -93
  33. package/public/js/components/PageTitleBar.js +13 -13
  34. package/public/js/components/Picker.js +179 -179
  35. package/public/js/components/Popover.js +55 -55
  36. package/public/js/components/Sidebar.js +299 -299
  37. package/public/js/components/TerminalView.js +314 -314
  38. package/public/js/components/useDragSort.js +67 -67
  39. package/public/js/dialog.js +67 -67
  40. package/public/js/icons.js +177 -177
  41. package/public/js/main.js +132 -132
  42. package/public/js/pages/AboutPage.js +173 -165
  43. package/public/js/pages/ConfigurePage.js +513 -475
  44. package/public/js/pages/LaunchPage.js +369 -369
  45. package/public/js/pages/SessionsPage.js +101 -97
  46. package/public/js/state.js +231 -231
  47. package/scripts/dev.js +44 -11
  48. package/scripts/install.js +158 -158
  49. package/scripts/restart-helper.js +96 -0
  50. package/scripts/upgrade-helper.js +6 -1
  51. package/server.js +1282 -1254
  52. package/lib/cliSessionWatcher.js +0 -275
  53. package/public/manifest.webmanifest +0 -15
package/server.js CHANGED
@@ -1,1254 +1,1282 @@
1
- #!/usr/bin/env node
2
- 'use strict';
3
-
4
- const path = require('node:path');
5
- const os = require('node:os');
6
- const express = require('express');
7
-
8
- const { loadConfig, saveConfig, DATA_DIR } = require('./lib/config');
9
- const {
10
- listWorkspaces,
11
- findOrCreateWorkspace,
12
- ensureReposInWorkspace,
13
- isInside,
14
- } = require('./lib/workspace');
15
- const webTerminal = require('./lib/webTerminal');
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).
24
- let shuttingDown = false;
25
- async function gracefulShutdown(reason) {
26
- if (shuttingDown) return;
27
- shuttingDown = true;
28
- console.log(`[ccsm] shutting down · ${reason}`);
29
- // Mark all running sessions as exited (best-effort) so the next launch
30
- // doesn't show stale "running" rows.
31
- try {
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 {}
39
- try { webTerminal.killAll(); } catch {}
40
- process.exit(0);
41
- }
42
-
43
- const app = express();
44
- app.use(express.json({ limit: '1mb' }));
45
-
46
- // CORS · allow the hosted-frontend (GH Pages) origin to call /api/* and
47
- // open WebSockets. Listed explicitly — never reflect Origin or use '*' so
48
- // random web pages can't reach the local backend. Localhost dev calls
49
- // stay same-origin (browser doesn't add Origin header → middleware is a
50
- // no-op for them).
51
- const ALLOWED_ORIGINS = new Set([
52
- 'https://bakapiano.github.io',
53
- ]);
54
- app.use((req, res, next) => {
55
- const origin = req.headers.origin;
56
- if (origin && ALLOWED_ORIGINS.has(origin)) {
57
- res.setHeader('Access-Control-Allow-Origin', origin);
58
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
59
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
60
- res.setHeader('Vary', 'Origin');
61
- }
62
- if (req.method === 'OPTIONS') return res.sendStatus(204);
63
- next();
64
- });
65
-
66
- // Dev mode = running from a checkout (not from an npm-install location).
67
- // Used to gate two things: (a) serving static frontend from local public/
68
- // so a contributor can iterate without pushing to GH Pages; (b) hot-reload
69
- // SSE endpoint that watches public/ for changes. CCSM_NO_DEV=1 disables
70
- // both explicitly. In production (npm-installed), backend is API-only —
71
- // frontend lives at https://bakapiano.github.io/ccsm/ (router → per-version).
72
- const IS_DEV = !__dirname.includes(`${path.sep}node_modules${path.sep}`) && process.env.CCSM_NO_DEV !== '1';
73
-
74
- if (IS_DEV) {
75
- app.use(express.static(path.join(__dirname, 'public')));
76
- }
77
-
78
- const reloadClients = new Set();
79
- if (IS_DEV) {
80
- app.get('/api/dev/ping', (_req, res) => res.json({ dev: true }));
81
- app.get('/api/dev/reload', (req, res) => {
82
- res.setHeader('Content-Type', 'text/event-stream');
83
- res.setHeader('Cache-Control', 'no-cache, no-transform');
84
- res.setHeader('Connection', 'keep-alive');
85
- res.flushHeaders();
86
- res.write(': connected\n\n');
87
- reloadClients.add(res);
88
- const hb = setInterval(() => { try { res.write(': ping\n\n'); } catch {} }, 25000);
89
- req.on('close', () => { clearInterval(hb); reloadClients.delete(res); });
90
- });
91
- const publicDir = path.join(__dirname, 'public');
92
- const fs = require('node:fs');
93
- let debounce = null;
94
- fs.watch(publicDir, { recursive: true }, (_event, filename) => {
95
- clearTimeout(debounce);
96
- debounce = setTimeout(() => {
97
- if (reloadClients.size === 0) return;
98
- console.log(`[dev] reload · ${filename || '?'} → ${reloadClients.size} client(s)`);
99
- for (const r of reloadClients) {
100
- try { r.write(`event: reload\ndata: ${Date.now()}\n\n`); } catch {}
101
- }
102
- }, 80);
103
- });
104
- console.log('[dev] hot-reload watching public/');
105
- }
106
-
107
- function asyncH(fn) {
108
- return (req, res) => {
109
- Promise.resolve(fn(req, res)).catch((err) => {
110
- console.error('[api error]', err);
111
- res.status(500).json({ error: String(err && err.message || err) });
112
- });
113
- };
114
- }
115
-
116
- // ---- helpers ----
117
-
118
- function pickCli(cfg, requestedId) {
119
- const wanted = requestedId || cfg.defaultCliId;
120
- return cfg.clis.find((c) => c.id === wanted) || cfg.clis[0];
121
- }
122
-
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
- }
148
-
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
- }
158
-
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 };
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 };
171
- }
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
- }
177
-
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
- }
186
-
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;
192
- }
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
- // spawnEnv() also strips duplicate path-case keys so our override
208
- // doesn't get shadowed by the inherited `Path` from process.env.
209
- const env = spawnEnv(cli.env);
210
- const trySpawn = (executable) => webTerminal.spawn({
211
- id: sessionId,
212
- command: executable,
213
- args,
214
- cwd,
215
- env,
216
- meta: { ...meta, cliId: cli.id, cliName: cli.name },
217
- onData: () => { persistedSessions.touch(sessionId).catch(() => {}); },
218
- onExit: ({ exitCode }) => {
219
- stopWatcher(sessionId);
220
- persistedSessions.markExited(sessionId, exitCode).catch(() => {});
221
- },
222
- });
223
- try {
224
- const entry = trySpawn(exe);
225
- maybeWatchCliSessionId({ cli, cwd, ccsmSessionId: sessionId });
226
- return entry;
227
- } catch (e) {
228
- if (fallbackExe && /ENOENT|cannot find|not recognized/i.test(String(e && e.message || e))) {
229
- const entry = trySpawn(fallbackExe);
230
- maybeWatchCliSessionId({ cli, cwd, ccsmSessionId: sessionId });
231
- return entry;
232
- }
233
- throw e;
234
- }
235
- }
236
-
237
- // Start a fs-watch on the CLI's transcript directory so we can capture
238
- // the upstream session UUID for later precise --resume. Only kicks off
239
- // for CLI types we know how to watch (claude / codex / copilot).
240
- //
241
- // If the watcher times out (5 min with no transcript ever written), we
242
- // assume the user closed the CLI before it persisted anything so
243
- // there's nothing to resume to and the ccsm record is dead weight. Drop
244
- // the persistedSessions row and kill the PTY if it somehow lingers.
245
- //
246
- // IMPORTANT: if the record already has a captured cliSessionId (typical
247
- // for `resume` and for `adopt`-imported records), skip the watcher
248
- // entirely there's nothing left to capture, and the timeout-cleanup
249
- // would otherwise wipe a perfectly good record after 5 minutes of
250
- // "no new transcript".
251
- // Active upstream-session-id watchers, keyed by ccsm session id. We hold
252
- // onto the cleanup fn returned by cliSessionWatcher so we can tear them
253
- // down when the PTY exits or the record is deleted — a still-running
254
- // watcher whose ccsm session is gone would otherwise match a *future*
255
- // session that happens to spawn in the same cwd and stamp the wrong id
256
- // onto a dead record (or worse, onto a re-created record reusing memory).
257
- const activeWatchers = new Map(); // ccsmSessionId → cleanupFn
258
-
259
- function stopWatcher(ccsmSessionId) {
260
- const cleanup = activeWatchers.get(ccsmSessionId);
261
- if (!cleanup) return;
262
- activeWatchers.delete(ccsmSessionId);
263
- try { cleanup(); } catch {}
264
- }
265
-
266
- async function maybeWatchCliSessionId({ cli, cwd, ccsmSessionId }) {
267
- if (!cli || !['claude', 'codex', 'copilot'].includes(cli.type)) return;
268
- // If a previous watcher was still alive on this id (e.g. fast restart),
269
- // tear it down first.
270
- stopWatcher(ccsmSessionId);
271
- let excludeIds = [];
272
- try {
273
- const all = await persistedSessions.loadAll();
274
- const existing = all.find((s) => s.id === ccsmSessionId);
275
- if (existing?.cliSessionId) {
276
- console.log(`[cliSessionId] skip watcher · ${cli.type} session already known (${existing.cliSessionId})`);
277
- return;
278
- }
279
- // Other ccsm sessions' upstream UUIDs exclude so the watcher
280
- // doesn't misclaim a sibling's actively-touched transcript.
281
- excludeIds = all
282
- .filter((s) => s.id !== ccsmSessionId && s.cliSessionId)
283
- .map((s) => s.cliSessionId);
284
- } catch {}
285
- const cleanup = cliSessionWatcher.captureSessionId({
286
- cliType: cli.type,
287
- cwd,
288
- excludeIds,
289
- onCapture: (cliSessionId) => {
290
- activeWatchers.delete(ccsmSessionId);
291
- persistedSessions.update(ccsmSessionId, { cliSessionId }).catch((e) => {
292
- console.error('[cliSessionId] save failed:', e.message);
293
- });
294
- console.log(`[cliSessionId] captured ${cli.type} session ${cliSessionId} for ccsm ${ccsmSessionId}`);
295
- },
296
- onTimeout: () => {
297
- activeWatchers.delete(ccsmSessionId);
298
- console.warn(`[cliSessionId] timeout · removing ccsm session ${ccsmSessionId} (no ${cli.type} transcript)`);
299
- try { webTerminal.kill(ccsmSessionId); } catch {}
300
- persistedSessions.remove(ccsmSessionId).catch((e) => {
301
- console.error('[cliSessionId] remove failed:', e.message);
302
- });
303
- },
304
- });
305
- if (cleanup) activeWatchers.set(ccsmSessionId, cleanup);
306
- }
307
-
308
- // Read user PATH from registry once at boot, prepend to process PATH.
309
- // On platforms other than Windows or if the read fails, fall back to
310
- // process.env.PATH unchanged.
311
- let mergedUserPath = null;
312
- function buildMergedUserPath() {
313
- if (process.platform !== 'win32') return process.env.PATH;
314
- try {
315
- const { spawnSync } = require('node:child_process');
316
- const r = spawnSync('reg.exe', ['query', 'HKCU\\Environment', '/v', 'PATH'], { encoding: 'utf8', windowsHide: true });
317
- if (r.status !== 0 || !r.stdout) return process.env.PATH;
318
- const line = r.stdout.split(/\r?\n/).find((l) => /\bPATH\b/i.test(l) && /REG_(EXPAND_)?SZ/i.test(l));
319
- if (!line) return process.env.PATH;
320
- const m = line.match(/REG_(?:EXPAND_)?SZ\s+(.+)$/);
321
- if (!m) return process.env.PATH;
322
- // Expand %VAR% references manually (REG_EXPAND_SZ keeps them literal).
323
- const userPath = m[1].replace(/%([^%]+)%/g, (_, name) => process.env[name] || '');
324
- const existing = (process.env.PATH || '').split(';').map((s) => s.trim()).filter(Boolean);
325
- const adds = userPath.split(';').map((s) => s.trim()).filter(Boolean);
326
- const merged = [];
327
- const seen = new Set();
328
- for (const p of [...adds, ...existing]) {
329
- const k = p.toLowerCase();
330
- if (seen.has(k)) continue;
331
- seen.add(k);
332
- merged.push(p);
333
- }
334
- return merged.join(';');
335
- } catch {
336
- return process.env.PATH;
337
- }
338
- }
339
- mergedUserPath = buildMergedUserPath();
340
-
341
- // Hand back a fresh env for spawning a child, with PATH overridden by
342
- // our merged user PATH and any duplicate case variants of "path"
343
- // stripped first. Windows env lookup is case-insensitive but the env
344
- // block we hand CreateProcess is an ordered byte buffer if both
345
- // `Path` (inherited from process.env, OS canonical case) and `PATH`
346
- // (our override) are present, Windows resolves to whichever comes
347
- // first in the block. Node's Object.keys preserves insertion order,
348
- // so the inherited `Path` would win and our merged override silently
349
- // disappear. Strip all path-shaped keys first, then add the merge.
350
- function spawnEnv(extraEnv = {}) {
351
- const env = { ...process.env, ...extraEnv };
352
- if (process.platform === 'win32') {
353
- for (const k of Object.keys(env)) {
354
- if (k.toLowerCase() === 'path') delete env[k];
355
- }
356
- }
357
- if (mergedUserPath) env.PATH = mergedUserPath;
358
- return env;
359
- }
360
-
361
- // ---- config ----
362
-
363
- // Per-CLI install probe. Looks up the command on PATH using `where` (win)
364
- // or `which` (posix). Result is cached forever — restart ccsm after
365
- // installing/uninstalling a CLI to refresh. Cheap (10ms cold, 0ms cached).
366
- const cliProbeCache = new Map();
367
- function probeCli(command) {
368
- if (!command) return null;
369
- if (cliProbeCache.has(command)) return cliProbeCache.get(command);
370
- const { spawnSync } = require('node:child_process');
371
- let resolvedPath = null;
372
- try {
373
- const isWin = process.platform === 'win32';
374
- const cmd = isWin ? 'where.exe' : 'which';
375
- const env = { ...process.env };
376
- if (mergedUserPath) env.PATH = mergedUserPath;
377
- const r = spawnSync(cmd, [command], { encoding: 'utf8', windowsHide: true, env });
378
- if (r.status === 0 && r.stdout) {
379
- resolvedPath = r.stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0] || null;
380
- }
381
- } catch {}
382
- cliProbeCache.set(command, resolvedPath);
383
- return resolvedPath;
384
- }
385
-
386
- function decorateConfigWithProbes(cfg) {
387
- return {
388
- ...cfg,
389
- clis: (cfg.clis || []).map((c) => {
390
- const path = probeCli(c.command);
391
- return { ...c, installed: !!path, installPath: path };
392
- }),
393
- };
394
- }
395
-
396
- app.get('/api/config', asyncH(async (_req, res) => {
397
- res.json(decorateConfigWithProbes(await loadConfig()));
398
- }));
399
-
400
- app.put('/api/config', asyncH(async (req, res) => {
401
- const cfg = await saveConfig(req.body || {});
402
- res.json(decorateConfigWithProbes(cfg));
403
- }));
404
-
405
- // ---- CLI probe / test ----
406
- //
407
- // Run the user's configured command with `--version` and report back
408
- // stdout/stderr + whether the output looks like the claimed CLI type.
409
- // Used by the Configure page "Test" button so the user can verify the
410
- // command resolves + actually launches the right tool BEFORE saving.
411
- // Body: { command, args?, shell?, type? }. args is ignored for the
412
- // version probe we always append `--version` directly so the user's
413
- // runtime args (e.g. --dangerously-skip-permissions) don't perturb the
414
- // quick probe.
415
- app.post('/api/clis/test', asyncH(async (req, res) => {
416
- const { spawn } = require('node:child_process');
417
- const body = req.body || {};
418
- const command = String(body.command || '').trim();
419
- const shell = ['direct', 'pwsh', 'cmd'].includes(body.shell) ? body.shell : 'direct';
420
- const type = ['claude', 'codex', 'copilot', 'other'].includes(body.type) ? body.type : 'other';
421
- if (!command) return res.status(400).json({ error: 'command required' });
422
-
423
- // Build the test exec. Same shell-wrapping rules as resolveCommand,
424
- // but we force `--version` as the only arg and we DROP `-NoExit`
425
- // from the pwsh wrapper so pwsh terminates after printing.
426
- let exe, args;
427
- const cmd = command.replace(/^\.[\\\/]/, '');
428
- const versionArg = '--version';
429
- if (shell === 'pwsh') {
430
- const joined = `& ${/[\s'"\`$]/.test(cmd) ? `'${cmd.replace(/'/g, "''")}'` : cmd} ${versionArg}`;
431
- exe = 'pwsh.exe';
432
- args = ['-NoLogo', '-Command', joined];
433
- } else if (shell === 'cmd') {
434
- exe = process.env.ComSpec || 'cmd.exe';
435
- args = ['/d', '/s', '/c', `${cmd} ${versionArg}`];
436
- } else if (path.isAbsolute(cmd)) {
437
- const ext = path.extname(cmd).toLowerCase();
438
- if (ext === '.cmd' || ext === '.bat') {
439
- exe = process.env.ComSpec || 'cmd.exe';
440
- args = ['/d', '/s', '/c', cmd, versionArg];
441
- } else if (ext === '.ps1') {
442
- exe = 'powershell.exe';
443
- args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', cmd, versionArg];
444
- } else {
445
- exe = cmd;
446
- args = [versionArg];
447
- }
448
- } else {
449
- exe = process.env.ComSpec || 'cmd.exe';
450
- args = ['/d', '/s', '/c', cmd, versionArg];
451
- }
452
-
453
- const t0 = Date.now();
454
- let stdout = '';
455
- let stderr = '';
456
- let exitCode = null;
457
- let timedOut = false;
458
- let spawnError = null;
459
- try {
460
- const child = spawn(exe, args, { env: spawnEnv(), windowsHide: true });
461
- const killer = setTimeout(() => { timedOut = true; try { child.kill(); } catch {} }, 5000);
462
- child.stdout.on('data', (d) => { stdout += d.toString(); if (stdout.length > 8192) stdout = stdout.slice(0, 8192); });
463
- child.stderr.on('data', (d) => { stderr += d.toString(); if (stderr.length > 8192) stderr = stderr.slice(0, 8192); });
464
- exitCode = await new Promise((resolve, reject) => {
465
- child.on('exit', (code) => { clearTimeout(killer); resolve(code); });
466
- child.on('error', (err) => { clearTimeout(killer); reject(err); });
467
- });
468
- } catch (e) {
469
- spawnError = String(e && e.message || e);
470
- }
471
- const durationMs = Date.now() - t0;
472
-
473
- const out = (stdout + '\n' + stderr).toLowerCase();
474
- const PATTERNS = {
475
- claude: /claude/,
476
- codex: /codex|openai/,
477
- copilot: /copilot/,
478
- };
479
- const matchedType = type === 'other' ? null : (PATTERNS[type] ? PATTERNS[type].test(out) : null);
480
- const ok = !spawnError && !timedOut && exitCode === 0;
481
- res.json({
482
- ok, exitCode, durationMs, timedOut, spawnError,
483
- stdout: stdout.trim(),
484
- stderr: stderr.trim(),
485
- matchedType,
486
- expectedType: type,
487
- spawned: { exe, args },
488
- });
489
- }));
490
-
491
- // ---- folders ----
492
-
493
- app.get('/api/folders', asyncH(async (_req, res) => {
494
- const list = await folders.loadAll();
495
- list.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
496
- res.json({ folders: list });
497
- }));
498
-
499
- app.post('/api/folders', asyncH(async (req, res) => {
500
- const name = req.body && req.body.name;
501
- if (!name) return res.status(400).json({ error: 'name required' });
502
- res.json({ folder: await folders.create({ name }) });
503
- }));
504
-
505
- app.put('/api/folders/:id', asyncH(async (req, res) => {
506
- const updated = await folders.update(req.params.id, req.body || {});
507
- if (!updated) return res.status(404).json({ error: 'not found' });
508
- res.json({ folder: updated });
509
- }));
510
-
511
- app.delete('/api/folders/:id', asyncH(async (req, res) => {
512
- // Move all sessions in this folder to Unsorted before delete.
513
- const all = await persistedSessions.loadAll();
514
- for (const s of all) {
515
- if (s.folderId === req.params.id) {
516
- await persistedSessions.setFolder(s.id, null);
517
- }
518
- }
519
- const removed = await folders.remove(req.params.id);
520
- res.json({ removed });
521
- }));
522
-
523
- app.post('/api/folders/reorder', asyncH(async (req, res) => {
524
- const ids = req.body && req.body.ids;
525
- if (!Array.isArray(ids)) return res.status(400).json({ error: 'ids array required' });
526
- const next = await folders.reorder(ids);
527
- res.json({ folders: next });
528
- }));
529
-
530
- // ---- sessions (persisted, ccsm-owned) ----
531
-
532
- app.get('/api/sessions', asyncH(async (_req, res) => {
533
- const list = await persistedSessions.loadAll();
534
- // Cross-check status against live PTY pool so a stale "running" record
535
- // doesn't survive a server restart.
536
- const live = new Set(webTerminal.list().filter((t) => !t.exitedAt).map((t) => t.id));
537
- for (const s of list) {
538
- if (s.status === 'running' && !live.has(s.id)) {
539
- s.status = 'exited';
540
- }
541
- }
542
- res.json({ sessions: list, takenAt: Date.now() });
543
- }));
544
-
545
- app.put('/api/sessions/:id', asyncH(async (req, res) => {
546
- const patch = {};
547
- if (typeof req.body.title === 'string') patch.title = req.body.title;
548
- if ('folderId' in (req.body || {})) patch.folderId = req.body.folderId || null;
549
- const updated = await persistedSessions.update(req.params.id, patch);
550
- if (!updated) return res.status(404).json({ error: 'not found' });
551
- res.json({ session: updated });
552
- }));
553
-
554
- app.delete('/api/sessions/:id', asyncH(async (req, res) => {
555
- // Kill PTY first if it's still alive, then drop the record.
556
- stopWatcher(req.params.id);
557
- try { webTerminal.kill(req.params.id); } catch {}
558
- const removed = await persistedSessions.remove(req.params.id);
559
- res.json({ removed });
560
- }));
561
-
562
- // ---- workspaces ----
563
-
564
- // ---- directory browser ----
565
- // Lets the launch picker walk the filesystem so users can pick any
566
- // existing directory as the session cwd. Returns the immediate child
567
- // dirs of `path` (defaults to home), plus a few hardcoded "starts"
568
- // (home, workDir, drive roots on Windows).
569
- app.get('/api/browse', asyncH(async (req, res) => {
570
- const fs = require('node:fs/promises');
571
- const os = require('node:os');
572
- const target = req.query.path ? path.resolve(String(req.query.path)) : os.homedir();
573
- let entries = [];
574
- let exists = true;
575
- try {
576
- const list = await fs.readdir(target, { withFileTypes: true });
577
- entries = list
578
- .filter((d) => d.isDirectory() && !d.name.startsWith('.'))
579
- .map((d) => ({ name: d.name, path: path.join(target, d.name) }))
580
- .sort((a, b) => a.name.localeCompare(b.name));
581
- } catch (e) {
582
- exists = false;
583
- }
584
- const parent = path.dirname(target);
585
- const cfg = await loadConfig();
586
- const starts = [
587
- { label: 'Home', path: os.homedir() },
588
- { label: 'Work dir', path: cfg.workDir },
589
- ];
590
- if (process.platform === 'win32') {
591
- // Best-effort drive enumeration so users on D:\ etc can hop roots.
592
- for (const letter of ['C', 'D', 'E', 'F', 'G', 'H']) {
593
- const root = `${letter}:\\`;
594
- try { await fs.access(root); starts.push({ label: `${letter}:\\`, path: root }); }
595
- catch {}
596
- }
597
- }
598
- res.json({
599
- path: target,
600
- parent: parent === target ? null : parent,
601
- exists,
602
- entries,
603
- starts,
604
- });
605
- }));
606
-
607
- app.get('/api/workspaces', asyncH(async (req, res) => {
608
- const cfg = await loadConfig();
609
- const workspaces = await listWorkspaces({
610
- workDir: cfg.workDir,
611
- repos: cfg.repos,
612
- });
613
- // Recompute inUse based on persistedSessions: a workspace is in use
614
- // iff any RUNNING ccsm session lives at-or-inside it.
615
- const allSess = await persistedSessions.loadAll();
616
- const busy = new Set(
617
- allSess.filter((s) => s.status === 'running').map((s) => path.resolve(s.cwd).toLowerCase())
618
- );
619
- for (const w of workspaces) {
620
- w.inUse = busy.has(path.resolve(w.path).toLowerCase());
621
- w.sessionsHere = allSess
622
- .filter((s) => s.status === 'running' && path.resolve(s.cwd).toLowerCase() === path.resolve(w.path).toLowerCase())
623
- .map((s) => s.id);
624
- }
625
- res.json({ workDir: cfg.workDir, repos: cfg.repos, workspaces });
626
- }));
627
-
628
- // Delete a workspace directory. Refuses if any RUNNING session lives
629
- // inside it, or if the resolved path escapes workDir. The name comes
630
- // from the URL — we resolve it against workDir and verify containment.
631
- app.delete('/api/workspaces/:name', asyncH(async (req, res) => {
632
- const fsp = require('node:fs/promises');
633
- const cfg = await loadConfig();
634
- const name = String(req.params.name || '');
635
- // Reject anything that tries to escape via separators / traversal.
636
- if (!name || /[\\/]|^\.\.$|^\.$/.test(name)) {
637
- return res.status(400).json({ error: 'invalid workspace name' });
638
- }
639
- const target = path.resolve(cfg.workDir, name);
640
- if (!isInside(target, cfg.workDir) || path.resolve(target) === path.resolve(cfg.workDir)) {
641
- return res.status(400).json({ error: 'workspace must live under workDir' });
642
- }
643
- try {
644
- const st = await fsp.stat(target);
645
- if (!st.isDirectory()) return res.status(400).json({ error: 'not a directory' });
646
- } catch {
647
- return res.status(404).json({ error: 'workspace not found' });
648
- }
649
- const allSess = await persistedSessions.loadAll();
650
- const inUse = allSess.some((s) =>
651
- s.status === 'running' && isInside(s.cwd, target)
652
- );
653
- if (inUse) return res.status(409).json({ error: 'workspace is in use by a running session' });
654
- await fsp.rm(target, { recursive: true, force: true });
655
- res.json({ ok: true });
656
- }));
657
-
658
- // ---- new session ----
659
- // body: { cliId?, repos?, workspace?, folderId?, launch?: true }
660
- // Streams NDJSON: workspace / clone-* / launched / done.
661
- app.post('/api/sessions/new', async (req, res) => {
662
- res.setHeader('Content-Type', 'application/x-ndjson');
663
- res.setHeader('Cache-Control', 'no-cache, no-transform');
664
- res.setHeader('X-Accel-Buffering', 'no');
665
- if (typeof res.flushHeaders === 'function') res.flushHeaders();
666
-
667
- const emit = (obj) => { res.write(JSON.stringify(obj) + '\n'); };
668
- const fail = (msg, extra) => {
669
- emit({ type: 'done', success: false, error: msg, ...extra });
670
- res.end();
671
- };
672
-
673
- try {
674
- const cfg = await loadConfig();
675
- const cli = pickCli(cfg, req.body && req.body.cliId);
676
- if (!cli) return fail('No CLI configured. Add one in Configure → CLIs.');
677
-
678
- const explicitRepos = Array.isArray(req.body && req.body.repos);
679
- const wantedNames = explicitRepos
680
- ? req.body.repos
681
- : cfg.repos.filter((r) => r.defaultSelected).map((r) => r.name);
682
- const wantedRepos = cfg.repos.filter((r) => wantedNames.includes(r.name));
683
- if (wantedRepos.length === 0 && !explicitRepos && wantedNames.length > 0) {
684
- return fail('No matching repos found');
685
- }
686
-
687
- let workspace;
688
- let created = false;
689
- // Three cwd modes:
690
- // 1. body.cwd user picked an existing directory; skip clone.
691
- // 2. body.workspace reuse a named workspace under workDir.
692
- // 3. (neither) auto-allocate a fresh ws-N.
693
- if (req.body && req.body.cwd) {
694
- const fsmod = require('node:fs/promises');
695
- const cwd = path.resolve(String(req.body.cwd));
696
- try {
697
- const st = await fsmod.stat(cwd);
698
- if (!st.isDirectory()) return fail(`${cwd} is not a directory`);
699
- } catch {
700
- return fail(`directory not found: ${cwd}`);
701
- }
702
- workspace = { name: path.basename(cwd) || cwd, path: cwd };
703
- } else if (req.body && req.body.workspace) {
704
- const all = await listWorkspaces({ workDir: cfg.workDir, repos: cfg.repos });
705
- workspace = all.find((w) => w.name === req.body.workspace);
706
- if (!workspace) return fail(`workspace ${req.body.workspace} not found`);
707
- } else {
708
- const r = await findOrCreateWorkspace({
709
- workDir: cfg.workDir,
710
- repos: cfg.repos,
711
- requireUnused: true,
712
- });
713
- workspace = r.workspace;
714
- created = r.created;
715
- }
716
- emit({ type: 'workspace', workspace, created });
717
-
718
- // Skip clone entirely when user picked an existing directory — we
719
- // don't want to dump random repos into someone's project.
720
- const cloneResults = (req.body && req.body.cwd) ? [] : await ensureReposInWorkspace({
721
- workspacePath: workspace.path,
722
- repos: wantedRepos,
723
- onRepoStart: (repo) =>
724
- emit({ type: 'clone-start', repo: repo.name, url: repo.url }),
725
- onProgress: (repo, p) =>
726
- emit({ type: 'clone-progress', repo: repo.name, ...p }),
727
- onLine: (repo, line) =>
728
- emit({ type: 'clone-line', repo: repo.name, line }),
729
- onRepoEnd: (repo, result) =>
730
- emit({ type: 'clone-end', repo: repo.name, ...result }),
731
- });
732
- const failed = cloneResults.filter((r) => !r.ok);
733
- if (failed.length > 0) return fail('Some repos failed to clone', { cloneResults });
734
-
735
- const shouldLaunch = req.body && req.body.launch !== false;
736
- let launched = null;
737
- if (shouldLaunch) {
738
- // Create the persistedSessions record FIRST so spawnCliSession can
739
- // use its id as the PTY id (matching ids simplify resume/attach).
740
- const record = await persistedSessions.create({
741
- cliId: cli.id,
742
- cwd: workspace.path,
743
- workspace: workspace.name,
744
- repos: wantedRepos.map((r) => r.name),
745
- folderId: (req.body && req.body.folderId) || null,
746
- title: '',
747
- });
748
- try {
749
- const entry = spawnCliSession({
750
- cli,
751
- cwd: workspace.path,
752
- sessionId: record.id,
753
- meta: { title: workspace.name, workspace: workspace.name, cwd: workspace.path },
754
- });
755
- await persistedSessions.markRunning(record.id, entry.meta.pid);
756
- launched = { id: record.id, pid: entry.meta.pid, cliId: cli.id };
757
- emit({ type: 'launched', launched });
758
- } catch (e) {
759
- await persistedSessions.markExited(record.id, null);
760
- return fail(`spawn failed: ${e.message}`);
761
- }
762
- }
763
-
764
- emit({ type: 'done', success: true, workspace, created, cloneResults, launched });
765
- res.end();
766
- } catch (e) {
767
- console.error('[/api/sessions/new]', e);
768
- fail(String(e && e.message || e));
769
- }
770
- });
771
-
772
- // ---- list local CLI sessions discovered on disk (for "adopt") ----
773
- // Returns sessions found in ~/.claude / ~/.codex / ~/.copilot that
774
- // aren't yet adopted by ccsm. Frontend uses this in the Import modal.
775
- app.get('/api/cli-sessions/:cliType', asyncH(async (req, res) => {
776
- const type = String(req.params.cliType || '').toLowerCase();
777
- if (!['claude', 'codex', 'copilot'].includes(type)) {
778
- return res.status(400).json({ error: `unsupported cli type: ${type}` });
779
- }
780
- const offset = Math.max(0, Number(req.query.offset) || 0);
781
- const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 30));
782
-
783
- const [page, adopted] = await Promise.all([
784
- localCliSessions.listPaginated(type, { offset, limit }),
785
- persistedSessions.loadAll(),
786
- ]);
787
-
788
- const adoptedIds = new Set(adopted.map((s) => s.cliSessionId).filter(Boolean));
789
- const sessions = page.sessions.map((s) => ({
790
- ...s,
791
- adopted: adoptedIds.has(s.cliSessionId),
792
- }));
793
- res.json({
794
- sessions,
795
- totalActive: page.totalActive,
796
- totalNonActive: page.totalNonActive,
797
- total: page.totalActive + page.totalNonActive,
798
- offset: page.offset,
799
- limit: page.limit,
800
- hasMore: page.hasMore,
801
- });
802
- }));
803
-
804
- // ---- adopt: create a ccsm record pointing at an existing CLI session ----
805
- // Body: { cliId, cliSessionId, cwd, title?, folderId? }
806
- // Doesn't spawn the new entry shows up as "exited" in the sidebar;
807
- // clicking it kicks off the regular resume flow which uses
808
- // `cli.resumeIdArgs` ('--resume <id>') so the upstream session reattaches.
809
- app.post('/api/sessions/adopt', asyncH(async (req, res) => {
810
- const { cliId, cliSessionId, cwd, title, folderId } = req.body || {};
811
- if (!cliId || !cliSessionId || !cwd) {
812
- return res.status(400).json({ error: 'cliId, cliSessionId and cwd required' });
813
- }
814
- const cfg = await loadConfig();
815
- const cli = pickCli(cfg, cliId);
816
- if (!cli) return res.status(400).json({ error: `CLI ${cliId} not configured` });
817
-
818
- // Normalize the cwd up front. /api/sessions/new also resolves cwd, and
819
- // the workspaces "in use" check (GET /api/workspaces) does
820
- // path.resolve(s.cwd).toLowerCase() — adopted records must match the
821
- // same shape, otherwise an adopted+running session leaves its
822
- // workspace falsely marked as free and a fresh launch could collide.
823
- const resolvedCwd = path.resolve(cwd);
824
- try {
825
- const fsmod = require('node:fs/promises');
826
- const st = await fsmod.stat(resolvedCwd);
827
- if (!st.isDirectory()) {
828
- return res.status(400).json({ error: `cwd is not a directory: ${resolvedCwd}` });
829
- }
830
- } catch (e) {
831
- return res.status(400).json({ error: `cwd not found: ${resolvedCwd}` });
832
- }
833
-
834
- // Refuse duplicates: if any ccsm record already owns this upstream
835
- // session id, return it so the caller can jump to it.
836
- const all = await persistedSessions.loadAll();
837
- const dup = all.find((s) => s.cliSessionId === cliSessionId);
838
- if (dup) return res.json({ session: dup, alreadyAdopted: true });
839
-
840
- const workspace = path.basename(resolvedCwd) || resolvedCwd;
841
- // Create directly with status='exited' + cliSessionId set, so a
842
- // concurrent GET /api/sessions can never observe a "running but no
843
- // PTY" intermediate state.
844
- const record = await persistedSessions.create({
845
- cliId,
846
- cwd: resolvedCwd,
847
- workspace,
848
- folderId: folderId || null,
849
- title: title || '',
850
- repos: [],
851
- status: 'exited',
852
- cliSessionId,
853
- });
854
- res.json({ session: record, alreadyAdopted: false });
855
- }));
856
-
857
- // ---- resume a previous session in the same cwd / cli ----
858
- app.post('/api/sessions/:id/resume', asyncH(async (req, res) => {
859
- const record = await persistedSessions.get(req.params.id);
860
- if (!record) return res.status(404).json({ error: 'session not found' });
861
- // Already running and attached → no-op, just return its id.
862
- const live = webTerminal.get(record.id);
863
- if (live && !live.exitedAt) {
864
- // Pool says we're alive but the record may be stale (e.g. a prior
865
- // markRunning got clobbered by an OLD entry's onExit before the
866
- // respawn-guard landed, or boot mark-exited ran after a pool entry
867
- // was already wired). Reconcile the file to match the pool so the
868
- // frontend doesn't get stuck on "Resuming session…" forever.
869
- if (record.status !== 'running' || record.pid !== live.meta.pid) {
870
- try { await persistedSessions.markRunning(record.id, live.meta.pid); } catch {}
871
- }
872
- return res.json({ launched: { id: record.id, pid: live.meta.pid, cliId: record.cliId } });
873
- }
874
- const cfg = await loadConfig();
875
- const cli = pickCli(cfg, record.cliId);
876
- if (!cli) return res.status(400).json({ error: `CLI ${record.cliId} no longer configured` });
877
- try {
878
- // Prefer precise --resume <cliSessionId> when we have one captured;
879
- // fall back to cli.resumeArgs (--continue / resume --last) otherwise.
880
- const extraArgs = buildResumeArgs(cli, record);
881
- const entry = spawnCliSession({
882
- cli,
883
- cwd: record.cwd,
884
- sessionId: record.id,
885
- meta: { title: record.title || record.workspace, workspace: record.workspace, cwd: record.cwd },
886
- extraArgs,
887
- });
888
- await persistedSessions.markRunning(record.id, entry.meta.pid);
889
- res.json({ launched: { id: record.id, pid: entry.meta.pid, cliId: cli.id } });
890
- } catch (e) {
891
- res.status(500).json({ error: e.message });
892
- }
893
- }));
894
-
895
- // Build the args appended on resume:
896
- // When ccsm has captured the upstream CLI's session UUID and the CLI
897
- // defines `resumeIdArgs` (e.g. ['--resume', '<id>']), we substitute the
898
- // <id> placeholder and use those for a precise resume. Otherwise we
899
- // fall back to `cli.resumeArgs` (e.g. ['--continue']).
900
- function buildResumeArgs(cli, record) {
901
- const id = record.cliSessionId;
902
- const tpl = Array.isArray(cli.resumeIdArgs) ? cli.resumeIdArgs : [];
903
- if (id && tpl.length > 0) {
904
- return tpl.map((a) => (typeof a === 'string' ? a.replace(/<id>/g, id) : a));
905
- }
906
- return Array.isArray(cli.resumeArgs) ? cli.resumeArgs : [];
907
- }
908
-
909
- // ---- capabilities probe ----
910
- app.get('/api/capabilities', (_req, res) => res.json({
911
- webTerminal: webTerminal.available,
912
- webTerminalError: webTerminal.available ? null : String(webTerminal.loadError?.message || 'unavailable'),
913
- }));
914
-
915
- // ---- health ----
916
- const pkg = require('./package.json');
917
- app.get('/api/health', (_req, res) => res.json({ ok: true, pid: process.pid, version: pkg.version, name: pkg.name }));
918
-
919
- // ---- lifecycle ----
920
- let currentPort = 0;
921
- let frontendUrl = '';
922
- let lastHeartbeat = Date.now();
923
- let heartbeatSeen = false;
924
- const HEARTBEAT_TIMEOUT_MS = 90_000;
925
-
926
- app.post('/api/heartbeat', (_req, res) => {
927
- lastHeartbeat = Date.now();
928
- heartbeatSeen = true;
929
- res.json({ ok: true });
930
- });
931
-
932
- app.post('/api/spawn-browser', asyncH(async (_req, res) => {
933
- const opened = openInBrowser(frontendUrl || `http://localhost:${currentPort}`);
934
- res.json({ ok: true, mode: opened.kind, url: frontendUrl });
935
- }));
936
-
937
- app.post('/api/shutdown', (_req, res) => {
938
- res.json({ ok: true, bye: 'shutting down' });
939
- setImmediate(() => gracefulShutdown('/api/shutdown'));
940
- });
941
-
942
- // ---- version / upgrade ----
943
- // `/api/version` reports the installed version (= pkg.version) and, if
944
- // reachable, the latest published on the npm registry. The result is
945
- // cached for 30 minutes in memory so the AboutPage poll doesn't hit the
946
- // registry on every render.
947
- //
948
- // `/api/upgrade` kicks off `npm i -g @bakapiano/ccsm@latest` as a
949
- // detached child. When the install completes, the child re-spawns `ccsm`
950
- // (also detached) so the launcher comes back up on the new version, and
951
- // the current server gracefulShutdowns. The frontend's OfflineBanner
952
- // covers the gap; the version router picks up the new version on the
953
- // next probe.
954
- const VERSION_CACHE_MS = 30 * 60_000;
955
- let versionCache = null; // { latest, fetchedAt }
956
- let upgradeInFlight = false;
957
-
958
- async function fetchLatestFromNpm() {
959
- // Node 18+ has a global fetch. Time out the registry call to avoid
960
- // hanging the response when the user is offline / behind a captive
961
- // portal.
962
- const ctrl = new AbortController();
963
- const t = setTimeout(() => ctrl.abort(), 4000);
964
- try {
965
- const r = await fetch('https://registry.npmjs.org/@bakapiano%2Fccsm/latest', {
966
- headers: { 'Accept': 'application/json' },
967
- signal: ctrl.signal,
968
- });
969
- if (!r.ok) throw new Error(`registry HTTP ${r.status}`);
970
- const j = await r.json();
971
- return String(j.version || '');
972
- } finally {
973
- clearTimeout(t);
974
- }
975
- }
976
-
977
- function cmpSemver(a, b) {
978
- const pa = String(a || '').split('.').map(Number);
979
- const pb = String(b || '').split('.').map(Number);
980
- for (let i = 0; i < 3; i++) {
981
- const x = pa[i] || 0, y = pb[i] || 0;
982
- if (x > y) return 1;
983
- if (x < y) return -1;
984
- }
985
- return 0;
986
- }
987
-
988
- app.get('/api/version', asyncH(async (req, res) => {
989
- const force = String(req.query.refresh || '') === '1';
990
- const now = Date.now();
991
- if (!force && versionCache && (now - versionCache.fetchedAt) < VERSION_CACHE_MS) {
992
- return res.json({
993
- current: pkg.version,
994
- latest: versionCache.latest,
995
- updateAvailable: cmpSemver(versionCache.latest, pkg.version) > 0,
996
- fetchedAt: versionCache.fetchedAt,
997
- cached: true,
998
- });
999
- }
1000
- try {
1001
- const latest = await fetchLatestFromNpm();
1002
- versionCache = { latest, fetchedAt: now };
1003
- res.json({
1004
- current: pkg.version,
1005
- latest,
1006
- updateAvailable: cmpSemver(latest, pkg.version) > 0,
1007
- fetchedAt: now,
1008
- cached: false,
1009
- });
1010
- } catch (e) {
1011
- // Swallow: surface "unknown" so the UI doesn't keep showing a stale
1012
- // "update available" badge based on a 6-hour-old cached value.
1013
- res.json({
1014
- current: pkg.version,
1015
- latest: null,
1016
- updateAvailable: false,
1017
- fetchedAt: now,
1018
- error: String(e.message || e),
1019
- });
1020
- }
1021
- }));
1022
-
1023
- app.post('/api/upgrade', asyncH(async (req, res) => {
1024
- if (upgradeInFlight) {
1025
- return res.status(409).json({ error: 'upgrade already in progress' });
1026
- }
1027
- const body = req.body || {};
1028
- const target = String(body.target || 'latest');
1029
- // Refuse anything that doesn't look like a semver dist-tag or version
1030
- // defends against `;` etc. winding up in the spawn argv even though
1031
- // we don't shell out.
1032
- if (!/^[a-z0-9.+\-^~]+$/i.test(target)) {
1033
- return res.status(400).json({ error: `invalid target: ${target}` });
1034
- }
1035
- // Optional sandbox install prefix (for testing without disturbing the
1036
- // user's real global ccsm). Validated as a plain absolute path so it
1037
- // can't be a flag injection.
1038
- const installPrefix = body.installPrefix ? String(body.installPrefix) : '';
1039
- if (installPrefix && (installPrefix.startsWith('-') || !path.isAbsolute(installPrefix))) {
1040
- return res.status(400).json({ error: 'installPrefix must be an absolute path' });
1041
- }
1042
- const respawn = body.respawn === false ? '0' : '1';
1043
- upgradeInFlight = true;
1044
- console.log(`[upgrade] target=${target}${installPrefix ? ` prefix=${installPrefix}` : ''}${respawn === '0' ? ' (no respawn)' : ''}`);
1045
-
1046
- // The helper runs OUTSIDE the package dir so npm can rename it
1047
- // without fighting open file handles. Copy the script to os.tmpdir()
1048
- // and spawn from there.
1049
- const fsp = require('node:fs/promises');
1050
- const helperSrc = path.join(__dirname, 'scripts', 'upgrade-helper.js');
1051
- const helperTmp = path.join(os.tmpdir(), `ccsm-upgrade-${process.pid}-${Date.now()}.js`);
1052
- try {
1053
- await fsp.copyFile(helperSrc, helperTmp);
1054
- } catch (e) {
1055
- upgradeInFlight = false;
1056
- return res.status(500).json({ error: `helper copy failed: ${e.message}` });
1057
- }
1058
- const args = [helperTmp, target, String(currentPort), String(process.pid), installPrefix, respawn];
1059
-
1060
- res.json({ ok: true, started: true, target, helper: helperTmp });
1061
-
1062
- // Flush response, then spawn helper detached and gracefulShutdown so
1063
- // the helper's npm install isn't fighting our open file handles.
1064
- setImmediate(() => {
1065
- const { spawn } = require('node:child_process');
1066
- try {
1067
- const child = spawn(process.execPath, args, {
1068
- detached: true,
1069
- stdio: 'ignore',
1070
- windowsHide: true,
1071
- shell: false,
1072
- });
1073
- child.unref();
1074
- console.log(`[upgrade] helper pid=${child.pid}, shutting down`);
1075
- } catch (e) {
1076
- console.error('[upgrade] helper spawn failed:', e.message);
1077
- upgradeInFlight = false;
1078
- return;
1079
- }
1080
- setTimeout(() => gracefulShutdown('upgrade'), 500);
1081
- });
1082
- }));
1083
-
1084
-
1085
- function listenWithFallback(preferred) {
1086
- return new Promise((resolve, reject) => {
1087
- const attempt = (port, tries) => {
1088
- const server = app.listen(port);
1089
- server.once('listening', () => resolve({ server, port: server.address().port }));
1090
- server.once('error', (err) => {
1091
- if (err.code !== 'EADDRINUSE') return reject(err);
1092
- if (tries < 9) attempt(port + 1, tries + 1);
1093
- else if (tries === 9) attempt(0, tries + 1);
1094
- else reject(err);
1095
- });
1096
- };
1097
- attempt(preferred, 0);
1098
- });
1099
- }
1100
-
1101
- function findAppModeBrowser() {
1102
- const candidates = [
1103
- 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
1104
- 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
1105
- 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
1106
- process.env.LOCALAPPDATA &&
1107
- path.join(process.env.LOCALAPPDATA, 'Google\\Chrome\\Application\\chrome.exe'),
1108
- 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
1109
- ].filter(Boolean);
1110
- const fs = require('node:fs');
1111
- for (const p of candidates) {
1112
- if (fs.existsSync(p)) return p;
1113
- }
1114
- return null;
1115
- }
1116
-
1117
- // Auto-open the frontend in a browser when ccsm boots. Strategy: try a
1118
- // chromeless app window first (Edge/Chrome --app=); if neither is
1119
- // installed, fall back to the OS default browser as a regular tab. On
1120
- // non-Windows we skip the bundled launcher isn't ported yet.
1121
- function openInBrowser(url) {
1122
- if (process.platform !== 'win32') return { kind: 'none', child: null };
1123
- const { spawn } = require('node:child_process');
1124
- const fs = require('node:fs');
1125
- const exe = findAppModeBrowser();
1126
- if (exe) {
1127
- const profileDir = path.join(DATA_DIR, 'browser-profile');
1128
- fs.mkdirSync(profileDir, { recursive: true });
1129
- const child = spawn(
1130
- exe,
1131
- [
1132
- `--app=${url}`,
1133
- `--user-data-dir=${profileDir}`,
1134
- '--window-size=1500,1100',
1135
- '--no-first-run',
1136
- '--no-default-browser-check',
1137
- ],
1138
- { detached: true, stdio: 'ignore' }
1139
- );
1140
- child.unref();
1141
- return { kind: 'app', child };
1142
- }
1143
- console.log('[ccsm] no Edge/Chrome found, opening default browser');
1144
- const child = spawn('cmd.exe', ['/c', 'start', '', url], {
1145
- detached: true,
1146
- stdio: 'ignore',
1147
- windowsHide: true,
1148
- });
1149
- child.unref();
1150
- return { kind: 'tab', child: null };
1151
- }
1152
-
1153
- (async () => {
1154
- const cfg = await loadConfig();
1155
- const preferredPort = process.env.CCSM_PORT ? Number(process.env.CCSM_PORT) : cfg.port;
1156
- const { server, port } = await listenWithFallback(preferredPort);
1157
- currentPort = port;
1158
-
1159
- // On boot, mark any persisted "running" sessions as exited — they
1160
- // belong to a previous server process whose PTYs are gone.
1161
- try {
1162
- const all = await persistedSessions.loadAll();
1163
- for (const s of all) {
1164
- if (s.status === 'running') {
1165
- await persistedSessions.markExited(s.id, null);
1166
- }
1167
- }
1168
- } catch (e) {
1169
- console.error('[ccsm] could not reconcile persisted sessions:', e.message);
1170
- }
1171
-
1172
- // Prewarm `tasklist` cache used by the import modal's "live" markers —
1173
- // it takes ~500ms on Windows and is the single biggest contributor to
1174
- // a slow Import dialog cold-open. Fire in the background; the lib also
1175
- // starts its own 15s refresh loop.
1176
- try { localCliSessions.prewarmLivePids(['claude.exe']); } catch {}
1177
-
1178
- if (webTerminal.available) {
1179
- let WebSocketServer;
1180
- try { ({ WebSocketServer } = require('ws')); } catch {}
1181
- if (WebSocketServer) {
1182
- const wss = new WebSocketServer({ noServer: true });
1183
- server.on('upgrade', (req, socket, head) => {
1184
- const origin = req.headers.origin;
1185
- if (origin && !ALLOWED_ORIGINS.has(origin) && !/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(origin)) {
1186
- socket.destroy();
1187
- return;
1188
- }
1189
- const m = req.url && req.url.match(/^\/ws\/terminal\/([^\/?#]+)/);
1190
- if (!m) { socket.destroy(); return; }
1191
- const id = decodeURIComponent(m[1]);
1192
- wss.handleUpgrade(req, socket, head, (ws) => webTerminal.attach(id, ws));
1193
- });
1194
- console.log('[ccsm] web terminal bridge active (WebSocket /ws/terminal/:id)');
1195
- }
1196
- }
1197
-
1198
- for (const sig of ['SIGINT', 'SIGTERM']) {
1199
- process.on(sig, () => gracefulShutdown(sig));
1200
- }
1201
- process.on('exit', () => { try { webTerminal.killAll(); } catch {} });
1202
-
1203
- const apiUrl = `http://localhost:${port}`;
1204
- const FRONTEND_URL = IS_DEV
1205
- ? apiUrl
1206
- : 'https://bakapiano.github.io/ccsm/';
1207
- frontendUrl = FRONTEND_URL;
1208
- console.log(`ccsm listening on ${apiUrl}${port !== preferredPort ? ` (requested ${preferredPort}, was taken)` : ''}`);
1209
- console.log(`frontend at ${FRONTEND_URL}`);
1210
- console.log(`data dir: ${DATA_DIR}`);
1211
- console.log(`work dir: ${cfg.workDir}`);
1212
- console.log(`clis: ${cfg.clis.map((c) => c.id).join(', ')} (default: ${cfg.defaultCliId})`);
1213
-
1214
- // CCSM_NO_BROWSER=1 (set by the ccsm:// protocol launcher) suppresses
1215
- // the auto-open entirely. Otherwise try app-mode (chromeless Edge/Chrome
1216
- // window); if no such browser is installed, openInBrowser falls back to
1217
- // the OS default browser on its own.
1218
- const opened = process.env.CCSM_NO_BROWSER === '1'
1219
- ? { kind: 'none', child: null }
1220
- : openInBrowser(FRONTEND_URL);
1221
-
1222
- if (opened.kind === 'app' && opened.child && process.env.CCSM_KEEP_ALIVE !== '1') {
1223
- const launchedAt = Date.now();
1224
- opened.child.on('exit', () => {
1225
- const alive = Date.now() - launchedAt;
1226
- if (alive < 5000) {
1227
- console.log(`[ccsm] spawned browser child exited in ${alive}ms · handed off to an existing Edge instance, staying alive`);
1228
- return;
1229
- }
1230
- const closedAt = Date.now();
1231
- setTimeout(() => {
1232
- if (lastHeartbeat > closedAt + 100) {
1233
- console.log('[ccsm] browser closed but another client is heartbeating · staying alive');
1234
- return;
1235
- }
1236
- gracefulShutdown('browser window closed');
1237
- }, 12_000);
1238
- });
1239
- console.log('[ccsm] tied to browser window — close it to stop ccsm');
1240
- }
1241
-
1242
- if (process.env.CCSM_LAUNCHER === '1' && process.env.CCSM_KEEP_ALIVE !== '1') {
1243
- setInterval(() => {
1244
- if (!heartbeatSeen) return;
1245
- if (Date.now() - lastHeartbeat > HEARTBEAT_TIMEOUT_MS) {
1246
- gracefulShutdown(`no heartbeat for ${HEARTBEAT_TIMEOUT_MS / 1000}s`);
1247
- }
1248
- }, 30_000);
1249
- console.log('[ccsm] heartbeat watchdog active');
1250
- }
1251
- })().catch((err) => {
1252
- console.error('startup failed:', err);
1253
- process.exit(1);
1254
- });
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const path = require('node:path');
5
+ const os = require('node:os');
6
+ const crypto = require('node:crypto');
7
+ const express = require('express');
8
+
9
+ const { loadConfig, saveConfig, DATA_DIR } = require('./lib/config');
10
+ const {
11
+ listWorkspaces,
12
+ findOrCreateWorkspace,
13
+ ensureReposInWorkspace,
14
+ isInside,
15
+ } = require('./lib/workspace');
16
+ const webTerminal = require('./lib/webTerminal');
17
+ const persistedSessions = require('./lib/persistedSessions');
18
+ const folders = require('./lib/folders');
19
+ // Upstream CLI session-id capture used to live in lib/cliSessionWatcher
20
+ // (poll the CLI's transcript dir, match by cwd). It's gone now — for
21
+ // CLIs that expose a "set the UUID for a new session" flag (claude +
22
+ // copilot both have --session-id <uuid>) we pre-generate the id in
23
+ // /api/sessions/new and pass it via cli.newSessionIdArgs. For CLIs
24
+ // without that flag (codex) we just don't capture an id; the user
25
+ // gets cli.resumeArgs (--continue / resume --last) on relaunch.
26
+ const localCliSessions = require('./lib/localCliSessions');
27
+
28
+ // One unified exit path: kill PTY children, then exit. v1.0 dropped the
29
+ // snapshot-on-exit behaviour because the new persistedSessions store is
30
+ // the source of truth (and is always on disk, not in memory).
31
+ let shuttingDown = false;
32
+ async function gracefulShutdown(reason) {
33
+ if (shuttingDown) return;
34
+ shuttingDown = true;
35
+ console.log(`[ccsm] shutting down · ${reason}`);
36
+ // Mark all running sessions as exited (best-effort) so the next launch
37
+ // doesn't show stale "running" rows.
38
+ try {
39
+ const all = await persistedSessions.loadAll();
40
+ for (const s of all) {
41
+ if (s.status === 'running') {
42
+ await persistedSessions.markExited(s.id, null).catch(() => {});
43
+ }
44
+ }
45
+ } catch {}
46
+ try { webTerminal.killAll(); } catch {}
47
+ process.exit(0);
48
+ }
49
+
50
+ const app = express();
51
+ app.use(express.json({ limit: '1mb' }));
52
+
53
+ // CORS · allow the hosted-frontend (GH Pages) origin to call /api/* and
54
+ // open WebSockets. Listed explicitly never reflect Origin or use '*' so
55
+ // random web pages can't reach the local backend. Localhost dev calls
56
+ // stay same-origin (browser doesn't add Origin header → middleware is a
57
+ // no-op for them).
58
+ const ALLOWED_ORIGINS = new Set([
59
+ 'https://bakapiano.github.io',
60
+ ]);
61
+ app.use((req, res, next) => {
62
+ const origin = req.headers.origin;
63
+ if (origin && ALLOWED_ORIGINS.has(origin)) {
64
+ res.setHeader('Access-Control-Allow-Origin', origin);
65
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
66
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
67
+ res.setHeader('Vary', 'Origin');
68
+ }
69
+ if (req.method === 'OPTIONS') return res.sendStatus(204);
70
+ next();
71
+ });
72
+
73
+ // Dev mode = running from a checkout (not from an npm-install location).
74
+ // Used to gate two things: (a) serving static frontend from local public/
75
+ // so a contributor can iterate without pushing to GH Pages; (b) hot-reload
76
+ // SSE endpoint that watches public/ for changes. CCSM_NO_DEV=1 disables
77
+ // both explicitly. In production (npm-installed), backend is API-only —
78
+ // frontend lives at https://bakapiano.github.io/ccsm/ (router → per-version).
79
+ const IS_DEV = !__dirname.includes(`${path.sep}node_modules${path.sep}`) && process.env.CCSM_NO_DEV !== '1';
80
+
81
+ if (IS_DEV) {
82
+ app.use(express.static(path.join(__dirname, 'public')));
83
+ }
84
+
85
+ const reloadClients = new Set();
86
+ if (IS_DEV) {
87
+ app.get('/api/dev/ping', (_req, res) => res.json({ dev: true }));
88
+ app.get('/api/dev/reload', (req, res) => {
89
+ res.setHeader('Content-Type', 'text/event-stream');
90
+ res.setHeader('Cache-Control', 'no-cache, no-transform');
91
+ res.setHeader('Connection', 'keep-alive');
92
+ res.flushHeaders();
93
+ res.write(': connected\n\n');
94
+ reloadClients.add(res);
95
+ const hb = setInterval(() => { try { res.write(': ping\n\n'); } catch {} }, 25000);
96
+ req.on('close', () => { clearInterval(hb); reloadClients.delete(res); });
97
+ });
98
+ const publicDir = path.join(__dirname, 'public');
99
+ const fs = require('node:fs');
100
+ let debounce = null;
101
+ fs.watch(publicDir, { recursive: true }, (_event, filename) => {
102
+ clearTimeout(debounce);
103
+ debounce = setTimeout(() => {
104
+ if (reloadClients.size === 0) return;
105
+ console.log(`[dev] reload · ${filename || '?'} → ${reloadClients.size} client(s)`);
106
+ for (const r of reloadClients) {
107
+ try { r.write(`event: reload\ndata: ${Date.now()}\n\n`); } catch {}
108
+ }
109
+ }, 80);
110
+ });
111
+ console.log('[dev] hot-reload watching public/');
112
+ }
113
+
114
+ function asyncH(fn) {
115
+ return (req, res) => {
116
+ Promise.resolve(fn(req, res)).catch((err) => {
117
+ console.error('[api error]', err);
118
+ res.status(500).json({ error: String(err && err.message || err) });
119
+ });
120
+ };
121
+ }
122
+
123
+ // ---- helpers ----
124
+
125
+ function pickCli(cfg, requestedId) {
126
+ const wanted = requestedId || cfg.defaultCliId;
127
+ return cfg.clis.find((c) => c.id === wanted) || cfg.clis[0];
128
+ }
129
+
130
+ // Resolve how to spawn a CLI command. Windows quirks:
131
+ // v1.1 spawn strategy is now caller-controlled via cli.shell:
132
+ // 'direct' — pty.spawn(command, args). Real .exe / absolute paths only.
133
+ // Won't find pwsh aliases / functions.
134
+ // 'pwsh' — wrap in `pwsh.exe -NoLogo -NoExit -Command "& { cmd args }"`.
135
+ // Loads $PROFILE → pwsh aliases / functions (`ccp`, `cxp`) work.
136
+ // Falls back to powershell.exe (5.x) if pwsh.exe absent.
137
+ // 'cmd' wrap in `cmd.exe /d /s /c "cmd args"`. Resolves doskey aliases
138
+ // and PATH-only names without pwsh dependency.
139
+ function resolveCommand(commandRaw, userArgs = [], shell = 'direct') {
140
+ if (!commandRaw) throw new Error('cli.command is empty');
141
+ const cmd = commandRaw.replace(/^\.[\\\/]/, '');
142
+
143
+ if (shell === 'pwsh') {
144
+ // Build a single -Command string so pwsh tokenizes args itself. The
145
+ // `& { ... }` wrapper makes pwsh execute the line as a script block —
146
+ // critical for functions (which aren't visible without invocation).
147
+ const joined = [cmd, ...userArgs.map(quoteForPwsh)].join(' ');
148
+ return {
149
+ exe: 'pwsh.exe',
150
+ prefixArgs: ['-NoLogo', '-NoExit', '-Command', `& { ${joined} }`],
151
+ fallbackExe: 'powershell.exe',
152
+ consumesUserArgs: true,
153
+ };
154
+ }
155
+
156
+ if (shell === 'cmd') {
157
+ // /d skips AutoRun, /s preserves quoting, /c runs and exits.
158
+ const joined = [cmd, ...userArgs.map(quoteForCmd)].join(' ');
159
+ return {
160
+ exe: process.env.ComSpec || 'cmd.exe',
161
+ prefixArgs: ['/d', '/s', '/c', joined],
162
+ consumesUserArgs: true,
163
+ };
164
+ }
165
+
166
+ // shell === 'direct' — bare pty.spawn. Honour .cmd/.bat/.ps1 extensions
167
+ // when an absolute path was provided so they still work without an
168
+ // explicit shell choice.
169
+ if (path.isAbsolute(cmd)) {
170
+ const ext = path.extname(cmd).toLowerCase();
171
+ if (ext === '.cmd' || ext === '.bat') {
172
+ return { exe: process.env.ComSpec || 'cmd.exe', prefixArgs: ['/d', '/s', '/c', cmd], consumesUserArgs: false };
173
+ }
174
+ if (ext === '.ps1') {
175
+ return { exe: 'powershell.exe', prefixArgs: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', cmd], consumesUserArgs: false };
176
+ }
177
+ return { exe: cmd, prefixArgs: [], consumesUserArgs: false };
178
+ }
179
+ // Bare name with shell=direct: defer to cmd.exe so Windows resolves
180
+ // against PATH. Same behavior as before — preserves user expectations
181
+ // for `claude` / `codex` configs that don't set shell.
182
+ return { exe: process.env.ComSpec || 'cmd.exe', prefixArgs: ['/d', '/s', '/c', cmd], consumesUserArgs: false };
183
+ }
184
+
185
+ function quoteForPwsh(s) {
186
+ if (s === '' || /[\s'"`$]/.test(s)) return `'${String(s).replace(/'/g, "''")}'`;
187
+ return s;
188
+ }
189
+ function quoteForCmd(s) {
190
+ if (s === '' || /[\s"&|<>^]/.test(s)) return `"${String(s).replace(/"/g, '""')}"`;
191
+ return s;
192
+ }
193
+
194
+ function spawnCliSession({ cli, cwd, sessionId, meta, extraArgs = [] }) {
195
+ if (!webTerminal.available) {
196
+ const e = new Error('node-pty unavailable · cannot spawn web terminal');
197
+ e.code = 'PTY_UNAVAILABLE';
198
+ throw e;
199
+ }
200
+ // For shell wrappers (pwsh/cmd) we need to bake BOTH cli.args and
201
+ // extraArgs into the single quoted command string — otherwise extraArgs
202
+ // would become args to the shell itself, not the wrapped command.
203
+ // Re-resolve here when extraArgs is present so the quoting is correct.
204
+ const resolved = resolveCommand(
205
+ cli.command,
206
+ [...(cli.args || []), ...extraArgs],
207
+ cli.shell || 'direct',
208
+ );
209
+ const { exe, prefixArgs, fallbackExe, consumesUserArgs } = resolved;
210
+ const args = consumesUserArgs
211
+ ? prefixArgs
212
+ : [...prefixArgs, ...(cli.args || []), ...extraArgs];
213
+ // Merge user-scope PATH from registry into the env we hand the PTY.
214
+ // spawnEnv() also strips duplicate path-case keys so our override
215
+ // doesn't get shadowed by the inherited `Path` from process.env.
216
+ const env = spawnEnv(cli.env);
217
+ const trySpawn = (executable) => webTerminal.spawn({
218
+ id: sessionId,
219
+ command: executable,
220
+ args,
221
+ cwd,
222
+ env,
223
+ meta: { ...meta, cliId: cli.id, cliName: cli.name },
224
+ onData: () => { persistedSessions.touch(sessionId).catch(() => {}); },
225
+ onExit: ({ exitCode }) => {
226
+ persistedSessions.markExited(sessionId, exitCode).catch(() => {});
227
+ },
228
+ });
229
+ try {
230
+ const entry = trySpawn(exe);
231
+ return entry;
232
+ } catch (e) {
233
+ if (fallbackExe && /ENOENT|cannot find|not recognized/i.test(String(e && e.message || e))) {
234
+ const entry = trySpawn(fallbackExe);
235
+ return entry;
236
+ }
237
+ throw e;
238
+ }
239
+ }
240
+
241
+ // Read user PATH from registry once at boot, prepend to process PATH.
242
+ // On platforms other than Windows or if the read fails, fall back to
243
+ // process.env.PATH unchanged.
244
+ let mergedUserPath = null;
245
+ function buildMergedUserPath() {
246
+ if (process.platform !== 'win32') return process.env.PATH;
247
+ try {
248
+ const { spawnSync } = require('node:child_process');
249
+ const r = spawnSync('reg.exe', ['query', 'HKCU\\Environment', '/v', 'PATH'], { encoding: 'utf8', windowsHide: true });
250
+ if (r.status !== 0 || !r.stdout) return process.env.PATH;
251
+ const line = r.stdout.split(/\r?\n/).find((l) => /\bPATH\b/i.test(l) && /REG_(EXPAND_)?SZ/i.test(l));
252
+ if (!line) return process.env.PATH;
253
+ const m = line.match(/REG_(?:EXPAND_)?SZ\s+(.+)$/);
254
+ if (!m) return process.env.PATH;
255
+ // Expand %VAR% references manually (REG_EXPAND_SZ keeps them literal).
256
+ const userPath = m[1].replace(/%([^%]+)%/g, (_, name) => process.env[name] || '');
257
+ const existing = (process.env.PATH || '').split(';').map((s) => s.trim()).filter(Boolean);
258
+ const adds = userPath.split(';').map((s) => s.trim()).filter(Boolean);
259
+ const merged = [];
260
+ const seen = new Set();
261
+ for (const p of [...adds, ...existing]) {
262
+ const k = p.toLowerCase();
263
+ if (seen.has(k)) continue;
264
+ seen.add(k);
265
+ merged.push(p);
266
+ }
267
+ return merged.join(';');
268
+ } catch {
269
+ return process.env.PATH;
270
+ }
271
+ }
272
+ mergedUserPath = buildMergedUserPath();
273
+
274
+ // Hand back a fresh env for spawning a child, with PATH overridden by
275
+ // our merged user PATH and any duplicate case variants of "path"
276
+ // stripped first. Windows env lookup is case-insensitive but the env
277
+ // block we hand CreateProcess is an ordered byte buffer — if both
278
+ // `Path` (inherited from process.env, OS canonical case) and `PATH`
279
+ // (our override) are present, Windows resolves to whichever comes
280
+ // first in the block. Node's Object.keys preserves insertion order,
281
+ // so the inherited `Path` would win and our merged override silently
282
+ // disappear. Strip all path-shaped keys first, then add the merge.
283
+ function spawnEnv(extraEnv = {}) {
284
+ const env = { ...process.env, ...extraEnv };
285
+ if (process.platform === 'win32') {
286
+ for (const k of Object.keys(env)) {
287
+ if (k.toLowerCase() === 'path') delete env[k];
288
+ }
289
+ }
290
+ if (mergedUserPath) env.PATH = mergedUserPath;
291
+ return env;
292
+ }
293
+
294
+ // ---- config ----
295
+
296
+ // Per-CLI install probe. Looks up the command on PATH using `where` (win)
297
+ // or `which` (posix). Result is cached forever — restart ccsm after
298
+ // installing/uninstalling a CLI to refresh. Cheap (10ms cold, 0ms cached).
299
+ const cliProbeCache = new Map();
300
+ function probeCli(command) {
301
+ if (!command) return null;
302
+ if (cliProbeCache.has(command)) return cliProbeCache.get(command);
303
+ const { spawnSync } = require('node:child_process');
304
+ let resolvedPath = null;
305
+ try {
306
+ const isWin = process.platform === 'win32';
307
+ const cmd = isWin ? 'where.exe' : 'which';
308
+ const env = { ...process.env };
309
+ if (mergedUserPath) env.PATH = mergedUserPath;
310
+ const r = spawnSync(cmd, [command], { encoding: 'utf8', windowsHide: true, env });
311
+ if (r.status === 0 && r.stdout) {
312
+ resolvedPath = r.stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0] || null;
313
+ }
314
+ } catch {}
315
+ cliProbeCache.set(command, resolvedPath);
316
+ return resolvedPath;
317
+ }
318
+
319
+ function decorateConfigWithProbes(cfg) {
320
+ return {
321
+ ...cfg,
322
+ clis: (cfg.clis || []).map((c) => {
323
+ const path = probeCli(c.command);
324
+ return { ...c, installed: !!path, installPath: path };
325
+ }),
326
+ };
327
+ }
328
+
329
+ app.get('/api/config', asyncH(async (_req, res) => {
330
+ res.json(decorateConfigWithProbes(await loadConfig()));
331
+ }));
332
+
333
+ app.put('/api/config', asyncH(async (req, res) => {
334
+ const cfg = await saveConfig(req.body || {});
335
+ res.json(decorateConfigWithProbes(cfg));
336
+ }));
337
+
338
+ // ---- CLI probe / test ----
339
+ //
340
+ // Run the user's configured command with `--version` and report back
341
+ // stdout/stderr + whether the output looks like the claimed CLI type.
342
+ // Used by the Configure page "Test" button so the user can verify the
343
+ // command resolves + actually launches the right tool BEFORE saving.
344
+ // Body: { command, args?, shell?, type? }. args is ignored for the
345
+ // version probe we always append `--version` directly so the user's
346
+ // runtime args (e.g. --dangerously-skip-permissions) don't perturb the
347
+ // quick probe.
348
+ app.post('/api/clis/test', asyncH(async (req, res) => {
349
+ const { spawn } = require('node:child_process');
350
+ const body = req.body || {};
351
+ const command = String(body.command || '').trim();
352
+ const shell = ['direct', 'pwsh', 'cmd'].includes(body.shell) ? body.shell : 'direct';
353
+ const type = ['claude', 'codex', 'copilot', 'other'].includes(body.type) ? body.type : 'other';
354
+ if (!command) return res.status(400).json({ error: 'command required' });
355
+
356
+ // Build the test exec. Same shell-wrapping rules as resolveCommand,
357
+ // but we force `--version` as the only arg and we DROP `-NoExit`
358
+ // from the pwsh wrapper so pwsh terminates after printing.
359
+ let exe, args;
360
+ const cmd = command.replace(/^\.[\\\/]/, '');
361
+ const versionArg = '--version';
362
+ if (shell === 'pwsh') {
363
+ const joined = `& ${/[\s'"\`$]/.test(cmd) ? `'${cmd.replace(/'/g, "''")}'` : cmd} ${versionArg}`;
364
+ exe = 'pwsh.exe';
365
+ args = ['-NoLogo', '-Command', joined];
366
+ } else if (shell === 'cmd') {
367
+ exe = process.env.ComSpec || 'cmd.exe';
368
+ args = ['/d', '/s', '/c', `${cmd} ${versionArg}`];
369
+ } else if (path.isAbsolute(cmd)) {
370
+ const ext = path.extname(cmd).toLowerCase();
371
+ if (ext === '.cmd' || ext === '.bat') {
372
+ exe = process.env.ComSpec || 'cmd.exe';
373
+ args = ['/d', '/s', '/c', cmd, versionArg];
374
+ } else if (ext === '.ps1') {
375
+ exe = 'powershell.exe';
376
+ args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', cmd, versionArg];
377
+ } else {
378
+ exe = cmd;
379
+ args = [versionArg];
380
+ }
381
+ } else {
382
+ exe = process.env.ComSpec || 'cmd.exe';
383
+ args = ['/d', '/s', '/c', cmd, versionArg];
384
+ }
385
+
386
+ const t0 = Date.now();
387
+ let stdout = '';
388
+ let stderr = '';
389
+ let exitCode = null;
390
+ let timedOut = false;
391
+ let spawnError = null;
392
+ try {
393
+ const child = spawn(exe, args, { env: spawnEnv(), windowsHide: true });
394
+ const killer = setTimeout(() => { timedOut = true; try { child.kill(); } catch {} }, 5000);
395
+ child.stdout.on('data', (d) => { stdout += d.toString(); if (stdout.length > 8192) stdout = stdout.slice(0, 8192); });
396
+ child.stderr.on('data', (d) => { stderr += d.toString(); if (stderr.length > 8192) stderr = stderr.slice(0, 8192); });
397
+ exitCode = await new Promise((resolve, reject) => {
398
+ child.on('exit', (code) => { clearTimeout(killer); resolve(code); });
399
+ child.on('error', (err) => { clearTimeout(killer); reject(err); });
400
+ });
401
+ } catch (e) {
402
+ spawnError = String(e && e.message || e);
403
+ }
404
+ const durationMs = Date.now() - t0;
405
+
406
+ const out = (stdout + '\n' + stderr).toLowerCase();
407
+ const PATTERNS = {
408
+ claude: /claude/,
409
+ codex: /codex|openai/,
410
+ copilot: /copilot/,
411
+ };
412
+ const matchedType = type === 'other' ? null : (PATTERNS[type] ? PATTERNS[type].test(out) : null);
413
+ const ok = !spawnError && !timedOut && exitCode === 0;
414
+ res.json({
415
+ ok, exitCode, durationMs, timedOut, spawnError,
416
+ stdout: stdout.trim(),
417
+ stderr: stderr.trim(),
418
+ matchedType,
419
+ expectedType: type,
420
+ spawned: { exe, args },
421
+ });
422
+ }));
423
+
424
+ // ---- folders ----
425
+
426
+ app.get('/api/folders', asyncH(async (_req, res) => {
427
+ const list = await folders.loadAll();
428
+ list.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
429
+ res.json({ folders: list });
430
+ }));
431
+
432
+ app.post('/api/folders', asyncH(async (req, res) => {
433
+ const name = req.body && req.body.name;
434
+ if (!name) return res.status(400).json({ error: 'name required' });
435
+ res.json({ folder: await folders.create({ name }) });
436
+ }));
437
+
438
+ app.put('/api/folders/:id', asyncH(async (req, res) => {
439
+ const updated = await folders.update(req.params.id, req.body || {});
440
+ if (!updated) return res.status(404).json({ error: 'not found' });
441
+ res.json({ folder: updated });
442
+ }));
443
+
444
+ app.delete('/api/folders/:id', asyncH(async (req, res) => {
445
+ // Move all sessions in this folder to Unsorted before delete.
446
+ const all = await persistedSessions.loadAll();
447
+ for (const s of all) {
448
+ if (s.folderId === req.params.id) {
449
+ await persistedSessions.setFolder(s.id, null);
450
+ }
451
+ }
452
+ const removed = await folders.remove(req.params.id);
453
+ res.json({ removed });
454
+ }));
455
+
456
+ app.post('/api/folders/reorder', asyncH(async (req, res) => {
457
+ const ids = req.body && req.body.ids;
458
+ if (!Array.isArray(ids)) return res.status(400).json({ error: 'ids array required' });
459
+ const next = await folders.reorder(ids);
460
+ res.json({ folders: next });
461
+ }));
462
+
463
+ // ---- sessions (persisted, ccsm-owned) ----
464
+
465
+ app.get('/api/sessions', asyncH(async (_req, res) => {
466
+ const list = await persistedSessions.loadAll();
467
+ // Cross-check status against live PTY pool so a stale "running" record
468
+ // doesn't survive a server restart.
469
+ const live = new Set(webTerminal.list().filter((t) => !t.exitedAt).map((t) => t.id));
470
+ for (const s of list) {
471
+ if (s.status === 'running' && !live.has(s.id)) {
472
+ s.status = 'exited';
473
+ }
474
+ }
475
+ // Per-session activity probe (transcript mtime → working/idle). Cheap
476
+ // when cached — most calls are a single fs.stat(). Only runs for
477
+ // running sessions; exited ones get 'unknown'.
478
+ const cfg = await loadConfig();
479
+ const cliById = new Map((cfg.clis || []).map((c) => [c.id, c]));
480
+ const { probeActivity } = require('./lib/cliActivity');
481
+ await Promise.all(list.map(async (s) => {
482
+ if (s.status !== 'running') { s.activity = 'unknown'; return; }
483
+ try { s.activity = await probeActivity(s, cliById.get(s.cliId)); }
484
+ catch { s.activity = 'unknown'; }
485
+ }));
486
+ res.json({ sessions: list, takenAt: Date.now() });
487
+ }));
488
+
489
+ app.put('/api/sessions/:id', asyncH(async (req, res) => {
490
+ const patch = {};
491
+ if (typeof req.body.title === 'string') patch.title = req.body.title;
492
+ if ('folderId' in (req.body || {})) patch.folderId = req.body.folderId || null;
493
+ const updated = await persistedSessions.update(req.params.id, patch);
494
+ if (!updated) return res.status(404).json({ error: 'not found' });
495
+ res.json({ session: updated });
496
+ }));
497
+
498
+ app.delete('/api/sessions/:id', asyncH(async (req, res) => {
499
+ // Kill PTY first if it's still alive, then drop the record.
500
+ try { webTerminal.kill(req.params.id); } catch {}
501
+ const removed = await persistedSessions.remove(req.params.id);
502
+ try { require('./lib/cliActivity').releaseSession(req.params.id); } catch {}
503
+ res.json({ removed });
504
+ }));
505
+
506
+ // ---- workspaces ----
507
+
508
+ // ---- directory browser ----
509
+ // Lets the launch picker walk the filesystem so users can pick any
510
+ // existing directory as the session cwd. Returns the immediate child
511
+ // dirs of `path` (defaults to home), plus a few hardcoded "starts"
512
+ // (home, workDir, drive roots on Windows).
513
+ app.get('/api/browse', asyncH(async (req, res) => {
514
+ const fs = require('node:fs/promises');
515
+ const os = require('node:os');
516
+ const target = req.query.path ? path.resolve(String(req.query.path)) : os.homedir();
517
+ let entries = [];
518
+ let exists = true;
519
+ try {
520
+ const list = await fs.readdir(target, { withFileTypes: true });
521
+ entries = list
522
+ .filter((d) => d.isDirectory() && !d.name.startsWith('.'))
523
+ .map((d) => ({ name: d.name, path: path.join(target, d.name) }))
524
+ .sort((a, b) => a.name.localeCompare(b.name));
525
+ } catch (e) {
526
+ exists = false;
527
+ }
528
+ const parent = path.dirname(target);
529
+ const cfg = await loadConfig();
530
+ const starts = [
531
+ { label: 'Home', path: os.homedir() },
532
+ { label: 'Work dir', path: cfg.workDir },
533
+ ];
534
+ if (process.platform === 'win32') {
535
+ // Best-effort drive enumeration so users on D:\ etc can hop roots.
536
+ for (const letter of ['C', 'D', 'E', 'F', 'G', 'H']) {
537
+ const root = `${letter}:\\`;
538
+ try { await fs.access(root); starts.push({ label: `${letter}:\\`, path: root }); }
539
+ catch {}
540
+ }
541
+ }
542
+ res.json({
543
+ path: target,
544
+ parent: parent === target ? null : parent,
545
+ exists,
546
+ entries,
547
+ starts,
548
+ });
549
+ }));
550
+
551
+ app.get('/api/workspaces', asyncH(async (req, res) => {
552
+ const cfg = await loadConfig();
553
+ const workspaces = await listWorkspaces({
554
+ workDir: cfg.workDir,
555
+ repos: cfg.repos,
556
+ });
557
+ // Recompute inUse based on persistedSessions: a workspace is in use
558
+ // iff any RUNNING ccsm session lives at-or-inside it.
559
+ const allSess = await persistedSessions.loadAll();
560
+ const busy = new Set(
561
+ allSess.filter((s) => s.status === 'running').map((s) => path.resolve(s.cwd).toLowerCase())
562
+ );
563
+ for (const w of workspaces) {
564
+ w.inUse = busy.has(path.resolve(w.path).toLowerCase());
565
+ w.sessionsHere = allSess
566
+ .filter((s) => s.status === 'running' && path.resolve(s.cwd).toLowerCase() === path.resolve(w.path).toLowerCase())
567
+ .map((s) => s.id);
568
+ }
569
+ res.json({ workDir: cfg.workDir, repos: cfg.repos, workspaces });
570
+ }));
571
+
572
+ // Delete a workspace directory. Refuses if any RUNNING session lives
573
+ // inside it, or if the resolved path escapes workDir. The name comes
574
+ // from the URL — we resolve it against workDir and verify containment.
575
+ app.delete('/api/workspaces/:name', asyncH(async (req, res) => {
576
+ const fsp = require('node:fs/promises');
577
+ const cfg = await loadConfig();
578
+ const name = String(req.params.name || '');
579
+ // Reject anything that tries to escape via separators / traversal.
580
+ if (!name || /[\\/]|^\.\.$|^\.$/.test(name)) {
581
+ return res.status(400).json({ error: 'invalid workspace name' });
582
+ }
583
+ const target = path.resolve(cfg.workDir, name);
584
+ if (!isInside(target, cfg.workDir) || path.resolve(target) === path.resolve(cfg.workDir)) {
585
+ return res.status(400).json({ error: 'workspace must live under workDir' });
586
+ }
587
+ try {
588
+ const st = await fsp.stat(target);
589
+ if (!st.isDirectory()) return res.status(400).json({ error: 'not a directory' });
590
+ } catch {
591
+ return res.status(404).json({ error: 'workspace not found' });
592
+ }
593
+ const allSess = await persistedSessions.loadAll();
594
+ const inUse = allSess.some((s) =>
595
+ s.status === 'running' && isInside(s.cwd, target)
596
+ );
597
+ if (inUse) return res.status(409).json({ error: 'workspace is in use by a running session' });
598
+ await fsp.rm(target, { recursive: true, force: true });
599
+ res.json({ ok: true });
600
+ }));
601
+
602
+ // ---- new session ----
603
+ // body: { cliId?, repos?, workspace?, folderId?, launch?: true }
604
+ // Streams NDJSON: workspace / clone-* / launched / done.
605
+ app.post('/api/sessions/new', async (req, res) => {
606
+ res.setHeader('Content-Type', 'application/x-ndjson');
607
+ res.setHeader('Cache-Control', 'no-cache, no-transform');
608
+ res.setHeader('X-Accel-Buffering', 'no');
609
+ if (typeof res.flushHeaders === 'function') res.flushHeaders();
610
+
611
+ const emit = (obj) => { res.write(JSON.stringify(obj) + '\n'); };
612
+ const fail = (msg, extra) => {
613
+ emit({ type: 'done', success: false, error: msg, ...extra });
614
+ res.end();
615
+ };
616
+
617
+ try {
618
+ const cfg = await loadConfig();
619
+ const cli = pickCli(cfg, req.body && req.body.cliId);
620
+ if (!cli) return fail('No CLI configured. Add one in Configure → CLIs.');
621
+
622
+ const explicitRepos = Array.isArray(req.body && req.body.repos);
623
+ const wantedNames = explicitRepos
624
+ ? req.body.repos
625
+ : cfg.repos.filter((r) => r.defaultSelected).map((r) => r.name);
626
+ const wantedRepos = cfg.repos.filter((r) => wantedNames.includes(r.name));
627
+ if (wantedRepos.length === 0 && !explicitRepos && wantedNames.length > 0) {
628
+ return fail('No matching repos found');
629
+ }
630
+
631
+ let workspace;
632
+ let created = false;
633
+ // Three cwd modes:
634
+ // 1. body.cwd — user picked an existing directory; skip clone.
635
+ // 2. body.workspace reuse a named workspace under workDir.
636
+ // 3. (neither) — auto-allocate a fresh ws-N.
637
+ if (req.body && req.body.cwd) {
638
+ const fsmod = require('node:fs/promises');
639
+ const cwd = path.resolve(String(req.body.cwd));
640
+ try {
641
+ const st = await fsmod.stat(cwd);
642
+ if (!st.isDirectory()) return fail(`${cwd} is not a directory`);
643
+ } catch {
644
+ return fail(`directory not found: ${cwd}`);
645
+ }
646
+ workspace = { name: path.basename(cwd) || cwd, path: cwd };
647
+ } else if (req.body && req.body.workspace) {
648
+ const all = await listWorkspaces({ workDir: cfg.workDir, repos: cfg.repos });
649
+ workspace = all.find((w) => w.name === req.body.workspace);
650
+ if (!workspace) return fail(`workspace ${req.body.workspace} not found`);
651
+ } else {
652
+ const r = await findOrCreateWorkspace({
653
+ workDir: cfg.workDir,
654
+ repos: cfg.repos,
655
+ requireUnused: true,
656
+ });
657
+ workspace = r.workspace;
658
+ created = r.created;
659
+ }
660
+ emit({ type: 'workspace', workspace, created });
661
+
662
+ // Skip clone entirely when user picked an existing directory — we
663
+ // don't want to dump random repos into someone's project.
664
+ const cloneResults = (req.body && req.body.cwd) ? [] : await ensureReposInWorkspace({
665
+ workspacePath: workspace.path,
666
+ repos: wantedRepos,
667
+ onRepoStart: (repo) =>
668
+ emit({ type: 'clone-start', repo: repo.name, url: repo.url }),
669
+ onProgress: (repo, p) =>
670
+ emit({ type: 'clone-progress', repo: repo.name, ...p }),
671
+ onLine: (repo, line) =>
672
+ emit({ type: 'clone-line', repo: repo.name, line }),
673
+ onRepoEnd: (repo, result) =>
674
+ emit({ type: 'clone-end', repo: repo.name, ...result }),
675
+ });
676
+ const failed = cloneResults.filter((r) => !r.ok);
677
+ if (failed.length > 0) return fail('Some repos failed to clone', { cloneResults });
678
+
679
+ const shouldLaunch = req.body && req.body.launch !== false;
680
+ let launched = null;
681
+ if (shouldLaunch) {
682
+ // Pre-assign the upstream CLI session UUID so we never have to
683
+ // poll/scan the transcript dir to find out what id the CLI picked.
684
+ // - claude / copilot expose `--session-id <uuid>` natively.
685
+ // - codex has no flag, but accepts `resume <uuid>` against a
686
+ // pre-existing rollout file. We seed a fake file (see
687
+ // lib/codexSeed.js) so the first launch is a resume against
688
+ // our seed; codex then appends to the same file.
689
+ const newIdTpl = Array.isArray(cli.newSessionIdArgs) ? cli.newSessionIdArgs : [];
690
+ const preAssignedId = newIdTpl.length > 0 ? crypto.randomUUID() : null;
691
+ const newSessionArgs = preAssignedId
692
+ ? newIdTpl.map((a) => (typeof a === 'string' ? a.replace(/<id>/g, preAssignedId) : a))
693
+ : [];
694
+
695
+ if (preAssignedId && cli.type === 'codex') {
696
+ try {
697
+ const { seedCodexSession } = require('./lib/codexSeed');
698
+ await seedCodexSession({ id: preAssignedId, cwd: workspace.path, cli });
699
+ } catch (e) {
700
+ return fail(`codex seed failed: ${e.message}`);
701
+ }
702
+ }
703
+
704
+ // Create the persistedSessions record FIRST so spawnCliSession can
705
+ // use its id as the PTY id (matching ids simplify resume/attach).
706
+ const record = await persistedSessions.create({
707
+ cliId: cli.id,
708
+ cwd: workspace.path,
709
+ workspace: workspace.name,
710
+ repos: wantedRepos.map((r) => r.name),
711
+ folderId: (req.body && req.body.folderId) || null,
712
+ title: '',
713
+ cliSessionId: preAssignedId || undefined,
714
+ });
715
+ try {
716
+ const entry = spawnCliSession({
717
+ cli,
718
+ cwd: workspace.path,
719
+ sessionId: record.id,
720
+ meta: { title: workspace.name, workspace: workspace.name, cwd: workspace.path },
721
+ extraArgs: newSessionArgs,
722
+ });
723
+ await persistedSessions.markRunning(record.id, entry.meta.pid);
724
+ launched = { id: record.id, pid: entry.meta.pid, cliId: cli.id };
725
+ emit({ type: 'launched', launched });
726
+ } catch (e) {
727
+ await persistedSessions.markExited(record.id, null);
728
+ return fail(`spawn failed: ${e.message}`);
729
+ }
730
+ }
731
+
732
+ emit({ type: 'done', success: true, workspace, created, cloneResults, launched });
733
+ res.end();
734
+ } catch (e) {
735
+ console.error('[/api/sessions/new]', e);
736
+ fail(String(e && e.message || e));
737
+ }
738
+ });
739
+
740
+ // ---- list local CLI sessions discovered on disk (for "adopt") ----
741
+ // Returns sessions found in ~/.claude / ~/.codex / ~/.copilot that
742
+ // aren't yet adopted by ccsm. Frontend uses this in the Import modal.
743
+ app.get('/api/cli-sessions/:cliType', asyncH(async (req, res) => {
744
+ const type = String(req.params.cliType || '').toLowerCase();
745
+ if (!['claude', 'codex', 'copilot'].includes(type)) {
746
+ return res.status(400).json({ error: `unsupported cli type: ${type}` });
747
+ }
748
+ const offset = Math.max(0, Number(req.query.offset) || 0);
749
+ const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 30));
750
+
751
+ const [page, adopted] = await Promise.all([
752
+ localCliSessions.listPaginated(type, { offset, limit }),
753
+ persistedSessions.loadAll(),
754
+ ]);
755
+
756
+ const adoptedIds = new Set(adopted.map((s) => s.cliSessionId).filter(Boolean));
757
+ const sessions = page.sessions.map((s) => ({
758
+ ...s,
759
+ adopted: adoptedIds.has(s.cliSessionId),
760
+ }));
761
+ res.json({
762
+ sessions,
763
+ totalActive: page.totalActive,
764
+ totalNonActive: page.totalNonActive,
765
+ total: page.totalActive + page.totalNonActive,
766
+ offset: page.offset,
767
+ limit: page.limit,
768
+ hasMore: page.hasMore,
769
+ });
770
+ }));
771
+
772
+ // ---- adopt: create a ccsm record pointing at an existing CLI session ----
773
+ // Body: { cliId, cliSessionId, cwd, title?, folderId? }
774
+ // Doesn't spawn the new entry shows up as "exited" in the sidebar;
775
+ // clicking it kicks off the regular resume flow which uses
776
+ // `cli.resumeIdArgs` ('--resume <id>') so the upstream session reattaches.
777
+ app.post('/api/sessions/adopt', asyncH(async (req, res) => {
778
+ const { cliId, cliSessionId, cwd, title, folderId } = req.body || {};
779
+ if (!cliId || !cliSessionId || !cwd) {
780
+ return res.status(400).json({ error: 'cliId, cliSessionId and cwd required' });
781
+ }
782
+ const cfg = await loadConfig();
783
+ const cli = pickCli(cfg, cliId);
784
+ if (!cli) return res.status(400).json({ error: `CLI ${cliId} not configured` });
785
+
786
+ // Normalize the cwd up front. /api/sessions/new also resolves cwd, and
787
+ // the workspaces "in use" check (GET /api/workspaces) does
788
+ // path.resolve(s.cwd).toLowerCase() — adopted records must match the
789
+ // same shape, otherwise an adopted+running session leaves its
790
+ // workspace falsely marked as free and a fresh launch could collide.
791
+ const resolvedCwd = path.resolve(cwd);
792
+ try {
793
+ const fsmod = require('node:fs/promises');
794
+ const st = await fsmod.stat(resolvedCwd);
795
+ if (!st.isDirectory()) {
796
+ return res.status(400).json({ error: `cwd is not a directory: ${resolvedCwd}` });
797
+ }
798
+ } catch (e) {
799
+ return res.status(400).json({ error: `cwd not found: ${resolvedCwd}` });
800
+ }
801
+
802
+ // Refuse duplicates: if any ccsm record already owns this upstream
803
+ // session id, return it so the caller can jump to it.
804
+ const all = await persistedSessions.loadAll();
805
+ const dup = all.find((s) => s.cliSessionId === cliSessionId);
806
+ if (dup) return res.json({ session: dup, alreadyAdopted: true });
807
+
808
+ const workspace = path.basename(resolvedCwd) || resolvedCwd;
809
+ // Create directly with status='exited' + cliSessionId set, so a
810
+ // concurrent GET /api/sessions can never observe a "running but no
811
+ // PTY" intermediate state.
812
+ const record = await persistedSessions.create({
813
+ cliId,
814
+ cwd: resolvedCwd,
815
+ workspace,
816
+ folderId: folderId || null,
817
+ title: title || '',
818
+ repos: [],
819
+ status: 'exited',
820
+ cliSessionId,
821
+ });
822
+ res.json({ session: record, alreadyAdopted: false });
823
+ }));
824
+
825
+ // ---- resume a previous session in the same cwd / cli ----
826
+ app.post('/api/sessions/:id/resume', asyncH(async (req, res) => {
827
+ const record = await persistedSessions.get(req.params.id);
828
+ if (!record) return res.status(404).json({ error: 'session not found' });
829
+ // Already running and attached → no-op, just return its id.
830
+ const live = webTerminal.get(record.id);
831
+ if (live && !live.exitedAt) {
832
+ // Pool says we're alive but the record may be stale (e.g. a prior
833
+ // markRunning got clobbered by an OLD entry's onExit before the
834
+ // respawn-guard landed, or boot mark-exited ran after a pool entry
835
+ // was already wired). Reconcile the file to match the pool so the
836
+ // frontend doesn't get stuck on "Resuming session…" forever.
837
+ if (record.status !== 'running' || record.pid !== live.meta.pid) {
838
+ try { await persistedSessions.markRunning(record.id, live.meta.pid); } catch {}
839
+ }
840
+ return res.json({ launched: { id: record.id, pid: live.meta.pid, cliId: record.cliId } });
841
+ }
842
+ const cfg = await loadConfig();
843
+ const cli = pickCli(cfg, record.cliId);
844
+ if (!cli) return res.status(400).json({ error: `CLI ${record.cliId} no longer configured` });
845
+ try {
846
+ // Resume always uses the captured upstream session UUID. With the
847
+ // pre-assignment refactor every ccsm-launched session has one (via
848
+ // newSessionIdArgs flag or the codex seed trick), and adopted
849
+ // sessions inherit theirs from the disk scan.
850
+ const extraArgs = buildResumeArgs(cli, record);
851
+ const entry = spawnCliSession({
852
+ cli,
853
+ cwd: record.cwd,
854
+ sessionId: record.id,
855
+ meta: { title: record.title || record.workspace, workspace: record.workspace, cwd: record.cwd },
856
+ extraArgs,
857
+ });
858
+ await persistedSessions.markRunning(record.id, entry.meta.pid);
859
+ res.json({ launched: { id: record.id, pid: entry.meta.pid, cliId: cli.id } });
860
+ } catch (e) {
861
+ res.status(500).json({ error: e.message });
862
+ }
863
+ }));
864
+
865
+ // Build the args appended on resume: substitute the captured upstream
866
+ // session UUID into cli.resumeIdArgs (e.g. ['--resume', '<id>']
867
+ // ['--resume', '7c28...']). Throws if either piece is missing by
868
+ // design every ccsm session has a pre-assigned id, so missing one means
869
+ // something upstream is misconfigured (adopt without id, user-added CLI
870
+ // without resumeIdArgs, etc.) and we surface that instead of silently
871
+ // re-launching without the id.
872
+ function buildResumeArgs(cli, record) {
873
+ const id = record.cliSessionId;
874
+ const tpl = Array.isArray(cli.resumeIdArgs) ? cli.resumeIdArgs : [];
875
+ if (!id) throw new Error(`session ${record.id} has no cliSessionId — cannot resume`);
876
+ if (tpl.length === 0) throw new Error(`CLI ${cli.id} has no resumeIdArgs configured`);
877
+ return tpl.map((a) => (typeof a === 'string' ? a.replace(/<id>/g, id) : a));
878
+ }
879
+
880
+ // ---- capabilities probe ----
881
+ app.get('/api/capabilities', (_req, res) => res.json({
882
+ webTerminal: webTerminal.available,
883
+ webTerminalError: webTerminal.available ? null : String(webTerminal.loadError?.message || 'unavailable'),
884
+ }));
885
+
886
+ // ---- health ----
887
+ const pkg = require('./package.json');
888
+ app.get('/api/health', (_req, res) => res.json({ ok: true, pid: process.pid, version: pkg.version, name: pkg.name }));
889
+
890
+ // ---- lifecycle ----
891
+ let currentPort = 0;
892
+ let frontendUrl = '';
893
+ let lastHeartbeat = Date.now();
894
+ let heartbeatSeen = false;
895
+ const HEARTBEAT_TIMEOUT_MS = 90_000;
896
+
897
+ app.post('/api/heartbeat', (_req, res) => {
898
+ lastHeartbeat = Date.now();
899
+ heartbeatSeen = true;
900
+ res.json({ ok: true });
901
+ });
902
+
903
+ app.post('/api/spawn-browser', asyncH(async (_req, res) => {
904
+ const opened = openInBrowser(frontendUrl || `http://localhost:${currentPort}`);
905
+ res.json({ ok: true, mode: opened.kind, url: frontendUrl });
906
+ }));
907
+
908
+ app.post('/api/shutdown', (_req, res) => {
909
+ res.json({ ok: true, bye: 'shutting down' });
910
+ setImmediate(() => gracefulShutdown('/api/shutdown'));
911
+ });
912
+
913
+ // Restart: in production, spawn the restart-helper detached then
914
+ // gracefulShutdown — the helper waits for the port to free and respawns
915
+ // `ccsm.cmd` (with CCSM_NO_BROWSER so we don't pop a new window — the
916
+ // frontend bounces through OfflineBanner / version router back into the
917
+ // new backend). In dev (CCSM_DEV=1, set by scripts/dev.js), we skip the
918
+ // helper entirely: just gracefulShutdown. scripts/dev.js sees its child
919
+ // exit and respawns `node --watch server.js` from the checkout, picking
920
+ // up any code changes.
921
+ let restartInFlight = false;
922
+ app.post('/api/restart', asyncH(async (_req, res) => {
923
+ if (restartInFlight) {
924
+ return res.status(409).json({ error: 'restart already in progress' });
925
+ }
926
+ restartInFlight = true;
927
+
928
+ if (process.env.CCSM_DEV === '1') {
929
+ res.json({ ok: true, started: true, mode: 'dev', closeFrontend: false });
930
+ setImmediate(() => gracefulShutdown('restart (dev)'));
931
+ return;
932
+ }
933
+
934
+ const fsp = require('node:fs/promises');
935
+ const helperSrc = path.join(__dirname, 'scripts', 'restart-helper.js');
936
+ const helperTmp = path.join(os.tmpdir(), `ccsm-restart-${process.pid}-${Date.now()}.js`);
937
+ try {
938
+ await fsp.copyFile(helperSrc, helperTmp);
939
+ } catch (e) {
940
+ restartInFlight = false;
941
+ return res.status(500).json({ error: `helper copy failed: ${e.message}` });
942
+ }
943
+ const args = [helperTmp, String(currentPort), String(process.pid)];
944
+ // closeFrontend asks the calling tab to window.close() itself the
945
+ // helper will respawn ccsm WITHOUT CCSM_NO_BROWSER, so a fresh window
946
+ // pops up once the new backend is listening. Net effect: the user
947
+ // never sees the OfflineBanner during a restart.
948
+ res.json({ ok: true, started: true, helper: helperTmp, closeFrontend: true });
949
+
950
+ setImmediate(() => {
951
+ const { spawn } = require('node:child_process');
952
+ try {
953
+ const child = spawn(process.execPath, args, {
954
+ detached: true,
955
+ stdio: 'ignore',
956
+ windowsHide: true,
957
+ shell: false,
958
+ });
959
+ child.unref();
960
+ console.log(`[restart] helper pid=${child.pid}, shutting down`);
961
+ } catch (e) {
962
+ console.error('[restart] helper spawn failed:', e.message);
963
+ restartInFlight = false;
964
+ return;
965
+ }
966
+ setTimeout(() => gracefulShutdown('restart'), 500);
967
+ });
968
+ }));
969
+
970
+ // ---- version / upgrade ----
971
+ // `/api/version` reports the installed version (= pkg.version) and, if
972
+ // reachable, the latest published on the npm registry. The result is
973
+ // cached for 30 minutes in memory so the AboutPage poll doesn't hit the
974
+ // registry on every render.
975
+ //
976
+ // `/api/upgrade` kicks off `npm i -g @bakapiano/ccsm@latest` as a
977
+ // detached child. When the install completes, the child re-spawns `ccsm`
978
+ // (also detached) so the launcher comes back up on the new version, and
979
+ // the current server gracefulShutdowns. The frontend's OfflineBanner
980
+ // covers the gap; the version router picks up the new version on the
981
+ // next probe.
982
+ const VERSION_CACHE_MS = 30 * 60_000;
983
+ let versionCache = null; // { latest, fetchedAt }
984
+ let upgradeInFlight = false;
985
+
986
+ async function fetchLatestFromNpm() {
987
+ // Node 18+ has a global fetch. Time out the registry call to avoid
988
+ // hanging the response when the user is offline / behind a captive
989
+ // portal.
990
+ const ctrl = new AbortController();
991
+ const t = setTimeout(() => ctrl.abort(), 4000);
992
+ try {
993
+ const r = await fetch('https://registry.npmjs.org/@bakapiano%2Fccsm/latest', {
994
+ headers: { 'Accept': 'application/json' },
995
+ signal: ctrl.signal,
996
+ });
997
+ if (!r.ok) throw new Error(`registry HTTP ${r.status}`);
998
+ const j = await r.json();
999
+ return String(j.version || '');
1000
+ } finally {
1001
+ clearTimeout(t);
1002
+ }
1003
+ }
1004
+
1005
+ function cmpSemver(a, b) {
1006
+ const pa = String(a || '').split('.').map(Number);
1007
+ const pb = String(b || '').split('.').map(Number);
1008
+ for (let i = 0; i < 3; i++) {
1009
+ const x = pa[i] || 0, y = pb[i] || 0;
1010
+ if (x > y) return 1;
1011
+ if (x < y) return -1;
1012
+ }
1013
+ return 0;
1014
+ }
1015
+
1016
+ app.get('/api/version', asyncH(async (req, res) => {
1017
+ const force = String(req.query.refresh || '') === '1';
1018
+ const now = Date.now();
1019
+ if (!force && versionCache && (now - versionCache.fetchedAt) < VERSION_CACHE_MS) {
1020
+ return res.json({
1021
+ current: pkg.version,
1022
+ latest: versionCache.latest,
1023
+ updateAvailable: cmpSemver(versionCache.latest, pkg.version) > 0,
1024
+ fetchedAt: versionCache.fetchedAt,
1025
+ cached: true,
1026
+ });
1027
+ }
1028
+ try {
1029
+ const latest = await fetchLatestFromNpm();
1030
+ versionCache = { latest, fetchedAt: now };
1031
+ res.json({
1032
+ current: pkg.version,
1033
+ latest,
1034
+ updateAvailable: cmpSemver(latest, pkg.version) > 0,
1035
+ fetchedAt: now,
1036
+ cached: false,
1037
+ });
1038
+ } catch (e) {
1039
+ // Swallow: surface "unknown" so the UI doesn't keep showing a stale
1040
+ // "update available" badge based on a 6-hour-old cached value.
1041
+ res.json({
1042
+ current: pkg.version,
1043
+ latest: null,
1044
+ updateAvailable: false,
1045
+ fetchedAt: now,
1046
+ error: String(e.message || e),
1047
+ });
1048
+ }
1049
+ }));
1050
+
1051
+ app.post('/api/upgrade', asyncH(async (req, res) => {
1052
+ if (upgradeInFlight) {
1053
+ return res.status(409).json({ error: 'upgrade already in progress' });
1054
+ }
1055
+ const body = req.body || {};
1056
+ const target = String(body.target || 'latest');
1057
+ // Refuse anything that doesn't look like a semver dist-tag or version
1058
+ // defends against `;` etc. winding up in the spawn argv even though
1059
+ // we don't shell out.
1060
+ if (!/^[a-z0-9.+\-^~]+$/i.test(target)) {
1061
+ return res.status(400).json({ error: `invalid target: ${target}` });
1062
+ }
1063
+ // Optional sandbox install prefix (for testing without disturbing the
1064
+ // user's real global ccsm). Validated as a plain absolute path so it
1065
+ // can't be a flag injection.
1066
+ const installPrefix = body.installPrefix ? String(body.installPrefix) : '';
1067
+ if (installPrefix && (installPrefix.startsWith('-') || !path.isAbsolute(installPrefix))) {
1068
+ return res.status(400).json({ error: 'installPrefix must be an absolute path' });
1069
+ }
1070
+ const respawn = body.respawn === false ? '0' : '1';
1071
+ upgradeInFlight = true;
1072
+ console.log(`[upgrade] target=${target}${installPrefix ? ` prefix=${installPrefix}` : ''}${respawn === '0' ? ' (no respawn)' : ''}`);
1073
+
1074
+ // The helper runs OUTSIDE the package dir so npm can rename it
1075
+ // without fighting open file handles. Copy the script to os.tmpdir()
1076
+ // and spawn from there.
1077
+ const fsp = require('node:fs/promises');
1078
+ const helperSrc = path.join(__dirname, 'scripts', 'upgrade-helper.js');
1079
+ const helperTmp = path.join(os.tmpdir(), `ccsm-upgrade-${process.pid}-${Date.now()}.js`);
1080
+ try {
1081
+ await fsp.copyFile(helperSrc, helperTmp);
1082
+ } catch (e) {
1083
+ upgradeInFlight = false;
1084
+ return res.status(500).json({ error: `helper copy failed: ${e.message}` });
1085
+ }
1086
+ const args = [helperTmp, target, String(currentPort), String(process.pid), installPrefix, respawn];
1087
+
1088
+ res.json({ ok: true, started: true, target, helper: helperTmp, closeFrontend: true });
1089
+
1090
+ // Flush response, then spawn helper detached and gracefulShutdown so
1091
+ // the helper's npm install isn't fighting our open file handles.
1092
+ setImmediate(() => {
1093
+ const { spawn } = require('node:child_process');
1094
+ try {
1095
+ const child = spawn(process.execPath, args, {
1096
+ detached: true,
1097
+ stdio: 'ignore',
1098
+ windowsHide: true,
1099
+ shell: false,
1100
+ });
1101
+ child.unref();
1102
+ console.log(`[upgrade] helper pid=${child.pid}, shutting down`);
1103
+ } catch (e) {
1104
+ console.error('[upgrade] helper spawn failed:', e.message);
1105
+ upgradeInFlight = false;
1106
+ return;
1107
+ }
1108
+ setTimeout(() => gracefulShutdown('upgrade'), 500);
1109
+ });
1110
+ }));
1111
+
1112
+
1113
+ function listenWithFallback(preferred) {
1114
+ return new Promise((resolve, reject) => {
1115
+ const attempt = (port, tries) => {
1116
+ const server = app.listen(port);
1117
+ server.once('listening', () => resolve({ server, port: server.address().port }));
1118
+ server.once('error', (err) => {
1119
+ if (err.code !== 'EADDRINUSE') return reject(err);
1120
+ if (tries < 9) attempt(port + 1, tries + 1);
1121
+ else if (tries === 9) attempt(0, tries + 1);
1122
+ else reject(err);
1123
+ });
1124
+ };
1125
+ attempt(preferred, 0);
1126
+ });
1127
+ }
1128
+
1129
+ function findAppModeBrowser() {
1130
+ const candidates = [
1131
+ 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
1132
+ 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
1133
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
1134
+ process.env.LOCALAPPDATA &&
1135
+ path.join(process.env.LOCALAPPDATA, 'Google\\Chrome\\Application\\chrome.exe'),
1136
+ 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
1137
+ ].filter(Boolean);
1138
+ const fs = require('node:fs');
1139
+ for (const p of candidates) {
1140
+ if (fs.existsSync(p)) return p;
1141
+ }
1142
+ return null;
1143
+ }
1144
+
1145
+ // Auto-open the frontend in a browser when ccsm boots. Strategy: try a
1146
+ // chromeless app window first (Edge/Chrome --app=); if neither is
1147
+ // installed, fall back to the OS default browser as a regular tab. On
1148
+ // non-Windows we skip — the bundled launcher isn't ported yet.
1149
+ function openInBrowser(url) {
1150
+ if (process.platform !== 'win32') return { kind: 'none', child: null };
1151
+ const { spawn } = require('node:child_process');
1152
+ const fs = require('node:fs');
1153
+ const exe = findAppModeBrowser();
1154
+ if (exe) {
1155
+ const profileDir = path.join(DATA_DIR, 'browser-profile');
1156
+ fs.mkdirSync(profileDir, { recursive: true });
1157
+ const child = spawn(
1158
+ exe,
1159
+ [
1160
+ `--app=${url}`,
1161
+ `--user-data-dir=${profileDir}`,
1162
+ '--window-size=1500,1100',
1163
+ '--no-first-run',
1164
+ '--no-default-browser-check',
1165
+ ],
1166
+ { detached: true, stdio: 'ignore' }
1167
+ );
1168
+ child.unref();
1169
+ return { kind: 'app', child };
1170
+ }
1171
+ console.log('[ccsm] no Edge/Chrome found, opening default browser');
1172
+ const child = spawn('cmd.exe', ['/c', 'start', '', url], {
1173
+ detached: true,
1174
+ stdio: 'ignore',
1175
+ windowsHide: true,
1176
+ });
1177
+ child.unref();
1178
+ return { kind: 'tab', child: null };
1179
+ }
1180
+
1181
+ (async () => {
1182
+ const cfg = await loadConfig();
1183
+ const preferredPort = process.env.CCSM_PORT ? Number(process.env.CCSM_PORT) : cfg.port;
1184
+ const { server, port } = await listenWithFallback(preferredPort);
1185
+ currentPort = port;
1186
+
1187
+ // On boot, mark any persisted "running" sessions as exited — they
1188
+ // belong to a previous server process whose PTYs are gone.
1189
+ try {
1190
+ const all = await persistedSessions.loadAll();
1191
+ for (const s of all) {
1192
+ if (s.status === 'running') {
1193
+ await persistedSessions.markExited(s.id, null);
1194
+ }
1195
+ }
1196
+ } catch (e) {
1197
+ console.error('[ccsm] could not reconcile persisted sessions:', e.message);
1198
+ }
1199
+
1200
+ // Prewarm `tasklist` cache used by the import modal's "live" markers —
1201
+ // it takes ~500ms on Windows and is the single biggest contributor to
1202
+ // a slow Import dialog cold-open. Fire in the background; the lib also
1203
+ // starts its own 15s refresh loop.
1204
+ try { localCliSessions.prewarmLivePids(['claude.exe']); } catch {}
1205
+
1206
+ if (webTerminal.available) {
1207
+ let WebSocketServer;
1208
+ try { ({ WebSocketServer } = require('ws')); } catch {}
1209
+ if (WebSocketServer) {
1210
+ const wss = new WebSocketServer({ noServer: true });
1211
+ server.on('upgrade', (req, socket, head) => {
1212
+ const origin = req.headers.origin;
1213
+ if (origin && !ALLOWED_ORIGINS.has(origin) && !/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(origin)) {
1214
+ socket.destroy();
1215
+ return;
1216
+ }
1217
+ const m = req.url && req.url.match(/^\/ws\/terminal\/([^\/?#]+)/);
1218
+ if (!m) { socket.destroy(); return; }
1219
+ const id = decodeURIComponent(m[1]);
1220
+ wss.handleUpgrade(req, socket, head, (ws) => webTerminal.attach(id, ws));
1221
+ });
1222
+ console.log('[ccsm] web terminal bridge active (WebSocket /ws/terminal/:id)');
1223
+ }
1224
+ }
1225
+
1226
+ for (const sig of ['SIGINT', 'SIGTERM']) {
1227
+ process.on(sig, () => gracefulShutdown(sig));
1228
+ }
1229
+ process.on('exit', () => { try { webTerminal.killAll(); } catch {} });
1230
+
1231
+ const apiUrl = `http://localhost:${port}`;
1232
+ const FRONTEND_URL = IS_DEV
1233
+ ? apiUrl
1234
+ : 'https://bakapiano.github.io/ccsm/';
1235
+ frontendUrl = FRONTEND_URL;
1236
+ console.log(`ccsm listening on ${apiUrl}${port !== preferredPort ? ` (requested ${preferredPort}, was taken)` : ''}`);
1237
+ console.log(`frontend at ${FRONTEND_URL}`);
1238
+ console.log(`data dir: ${DATA_DIR}`);
1239
+ console.log(`work dir: ${cfg.workDir}`);
1240
+ console.log(`clis: ${cfg.clis.map((c) => c.id).join(', ')} (default: ${cfg.defaultCliId})`);
1241
+
1242
+ // CCSM_NO_BROWSER=1 (set by the ccsm:// protocol launcher) suppresses
1243
+ // the auto-open entirely. Otherwise try app-mode (chromeless Edge/Chrome
1244
+ // window); if no such browser is installed, openInBrowser falls back to
1245
+ // the OS default browser on its own.
1246
+ const opened = process.env.CCSM_NO_BROWSER === '1'
1247
+ ? { kind: 'none', child: null }
1248
+ : openInBrowser(FRONTEND_URL);
1249
+
1250
+ if (opened.kind === 'app' && opened.child && process.env.CCSM_KEEP_ALIVE !== '1') {
1251
+ const launchedAt = Date.now();
1252
+ opened.child.on('exit', () => {
1253
+ const alive = Date.now() - launchedAt;
1254
+ if (alive < 5000) {
1255
+ console.log(`[ccsm] spawned browser child exited in ${alive}ms · handed off to an existing Edge instance, staying alive`);
1256
+ return;
1257
+ }
1258
+ const closedAt = Date.now();
1259
+ setTimeout(() => {
1260
+ if (lastHeartbeat > closedAt + 100) {
1261
+ console.log('[ccsm] browser closed but another client is heartbeating · staying alive');
1262
+ return;
1263
+ }
1264
+ gracefulShutdown('browser window closed');
1265
+ }, 12_000);
1266
+ });
1267
+ console.log('[ccsm] tied to browser window — close it to stop ccsm');
1268
+ }
1269
+
1270
+ if (process.env.CCSM_LAUNCHER === '1' && process.env.CCSM_KEEP_ALIVE !== '1') {
1271
+ setInterval(() => {
1272
+ if (!heartbeatSeen) return;
1273
+ if (Date.now() - lastHeartbeat > HEARTBEAT_TIMEOUT_MS) {
1274
+ gracefulShutdown(`no heartbeat for ${HEARTBEAT_TIMEOUT_MS / 1000}s`);
1275
+ }
1276
+ }, 30_000);
1277
+ console.log('[ccsm] heartbeat watchdog active');
1278
+ }
1279
+ })().catch((err) => {
1280
+ console.error('startup failed:', err);
1281
+ process.exit(1);
1282
+ });