@bakapiano/ccsm 0.5.0 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +172 -38
  2. package/bin/ccsm.js +194 -0
  3. package/lib/config.js +1 -0
  4. package/lib/favorites.js +23 -45
  5. package/lib/focus.js +90 -14
  6. package/lib/jsonStore.js +60 -0
  7. package/lib/labels.js +29 -0
  8. package/lib/webTerminal.js +173 -0
  9. package/lib/workspace.js +8 -4
  10. package/package.json +11 -3
  11. package/public/css/base.css +82 -0
  12. package/public/css/cards.css +149 -0
  13. package/public/css/feedback.css +219 -0
  14. package/public/css/forms.css +282 -0
  15. package/public/css/layout.css +107 -0
  16. package/public/css/modal.css +169 -0
  17. package/public/css/responsive.css +10 -0
  18. package/public/css/sidebar.css +165 -0
  19. package/public/css/tables.css +266 -0
  20. package/public/css/terminals.css +112 -0
  21. package/public/css/tokens.css +63 -0
  22. package/public/css/wco.css +70 -0
  23. package/public/css/widgets.css +204 -0
  24. package/public/favicon.svg +18 -0
  25. package/public/index.html +53 -379
  26. package/public/js/actions.js +87 -0
  27. package/public/js/api.js +103 -0
  28. package/public/js/backend.js +28 -0
  29. package/public/js/components/App.js +45 -0
  30. package/public/js/components/Card.js +24 -0
  31. package/public/js/components/DialogHost.js +45 -0
  32. package/public/js/components/Fab.js +11 -0
  33. package/public/js/components/FavoritesTable.js +81 -0
  34. package/public/js/components/Footer.js +12 -0
  35. package/public/js/components/NewSessionModal.js +142 -0
  36. package/public/js/components/OfflineBanner.js +52 -0
  37. package/public/js/components/PageHead.js +33 -0
  38. package/public/js/components/Pagination.js +27 -0
  39. package/public/js/components/ProgressList.js +32 -0
  40. package/public/js/components/RecentTable.js +68 -0
  41. package/public/js/components/RepoPicker.js +40 -0
  42. package/public/js/components/ReposEditor.js +74 -0
  43. package/public/js/components/ServerStatus.js +18 -0
  44. package/public/js/components/SessionsTable.js +71 -0
  45. package/public/js/components/Sidebar.js +52 -0
  46. package/public/js/components/SnapshotPanel.js +77 -0
  47. package/public/js/components/TerminalView.js +108 -0
  48. package/public/js/components/TitleCell.js +40 -0
  49. package/public/js/components/Toast.js +8 -0
  50. package/public/js/components/WorkspacePicker.js +19 -0
  51. package/public/js/components/WorkspacesGrid.js +41 -0
  52. package/public/js/dialog.js +59 -0
  53. package/public/js/html.js +6 -0
  54. package/public/js/icons.js +114 -0
  55. package/public/js/main.js +81 -0
  56. package/public/js/pages/AboutPage.js +85 -0
  57. package/public/js/pages/ConfigurePage.js +194 -0
  58. package/public/js/pages/LaunchPage.js +117 -0
  59. package/public/js/pages/SessionsPage.js +47 -0
  60. package/public/js/pages/TerminalsPage.js +74 -0
  61. package/public/js/state.js +87 -0
  62. package/public/js/streaming.js +96 -0
  63. package/public/js/toast.js +14 -0
  64. package/public/js/util.js +24 -0
  65. package/public/manifest.webmanifest +14 -0
  66. package/scripts/install.js +111 -0
  67. package/scripts/uninstall.js +56 -0
  68. package/server.js +314 -31
  69. package/public/app.js +0 -894
  70. package/public/styles.css +0 -1204
package/server.js CHANGED
@@ -6,6 +6,7 @@ const express = require('express');
6
6
 
7
7
  const { listSessions, listRecentSessions, findSessionMetadata } = require('./lib/sessions');
8
8
  const { listFavorites, addFavorite, removeFavorite, loadFavorites } = require('./lib/favorites');
9
+ const { loadLabels, setLabel, removeLabel } = require('./lib/labels');
9
10
  const { loadConfig, saveConfig, DATA_DIR } = require('./lib/config');
10
11
  const {
11
12
  saveSnapshot,
@@ -31,6 +32,35 @@ const {
31
32
  snapshotWindowsOf,
32
33
  focusNewlyOpenedHwnd,
33
34
  } = require('./lib/focus');
35
+ const webTerminal = require('./lib/webTerminal');
36
+
37
+ // One unified exit path so every reason-for-shutdown gets the same
38
+ // cleanup: final snapshot save (so the next launch can restore current
39
+ // state) + PTY children killed. Idempotent — concurrent triggers are no-ops.
40
+ let shuttingDown = false;
41
+ async function gracefulShutdown(reason) {
42
+ if (shuttingDown) return;
43
+ shuttingDown = true;
44
+ console.log(`[ccsm] shutting down · ${reason}`);
45
+
46
+ // Final snapshot. Wrap in a race so a wedged disk doesn't hang us
47
+ // indefinitely — 2s is generous (typical save is <300ms).
48
+ try {
49
+ const cfg = await loadConfig();
50
+ await Promise.race([
51
+ saveSnapshot({ keep: cfg.snapshotHistoryKeep }),
52
+ new Promise((_, reject) => setTimeout(() => reject(new Error('save timeout (2s)')), 2000)),
53
+ ]);
54
+ console.log('[ccsm] final snapshot saved');
55
+ } catch (e) {
56
+ console.error('[ccsm] final snapshot skipped:', e.message);
57
+ }
58
+
59
+ // Kill any in-process PTY children so they don't outlive us.
60
+ try { webTerminal.killAll(); } catch {}
61
+
62
+ process.exit(0);
63
+ }
34
64
 
35
65
  async function autoFocusAfterLaunch({ terminal, beforeHwnds, autoFocus }) {
36
66
  if (!autoFocus) return;
@@ -45,7 +75,68 @@ async function autoFocusAfterLaunch({ terminal, beforeHwnds, autoFocus }) {
45
75
 
46
76
  const app = express();
47
77
  app.use(express.json({ limit: '1mb' }));
48
- app.use(express.static(path.join(__dirname, 'public')));
78
+
79
+ // CORS · allow the hosted-frontend (GH Pages) origin to call /api/* and
80
+ // open WebSockets. Listed explicitly — never reflect Origin or use '*' so
81
+ // random web pages can't reach the local backend. Localhost dev calls
82
+ // stay same-origin (browser doesn't add Origin header → middleware is a
83
+ // no-op for them).
84
+ const ALLOWED_ORIGINS = new Set([
85
+ 'https://bakapiano.github.io',
86
+ ]);
87
+ app.use((req, res, next) => {
88
+ const origin = req.headers.origin;
89
+ if (origin && ALLOWED_ORIGINS.has(origin)) {
90
+ res.setHeader('Access-Control-Allow-Origin', origin);
91
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
92
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
93
+ res.setHeader('Vary', 'Origin');
94
+ }
95
+ if (req.method === 'OPTIONS') return res.sendStatus(204);
96
+ next();
97
+ });
98
+
99
+ // Dev mode = running from a checkout (not from an npm-install location).
100
+ // Used to gate two things: (a) serving static frontend from local public/
101
+ // so a contributor can iterate without pushing to GH Pages; (b) hot-reload
102
+ // SSE endpoint that watches public/ for changes. CCSM_NO_DEV=1 disables
103
+ // both explicitly. In production (npm-installed), backend is API-only —
104
+ // frontend lives at https://bakapiano.github.io/cssm/v1/.
105
+ const IS_DEV = !__dirname.includes(`${path.sep}node_modules${path.sep}`) && process.env.CCSM_NO_DEV !== '1';
106
+
107
+ if (IS_DEV) {
108
+ app.use(express.static(path.join(__dirname, 'public')));
109
+ }
110
+
111
+ const reloadClients = new Set();
112
+ if (IS_DEV) {
113
+ app.get('/api/dev/ping', (_req, res) => res.json({ dev: true }));
114
+ app.get('/api/dev/reload', (req, res) => {
115
+ res.setHeader('Content-Type', 'text/event-stream');
116
+ res.setHeader('Cache-Control', 'no-cache, no-transform');
117
+ res.setHeader('Connection', 'keep-alive');
118
+ res.flushHeaders();
119
+ res.write(': connected\n\n');
120
+ reloadClients.add(res);
121
+ // Heartbeat every 25s so intermediate proxies don't kill the stream.
122
+ const hb = setInterval(() => { try { res.write(': ping\n\n'); } catch {} }, 25000);
123
+ req.on('close', () => { clearInterval(hb); reloadClients.delete(res); });
124
+ });
125
+ const publicDir = path.join(__dirname, 'public');
126
+ const fs = require('node:fs');
127
+ let debounce = null;
128
+ fs.watch(publicDir, { recursive: true }, (_event, filename) => {
129
+ clearTimeout(debounce);
130
+ debounce = setTimeout(() => {
131
+ if (reloadClients.size === 0) return;
132
+ console.log(`[dev] reload · ${filename || '?'} → ${reloadClients.size} client(s)`);
133
+ for (const r of reloadClients) {
134
+ try { r.write(`event: reload\ndata: ${Date.now()}\n\n`); } catch {}
135
+ }
136
+ }, 80);
137
+ });
138
+ console.log('[dev] hot-reload watching public/');
139
+ }
49
140
 
50
141
  function asyncH(fn) {
51
142
  return (req, res) => {
@@ -105,6 +196,29 @@ app.delete('/api/favorites/:sessionId', asyncH(async (req, res) => {
105
196
  res.json({ removed });
106
197
  }));
107
198
 
199
+ // ---- labels (rename overrides) ----
200
+ // Custom display titles keyed by sessionId. Empty body / empty label is
201
+ // treated as a delete.
202
+ app.get('/api/labels', asyncH(async (_req, res) => {
203
+ const labels = await loadLabels();
204
+ res.json({ labels });
205
+ }));
206
+
207
+ app.put('/api/labels/:sessionId', asyncH(async (req, res) => {
208
+ const label = req.body && req.body.label;
209
+ if (!label || !String(label).trim()) {
210
+ const removed = await removeLabel(req.params.sessionId);
211
+ return res.json({ removed });
212
+ }
213
+ const saved = await setLabel(req.params.sessionId, label);
214
+ res.json({ label: saved });
215
+ }));
216
+
217
+ app.delete('/api/labels/:sessionId', asyncH(async (req, res) => {
218
+ const removed = await removeLabel(req.params.sessionId);
219
+ res.json({ removed });
220
+ }));
221
+
108
222
  // ---- config ----
109
223
 
110
224
  app.get('/api/config', asyncH(async (_req, res) => {
@@ -259,22 +373,48 @@ app.post('/api/sessions/new', async (req, res) => {
259
373
  const shouldLaunch = req.body && req.body.launch !== false;
260
374
  let launched = null;
261
375
  if (shouldLaunch) {
262
- const beforeHwnds = await snapshotWindowsOf(
263
- processNameFor(cfg.terminal) || 'WindowsTerminal.exe'
264
- );
265
- launched = launchNewClaude({
266
- cwd: workspace.path,
267
- title: workspace.name,
268
- terminal: cfg.terminal,
269
- claudeCommand: cfg.claudeCommand,
270
- commandShell: cfg.commandShell || "pwsh",
271
- });
272
- emit({ type: 'launched', launched });
273
- autoFocusAfterLaunch({
274
- terminal: cfg.terminal,
275
- beforeHwnds,
276
- autoFocus: cfg.autoFocusOnLaunch !== false,
277
- });
376
+ // mode = 'web' → spawn the claude command as an in-process PTY whose
377
+ // stdio is bridged to xterm.js via WebSocket. The session
378
+ // lives in webTerminal's pool until killed or claude
379
+ // exits. No wt window opens.
380
+ // mode = 'wt' (default) → existing behaviour: launch via wt window.
381
+ const mode = req.body && req.body.terminal === 'web' ? 'web' : 'wt';
382
+
383
+ if (mode === 'web') {
384
+ if (!webTerminal.available) {
385
+ return fail('node-pty is not installed · web terminal mode unavailable');
386
+ }
387
+ // Wrap in pwsh so config.claudeCommand can be an alias / function
388
+ // defined in the user's profile (e.g. `cc`), same trick wt uses.
389
+ const cmd = cfg.claudeCommand || 'claude';
390
+ const wrap = (cfg.commandShell || 'pwsh') === 'powershell' ? 'powershell.exe' : 'pwsh.exe';
391
+ const entry = webTerminal.spawn({
392
+ command: wrap,
393
+ args: ['-NoExit', '-NoLogo', '-Command', `Set-Location -LiteralPath '${workspace.path.replace(/'/g, "''")}'; & '${cmd.replace(/'/g, "''")}'`],
394
+ cwd: workspace.path,
395
+ meta: { title: workspace.name, workspace: workspace.name, cwd: workspace.path },
396
+ });
397
+ launched = { mode: 'web', id: entry.id, pid: entry.meta.pid, terminal: 'web' };
398
+ emit({ type: 'launched', launched });
399
+ } else {
400
+ const beforeHwnds = await snapshotWindowsOf(
401
+ processNameFor(cfg.terminal) || 'WindowsTerminal.exe'
402
+ );
403
+ launched = launchNewClaude({
404
+ cwd: workspace.path,
405
+ title: workspace.name,
406
+ terminal: cfg.terminal,
407
+ claudeCommand: cfg.claudeCommand,
408
+ commandShell: cfg.commandShell || 'pwsh',
409
+ });
410
+ launched = { mode: 'wt', ...launched };
411
+ emit({ type: 'launched', launched });
412
+ autoFocusAfterLaunch({
413
+ terminal: cfg.terminal,
414
+ beforeHwnds,
415
+ autoFocus: cfg.autoFocusOnLaunch !== false,
416
+ });
417
+ }
278
418
  }
279
419
 
280
420
  emit({
@@ -340,11 +480,13 @@ app.post('/api/sessions/:sessionId/focus', asyncH(async (req, res) => {
340
480
  const sessions = await listSessions();
341
481
  const s = sessions.find((x) => x.sessionId === sessionId);
342
482
  if (!s) return res.status(404).json({ error: `session ${sessionId} not live` });
483
+ const cfg = await loadConfig();
343
484
  const result = await focusBySession({
344
485
  pid: s.pid,
345
486
  sessionId: s.sessionId,
346
487
  title: s.title,
347
488
  cwd: s.cwd,
489
+ moveToCenter: !!cfg.focusMovesToCenter,
348
490
  });
349
491
  res.json({ session: { pid: s.pid, sessionId: s.sessionId, cwd: s.cwd, title: s.title }, ...result });
350
492
  }));
@@ -352,8 +494,60 @@ app.post('/api/sessions/:sessionId/focus', asyncH(async (req, res) => {
352
494
  // ---- terminal kinds ----
353
495
  app.get('/api/terminals', (_req, res) => res.json({ terminals: listTerminalKinds() }));
354
496
 
497
+ // ---- capabilities probe · used by the frontend to decide whether to show
498
+ // the "open in this page" radio option. node-pty is optional, install-failure
499
+ // degrades us to wt-only. ----
500
+ app.get('/api/capabilities', (_req, res) => res.json({
501
+ webTerminal: webTerminal.available,
502
+ webTerminalError: webTerminal.available ? null : String(webTerminal.loadError?.message || 'unavailable'),
503
+ }));
504
+
505
+ // ---- web terminals · list / kill ----
506
+ // (creation happens through /api/sessions/new with terminal:'web'; attach is
507
+ // over WebSocket below.)
508
+ app.get('/api/sessions/web', (_req, res) => res.json({ terminals: webTerminal.list() }));
509
+
510
+ app.delete('/api/sessions/web/:id', (req, res) => {
511
+ const ok = webTerminal.kill(req.params.id);
512
+ res.json({ killed: ok });
513
+ });
514
+
355
515
  // ---- health ----
356
- app.get('/api/health', (_req, res) => res.json({ ok: true, pid: process.pid }));
516
+ const pkg = require('./package.json');
517
+ app.get('/api/health', (_req, res) => res.json({ ok: true, pid: process.pid, version: pkg.version, name: pkg.name }));
518
+
519
+ // ---- lifecycle ----
520
+ // State shared by /api/spawn-browser (opens another window into this server)
521
+ // and the heartbeat watchdog (exits the server if no client has pinged for
522
+ // HEARTBEAT_TIMEOUT_MS). Heartbeat is the safety net behind the primary
523
+ // "browser child exits → server exits" mechanism wired up after listen.
524
+ let currentPort = 0;
525
+ let frontendUrl = '';
526
+ let lastHeartbeat = Date.now();
527
+ let heartbeatSeen = false;
528
+ const HEARTBEAT_TIMEOUT_MS = 90_000;
529
+
530
+ app.post('/api/heartbeat', (_req, res) => {
531
+ lastHeartbeat = Date.now();
532
+ heartbeatSeen = true;
533
+ res.json({ ok: true });
534
+ });
535
+
536
+ app.post('/api/spawn-browser', asyncH(async (_req, res) => {
537
+ const cfg = await loadConfig();
538
+ const mode = cfg.browserMode || (cfg.autoOpenBrowser === false ? 'none' : 'app');
539
+ openInBrowser(frontendUrl || `http://localhost:${currentPort}`, mode);
540
+ res.json({ ok: true, mode, url: frontendUrl });
541
+ }));
542
+
543
+ // Graceful shutdown · the uninstall script and the auto-upgrade path in
544
+ // the launcher both call this. We reply first so the caller doesn't see
545
+ // a torn connection, then exit on the next tick.
546
+ app.post('/api/shutdown', (_req, res) => {
547
+ res.json({ ok: true, bye: 'shutting down' });
548
+ // setImmediate so the response flushes before we tear the server down.
549
+ setImmediate(() => gracefulShutdown('/api/shutdown'));
550
+ });
357
551
 
358
552
  // ---- auto-snapshot scheduler ----
359
553
  let snapshotTimer = null;
@@ -451,26 +645,115 @@ function openInBrowser(url, mode) {
451
645
 
452
646
  (async () => {
453
647
  const cfg = await loadConfig();
454
- const { port } = await listenWithFallback(cfg.port);
455
- const url = `http://localhost:${port}`;
456
- console.log(`ccsm listening on ${url}${port !== cfg.port ? ` (requested ${cfg.port}, was taken)` : ''}`);
648
+ const { server, port } = await listenWithFallback(cfg.port);
649
+ currentPort = port;
650
+
651
+ // WebSocket upgrade for /ws/terminal/:id → bridges xterm.js to a PTY
652
+ // entry in webTerminal's pool. Only enabled when node-pty loaded; the
653
+ // /api/capabilities endpoint advertises this to the frontend.
654
+ if (webTerminal.available) {
655
+ let WebSocketServer;
656
+ try { ({ WebSocketServer } = require('ws')); } catch {}
657
+ if (WebSocketServer) {
658
+ const wss = new WebSocketServer({ noServer: true });
659
+ server.on('upgrade', (req, socket, head) => {
660
+ // Origin check · same allow-list as REST CORS. Browsers always
661
+ // send Origin on WebSocket upgrades; missing Origin = non-browser
662
+ // client which we tolerate (curl etc).
663
+ const origin = req.headers.origin;
664
+ if (origin && !ALLOWED_ORIGINS.has(origin) && !/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(origin)) {
665
+ socket.destroy();
666
+ return;
667
+ }
668
+ const m = req.url && req.url.match(/^\/ws\/terminal\/([^\/?#]+)/);
669
+ if (!m) { socket.destroy(); return; }
670
+ const id = decodeURIComponent(m[1]);
671
+ wss.handleUpgrade(req, socket, head, (ws) => webTerminal.attach(id, ws));
672
+ });
673
+ console.log('[ccsm] web terminal bridge active (WebSocket /ws/terminal/:id)');
674
+ }
675
+ }
676
+
677
+ // OS signals · run a graceful shutdown (which saves a final snapshot
678
+ // and kills PTY children) before exiting.
679
+ for (const sig of ['SIGINT', 'SIGTERM']) {
680
+ process.on(sig, () => gracefulShutdown(sig));
681
+ }
682
+ // Last-resort cleanup on sync exit (process.on('exit') can't await
683
+ // anything, so it's only a safety net for PTY children).
684
+ process.on('exit', () => { try { webTerminal.killAll(); } catch {} });
685
+ const apiUrl = `http://localhost:${port}`;
686
+ // What URL we open in the auto-spawned browser:
687
+ // dev → localhost (backend still serves public/ here)
688
+ // prod → hosted frontend on GH Pages (backend is API-only)
689
+ const FRONTEND_URL = IS_DEV
690
+ ? apiUrl
691
+ : 'https://bakapiano.github.io/cssm/v1/';
692
+ frontendUrl = FRONTEND_URL;
693
+ console.log(`ccsm listening on ${apiUrl}${port !== cfg.port ? ` (requested ${cfg.port}, was taken)` : ''}`);
694
+ console.log(`frontend at ${FRONTEND_URL}`);
457
695
  console.log(`data dir: ${DATA_DIR}`);
458
696
  console.log(`work dir: ${cfg.workDir}`);
459
697
  console.log(`terminal: ${cfg.terminal} · ${cfg.claudeCommand}${cfg.terminal === 'wt' ? ` (via ${cfg.commandShell})` : ''}`);
460
- const mode = cfg.browserMode || (cfg.autoOpenBrowser === false ? 'none' : 'app');
461
- const opened = openInBrowser(url, mode);
462
-
463
- // Interactive npx-style launch: tie server lifetime to the chromeless
464
- // browser window. When the user closes the window, msedge.exe (with its
465
- // own --user-data-dir process group) exits we hear that and shut down
466
- // so the terminal returns. Headless / nohup launches stay running.
467
- if (opened.kind === 'app' && opened.child && process.stdout.isTTY) {
698
+ // CCSM_NO_BROWSER=1 (set by the launcher when responding to a ccsm://
699
+ // protocol click) suppresses the auto-spawned browser window — the
700
+ // caller already has one open and just needs the backend to come up.
701
+ const mode = process.env.CCSM_NO_BROWSER === '1'
702
+ ? 'none'
703
+ : (cfg.browserMode || (cfg.autoOpenBrowser === false ? 'none' : 'app'));
704
+ const opened = openInBrowser(FRONTEND_URL, mode);
705
+
706
+ // Primary lifecycle: tie this server's lifetime to the chromeless
707
+ // browser window. msedge.exe runs with its own --user-data-dir process
708
+ // group, so when the user closes the window it actually exits — and
709
+ // the spawned child handle we hold here fires 'exit'. Skip if the user
710
+ // explicitly asked the server to stay alive (e.g. an automation host).
711
+ if (opened.kind === 'app' && opened.child && process.env.CCSM_KEEP_ALIVE !== '1') {
712
+ const launchedAt = Date.now();
468
713
  opened.child.on('exit', () => {
469
- console.log('[ccsm] browser window closed · shutting down');
470
- process.exit(0);
714
+ const alive = Date.now() - launchedAt;
715
+ // Edge --app= often spawns a process that immediately hands its URL
716
+ // off to an existing Edge profile process group and exits — our
717
+ // child handle dies milliseconds after creation. Treat any exit
718
+ // inside the first 5s as a hand-off, not a real close.
719
+ if (alive < 5000) {
720
+ console.log(`[ccsm] spawned browser child exited in ${alive}ms · handed off to an existing Edge instance, staying alive`);
721
+ return;
722
+ }
723
+ // Defer the kill decision by one full frontend ping cycle (~12s,
724
+ // matching the 10s heartbeat cadence below). Any heartbeat that
725
+ // arrives AFTER this moment must be from a DIFFERENT client (the
726
+ // closing browser couldn't ping after it died) — so a hosted-tab
727
+ // user is still around and we should stay alive.
728
+ const closedAt = Date.now();
729
+ setTimeout(() => {
730
+ if (lastHeartbeat > closedAt + 100) {
731
+ console.log('[ccsm] browser closed but another client is heartbeating · staying alive');
732
+ return;
733
+ }
734
+ gracefulShutdown('browser window closed');
735
+ }, 12_000);
471
736
  });
472
737
  console.log('[ccsm] tied to browser window — close it to stop ccsm');
473
738
  }
739
+
740
+ // Heartbeat watchdog · only activated when launched via bin/ccsm.js
741
+ // (CCSM_LAUNCHER=1). Catches cases the primary mechanism misses: the
742
+ // browser was killed forcibly, msedge crashed without a clean exit, or
743
+ // the user opened the URL in tab-mode in their own browser instead of
744
+ // the chromeless app window. We don't kill until we've seen at least
745
+ // one heartbeat — that way a freshly-booted server with no client yet
746
+ // doesn't suicide.
747
+ if (process.env.CCSM_LAUNCHER === '1' && process.env.CCSM_KEEP_ALIVE !== '1') {
748
+ setInterval(() => {
749
+ if (!heartbeatSeen) return;
750
+ if (Date.now() - lastHeartbeat > HEARTBEAT_TIMEOUT_MS) {
751
+ gracefulShutdown(`no heartbeat for ${HEARTBEAT_TIMEOUT_MS / 1000}s`);
752
+ }
753
+ }, 30_000);
754
+ console.log('[ccsm] heartbeat watchdog active');
755
+ }
756
+
474
757
  startSnapshotLoop();
475
758
  })().catch((err) => {
476
759
  console.error('startup failed:', err);