@bakapiano/ccsm 0.3.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 +61 -3
- package/lib/favorites.js +73 -0
- package/lib/focus.js +58 -0
- package/lib/sessions.js +153 -0
- package/package.json +1 -1
- package/public/app.js +591 -252
- package/public/index.html +375 -143
- package/public/styles.css +1193 -125
- package/server.js +72 -8
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
|
-
|
|
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,7 +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
|
-
|
|
|
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}` |
|
|
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 |
|
|
92
100
|
| GET | `/api/terminals` | enumerate built-in terminal kinds + their process names |
|
|
93
101
|
| GET | `/api/health` | sanity ping |
|
|
94
102
|
|
|
@@ -112,6 +120,8 @@ ccsm uses variant 1 and always `path.resolve()`s the cwd first — defends again
|
|
|
112
120
|
|
|
113
121
|
**Don't test wt launching via `node -e "..."` inside `bash -c`.** Backslashes get eaten by shell quoting and the JS string ends up malformed. Write a `.js` file and `node file.js` instead.
|
|
114
122
|
|
|
123
|
+
**focusBySession — title-based wt window matching.** Walking up the claude.exe PID parent chain and taking `MainWindowHandle` of the wt process *always* returns the same canonical window in modern multi-window single-process wt — clicking different sessions all focused the same window. `focusBySession` instead lists all visible wt windows (`EnumWindows` filtered by process name), strips the leading wt status glyph (`✳ `, `⠐ `, `⠠ ` …) and compares to the session's ai-title. Falls back to title-substring, then cwd-basename, then the old PID-parent walk when no unique match — at least the user sees *some* wt window even if it's the wrong tab. Caveat: for sessions sitting in an inactive tab of a multi-tab wt window, the window title shows the *active* tab's title — so we can't find them by title alone and the fallback is wrong-tab.
|
|
124
|
+
|
|
115
125
|
**Focus helper (`lib/focus.js`).** One `powershell.exe -EncodedCommand <base64>` invocation per call, dispatched by `CCSM_FOCUS_MODE` env var into modes `list` (enumerate visible top-level windows owned by a process name), `focus-hwnd` (activate a specific HWND), `focus-pid` (walk parent chain to MainWindowHandle then activate). Encoded as UTF-16-LE-base64 — passing the C# block via stdin to `-Command -` silently produces no output, so we don't. Three gotchas baked in:
|
|
116
126
|
1. C# `out _` discard breaks PowerShell 5.1's bundled C# compiler — use a named `uint dummy`.
|
|
117
127
|
2. Windows blocks background processes from `SetForegroundWindow` — synthesize an Alt-key down/up via `keybd_event 0x12` first ("Alt-key trick") to qualify our process. Without it, `activated` returns false even though the window is found.
|
|
@@ -127,10 +137,58 @@ ccsm uses variant 1 and always `path.resolve()`s the cwd first — defends again
|
|
|
127
137
|
|
|
128
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--`.
|
|
129
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
|
+
|
|
130
187
|
## Extending
|
|
131
188
|
|
|
132
189
|
When adding features, the natural extension points:
|
|
133
190
|
- New REST routes: `server.js` (keep them under `/api/*`, use `asyncH` wrapper).
|
|
134
|
-
- Frontend section: add a `<section class="
|
|
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.
|
|
135
193
|
- Workspace lifecycle (delete, rename): `lib/workspace.js`.
|
|
136
194
|
- Different launch modes (e.g., stacked tabs): `lib/launcher.js` — but check first whether the "one window per session" decision still holds.
|
package/lib/favorites.js
ADDED
|
@@ -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/focus.js
CHANGED
|
@@ -192,6 +192,63 @@ async function focusByPid(pid) {
|
|
|
192
192
|
return await runPsHelper('focus-pid', n);
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
+
// Strip the leading wt status glyph + whitespace ("✳ ", "⠐ ", "⠠ " etc) so
|
|
196
|
+
// the title compares cleanly. wt prefixes the tab title with a Braille-style
|
|
197
|
+
// progress glyph or a sparkle/asterisk depending on activity.
|
|
198
|
+
function cleanWtTitle(t) {
|
|
199
|
+
return String(t || '').replace(/^[^\w一-鿿]+/, '').trim();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Best-effort: focus the wt window that belongs to a specific session.
|
|
203
|
+
// Modern wt is single-process multi-window, so walking from claude.exe PID
|
|
204
|
+
// up to wt.exe + taking MainWindowHandle always returns the same canonical
|
|
205
|
+
// window regardless of which tab the session is actually in. Instead we
|
|
206
|
+
// list all wt windows and match by tab title against the session's AI
|
|
207
|
+
// title / cwd basename.
|
|
208
|
+
async function focusBySession({ pid, sessionId, title, cwd }) {
|
|
209
|
+
const procName = 'WindowsTerminal.exe';
|
|
210
|
+
const cands = await listWindowsOf(procName);
|
|
211
|
+
|
|
212
|
+
const cleanedTitle = title ? cleanWtTitle(title) : '';
|
|
213
|
+
const cwdBase = cwd ? require('node:path').basename(cwd) : '';
|
|
214
|
+
|
|
215
|
+
// 1. Exact match on cleaned ai-title
|
|
216
|
+
if (cleanedTitle) {
|
|
217
|
+
const exact = cands.filter((w) => cleanWtTitle(w.title) === cleanedTitle);
|
|
218
|
+
if (exact.length === 1) {
|
|
219
|
+
const r = await focusByHwnd(exact[0].hwnd);
|
|
220
|
+
return { ...r, matchedBy: 'title-exact', hwnd: exact[0].hwnd, windowTitle: exact[0].title };
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// 2. Title substring match (some shells append " - <cwd>" etc)
|
|
225
|
+
if (cleanedTitle) {
|
|
226
|
+
const subs = cands.filter((w) => w.title.includes(cleanedTitle));
|
|
227
|
+
if (subs.length === 1) {
|
|
228
|
+
const r = await focusByHwnd(subs[0].hwnd);
|
|
229
|
+
return { ...r, matchedBy: 'title-substring', hwnd: subs[0].hwnd, windowTitle: subs[0].title };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// 3. Match by cwd basename (workspace name shows up in title for fresh launches)
|
|
234
|
+
if (cwdBase) {
|
|
235
|
+
const byCwd = cands.filter((w) => w.title.includes(cwdBase));
|
|
236
|
+
if (byCwd.length === 1) {
|
|
237
|
+
const r = await focusByHwnd(byCwd[0].hwnd);
|
|
238
|
+
return { ...r, matchedBy: 'cwd-basename', hwnd: byCwd[0].hwnd, windowTitle: byCwd[0].title };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// 4. Fall back to PID parent-chain walk (returns the wt process's
|
|
243
|
+
// canonical MainWindowHandle — may be the wrong window when wt is
|
|
244
|
+
// multi-window single-process, but better than nothing).
|
|
245
|
+
if (pid) {
|
|
246
|
+
const r = await focusByPid(pid);
|
|
247
|
+
return { ...r, matchedBy: 'pid-fallback', ambiguous: true };
|
|
248
|
+
}
|
|
249
|
+
return { ok: false, error: 'no match by title/cwd and no pid given', matchedBy: 'none' };
|
|
250
|
+
}
|
|
251
|
+
|
|
195
252
|
async function focusByHwnd(hwnd) {
|
|
196
253
|
return await runPsHelper('focus-hwnd', hwnd);
|
|
197
254
|
}
|
|
@@ -228,6 +285,7 @@ async function focusNewlyOpenedHwnd(beforeHwnds, processName, opts = {}) {
|
|
|
228
285
|
|
|
229
286
|
module.exports = {
|
|
230
287
|
focusByPid,
|
|
288
|
+
focusBySession,
|
|
231
289
|
focusByHwnd,
|
|
232
290
|
listWindowsOf,
|
|
233
291
|
snapshotWindowsOf,
|
package/lib/sessions.js
CHANGED
|
@@ -110,8 +110,161 @@ async function listSessions() {
|
|
|
110
110
|
.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
// Pull cwd/title/firstTimestamp from a jsonl by reading the head (cwd lives in
|
|
114
|
+
// any user/assistant/attachment line) and tailing for the last ai-title.
|
|
115
|
+
async function readJsonlMetadata(jsonlPath) {
|
|
116
|
+
let fd;
|
|
117
|
+
try {
|
|
118
|
+
fd = await fs.open(jsonlPath, 'r');
|
|
119
|
+
const stat = await fd.stat();
|
|
120
|
+
if (stat.size === 0) return { size: 0 };
|
|
121
|
+
|
|
122
|
+
const HEAD = Math.min(stat.size, 128 * 1024);
|
|
123
|
+
const headBuf = Buffer.alloc(HEAD);
|
|
124
|
+
await fd.read(headBuf, 0, HEAD, 0);
|
|
125
|
+
const headText = headBuf.toString('utf8');
|
|
126
|
+
|
|
127
|
+
let cwd = null;
|
|
128
|
+
let gitBranch = null;
|
|
129
|
+
let firstTimestamp = null;
|
|
130
|
+
for (const line of headText.split('\n')) {
|
|
131
|
+
if (!line) continue;
|
|
132
|
+
if (cwd && firstTimestamp) break;
|
|
133
|
+
if (!line.includes('"cwd"') && !line.includes('"timestamp"')) continue;
|
|
134
|
+
try {
|
|
135
|
+
const obj = JSON.parse(line);
|
|
136
|
+
if (obj.cwd && !cwd) cwd = obj.cwd;
|
|
137
|
+
if (obj.gitBranch && !gitBranch) gitBranch = obj.gitBranch;
|
|
138
|
+
if (obj.timestamp && !firstTimestamp) firstTimestamp = obj.timestamp;
|
|
139
|
+
} catch {}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const TAIL = Math.min(stat.size, 512 * 1024);
|
|
143
|
+
const tailBuf = Buffer.alloc(TAIL);
|
|
144
|
+
await fd.read(tailBuf, 0, TAIL, Math.max(0, stat.size - TAIL));
|
|
145
|
+
const tailText = tailBuf.toString('utf8');
|
|
146
|
+
let title = null;
|
|
147
|
+
const tailLines = tailText.split('\n');
|
|
148
|
+
for (let i = tailLines.length - 1; i >= 0; i--) {
|
|
149
|
+
if (!tailLines[i].includes('"type":"ai-title"')) continue;
|
|
150
|
+
try {
|
|
151
|
+
const obj = JSON.parse(tailLines[i]);
|
|
152
|
+
if (obj.aiTitle) { title = obj.aiTitle; break; }
|
|
153
|
+
} catch {}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { cwd, gitBranch, firstTimestamp, title, size: stat.size };
|
|
157
|
+
} catch {
|
|
158
|
+
return {};
|
|
159
|
+
} finally {
|
|
160
|
+
if (fd) await fd.close().catch(() => {});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Cheap enumeration of jsonl session files — file stats only, no content read.
|
|
165
|
+
async function enumerateRecentCandidates({ excludeIds = null } = {}) {
|
|
166
|
+
let projectDirs;
|
|
167
|
+
try {
|
|
168
|
+
projectDirs = await fs.readdir(PROJECTS_DIR);
|
|
169
|
+
} catch {
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const candidates = [];
|
|
174
|
+
for (const slugDir of projectDirs) {
|
|
175
|
+
const dirPath = path.join(PROJECTS_DIR, slugDir);
|
|
176
|
+
let entries;
|
|
177
|
+
try {
|
|
178
|
+
entries = await fs.readdir(dirPath);
|
|
179
|
+
} catch {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
for (const file of entries) {
|
|
183
|
+
if (!file.endsWith('.jsonl')) continue;
|
|
184
|
+
const sessionId = file.slice(0, -'.jsonl'.length);
|
|
185
|
+
if (excludeIds && excludeIds.has(sessionId)) continue;
|
|
186
|
+
const fullPath = path.join(dirPath, file);
|
|
187
|
+
try {
|
|
188
|
+
const stat = await fs.stat(fullPath);
|
|
189
|
+
if (stat.size === 0) continue;
|
|
190
|
+
candidates.push({
|
|
191
|
+
sessionId,
|
|
192
|
+
slug: slugDir,
|
|
193
|
+
jsonlPath: fullPath,
|
|
194
|
+
mtime: stat.mtimeMs,
|
|
195
|
+
});
|
|
196
|
+
} catch {}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
candidates.sort((a, b) => b.mtime - a.mtime);
|
|
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);
|
|
211
|
+
|
|
212
|
+
const results = await Promise.all(
|
|
213
|
+
page.map(async (c) => {
|
|
214
|
+
const meta = await readJsonlMetadata(c.jsonlPath);
|
|
215
|
+
const firstTs = meta.firstTimestamp ? Date.parse(meta.firstTimestamp) : null;
|
|
216
|
+
return {
|
|
217
|
+
sessionId: c.sessionId,
|
|
218
|
+
cwd: meta.cwd || null,
|
|
219
|
+
title: meta.title || null,
|
|
220
|
+
gitBranch: meta.gitBranch || null,
|
|
221
|
+
updatedAt: c.mtime,
|
|
222
|
+
startedAt: Number.isFinite(firstTs) ? firstTs : null,
|
|
223
|
+
jsonlPath: c.jsonlPath,
|
|
224
|
+
};
|
|
225
|
+
})
|
|
226
|
+
);
|
|
227
|
+
|
|
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;
|
|
262
|
+
}
|
|
263
|
+
|
|
113
264
|
module.exports = {
|
|
114
265
|
listSessions,
|
|
266
|
+
listRecentSessions,
|
|
267
|
+
findSessionMetadata,
|
|
115
268
|
projectSlugForCwd,
|
|
116
269
|
getLiveClaudePids,
|
|
117
270
|
SESSIONS_DIR,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakapiano/ccsm",
|
|
3
|
-
"version": "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",
|