@bakapiano/ccsm 0.10.3 → 0.11.0

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