@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/CLAUDE.md CHANGED
@@ -34,6 +34,7 @@ D:\ccsm\
34
34
  ├── lib\
35
35
  │ ├── sessions.js # reads ~/.claude/sessions/*.json + cross-checks live PIDs (tasklist)
36
36
  │ │ # + pulls last ai-title from ~/.claude/projects/<cwd-slug>/<sessionId>.jsonl
37
+ │ │ # + listRecentSessions(limit, offset) returns paged {recent, total}
37
38
  │ ├── snapshot.js # save/load/rotate/restore — restore = launch one wt window per session
38
39
  │ ├── workspace.js # workspace = subfolder under workDir holding repo clones;
39
40
  │ │ # "in use" = any live session's cwd is at/under the workspace path
@@ -41,6 +42,7 @@ D:\ccsm\
41
42
  │ │ # path.resolve()s cwd; throws if cwd doesn't exist
42
43
  │ ├── focus.js # PowerShell + Win32 — listWindowsOf, focusByHwnd, focusByPid,
43
44
  │ │ # focusNewlyOpenedHwnd (HWND-diff for auto-focus on launch)
45
+ │ ├── favorites.js # user-pinned sessions, ~/.ccsm/favorites.json keyed by sessionId
44
46
  │ └── config.js # loadConfig/saveConfig with defaults
45
47
  ├── public\
46
48
  │ ├── index.html, app.js, styles.css # vanilla, auto-refresh every 5s
@@ -49,7 +51,9 @@ D:\ccsm\
49
51
  ~/.ccsm/ # or $CCSM_HOME
50
52
  ├── config.json # source of truth
51
53
  ├── snapshot.json # latest snapshot, rewritten every 60s
52
- └── snapshots/ # rotating history (default keep=30)
54
+ ├── snapshots/ # rotating history (default keep=30)
55
+ ├── favorites.json # { [sessionId]: { sessionId, cwd, title, gitBranch, addedAt } }
56
+ └── browser-profile/ # Edge/Chrome --user-data-dir when browserMode=app
53
57
  ```
54
58
 
55
59
  On first run, if a legacy `<repo>/data/` directory exists and `~/.ccsm/` is empty, `lib/config.js` copies the old data over (one-time, idempotent). The legacy dir is left in place — clean up manually after verifying.
@@ -88,8 +92,11 @@ The alternative ("one repo per workspace") was explicitly rejected — don't ref
88
92
  | POST | `/api/sessions/new` | body `{repos, workspace?, launch?}` — picks/creates ws, clones missing repos, launches wt |
89
93
  | POST | `/api/sessions/finder` | opens a wt with `claude` in `D:\ccsm` and the `finderPrompt` as opening message |
90
94
  | POST | `/api/sessions/:id/resume` | body `{cwd}` — launches `wt -d <cwd> claude --resume <id>` |
91
- | GET | `/api/sessions/recent` | recently-used sessions discovered from `~/.claude/projects/*/*.jsonl` mtimes, excluding currently-live ids |
95
+ | GET | `/api/sessions/recent` | recently-used sessions from `~/.claude/projects/*/*.jsonl` mtimes, excluding live ids · query `?limit=15&offset=0` for pagination · returns `{recent, total, limit, offset}` |
92
96
  | POST | `/api/sessions/:id/focus` | matches a wt window by title (cleaned of leading status glyphs) against the session's ai-title; falls back to PID-parent walk if no unique match |
97
+ | GET | `/api/favorites` | array of pinned sessions sorted by `addedAt` desc |
98
+ | POST | `/api/favorites/:id` | star a session; body `{cwd, title, gitBranch?, label?}` (snapshot of current row data so the favorite stays meaningful after the jsonl is gone) |
99
+ | DELETE | `/api/favorites/:id` | unstar |
93
100
  | GET | `/api/terminals` | enumerate built-in terminal kinds + their process names |
94
101
  | GET | `/api/health` | sanity ping |
95
102
 
@@ -130,10 +137,58 @@ ccsm uses variant 1 and always `path.resolve()`s the cwd first — defends again
130
137
 
131
138
  **`projectSlugForCwd`.** The path-to-slug mapping is `cwd.replace(/[:\\]/g, '-')`, e.g. `D:\ccsm` → `D--ccsm`, `C:\Users\foo` → `C--Users-foo`, `D:\` → `D--`.
132
139
 
140
+ ## Frontend design language
141
+
142
+ The UI deliberately copies **claude.ai's** calm light aesthetic — warm cream surfaces, generous spacing, soft borders, single Claude-orange accent. Don't dark-mode-ify or chrome-ify.
143
+
144
+ **Palette** (CSS vars in `public/styles.css`):
145
+ - `--bg` `#faf9f5` warm cream page background
146
+ - `--bg-elev` `#ffffff` card surfaces
147
+ - `--sidebar-bg` `#f3f0e8` slightly darker cream for the rail
148
+ - `--border` `#e8e3d5` hairlines
149
+ - `--ink` `#1a1815` body text (warm near-black)
150
+ - `--ink-mid` `#534e44` secondary
151
+ - `--ink-muted` `#8a8475` meta
152
+ - `--accent` `#c45f3f` Claude warm orange — for primary actions, focus rings, active states ONLY
153
+ - Status: green `#4a8a4a` idle · yellow `#c4892b` busy (pulsing) · red `#b73f3f` danger
154
+
155
+ **Type**:
156
+ - Body / headings: **Geist** (Google Fonts, 300–700). No Fraunces / no italic display.
157
+ - Mono: **JetBrains Mono** for paths, PIDs, sessionIds, branch tags, timestamps in meta.
158
+ - Always `font-variant-numeric: tabular-nums` on numeric cells.
159
+
160
+ **Layout**:
161
+ - **Sidebar** (collapsible, ~232px ↔ ~60px, state in `localStorage["ccsm.sidebar-collapsed"]`):
162
+ - top: brand mark (orange rounded square) + `ccsm.` wordmark
163
+ - mid: 3 nav items (Sessions / Launch / Configure) with stroke icons + label + optional badge
164
+ - divider + utility items (Refresh, Ask Claude)
165
+ - footer: collapse toggle (chevron flips on collapse via CSS rotate)
166
+ - **Main column**: page header (title + subtitle + meta row of port/terminal/clock) → content cards → footer status line
167
+ - Cards: `.card` (white, 10px radius, very soft `--shadow`), `.card-head` with title+meta, `.card-body` with optional `.card-body-flush` for tables.
168
+ - Tables: wrapped in `.table-scroll` (`overflow-x: auto`, min-width 760px) so narrow viewports scroll horizontally instead of cramping.
169
+
170
+ **Animation**:
171
+ - **Don't re-animate on refresh.** Rows have a one-shot staggered fade-in animation. `app.js` `markRendered(tableId)` adds `.no-anim` to the tbody after the first render via double `requestAnimationFrame`, so subsequent 5-second auto-refreshes don't restage every row (would strobe).
172
+ - Panel switch: 0.35s `panel-in` fade-up.
173
+ - Tab indicator: orange left bar on active sidebar item (`::before`).
174
+ - Busy status mark: green-pulse via `box-shadow` keyframes.
175
+
176
+ **Star (favorites) UI**:
177
+ - Star button sits **inside the title cell**, right next to the title text (not in its own column). Outline-style by default at 55% opacity; row-hover bumps to 100%; favorited state fills with the accent color.
178
+ - Click is delegated at the table level (`button[data-star]`). Toggle is **optimistic**: `state.favorites` updates and re-renders all 3 tables before the network call returns; failure shows a toast.
179
+ - Backend snapshots `cwd / title / gitBranch` into `favorites.json` so the favorite is still meaningful after the source jsonl is gone.
180
+
181
+ **No emoji in the UI** unless the user typed it (e.g. wt status glyphs in session titles). Use inline SVG icons everywhere (line stroke, 1.5–2px) so they take `currentColor` and live with the type weight.
182
+
183
+ ## Lifecycle: server tied to browser window
184
+
185
+ When the user launches via `npx @bakapiano/ccsm` from an interactive terminal (`process.stdout.isTTY === true`) AND `browserMode === 'app'`, the server keeps the spawned Edge/Chrome child handle and listens for its `exit` event. When the user closes the chromeless window, msedge.exe (running with its own `--user-data-dir=<DATA_DIR>/browser-profile` process group) exits, our hook fires `process.exit(0)`, and the terminal returns to a prompt. Headless / `nohup` launches don't get this hook (no TTY) and stay running.
186
+
133
187
  ## Extending
134
188
 
135
189
  When adding features, the natural extension points:
136
190
  - New REST routes: `server.js` (keep them under `/api/*`, use `asyncH` wrapper).
137
- - Frontend section: add a `<section class="panel">` in `public/index.html` and a render function in `public/app.js`.
191
+ - Frontend section: add a `<section class="card">` in `public/index.html` and a render function in `public/app.js`. Use `markRendered(tableId)` after the first render to suppress refresh strobing.
192
+ - Persistent user data: drop a JSON file under `~/.ccsm/` (like `favorites.json`) and wrap with a small lib module — config.js / favorites.js pattern.
138
193
  - Workspace lifecycle (delete, rename): `lib/workspace.js`.
139
194
  - Different launch modes (e.g., stacked tabs): `lib/launcher.js` — but check first whether the "one window per session" decision still holds.
@@ -0,0 +1,73 @@
1
+ 'use strict';
2
+
3
+ // User-pinned ("favorited") sessions. Stored as a JSON object keyed by
4
+ // sessionId at $DATA_DIR/favorites.json. Each entry captures enough
5
+ // metadata (cwd, title, gitBranch) to render the row even after the
6
+ // session's jsonl is gone — the entry is best-effort archival.
7
+
8
+ const fs = require('node:fs/promises');
9
+ const path = require('node:path');
10
+ const { DATA_DIR } = require('./config');
11
+
12
+ const FAVORITES_PATH = path.join(DATA_DIR, 'favorites.json');
13
+
14
+ async function loadFavorites() {
15
+ try {
16
+ const raw = await fs.readFile(FAVORITES_PATH, 'utf8');
17
+ const obj = JSON.parse(raw);
18
+ return obj && typeof obj === 'object' && !Array.isArray(obj) ? obj : {};
19
+ } catch (e) {
20
+ if (e.code === 'ENOENT') return {};
21
+ throw e;
22
+ }
23
+ }
24
+
25
+ async function saveFavorites(favs) {
26
+ await fs.writeFile(FAVORITES_PATH, JSON.stringify(favs, null, 2));
27
+ }
28
+
29
+ async function listFavorites() {
30
+ const favs = await loadFavorites();
31
+ return Object.values(favs).sort((a, b) => (b.addedAt || 0) - (a.addedAt || 0));
32
+ }
33
+
34
+ async function addFavorite(sessionId, info = {}) {
35
+ if (!sessionId) throw new Error('addFavorite: sessionId required');
36
+ const favs = await loadFavorites();
37
+ const existing = favs[sessionId];
38
+ favs[sessionId] = existing
39
+ ? { ...existing, ...info, sessionId }
40
+ : {
41
+ sessionId,
42
+ cwd: info.cwd || null,
43
+ title: info.title || null,
44
+ gitBranch: info.gitBranch || null,
45
+ label: info.label || null,
46
+ addedAt: Date.now(),
47
+ };
48
+ await saveFavorites(favs);
49
+ return favs[sessionId];
50
+ }
51
+
52
+ async function removeFavorite(sessionId) {
53
+ const favs = await loadFavorites();
54
+ if (!(sessionId in favs)) return false;
55
+ delete favs[sessionId];
56
+ await saveFavorites(favs);
57
+ return true;
58
+ }
59
+
60
+ async function hasFavorite(sessionId) {
61
+ const favs = await loadFavorites();
62
+ return sessionId in favs;
63
+ }
64
+
65
+ module.exports = {
66
+ loadFavorites,
67
+ saveFavorites,
68
+ listFavorites,
69
+ addFavorite,
70
+ removeFavorite,
71
+ hasFavorite,
72
+ FAVORITES_PATH,
73
+ };
package/lib/sessions.js CHANGED
@@ -161,10 +161,8 @@ async function readJsonlMetadata(jsonlPath) {
161
161
  }
162
162
  }
163
163
 
164
- // List every recently-used Claude session by enumerating ~/.claude/projects/
165
- // *.jsonl files. Excludes session ids that are currently live (caller passes
166
- // `excludeIds` from listSessions()). Sorted by file mtime desc.
167
- async function listRecentSessions({ limit = 50, excludeIds = null } = {}) {
164
+ // Cheap enumeration of jsonl session files file stats only, no content read.
165
+ async function enumerateRecentCandidates({ excludeIds = null } = {}) {
168
166
  let projectDirs;
169
167
  try {
170
168
  projectDirs = await fs.readdir(PROJECTS_DIR);
@@ -200,10 +198,19 @@ async function listRecentSessions({ limit = 50, excludeIds = null } = {}) {
200
198
  }
201
199
 
202
200
  candidates.sort((a, b) => b.mtime - a.mtime);
203
- const top = candidates.slice(0, limit);
201
+ return candidates;
202
+ }
203
+
204
+ // Paginated, sorted by mtime desc. Returns { recent, total } so callers can
205
+ // render pagination controls. Cheap stat-only enumeration happens for ALL
206
+ // candidates; expensive jsonl-content reads only on the page slice.
207
+ async function listRecentSessions({ limit = 50, offset = 0, excludeIds = null } = {}) {
208
+ const candidates = await enumerateRecentCandidates({ excludeIds });
209
+ const total = candidates.length;
210
+ const page = candidates.slice(offset, offset + limit);
204
211
 
205
212
  const results = await Promise.all(
206
- top.map(async (c) => {
213
+ page.map(async (c) => {
207
214
  const meta = await readJsonlMetadata(c.jsonlPath);
208
215
  const firstTs = meta.firstTimestamp ? Date.parse(meta.firstTimestamp) : null;
209
216
  return {
@@ -218,13 +225,46 @@ async function listRecentSessions({ limit = 50, excludeIds = null } = {}) {
218
225
  })
219
226
  );
220
227
 
221
- // Drop entries with no cwd — can't resume without one
222
- return results.filter((r) => r.cwd);
228
+ // Drop entries with no cwd — can't resume without one. Adjust total
229
+ // accordingly is tricky (would need to enrich all), so we don't — the
230
+ // total is the upper bound of resumable + non-resumable.
231
+ return { recent: results.filter((r) => r.cwd), total };
232
+ }
233
+
234
+ // Look up metadata for a specific sessionId by scanning the projects dir.
235
+ // Used by /api/favorites to display rich metadata for archived sessions.
236
+ async function findSessionMetadata(sessionId) {
237
+ let projectDirs;
238
+ try {
239
+ projectDirs = await fs.readdir(PROJECTS_DIR);
240
+ } catch {
241
+ return null;
242
+ }
243
+ for (const slugDir of projectDirs) {
244
+ const fullPath = path.join(PROJECTS_DIR, slugDir, `${sessionId}.jsonl`);
245
+ try {
246
+ const stat = await fs.stat(fullPath);
247
+ if (stat.size === 0) continue;
248
+ const meta = await readJsonlMetadata(fullPath);
249
+ const firstTs = meta.firstTimestamp ? Date.parse(meta.firstTimestamp) : null;
250
+ return {
251
+ sessionId,
252
+ cwd: meta.cwd || null,
253
+ title: meta.title || null,
254
+ gitBranch: meta.gitBranch || null,
255
+ updatedAt: stat.mtimeMs,
256
+ startedAt: Number.isFinite(firstTs) ? firstTs : null,
257
+ jsonlPath: fullPath,
258
+ };
259
+ } catch {}
260
+ }
261
+ return null;
223
262
  }
224
263
 
225
264
  module.exports = {
226
265
  listSessions,
227
266
  listRecentSessions,
267
+ findSessionMetadata,
228
268
  projectSlugForCwd,
229
269
  getLiveClaudePids,
230
270
  SESSIONS_DIR,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakapiano/ccsm",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Claude Code Session Manager — Windows web UI to manage many concurrent claude sessions: live list, snapshot/restore, focus existing window, new session in an isolated workspace with repo clones",
5
5
  "license": "MIT",
6
6
  "main": "server.js",