@bakapiano/ccsm 0.10.3 → 0.11.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.
Files changed (48) hide show
  1. package/CLAUDE.md +475 -475
  2. package/README.md +190 -190
  3. package/bin/ccsm.js +194 -194
  4. package/lib/cliSessionWatcher.js +249 -249
  5. package/lib/config.js +185 -185
  6. package/lib/folders.js +96 -96
  7. package/lib/localCliSessions.js +489 -177
  8. package/lib/persistedSessions.js +134 -134
  9. package/lib/webTerminal.js +208 -208
  10. package/lib/workspace.js +230 -255
  11. package/package.json +57 -57
  12. package/public/css/base.css +99 -99
  13. package/public/css/cards.css +183 -183
  14. package/public/css/feedback.css +303 -303
  15. package/public/css/forms.css +405 -405
  16. package/public/css/layout.css +160 -160
  17. package/public/css/modal.css +190 -183
  18. package/public/css/responsive.css +10 -10
  19. package/public/css/sidebar.css +616 -601
  20. package/public/css/terminals.css +294 -294
  21. package/public/css/tokens.css +81 -79
  22. package/public/css/wco.css +98 -98
  23. package/public/css/widgets.css +1596 -1375
  24. package/public/index.html +105 -103
  25. package/public/js/api.js +272 -260
  26. package/public/js/components/AdoptModal.js +343 -171
  27. package/public/js/components/App.js +35 -35
  28. package/public/js/components/DirectoryPicker.js +203 -203
  29. package/public/js/components/EntityFormModal.js +105 -105
  30. package/public/js/components/Modal.js +51 -51
  31. package/public/js/components/OfflineBanner.js +93 -93
  32. package/public/js/components/PageTitleBar.js +13 -13
  33. package/public/js/components/Picker.js +179 -179
  34. package/public/js/components/Popover.js +55 -55
  35. package/public/js/components/Sidebar.js +270 -270
  36. package/public/js/components/TerminalView.js +298 -298
  37. package/public/js/components/useDragSort.js +67 -67
  38. package/public/js/dialog.js +67 -67
  39. package/public/js/icons.js +177 -177
  40. package/public/js/main.js +140 -140
  41. package/public/js/pages/AboutPage.js +165 -165
  42. package/public/js/pages/ConfigurePage.js +475 -487
  43. package/public/js/pages/LaunchPage.js +369 -369
  44. package/public/js/pages/SessionsPage.js +97 -97
  45. package/public/js/state.js +231 -231
  46. package/public/manifest.webmanifest +15 -15
  47. package/scripts/install.js +137 -137
  48. package/server.js +1126 -1117
@@ -1,177 +1,489 @@
1
- 'use strict';
2
-
3
- // Discover existing CLI sessions on this machine and surface them so
4
- // ccsm can "adopt" them — i.e. create a persistedSessions record that
5
- // resumes the same upstream conversation later.
6
- //
7
- // Per CLI:
8
- // claude · ~/.claude/projects/<slug>/<uuid>.jsonl (uuid = id)
9
- // codex · ~/.codex/sessions/**/<uuid>.jsonl (uuid = id)
10
- // copilot · ~/.copilot/session-state/<uuid>/ (uuid = dir name;
11
- // cwd + summary in workspace.yaml)
12
- //
13
- // Each session is reported as:
14
- // { cliType, cliSessionId, cwd, mtime, summary }
15
- //
16
- // Heuristic for `summary`: the first user message text (claude/codex)
17
- // or the YAML `summary:` line (copilot). Truncated to 120 chars.
18
-
19
- const fs = require('node:fs');
20
- const fsp = require('node:fs/promises');
21
- const path = require('node:path');
22
- const os = require('node:os');
23
- const readline = require('node:readline');
24
-
25
- const SUMMARY_MAX = 120;
26
-
27
- async function listClaude() {
28
- const root = path.join(os.homedir(), '.claude', 'projects');
29
- let slugs;
30
- try { slugs = await fsp.readdir(root, { withFileTypes: true }); }
31
- catch { return []; }
32
- const out = [];
33
- for (const slug of slugs) {
34
- if (!slug.isDirectory()) continue;
35
- const slugDir = path.join(root, slug.name);
36
- let files;
37
- try { files = await fsp.readdir(slugDir, { withFileTypes: true }); }
38
- catch { continue; }
39
- for (const f of files) {
40
- if (!f.isFile() || !f.name.endsWith('.jsonl')) continue;
41
- const filepath = path.join(slugDir, f.name);
42
- const id = f.name.replace(/\.jsonl$/, '');
43
- let st; try { st = await fsp.stat(filepath); } catch { continue; }
44
- const { cwd, summary } = await parseClaudeJsonl(filepath);
45
- if (!cwd) continue;
46
- out.push({
47
- cliType: 'claude',
48
- cliSessionId: id,
49
- cwd,
50
- mtime: st.mtimeMs,
51
- summary,
52
- });
53
- }
54
- }
55
- return out;
56
- }
57
-
58
- async function listCodex() {
59
- const root = path.join(os.homedir(), '.codex', 'sessions');
60
- const out = [];
61
- await walkFiles(root, async (filepath) => {
62
- if (!filepath.endsWith('.jsonl')) return;
63
- const base = path.basename(filepath);
64
- const m = base.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i);
65
- if (!m) return;
66
- let st; try { st = await fsp.stat(filepath); } catch { return; }
67
- const { cwd, summary } = await parseClaudeJsonl(filepath); // same shape
68
- if (!cwd) return;
69
- out.push({
70
- cliType: 'codex',
71
- cliSessionId: m[1],
72
- cwd,
73
- mtime: st.mtimeMs,
74
- summary,
75
- });
76
- });
77
- return out;
78
- }
79
-
80
- async function listCopilot() {
81
- const root = path.join(os.homedir(), '.copilot', 'session-state');
82
- let dirs;
83
- try { dirs = await fsp.readdir(root, { withFileTypes: true }); }
84
- catch { return []; }
85
- const out = [];
86
- for (const d of dirs) {
87
- if (!d.isDirectory()) continue;
88
- const id = d.name;
89
- if (!/^[0-9a-f-]+$/i.test(id)) continue;
90
- const dirpath = path.join(root, id);
91
- let st; try { st = await fsp.stat(dirpath); } catch { continue; }
92
- const yaml = path.join(dirpath, 'workspace.yaml');
93
- let txt;
94
- try { txt = await fsp.readFile(yaml, 'utf8'); }
95
- catch { continue; }
96
- const cwd = (txt.match(/^\s*cwd\s*:\s*(.+?)\s*$/m) || [])[1] || null;
97
- const summary = (txt.match(/^\s*summary\s*:\s*(.+?)\s*$/m) || [])[1] || '';
98
- const updated = (txt.match(/^\s*updated_at\s*:\s*(.+?)\s*$/m) || [])[1];
99
- if (!cwd) continue;
100
- out.push({
101
- cliType: 'copilot',
102
- cliSessionId: id,
103
- cwd: cwd.trim(),
104
- mtime: updated ? Date.parse(updated) || st.mtimeMs : st.mtimeMs,
105
- summary: truncate(summary, SUMMARY_MAX),
106
- });
107
- }
108
- return out;
109
- }
110
-
111
- async function listForType(cliType) {
112
- if (cliType === 'claude') return listClaude();
113
- if (cliType === 'codex') return listCodex();
114
- if (cliType === 'copilot') return listCopilot();
115
- return [];
116
- }
117
-
118
- module.exports = { listForType, listClaude, listCodex, listCopilot };
119
-
120
- // ── helpers ─────────────────────────────────────────────────────────
121
-
122
- async function walkFiles(root, visit) {
123
- let entries;
124
- try { entries = await fsp.readdir(root, { withFileTypes: true }); }
125
- catch { return; }
126
- for (const e of entries) {
127
- const p = path.join(root, e.name);
128
- if (e.isDirectory()) await walkFiles(p, visit);
129
- else await visit(p);
130
- }
131
- }
132
-
133
- function truncate(s, n) {
134
- if (!s) return '';
135
- const t = String(s).replace(/\s+/g, ' ').trim();
136
- return t.length > n ? t.slice(0, n - 1) + '…' : t;
137
- }
138
-
139
- // Returns { cwd, summary } from the first ~30 lines of a claude/codex
140
- // jsonl. Looks for the first object with a `cwd` field, plus the first
141
- // user message text content for a 1-line preview.
142
- async function parseClaudeJsonl(filepath) {
143
- return new Promise((resolve) => {
144
- let stream;
145
- try { stream = fs.createReadStream(filepath, { encoding: 'utf8' }); }
146
- catch { resolve({ cwd: null, summary: '' }); return; }
147
- const rl = readline.createInterface({ input: stream });
148
- let count = 0;
149
- let cwd = null;
150
- let summary = '';
151
- let settled = false;
152
- const done = () => {
153
- if (settled) return;
154
- settled = true;
155
- try { rl.close(); } catch {}
156
- try { stream.destroy(); } catch {}
157
- resolve({ cwd, summary });
158
- };
159
- rl.on('line', (line) => {
160
- count++;
161
- try {
162
- const obj = JSON.parse(line);
163
- if (!cwd && obj && obj.cwd) cwd = obj.cwd;
164
- if (!summary && obj) {
165
- // First user text wins.
166
- if (obj.type === 'user' && obj.message?.content) {
167
- const c = obj.message.content;
168
- if (typeof c === 'string') summary = truncate(c, SUMMARY_MAX);
169
- }
170
- }
171
- } catch {}
172
- if (count >= 30 || (cwd && summary)) done();
173
- });
174
- rl.on('close', done);
175
- rl.on('error', done);
176
- });
177
- }
1
+ 'use strict';
2
+
3
+ // Discover existing CLI sessions on this machine and surface them so
4
+ // ccsm can "adopt" them — i.e. create a persistedSessions record that
5
+ // resumes the same upstream conversation later.
6
+ //
7
+ // Per CLI:
8
+ // claude · ~/.claude/projects/<slug>/<uuid>.jsonl (uuid = id)
9
+ // codex · ~/.codex/sessions/**/<uuid>.jsonl (uuid = id)
10
+ // copilot · ~/.copilot/session-state/<uuid>/ (uuid = dir name;
11
+ // cwd + summary in workspace.yaml)
12
+ //
13
+ // Each session is reported as:
14
+ // { cliType, cliSessionId, cwd, mtime, summary }
15
+ //
16
+ // Heuristic for `summary`: the first user message text (claude/codex)
17
+ // or the YAML `summary:` line (copilot). Truncated to 120 chars.
18
+ //
19
+ // Performance:
20
+ // - We read each jsonl's HEAD (first 16KB) directly via fd.read instead
21
+ // of going through readline+stream — readline init is the dominant
22
+ // cost when scanning hundreds of small files.
23
+ // - Files are parsed in parallel with a small concurrency cap (16) so
24
+ // the OS scheduler stays useful but we don't fire 300+ syscalls at
25
+ // once.
26
+ // - An in-process LRU caches parse results keyed by (filepath, mtime).
27
+ // Unchanged files on subsequent scans are O(1).
28
+
29
+ const fs = require('node:fs');
30
+ const fsp = require('node:fs/promises');
31
+ const path = require('node:path');
32
+ const os = require('node:os');
33
+
34
+ const SUMMARY_MAX = 120;
35
+ const HEAD_BYTES = 16 * 1024; // enough to catch cwd + first user msg
36
+ const CONCURRENCY = 16; // parallel parses per scan
37
+ const PARSE_CACHE_MAX = 5000;
38
+ const parseCache = new Map(); // `${path}|${mtimeMs}` → { cwd, summary }
39
+
40
+ function cacheGet(filepath, mtimeMs) {
41
+ return parseCache.get(`${filepath}|${mtimeMs}`);
42
+ }
43
+ function cachePut(filepath, mtimeMs, value) {
44
+ if (parseCache.size >= PARSE_CACHE_MAX) {
45
+ // Drop oldest insertion (Map keeps insertion order).
46
+ const firstKey = parseCache.keys().next().value;
47
+ parseCache.delete(firstKey);
48
+ }
49
+ parseCache.set(`${filepath}|${mtimeMs}`, value);
50
+ }
51
+
52
+ // Run `tasks` with a max concurrency cap. Each task is a `() => Promise`.
53
+ async function pmap(tasks, concurrency) {
54
+ const results = new Array(tasks.length);
55
+ let next = 0;
56
+ async function worker() {
57
+ while (true) {
58
+ const i = next++;
59
+ if (i >= tasks.length) return;
60
+ try { results[i] = await tasks[i](); }
61
+ catch { results[i] = null; }
62
+ }
63
+ }
64
+ await Promise.all(Array.from({ length: Math.min(concurrency, tasks.length) }, worker));
65
+ return results;
66
+ }
67
+
68
+ // ── Discover phase · cheap, just stat the files ─────────────────────
69
+ // Returns [{ id, filepath, mtimeMs }] for all jsonls under ~/.claude/projects,
70
+ // sorted by mtime desc. No content read, no parsing. Used both as the
71
+ // "list of candidates" for pagination AND as the source of truth for
72
+ // "what jsonl ids exist on disk".
73
+ async function discoverClaude() {
74
+ const root = path.join(os.homedir(), '.claude', 'projects');
75
+ let slugs;
76
+ try { slugs = await fsp.readdir(root, { withFileTypes: true }); }
77
+ catch { return []; }
78
+ const statTasks = [];
79
+ for (const slug of slugs) {
80
+ if (!slug.isDirectory()) continue;
81
+ const slugDir = path.join(root, slug.name);
82
+ statTasks.push(async () => {
83
+ let files;
84
+ try { files = await fsp.readdir(slugDir, { withFileTypes: true }); }
85
+ catch { return []; }
86
+ const inDir = [];
87
+ for (const f of files) {
88
+ if (!f.isFile() || !f.name.endsWith('.jsonl')) continue;
89
+ const filepath = path.join(slugDir, f.name);
90
+ let st; try { st = await fsp.stat(filepath); } catch { continue; }
91
+ inDir.push({
92
+ id: f.name.replace(/\.jsonl$/, ''),
93
+ filepath,
94
+ mtimeMs: st.mtimeMs,
95
+ });
96
+ }
97
+ return inDir;
98
+ });
99
+ }
100
+ const grouped = await pmap(statTasks, CONCURRENCY);
101
+ const all = grouped.flat().filter(Boolean);
102
+ all.sort((a, b) => b.mtimeMs - a.mtimeMs);
103
+ return all;
104
+ }
105
+
106
+ async function discoverCodex() {
107
+ const root = path.join(os.homedir(), '.codex', 'sessions');
108
+ const candidates = [];
109
+ await walkFiles(root, async (filepath, st) => {
110
+ if (!filepath.endsWith('.jsonl')) return;
111
+ const base = path.basename(filepath);
112
+ const m = base.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i);
113
+ if (!m) return;
114
+ candidates.push({ id: m[1], filepath, mtimeMs: st.mtimeMs });
115
+ });
116
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
117
+ return candidates;
118
+ }
119
+
120
+ // Hydrate a list of {id, filepath, mtimeMs} candidates into full session
121
+ // records by parsing each jsonl head. cliType is the discriminator in the
122
+ // returned record.
123
+ async function hydrateJsonl(candidates, cliType) {
124
+ const parseTasks = candidates.map((c) => async () => {
125
+ const { cwd, summary } = await parseJsonlHead(c.filepath, c.mtimeMs);
126
+ if (!cwd) return null;
127
+ return {
128
+ cliType,
129
+ cliSessionId: c.id,
130
+ cwd,
131
+ mtime: c.mtimeMs,
132
+ summary,
133
+ };
134
+ });
135
+ const parsed = await pmap(parseTasks, CONCURRENCY);
136
+ return parsed.filter(Boolean);
137
+ }
138
+
139
+ // Full-load variants (no pagination). Kept for back-compat callers /
140
+ // codex+copilot where the dataset is small.
141
+ async function listClaude() {
142
+ return hydrateJsonl(await discoverClaude(), 'claude');
143
+ }
144
+ async function listCodex() {
145
+ return hydrateJsonl(await discoverCodex(), 'codex');
146
+ }
147
+
148
+ async function listCopilot() {
149
+ const root = path.join(os.homedir(), '.copilot', 'session-state');
150
+ let dirs;
151
+ try { dirs = await fsp.readdir(root, { withFileTypes: true }); }
152
+ catch { return []; }
153
+ const out = [];
154
+ for (const d of dirs) {
155
+ if (!d.isDirectory()) continue;
156
+ const id = d.name;
157
+ if (!/^[0-9a-f-]+$/i.test(id)) continue;
158
+ const dirpath = path.join(root, id);
159
+ let st; try { st = await fsp.stat(dirpath); } catch { continue; }
160
+ const yaml = path.join(dirpath, 'workspace.yaml');
161
+ let txt;
162
+ try { txt = await fsp.readFile(yaml, 'utf8'); }
163
+ catch { continue; }
164
+ const cwd = (txt.match(/^\s*cwd\s*:\s*(.+?)\s*$/m) || [])[1] || null;
165
+ const summary = (txt.match(/^\s*summary\s*:\s*(.+?)\s*$/m) || [])[1] || '';
166
+ const updated = (txt.match(/^\s*updated_at\s*:\s*(.+?)\s*$/m) || [])[1];
167
+ if (!cwd) continue;
168
+ out.push({
169
+ cliType: 'copilot',
170
+ cliSessionId: id,
171
+ cwd: cwd.trim(),
172
+ mtime: updated ? Date.parse(updated) || st.mtimeMs : st.mtimeMs,
173
+ summary: truncate(summary, SUMMARY_MAX),
174
+ });
175
+ }
176
+ return out;
177
+ }
178
+
179
+ async function listForType(cliType) {
180
+ if (cliType === 'claude') return listClaude();
181
+ if (cliType === 'codex') return listCodex();
182
+ if (cliType === 'copilot') return listCopilot();
183
+ return [];
184
+ }
185
+
186
+ // ── Active-session detection ───────────────────────────────────────
187
+ // "Active" = a cli process is currently running with this session loaded.
188
+ // Claude: definitive — ~/.claude/sessions/<pid>.json has {pid, sessionId},
189
+ // cross-check pid is alive via tasklist /FI "IMAGENAME eq claude.exe".
190
+ // Codex / Copilot: no per-process session manifest we can read, so we
191
+ // fall back to mtime heuristic (jsonl/yaml touched in the last
192
+ // RECENT_MS = a session being actively written to is "active").
193
+ const RECENT_MS = 5 * 60 * 1000;
194
+
195
+ // tasklist is the expensive one — ~500ms on Windows for "list every
196
+ // process named claude.exe". Strategy:
197
+ //
198
+ // 1. Module-level cache keyed by procName.
199
+ // 2. A background refresh loop runs every LIVE_PIDS_REFRESH_MS while
200
+ // anyone has asked for the pids in the recent past. The foreground
201
+ // call ALWAYS returns the cached value immediately — it never waits
202
+ // for tasklist. Stale-while-revalidate, in other words.
203
+ // 3. First call ever (cache miss) blocks until tasklist returns once.
204
+ //
205
+ // Net effect: import-modal cold open shows the page in tens of ms, and
206
+ // the "active" markers are at most LIVE_PIDS_REFRESH_MS old.
207
+
208
+ const LIVE_PIDS_REFRESH_MS = 15_000;
209
+ const livePidsByProc = new Map(); // procName → { pids: Set<pid>, ts: number }
210
+ const livePidsRefresh = new Map(); // procName → setInterval handle
211
+ const livePidsInflight = new Map(); // procName → Promise<Set<pid>>
212
+
213
+ async function tasklistOnce(procName) {
214
+ if (process.platform !== 'win32') return new Set();
215
+ return new Promise((resolve) => {
216
+ const { exec } = require('node:child_process');
217
+ exec(`tasklist /FI "IMAGENAME eq ${procName}" /FO CSV /NH`,
218
+ { windowsHide: true, maxBuffer: 4 * 1024 * 1024 },
219
+ (err, stdout) => {
220
+ const pids = new Set();
221
+ if (!err && stdout) {
222
+ for (const line of stdout.split(/\r?\n/)) {
223
+ const m = line.match(/^"[^"]+","(\d+)"/);
224
+ if (m) pids.add(Number(m[1]));
225
+ }
226
+ }
227
+ resolve(pids);
228
+ });
229
+ });
230
+ }
231
+
232
+ function startRefreshLoop(procName) {
233
+ if (livePidsRefresh.has(procName)) return;
234
+ // unref so it doesn't keep the process alive.
235
+ const handle = setInterval(async () => {
236
+ try {
237
+ const pids = await tasklistOnce(procName);
238
+ livePidsByProc.set(procName, { pids, ts: Date.now() });
239
+ } catch {}
240
+ }, LIVE_PIDS_REFRESH_MS);
241
+ if (typeof handle.unref === 'function') handle.unref();
242
+ livePidsRefresh.set(procName, handle);
243
+ }
244
+
245
+ // Non-blocking lookup. Returns whatever the cache holds, even if empty.
246
+ // If there's no fresh data, kicks off a refresh in the background — the
247
+ // next request a few seconds later will see populated results. This is a
248
+ // deliberate tradeoff: tasklist on this machine sometimes spikes to
249
+ // 30+s, and we absolutely will not let import-modal cold-open inherit
250
+ // that latency. Worst case the first paint has `active: false` on every
251
+ // row and the second paint (after frontend re-fetches) has the correct
252
+ // markers — at most LIVE_PIDS_REFRESH_MS stale.
253
+ function getLivePids(procName) {
254
+ startRefreshLoop(procName); // idempotent — keeps cache fresh
255
+ const cached = livePidsByProc.get(procName);
256
+ if (cached) return cached.pids;
257
+
258
+ // Cache miss — kick off a tasklist if no one already has, but DON'T
259
+ // await it. Return empty for now; future calls will see the populated
260
+ // cache once it lands.
261
+ if (!livePidsInflight.has(procName)) {
262
+ const inflight = tasklistOnce(procName).then((pids) => {
263
+ livePidsByProc.set(procName, { pids, ts: Date.now() });
264
+ livePidsInflight.delete(procName);
265
+ return pids;
266
+ }).catch(() => { livePidsInflight.delete(procName); return new Set(); });
267
+ livePidsInflight.set(procName, inflight);
268
+ }
269
+ return new Set(); // immediate, empty
270
+ }
271
+
272
+ // Prewarm — called from server boot so the first user request to the
273
+ // import modal already hits the warm cache.
274
+ function prewarmLivePids(procNames = ['claude.exe']) {
275
+ for (const p of procNames) {
276
+ getLivePids(p).catch(() => {});
277
+ }
278
+ }
279
+
280
+ async function activeClaudeIds() {
281
+ const dir = path.join(os.homedir(), '.claude', 'sessions');
282
+ let files;
283
+ try { files = await fsp.readdir(dir); }
284
+ catch { return new Set(); }
285
+ // Non-blocking — if tasklist cache is cold, returns empty Set and
286
+ // schedules a background refresh. First-paint may miss live markers;
287
+ // subsequent re-fetches pick them up.
288
+ const livePids = getLivePids('claude.exe');
289
+ const ids = new Set();
290
+ await pmap(
291
+ files.filter((f) => f.endsWith('.json')).map((f) => async () => {
292
+ let raw; try { raw = await fsp.readFile(path.join(dir, f), 'utf8'); }
293
+ catch { return; }
294
+ try {
295
+ const obj = JSON.parse(raw);
296
+ if (obj && obj.sessionId && livePids.has(Number(obj.pid))) {
297
+ ids.add(obj.sessionId);
298
+ }
299
+ } catch {}
300
+ }),
301
+ CONCURRENCY,
302
+ );
303
+ return ids;
304
+ }
305
+
306
+ // Compute per-type active set. Returns Set<cliSessionId>.
307
+ async function getActiveIds(cliType) {
308
+ if (cliType === 'claude') return activeClaudeIds();
309
+ // codex / copilot: no manifest. Returning empty here; the caller falls
310
+ // back to mtime-recency in listForTypeWithActive below.
311
+ return new Set();
312
+ }
313
+
314
+ // Annotate listForType output with `active: bool`. Centralises the logic
315
+ // so server.js doesn't have to know about per-CLI quirks.
316
+ async function listForTypeWithActive(cliType) {
317
+ const [items, activeIds] = await Promise.all([
318
+ listForType(cliType),
319
+ getActiveIds(cliType),
320
+ ]);
321
+ const now = Date.now();
322
+ return items.map((it) => ({
323
+ ...it,
324
+ active: activeIds.has(it.cliSessionId)
325
+ // Fallback: any session touched within RECENT_MS is treated as
326
+ // active. Catches codex/copilot which don't expose a pid mapping.
327
+ || (now - it.mtime) < RECENT_MS,
328
+ }));
329
+ }
330
+
331
+ // Paginated list — the fast path used by the import modal.
332
+ //
333
+ // Strategy:
334
+ // 1. Discover phase = stat all candidates. Cheap, even at 1000+ files.
335
+ // 2. Compute active set in parallel.
336
+ // 3. ALWAYS hydrate every active candidate (they go first, never paged
337
+ // out — user explicitly wants live sessions visible up top).
338
+ // 4. For non-active, hydrate only `[offset, offset+limit)` sorted mtime
339
+ // desc. "Load more" = call again with the next offset.
340
+ //
341
+ // Returns: { sessions, totalActive, totalNonActive, offset, limit, hasMore }
342
+ async function listPaginated(cliType, { offset = 0, limit = 30 } = {}) {
343
+ // copilot's "discover" is also the parse (no separate jsonl head to read
344
+ // cheaply), so for now we just list all of it. Codex/Claude get the
345
+ // proper two-phase treatment.
346
+ if (cliType === 'copilot') {
347
+ const all = await listForTypeWithActive('copilot');
348
+ all.sort((a, b) => {
349
+ if (a.active !== b.active) return a.active ? -1 : 1;
350
+ return b.mtime - a.mtime;
351
+ });
352
+ return {
353
+ sessions: all,
354
+ totalActive: all.filter((x) => x.active).length,
355
+ totalNonActive: all.filter((x) => !x.active).length,
356
+ offset: 0,
357
+ limit: all.length,
358
+ hasMore: false,
359
+ };
360
+ }
361
+
362
+ const discover = cliType === 'codex' ? discoverCodex : discoverClaude;
363
+ const [candidates, activeIds] = await Promise.all([
364
+ discover(),
365
+ getActiveIds(cliType),
366
+ ]);
367
+ // Already sorted mtime desc inside discover.
368
+ const now = Date.now();
369
+ const isActiveCand = (c) =>
370
+ activeIds.has(c.id) || (now - c.mtimeMs) < RECENT_MS;
371
+ const active = candidates.filter(isActiveCand);
372
+ const rest = candidates.filter((c) => !isActiveCand(c));
373
+
374
+ // Slice non-active to the requested page.
375
+ const slice = rest.slice(offset, offset + limit);
376
+
377
+ // Hydrate active (always all) + slice of non-active.
378
+ // First page: hydrate both. Later pages: only the slice — frontend
379
+ // already has the active set from page 0.
380
+ const toHydrate = offset === 0 ? [...active, ...slice] : slice;
381
+ const hydrated = await hydrateJsonl(toHydrate, cliType);
382
+
383
+ // Stamp active flag back on. Doing it post-hydrate so we don't have
384
+ // to thread it through hydrateJsonl.
385
+ const activeIdSet = new Set(active.map((c) => c.id));
386
+ for (const s of hydrated) {
387
+ s.active = activeIdSet.has(s.cliSessionId)
388
+ || activeIds.has(s.cliSessionId)
389
+ || (now - s.mtime) < RECENT_MS;
390
+ }
391
+
392
+ return {
393
+ sessions: hydrated,
394
+ totalActive: active.length,
395
+ totalNonActive: rest.length,
396
+ offset,
397
+ limit,
398
+ hasMore: offset + limit < rest.length,
399
+ };
400
+ }
401
+
402
+ module.exports = {
403
+ listForType,
404
+ listForTypeWithActive,
405
+ listPaginated,
406
+ listClaude,
407
+ listCodex,
408
+ listCopilot,
409
+ getActiveIds,
410
+ prewarmLivePids,
411
+ };
412
+
413
+ // ── helpers ─────────────────────────────────────────────────────────
414
+
415
+ async function walkFiles(root, visit) {
416
+ let entries;
417
+ try { entries = await fsp.readdir(root, { withFileTypes: true }); }
418
+ catch { return; }
419
+ const tasks = entries.map((e) => async () => {
420
+ const p = path.join(root, e.name);
421
+ if (e.isDirectory()) {
422
+ await walkFiles(p, visit);
423
+ } else {
424
+ let st; try { st = await fsp.stat(p); } catch { return; }
425
+ await visit(p, st);
426
+ }
427
+ });
428
+ await pmap(tasks, CONCURRENCY);
429
+ }
430
+
431
+ function truncate(s, n) {
432
+ if (!s) return '';
433
+ const t = String(s).replace(/\s+/g, ' ').trim();
434
+ return t.length > n ? t.slice(0, n - 1) + '…' : t;
435
+ }
436
+
437
+ // Returns { cwd, summary } from a claude/codex jsonl by reading just the
438
+ // first 16KB directly. Way faster than readline+stream when scanning
439
+ // hundreds of files. Cached by (filepath, mtimeMs) so a repeat scan of
440
+ // unchanged files is O(1).
441
+ //
442
+ // cwd lives in the head of every jsonl (it's part of the per-message
443
+ // envelope), so 16KB is more than enough. First user text usually too;
444
+ // if it's beyond the head we just don't preview, that's fine.
445
+ async function parseJsonlHead(filepath, mtimeMs) {
446
+ const cached = cacheGet(filepath, mtimeMs);
447
+ if (cached) return cached;
448
+
449
+ let fh;
450
+ try { fh = await fsp.open(filepath, 'r'); }
451
+ catch { return { cwd: null, summary: '' }; }
452
+ const buf = Buffer.allocUnsafe(HEAD_BYTES);
453
+ let bytesRead = 0;
454
+ try {
455
+ const r = await fh.read(buf, 0, HEAD_BYTES, 0);
456
+ bytesRead = r.bytesRead || 0;
457
+ } catch {
458
+ /* leave bytesRead = 0 */
459
+ } finally {
460
+ try { await fh.close(); } catch {}
461
+ }
462
+ if (bytesRead === 0) {
463
+ const v = { cwd: null, summary: '' };
464
+ cachePut(filepath, mtimeMs, v);
465
+ return v;
466
+ }
467
+
468
+ const text = buf.slice(0, bytesRead).toString('utf8');
469
+ // Drop the trailing partial line — JSON.parse on it will fail anyway.
470
+ const lines = text.split('\n');
471
+ if (bytesRead === HEAD_BYTES) lines.pop();
472
+
473
+ let cwd = null;
474
+ let summary = '';
475
+ for (const line of lines) {
476
+ if (cwd && summary) break;
477
+ if (!line) continue;
478
+ let obj;
479
+ try { obj = JSON.parse(line); } catch { continue; }
480
+ if (!cwd && obj && obj.cwd) cwd = obj.cwd;
481
+ if (!summary && obj && obj.type === 'user' && obj.message?.content) {
482
+ const c = obj.message.content;
483
+ if (typeof c === 'string') summary = truncate(c, SUMMARY_MAX);
484
+ }
485
+ }
486
+ const v = { cwd, summary };
487
+ cachePut(filepath, mtimeMs, v);
488
+ return v;
489
+ }