@bakapiano/ccsm 0.3.0 → 0.4.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
@@ -88,7 +88,8 @@ The alternative ("one repo per workspace") was explicitly rejected — don't ref
88
88
  | POST | `/api/sessions/new` | body `{repos, workspace?, launch?}` — picks/creates ws, clones missing repos, launches wt |
89
89
  | POST | `/api/sessions/finder` | opens a wt with `claude` in `D:\ccsm` and the `finderPrompt` as opening message |
90
90
  | POST | `/api/sessions/:id/resume` | body `{cwd}` — launches `wt -d <cwd> claude --resume <id>` |
91
- | POST | `/api/sessions/:id/focus` | walks up the claude PID parent chain to find the wt window, raises it via SetForegroundWindow |
91
+ | GET | `/api/sessions/recent` | recently-used sessions discovered from `~/.claude/projects/*/*.jsonl` mtimes, excluding currently-live ids |
92
+ | 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 |
92
93
  | GET | `/api/terminals` | enumerate built-in terminal kinds + their process names |
93
94
  | GET | `/api/health` | sanity ping |
94
95
 
@@ -112,6 +113,8 @@ ccsm uses variant 1 and always `path.resolve()`s the cwd first — defends again
112
113
 
113
114
  **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
115
 
116
+ **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.
117
+
115
118
  **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
119
  1. C# `out _` discard breaks PowerShell 5.1's bundled C# compiler — use a named `uint dummy`.
117
120
  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.
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,121 @@ 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
+ // 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 } = {}) {
168
+ let projectDirs;
169
+ try {
170
+ projectDirs = await fs.readdir(PROJECTS_DIR);
171
+ } catch {
172
+ return [];
173
+ }
174
+
175
+ const candidates = [];
176
+ for (const slugDir of projectDirs) {
177
+ const dirPath = path.join(PROJECTS_DIR, slugDir);
178
+ let entries;
179
+ try {
180
+ entries = await fs.readdir(dirPath);
181
+ } catch {
182
+ continue;
183
+ }
184
+ for (const file of entries) {
185
+ if (!file.endsWith('.jsonl')) continue;
186
+ const sessionId = file.slice(0, -'.jsonl'.length);
187
+ if (excludeIds && excludeIds.has(sessionId)) continue;
188
+ const fullPath = path.join(dirPath, file);
189
+ try {
190
+ const stat = await fs.stat(fullPath);
191
+ if (stat.size === 0) continue;
192
+ candidates.push({
193
+ sessionId,
194
+ slug: slugDir,
195
+ jsonlPath: fullPath,
196
+ mtime: stat.mtimeMs,
197
+ });
198
+ } catch {}
199
+ }
200
+ }
201
+
202
+ candidates.sort((a, b) => b.mtime - a.mtime);
203
+ const top = candidates.slice(0, limit);
204
+
205
+ const results = await Promise.all(
206
+ top.map(async (c) => {
207
+ const meta = await readJsonlMetadata(c.jsonlPath);
208
+ const firstTs = meta.firstTimestamp ? Date.parse(meta.firstTimestamp) : null;
209
+ return {
210
+ sessionId: c.sessionId,
211
+ cwd: meta.cwd || null,
212
+ title: meta.title || null,
213
+ gitBranch: meta.gitBranch || null,
214
+ updatedAt: c.mtime,
215
+ startedAt: Number.isFinite(firstTs) ? firstTs : null,
216
+ jsonlPath: c.jsonlPath,
217
+ };
218
+ })
219
+ );
220
+
221
+ // Drop entries with no cwd — can't resume without one
222
+ return results.filter((r) => r.cwd);
223
+ }
224
+
113
225
  module.exports = {
114
226
  listSessions,
227
+ listRecentSessions,
115
228
  projectSlugForCwd,
116
229
  getLiveClaudePids,
117
230
  SESSIONS_DIR,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakapiano/ccsm",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",
package/public/app.js CHANGED
@@ -83,6 +83,29 @@ function renderSessions() {
83
83
  state.sessions.length ? `${state.sessions.length} live · last refresh ${new Date().toLocaleTimeString()}` : 'no live sessions';
84
84
  }
85
85
 
86
+ function renderRecent() {
87
+ const tb = $('#recentTable tbody');
88
+ tb.innerHTML = '';
89
+ const recent = state.recent || [];
90
+ for (const s of recent) {
91
+ const tr = document.createElement('tr');
92
+ tr.innerHTML = `
93
+ <td><div class="ellipsis" title="${escapeHtml(s.title || '')}">${escapeHtml(s.title || '(no title)')}</div>
94
+ <div class="mono small" title="${escapeHtml(s.sessionId)}">${escapeHtml(s.sessionId.slice(0,8))}…</div></td>
95
+ <td><div class="ellipsis mono" title="${escapeHtml(s.cwd || '')}">${escapeHtml(s.cwd || '')}</div></td>
96
+ <td class="mono small">${escapeHtml(s.gitBranch || '')}</td>
97
+ <td title="${escapeHtml(fmtTime(s.updatedAt))}">${escapeHtml(fmtAgo(s.updatedAt))}</td>
98
+ <td title="${escapeHtml(fmtTime(s.startedAt))}">${escapeHtml(fmtAgo(s.startedAt))}</td>
99
+ <td style="text-align:right;">
100
+ <button class="btn small btn-primary" data-continue="${escapeHtml(s.sessionId)}" data-cwd="${escapeHtml(s.cwd)}" title="open a new wt window with claude --resume">continue</button>
101
+ </td>
102
+ `;
103
+ tb.appendChild(tr);
104
+ }
105
+ $('#recentMeta').textContent =
106
+ recent.length ? `${recent.length} recent · last refresh ${new Date().toLocaleTimeString()}` : 'no recent sessions';
107
+ }
108
+
86
109
  // ---- snapshot render ----
87
110
 
88
111
  function renderSnapshot() {
@@ -221,6 +244,12 @@ async function loadSessions() {
221
244
  renderSessions();
222
245
  }
223
246
 
247
+ async function loadRecent() {
248
+ const r = await api('GET', '/api/sessions/recent?limit=50');
249
+ state.recent = r.recent;
250
+ renderRecent();
251
+ }
252
+
224
253
  async function loadConfig() {
225
254
  const [cfg, terminals] = await Promise.all([
226
255
  api('GET', '/api/config'),
@@ -249,7 +278,7 @@ async function loadWorkspaces() {
249
278
  }
250
279
 
251
280
  async function refreshAll() {
252
- await Promise.all([loadSessions(), loadSnapshot(), loadWorkspaces()]);
281
+ await Promise.all([loadSessions(), loadRecent(), loadSnapshot(), loadWorkspaces()]);
253
282
  }
254
283
 
255
284
  // ---- event wiring ----
@@ -298,6 +327,24 @@ function wireUp() {
298
327
  }
299
328
  });
300
329
 
330
+ $('#recentTable').addEventListener('click', async (ev) => {
331
+ const btn = ev.target.closest('button[data-continue]');
332
+ if (!btn) return;
333
+ const sessionId = btn.dataset.continue;
334
+ const cwd = btn.dataset.cwd;
335
+ btn.disabled = true;
336
+ try {
337
+ await api('POST', `/api/sessions/${sessionId}/resume`, { cwd });
338
+ toast(`continuing ${sessionId.slice(0, 8)}…`);
339
+ setTimeout(() => loadSessions().catch(() => {}), 3000);
340
+ setTimeout(() => loadRecent().catch(() => {}), 4000);
341
+ } catch (e) {
342
+ toast(e.message, 'error');
343
+ } finally {
344
+ btn.disabled = false;
345
+ }
346
+ });
347
+
301
348
  $('#finderBtn').onclick = async () => {
302
349
  try {
303
350
  await api('POST', '/api/sessions/finder');
@@ -408,6 +455,7 @@ function startAutoRefresh() {
408
455
  stopAutoRefresh();
409
456
  state.autoTimer = setInterval(() => {
410
457
  loadSessions().catch(() => {});
458
+ loadRecent().catch(() => {});
411
459
  loadSnapshot().catch(() => {});
412
460
  }, 5000);
413
461
  }
package/public/index.html CHANGED
@@ -49,6 +49,28 @@
49
49
  </div>
50
50
  </section>
51
51
 
52
+ <section class="panel">
53
+ <header class="panel-header">
54
+ <h2>recently closed</h2>
55
+ <span id="recentMeta" class="muted"></span>
56
+ </header>
57
+ <div class="table-wrap">
58
+ <table id="recentTable" class="data-table">
59
+ <thead>
60
+ <tr>
61
+ <th>title</th>
62
+ <th>cwd</th>
63
+ <th>branch</th>
64
+ <th>updated</th>
65
+ <th>started</th>
66
+ <th></th>
67
+ </tr>
68
+ </thead>
69
+ <tbody></tbody>
70
+ </table>
71
+ </div>
72
+ </section>
73
+
52
74
  <section class="panel">
53
75
  <header class="panel-header">
54
76
  <h2>snapshot</h2>
package/server.js CHANGED
@@ -4,7 +4,7 @@
4
4
  const path = require('node:path');
5
5
  const express = require('express');
6
6
 
7
- const { listSessions } = require('./lib/sessions');
7
+ const { listSessions, listRecentSessions } = require('./lib/sessions');
8
8
  const { loadConfig, saveConfig, DATA_DIR } = require('./lib/config');
9
9
  const {
10
10
  saveSnapshot,
@@ -26,6 +26,7 @@ const {
26
26
  } = require('./lib/launcher');
27
27
  const {
28
28
  focusByPid,
29
+ focusBySession,
29
30
  snapshotWindowsOf,
30
31
  focusNewlyOpenedHwnd,
31
32
  } = require('./lib/focus');
@@ -61,6 +62,14 @@ app.get('/api/sessions', asyncH(async (_req, res) => {
61
62
  res.json({ sessions, takenAt: Date.now() });
62
63
  }));
63
64
 
65
+ app.get('/api/sessions/recent', asyncH(async (req, res) => {
66
+ const limit = Math.min(200, Number(req.query.limit) || 50);
67
+ const live = await listSessions();
68
+ const excludeIds = new Set(live.map((s) => s.sessionId));
69
+ const recent = await listRecentSessions({ limit, excludeIds });
70
+ res.json({ recent, takenAt: Date.now() });
71
+ }));
72
+
64
73
  // ---- config ----
65
74
 
66
75
  app.get('/api/config', asyncH(async (_req, res) => {
@@ -296,8 +305,13 @@ app.post('/api/sessions/:sessionId/focus', asyncH(async (req, res) => {
296
305
  const sessions = await listSessions();
297
306
  const s = sessions.find((x) => x.sessionId === sessionId);
298
307
  if (!s) return res.status(404).json({ error: `session ${sessionId} not live` });
299
- const result = await focusByPid(s.pid);
300
- res.json({ session: { pid: s.pid, sessionId: s.sessionId, cwd: s.cwd }, ...result });
308
+ const result = await focusBySession({
309
+ pid: s.pid,
310
+ sessionId: s.sessionId,
311
+ title: s.title,
312
+ cwd: s.cwd,
313
+ });
314
+ res.json({ session: { pid: s.pid, sessionId: s.sessionId, cwd: s.cwd, title: s.title }, ...result });
301
315
  }));
302
316
 
303
317
  // ---- terminal kinds ----