@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.
Files changed (68) hide show
  1. package/CLAUDE.md +377 -123
  2. package/README.md +172 -38
  3. package/bin/ccsm.js +194 -0
  4. package/lib/favorites.js +23 -45
  5. package/lib/jsonStore.js +60 -0
  6. package/lib/labels.js +21 -41
  7. package/lib/webTerminal.js +173 -0
  8. package/package.json +11 -3
  9. package/public/css/base.css +82 -0
  10. package/public/css/cards.css +149 -0
  11. package/public/css/feedback.css +219 -0
  12. package/public/css/forms.css +282 -0
  13. package/public/css/layout.css +107 -0
  14. package/public/css/modal.css +169 -0
  15. package/public/css/responsive.css +10 -0
  16. package/public/css/sidebar.css +165 -0
  17. package/public/css/tables.css +266 -0
  18. package/public/css/terminals.css +112 -0
  19. package/public/css/tokens.css +63 -0
  20. package/public/css/wco.css +70 -0
  21. package/public/css/widgets.css +204 -0
  22. package/public/favicon.svg +1 -1
  23. package/public/index.html +52 -490
  24. package/public/js/actions.js +87 -0
  25. package/public/js/api.js +103 -0
  26. package/public/js/backend.js +28 -0
  27. package/public/js/components/App.js +45 -0
  28. package/public/js/components/Card.js +24 -0
  29. package/public/js/components/DialogHost.js +45 -0
  30. package/public/js/components/Fab.js +11 -0
  31. package/public/js/components/FavoritesTable.js +81 -0
  32. package/public/js/components/Footer.js +12 -0
  33. package/public/js/components/NewSessionModal.js +142 -0
  34. package/public/js/components/OfflineBanner.js +52 -0
  35. package/public/js/components/PageHead.js +33 -0
  36. package/public/js/components/Pagination.js +27 -0
  37. package/public/js/components/ProgressList.js +32 -0
  38. package/public/js/components/RecentTable.js +68 -0
  39. package/public/js/components/RepoPicker.js +40 -0
  40. package/public/js/components/ReposEditor.js +74 -0
  41. package/public/js/components/ServerStatus.js +18 -0
  42. package/public/js/components/SessionsTable.js +71 -0
  43. package/public/js/components/Sidebar.js +52 -0
  44. package/public/js/components/SnapshotPanel.js +77 -0
  45. package/public/js/components/TerminalView.js +108 -0
  46. package/public/js/components/TitleCell.js +40 -0
  47. package/public/js/components/Toast.js +8 -0
  48. package/public/js/components/WorkspacePicker.js +19 -0
  49. package/public/js/components/WorkspacesGrid.js +41 -0
  50. package/public/js/dialog.js +59 -0
  51. package/public/js/html.js +6 -0
  52. package/public/js/icons.js +114 -0
  53. package/public/js/main.js +81 -0
  54. package/public/js/pages/AboutPage.js +85 -0
  55. package/public/js/pages/ConfigurePage.js +194 -0
  56. package/public/js/pages/LaunchPage.js +117 -0
  57. package/public/js/pages/SessionsPage.js +47 -0
  58. package/public/js/pages/TerminalsPage.js +74 -0
  59. package/public/js/state.js +87 -0
  60. package/public/js/streaming.js +96 -0
  61. package/public/js/toast.js +14 -0
  62. package/public/js/util.js +24 -0
  63. package/public/manifest.webmanifest +14 -0
  64. package/scripts/install.js +132 -0
  65. package/scripts/uninstall.js +56 -0
  66. package/server.js +286 -30
  67. package/public/app.js +0 -1353
  68. 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
- 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
+ }
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
- const beforeHwnds = await snapshotWindowsOf(
287
- processNameFor(cfg.terminal) || 'WindowsTerminal.exe'
288
- );
289
- launched = launchNewClaude({
290
- cwd: workspace.path,
291
- title: workspace.name,
292
- terminal: cfg.terminal,
293
- claudeCommand: cfg.claudeCommand,
294
- commandShell: cfg.commandShell || "pwsh",
295
- });
296
- emit({ type: 'launched', launched });
297
- autoFocusAfterLaunch({
298
- terminal: cfg.terminal,
299
- beforeHwnds,
300
- autoFocus: cfg.autoFocusOnLaunch !== false,
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
- const url = `http://localhost:${port}`;
483
- 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}`);
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
- const mode = cfg.browserMode || (cfg.autoOpenBrowser === false ? 'none' : 'app');
488
- const opened = openInBrowser(url, mode);
489
-
490
- // Interactive npx-style launch: tie server lifetime to the chromeless
491
- // browser window. When the user closes the window, msedge.exe (with its
492
- // own --user-data-dir process group) exits we hear that and shut down
493
- // so the terminal returns. Headless / nohup launches stay running.
494
- 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();
495
713
  opened.child.on('exit', () => {
496
- console.log('[ccsm] browser window closed · shutting down');
497
- 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);
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);