@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.
- package/README.md +172 -38
- package/bin/ccsm.js +194 -0
- package/lib/config.js +1 -0
- package/lib/favorites.js +23 -45
- package/lib/focus.js +90 -14
- package/lib/jsonStore.js +60 -0
- package/lib/labels.js +29 -0
- package/lib/webTerminal.js +173 -0
- package/lib/workspace.js +8 -4
- 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 +18 -0
- package/public/index.html +53 -379
- 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 +111 -0
- package/scripts/uninstall.js +56 -0
- package/server.js +314 -31
- package/public/app.js +0 -894
- 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
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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
|
-
|
|
456
|
-
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
470
|
-
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);
|
|
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);
|