@bakapiano/ccsm 0.4.0 → 0.6.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,9 @@
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');
9
+ const { loadLabels, setLabel, removeLabel } = require('./lib/labels');
8
10
  const { loadConfig, saveConfig, DATA_DIR } = require('./lib/config');
9
11
  const {
10
12
  saveSnapshot,
@@ -63,11 +65,68 @@ app.get('/api/sessions', asyncH(async (_req, res) => {
63
65
  }));
64
66
 
65
67
  app.get('/api/sessions/recent', asyncH(async (req, res) => {
66
- const limit = Math.min(200, Number(req.query.limit) || 50);
68
+ const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 15));
69
+ const offset = Math.max(0, Number(req.query.offset) || 0);
67
70
  const live = await listSessions();
68
71
  const excludeIds = new Set(live.map((s) => s.sessionId));
69
- const recent = await listRecentSessions({ limit, excludeIds });
70
- res.json({ recent, takenAt: Date.now() });
72
+ const { recent, total } = await listRecentSessions({ limit, offset, excludeIds });
73
+ res.json({ recent, total, limit, offset, takenAt: Date.now() });
74
+ }));
75
+
76
+ // ---- favorites ----
77
+ // Sessions the user has starred. Stored at $DATA_DIR/favorites.json.
78
+ // Frontend usually GETs once at boot and updates optimistically.
79
+ app.get('/api/favorites', asyncH(async (_req, res) => {
80
+ const favorites = await listFavorites();
81
+ res.json({ favorites });
82
+ }));
83
+
84
+ app.post('/api/favorites/:sessionId', asyncH(async (req, res) => {
85
+ const sessionId = req.params.sessionId;
86
+ let info = req.body && typeof req.body === 'object' ? req.body : {};
87
+ // If client didn't supply title/cwd, try to look them up from the live
88
+ // session list or from the jsonl files on disk. This way star-from-empty
89
+ // (e.g. via API) still produces a usable favorite.
90
+ if (!info.cwd || !info.title) {
91
+ const live = await listSessions();
92
+ const livehit = live.find((s) => s.sessionId === sessionId);
93
+ if (livehit) {
94
+ info = { cwd: livehit.cwd, title: livehit.title, ...info };
95
+ } else {
96
+ const meta = await findSessionMetadata(sessionId);
97
+ if (meta) info = { cwd: meta.cwd, title: meta.title, gitBranch: meta.gitBranch, ...info };
98
+ }
99
+ }
100
+ const fav = await addFavorite(sessionId, info);
101
+ res.json({ favorite: fav });
102
+ }));
103
+
104
+ app.delete('/api/favorites/:sessionId', asyncH(async (req, res) => {
105
+ const removed = await removeFavorite(req.params.sessionId);
106
+ res.json({ removed });
107
+ }));
108
+
109
+ // ---- labels (rename overrides) ----
110
+ // Custom display titles keyed by sessionId. Empty body / empty label is
111
+ // treated as a delete.
112
+ app.get('/api/labels', asyncH(async (_req, res) => {
113
+ const labels = await loadLabels();
114
+ res.json({ labels });
115
+ }));
116
+
117
+ app.put('/api/labels/:sessionId', asyncH(async (req, res) => {
118
+ const label = req.body && req.body.label;
119
+ if (!label || !String(label).trim()) {
120
+ const removed = await removeLabel(req.params.sessionId);
121
+ return res.json({ removed });
122
+ }
123
+ const saved = await setLabel(req.params.sessionId, label);
124
+ res.json({ label: saved });
125
+ }));
126
+
127
+ app.delete('/api/labels/:sessionId', asyncH(async (req, res) => {
128
+ const removed = await removeLabel(req.params.sessionId);
129
+ res.json({ removed });
71
130
  }));
72
131
 
73
132
  // ---- config ----
@@ -305,11 +364,13 @@ app.post('/api/sessions/:sessionId/focus', asyncH(async (req, res) => {
305
364
  const sessions = await listSessions();
306
365
  const s = sessions.find((x) => x.sessionId === sessionId);
307
366
  if (!s) return res.status(404).json({ error: `session ${sessionId} not live` });
367
+ const cfg = await loadConfig();
308
368
  const result = await focusBySession({
309
369
  pid: s.pid,
310
370
  sessionId: s.sessionId,
311
371
  title: s.title,
312
372
  cwd: s.cwd,
373
+ moveToCenter: !!cfg.focusMovesToCenter,
313
374
  });
314
375
  res.json({ session: { pid: s.pid, sessionId: s.sessionId, cwd: s.cwd, title: s.title }, ...result });
315
376
  }));
@@ -318,7 +379,8 @@ app.post('/api/sessions/:sessionId/focus', asyncH(async (req, res) => {
318
379
  app.get('/api/terminals', (_req, res) => res.json({ terminals: listTerminalKinds() }));
319
380
 
320
381
  // ---- health ----
321
- app.get('/api/health', (_req, res) => res.json({ ok: true, pid: process.pid }));
382
+ const pkg = require('./package.json');
383
+ app.get('/api/health', (_req, res) => res.json({ ok: true, pid: process.pid, version: pkg.version, name: pkg.name }));
322
384
 
323
385
  // ---- auto-snapshot scheduler ----
324
386
  let snapshotTimer = null;
@@ -373,7 +435,7 @@ function findAppModeBrowser() {
373
435
  }
374
436
 
375
437
  function openInBrowser(url, mode) {
376
- if (process.platform !== 'win32' || mode === 'none') return;
438
+ if (mode === 'none' || process.platform !== 'win32') return { kind: 'none', child: null };
377
439
  const { spawn } = require('node:child_process');
378
440
  const fs = require('node:fs');
379
441
 
@@ -390,25 +452,28 @@ function openInBrowser(url, mode) {
390
452
  [
391
453
  `--app=${url}`,
392
454
  `--user-data-dir=${profileDir}`,
393
- '--window-size=1400,1000',
455
+ '--window-size=1500,1100',
394
456
  '--no-first-run',
395
457
  '--no-default-browser-check',
396
458
  ],
397
459
  { detached: true, stdio: 'ignore' }
398
460
  );
399
461
  child.unref();
400
- return;
462
+ return { kind: 'app', child };
401
463
  }
402
464
  console.log('[ccsm] no Edge/Chrome found for app mode, falling back to default browser');
403
465
  }
404
466
 
405
- // mode === 'tab' (or app-mode fallback)
467
+ // mode === 'tab' (or app-mode fallback). cmd's `start` builtin exits
468
+ // immediately after launching the default browser — the child handle
469
+ // isn't usable for lifecycle tracking.
406
470
  const child = spawn('cmd.exe', ['/c', 'start', '', url], {
407
471
  detached: true,
408
472
  stdio: 'ignore',
409
473
  windowsHide: true,
410
474
  });
411
475
  child.unref();
476
+ return { kind: 'tab', child: null };
412
477
  }
413
478
 
414
479
  (async () => {
@@ -420,7 +485,19 @@ function openInBrowser(url, mode) {
420
485
  console.log(`work dir: ${cfg.workDir}`);
421
486
  console.log(`terminal: ${cfg.terminal} · ${cfg.claudeCommand}${cfg.terminal === 'wt' ? ` (via ${cfg.commandShell})` : ''}`);
422
487
  const mode = cfg.browserMode || (cfg.autoOpenBrowser === false ? 'none' : 'app');
423
- openInBrowser(url, mode);
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) {
495
+ opened.child.on('exit', () => {
496
+ console.log('[ccsm] browser window closed · shutting down');
497
+ process.exit(0);
498
+ });
499
+ console.log('[ccsm] tied to browser window — close it to stop ccsm');
500
+ }
424
501
  startSnapshotLoop();
425
502
  })().catch((err) => {
426
503
  console.error('startup failed:', err);