@bakapiano/ccsm 0.4.0 → 0.5.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.
package/server.js CHANGED
@@ -4,7 +4,8 @@
4
4
  const path = require('node:path');
5
5
  const express = require('express');
6
6
 
7
- const { listSessions, listRecentSessions } = require('./lib/sessions');
7
+ const { listSessions, listRecentSessions, findSessionMetadata } = require('./lib/sessions');
8
+ const { listFavorites, addFavorite, removeFavorite, loadFavorites } = require('./lib/favorites');
8
9
  const { loadConfig, saveConfig, DATA_DIR } = require('./lib/config');
9
10
  const {
10
11
  saveSnapshot,
@@ -63,11 +64,45 @@ app.get('/api/sessions', asyncH(async (_req, res) => {
63
64
  }));
64
65
 
65
66
  app.get('/api/sessions/recent', asyncH(async (req, res) => {
66
- const limit = Math.min(200, Number(req.query.limit) || 50);
67
+ const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 15));
68
+ const offset = Math.max(0, Number(req.query.offset) || 0);
67
69
  const live = await listSessions();
68
70
  const excludeIds = new Set(live.map((s) => s.sessionId));
69
- const recent = await listRecentSessions({ limit, excludeIds });
70
- res.json({ recent, takenAt: Date.now() });
71
+ const { recent, total } = await listRecentSessions({ limit, offset, excludeIds });
72
+ res.json({ recent, total, limit, offset, takenAt: Date.now() });
73
+ }));
74
+
75
+ // ---- favorites ----
76
+ // Sessions the user has starred. Stored at $DATA_DIR/favorites.json.
77
+ // Frontend usually GETs once at boot and updates optimistically.
78
+ app.get('/api/favorites', asyncH(async (_req, res) => {
79
+ const favorites = await listFavorites();
80
+ res.json({ favorites });
81
+ }));
82
+
83
+ app.post('/api/favorites/:sessionId', asyncH(async (req, res) => {
84
+ const sessionId = req.params.sessionId;
85
+ let info = req.body && typeof req.body === 'object' ? req.body : {};
86
+ // If client didn't supply title/cwd, try to look them up from the live
87
+ // session list or from the jsonl files on disk. This way star-from-empty
88
+ // (e.g. via API) still produces a usable favorite.
89
+ if (!info.cwd || !info.title) {
90
+ const live = await listSessions();
91
+ const livehit = live.find((s) => s.sessionId === sessionId);
92
+ if (livehit) {
93
+ info = { cwd: livehit.cwd, title: livehit.title, ...info };
94
+ } else {
95
+ const meta = await findSessionMetadata(sessionId);
96
+ if (meta) info = { cwd: meta.cwd, title: meta.title, gitBranch: meta.gitBranch, ...info };
97
+ }
98
+ }
99
+ const fav = await addFavorite(sessionId, info);
100
+ res.json({ favorite: fav });
101
+ }));
102
+
103
+ app.delete('/api/favorites/:sessionId', asyncH(async (req, res) => {
104
+ const removed = await removeFavorite(req.params.sessionId);
105
+ res.json({ removed });
71
106
  }));
72
107
 
73
108
  // ---- config ----
@@ -373,7 +408,7 @@ function findAppModeBrowser() {
373
408
  }
374
409
 
375
410
  function openInBrowser(url, mode) {
376
- if (process.platform !== 'win32' || mode === 'none') return;
411
+ if (mode === 'none' || process.platform !== 'win32') return { kind: 'none', child: null };
377
412
  const { spawn } = require('node:child_process');
378
413
  const fs = require('node:fs');
379
414
 
@@ -390,25 +425,28 @@ function openInBrowser(url, mode) {
390
425
  [
391
426
  `--app=${url}`,
392
427
  `--user-data-dir=${profileDir}`,
393
- '--window-size=1400,1000',
428
+ '--window-size=1500,1100',
394
429
  '--no-first-run',
395
430
  '--no-default-browser-check',
396
431
  ],
397
432
  { detached: true, stdio: 'ignore' }
398
433
  );
399
434
  child.unref();
400
- return;
435
+ return { kind: 'app', child };
401
436
  }
402
437
  console.log('[ccsm] no Edge/Chrome found for app mode, falling back to default browser');
403
438
  }
404
439
 
405
- // mode === 'tab' (or app-mode fallback)
440
+ // mode === 'tab' (or app-mode fallback). cmd's `start` builtin exits
441
+ // immediately after launching the default browser — the child handle
442
+ // isn't usable for lifecycle tracking.
406
443
  const child = spawn('cmd.exe', ['/c', 'start', '', url], {
407
444
  detached: true,
408
445
  stdio: 'ignore',
409
446
  windowsHide: true,
410
447
  });
411
448
  child.unref();
449
+ return { kind: 'tab', child: null };
412
450
  }
413
451
 
414
452
  (async () => {
@@ -420,7 +458,19 @@ function openInBrowser(url, mode) {
420
458
  console.log(`work dir: ${cfg.workDir}`);
421
459
  console.log(`terminal: ${cfg.terminal} · ${cfg.claudeCommand}${cfg.terminal === 'wt' ? ` (via ${cfg.commandShell})` : ''}`);
422
460
  const mode = cfg.browserMode || (cfg.autoOpenBrowser === false ? 'none' : 'app');
423
- openInBrowser(url, mode);
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) {
468
+ opened.child.on('exit', () => {
469
+ console.log('[ccsm] browser window closed · shutting down');
470
+ process.exit(0);
471
+ });
472
+ console.log('[ccsm] tied to browser window — close it to stop ccsm');
473
+ }
424
474
  startSnapshotLoop();
425
475
  })().catch((err) => {
426
476
  console.error('startup failed:', err);