@bakapiano/ccsm 0.6.0 → 0.8.4
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.
- package/CLAUDE.md +377 -123
- package/README.md +172 -38
- package/bin/ccsm.js +194 -0
- package/lib/favorites.js +23 -45
- package/lib/jsonStore.js +60 -0
- package/lib/labels.js +21 -41
- package/lib/webTerminal.js +173 -0
- package/package.json +11 -3
- package/public/css/base.css +82 -0
- package/public/css/cards.css +149 -0
- package/public/css/feedback.css +219 -0
- package/public/css/forms.css +282 -0
- package/public/css/layout.css +107 -0
- package/public/css/modal.css +169 -0
- package/public/css/responsive.css +10 -0
- package/public/css/sidebar.css +165 -0
- package/public/css/tables.css +266 -0
- package/public/css/terminals.css +112 -0
- package/public/css/tokens.css +63 -0
- package/public/css/wco.css +70 -0
- package/public/css/widgets.css +204 -0
- package/public/favicon.svg +1 -1
- package/public/index.html +52 -490
- package/public/js/actions.js +87 -0
- package/public/js/api.js +103 -0
- package/public/js/backend.js +28 -0
- package/public/js/components/App.js +45 -0
- package/public/js/components/Card.js +24 -0
- package/public/js/components/DialogHost.js +45 -0
- package/public/js/components/Fab.js +11 -0
- package/public/js/components/FavoritesTable.js +81 -0
- package/public/js/components/Footer.js +12 -0
- package/public/js/components/NewSessionModal.js +142 -0
- package/public/js/components/OfflineBanner.js +52 -0
- package/public/js/components/PageHead.js +33 -0
- package/public/js/components/Pagination.js +27 -0
- package/public/js/components/ProgressList.js +32 -0
- package/public/js/components/RecentTable.js +68 -0
- package/public/js/components/RepoPicker.js +40 -0
- package/public/js/components/ReposEditor.js +74 -0
- package/public/js/components/ServerStatus.js +18 -0
- package/public/js/components/SessionsTable.js +71 -0
- package/public/js/components/Sidebar.js +52 -0
- package/public/js/components/SnapshotPanel.js +77 -0
- package/public/js/components/TerminalView.js +108 -0
- package/public/js/components/TitleCell.js +40 -0
- package/public/js/components/Toast.js +8 -0
- package/public/js/components/WorkspacePicker.js +19 -0
- package/public/js/components/WorkspacesGrid.js +41 -0
- package/public/js/dialog.js +59 -0
- package/public/js/html.js +6 -0
- package/public/js/icons.js +114 -0
- package/public/js/main.js +81 -0
- package/public/js/pages/AboutPage.js +85 -0
- package/public/js/pages/ConfigurePage.js +194 -0
- package/public/js/pages/LaunchPage.js +117 -0
- package/public/js/pages/SessionsPage.js +47 -0
- package/public/js/pages/TerminalsPage.js +74 -0
- package/public/js/state.js +87 -0
- package/public/js/streaming.js +96 -0
- package/public/js/toast.js +14 -0
- package/public/js/util.js +24 -0
- package/public/manifest.webmanifest +14 -0
- package/scripts/install.js +132 -0
- package/scripts/uninstall.js +56 -0
- package/server.js +286 -30
- package/public/app.js +0 -1353
- package/public/styles.css +0 -1639
package/server.js
CHANGED
|
@@ -32,6 +32,35 @@ const {
|
|
|
32
32
|
snapshotWindowsOf,
|
|
33
33
|
focusNewlyOpenedHwnd,
|
|
34
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
|
+
}
|
|
35
64
|
|
|
36
65
|
async function autoFocusAfterLaunch({ terminal, beforeHwnds, autoFocus }) {
|
|
37
66
|
if (!autoFocus) return;
|
|
@@ -46,7 +75,68 @@ async function autoFocusAfterLaunch({ terminal, beforeHwnds, autoFocus }) {
|
|
|
46
75
|
|
|
47
76
|
const app = express();
|
|
48
77
|
app.use(express.json({ limit: '1mb' }));
|
|
49
|
-
|
|
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
|
+
}
|
|
50
140
|
|
|
51
141
|
function asyncH(fn) {
|
|
52
142
|
return (req, res) => {
|
|
@@ -283,22 +373,48 @@ app.post('/api/sessions/new', async (req, res) => {
|
|
|
283
373
|
const shouldLaunch = req.body && req.body.launch !== false;
|
|
284
374
|
let launched = null;
|
|
285
375
|
if (shouldLaunch) {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
+
}
|
|
302
418
|
}
|
|
303
419
|
|
|
304
420
|
emit({
|
|
@@ -378,10 +494,61 @@ app.post('/api/sessions/:sessionId/focus', asyncH(async (req, res) => {
|
|
|
378
494
|
// ---- terminal kinds ----
|
|
379
495
|
app.get('/api/terminals', (_req, res) => res.json({ terminals: listTerminalKinds() }));
|
|
380
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
|
+
|
|
381
515
|
// ---- health ----
|
|
382
516
|
const pkg = require('./package.json');
|
|
383
517
|
app.get('/api/health', (_req, res) => res.json({ ok: true, pid: process.pid, version: pkg.version, name: pkg.name }));
|
|
384
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
|
+
});
|
|
551
|
+
|
|
385
552
|
// ---- auto-snapshot scheduler ----
|
|
386
553
|
let snapshotTimer = null;
|
|
387
554
|
async function startSnapshotLoop() {
|
|
@@ -478,26 +645,115 @@ function openInBrowser(url, mode) {
|
|
|
478
645
|
|
|
479
646
|
(async () => {
|
|
480
647
|
const cfg = await loadConfig();
|
|
481
|
-
const { port } = await listenWithFallback(cfg.port);
|
|
482
|
-
|
|
483
|
-
|
|
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}`);
|
|
484
695
|
console.log(`data dir: ${DATA_DIR}`);
|
|
485
696
|
console.log(`work dir: ${cfg.workDir}`);
|
|
486
697
|
console.log(`terminal: ${cfg.terminal} · ${cfg.claudeCommand}${cfg.terminal === 'wt' ? ` (via ${cfg.commandShell})` : ''}`);
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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();
|
|
495
713
|
opened.child.on('exit', () => {
|
|
496
|
-
|
|
497
|
-
process
|
|
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);
|
|
498
736
|
});
|
|
499
737
|
console.log('[ccsm] tied to browser window — close it to stop ccsm');
|
|
500
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
|
+
|
|
501
757
|
startSnapshotLoop();
|
|
502
758
|
})().catch((err) => {
|
|
503
759
|
console.error('startup failed:', err);
|