@bakapiano/ccsm 0.22.6 → 0.22.8

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 (61) hide show
  1. package/CLAUDE.md +521 -540
  2. package/README.md +186 -189
  3. package/bin/ccsm.js +235 -235
  4. package/lib/cliActivity.js +36 -139
  5. package/lib/codexSeed.js +126 -183
  6. package/lib/config.js +277 -274
  7. package/lib/devices.js +229 -229
  8. package/lib/folders.js +124 -124
  9. package/lib/persistedSessions.js +179 -139
  10. package/lib/tunnel.js +621 -621
  11. package/lib/webTerminal.js +225 -225
  12. package/lib/winPath.js +1 -1
  13. package/lib/workspace.js +233 -233
  14. package/package.json +57 -57
  15. package/public/css/base.css +99 -99
  16. package/public/css/cards.css +183 -183
  17. package/public/css/feedback.css +504 -504
  18. package/public/css/forms.css +453 -453
  19. package/public/css/layout.css +154 -154
  20. package/public/css/modal.css +190 -190
  21. package/public/css/responsive.css +176 -176
  22. package/public/css/sidebar.css +707 -707
  23. package/public/css/terminals.css +546 -546
  24. package/public/css/tokens.css +81 -81
  25. package/public/css/wco.css +196 -196
  26. package/public/css/widgets.css +2347 -2725
  27. package/public/index.html +152 -152
  28. package/public/js/api.js +349 -371
  29. package/public/js/backend.js +149 -149
  30. package/public/js/components/App.js +73 -73
  31. package/public/js/components/DirectoryPicker.js +203 -203
  32. package/public/js/components/EntityFormModal.js +153 -153
  33. package/public/js/components/Modal.js +57 -57
  34. package/public/js/components/OfflineBanner.js +67 -67
  35. package/public/js/components/PageTitleBar.js +13 -13
  36. package/public/js/components/PendingApprovalOverlay.js +128 -128
  37. package/public/js/components/Picker.js +179 -179
  38. package/public/js/components/Popover.js +55 -55
  39. package/public/js/components/RestartOverlay.js +36 -36
  40. package/public/js/components/Sidebar.js +380 -380
  41. package/public/js/components/TerminalInstance.js +28 -0
  42. package/public/js/components/useDragSort.js +67 -67
  43. package/public/js/dialog.js +67 -67
  44. package/public/js/icons.js +212 -212
  45. package/public/js/main.js +296 -296
  46. package/public/js/pages/AboutPage.js +90 -90
  47. package/public/js/pages/ConfigurePage.js +730 -713
  48. package/public/js/pages/LaunchPage.js +403 -421
  49. package/public/js/pages/RemotePage.js +743 -743
  50. package/public/js/pages/SessionsPage.js +54 -54
  51. package/public/js/state.js +335 -335
  52. package/public/js/util.js +1 -1
  53. package/scripts/dev.js +149 -149
  54. package/scripts/install.js +153 -153
  55. package/scripts/restart-helper.js +96 -96
  56. package/scripts/upgrade-helper.js +687 -687
  57. package/server.js +1748 -1817
  58. package/lib/localCliSessions.js +0 -519
  59. package/public/js/components/AdoptModal.js +0 -261
  60. package/public/manifest.webmanifest +0 -25
  61. package/public/setup/index.html +0 -567
@@ -1,519 +0,0 @@
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
- // Codex sessions can live under a RELOCATED CODEX_HOME. Some wrappers
107
- // point it at e.g. %LOCALAPPDATA%\<wrapper>\codex-home, so `~/.codex` is empty
108
- // of the sessions the user actually created. Seeding already honours this
109
- // (codexSeed.probeCodexHome runs `<cli> doctor`); the import scan must too,
110
- // or those relocated sessions are invisible in the adopt modal. Gather every
111
- // candidate `<home>/sessions` dir: each configured codex CLI's detected
112
- // home, the CODEX_HOME env override, and codex's own ~/.codex default.
113
- async function codexSessionRoots() {
114
- const roots = new Set();
115
- try {
116
- const { loadConfig } = require('./config');
117
- const { probeCodexHome } = require('./codexSeed');
118
- const cfg = await loadConfig();
119
- const codexClis = (cfg?.clis || []).filter((c) => c.type === 'codex' || c.id === 'codex');
120
- for (const c of codexClis) {
121
- if (!c.command) continue;
122
- let home = null;
123
- try { home = await probeCodexHome({ command: c.command, shell: c.shell }); }
124
- catch { /* probe is best-effort */ }
125
- if (home) roots.add(path.join(home, 'sessions'));
126
- }
127
- } catch { /* config/probe unavailable — fall through to defaults */ }
128
- if (process.env.CODEX_HOME) roots.add(path.join(process.env.CODEX_HOME, 'sessions'));
129
- roots.add(path.join(os.homedir(), '.codex', 'sessions'));
130
- return [...roots];
131
- }
132
-
133
- async function discoverCodex() {
134
- const roots = await codexSessionRoots();
135
- const candidates = [];
136
- const seen = new Set(); // dedup by session id across homes
137
- for (const root of roots) {
138
- await walkFiles(root, async (filepath, st) => {
139
- if (!filepath.endsWith('.jsonl')) return;
140
- const base = path.basename(filepath);
141
- 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);
142
- if (!m || seen.has(m[1])) return;
143
- seen.add(m[1]);
144
- candidates.push({ id: m[1], filepath, mtimeMs: st.mtimeMs });
145
- });
146
- }
147
- candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
148
- return candidates;
149
- }
150
-
151
- // Hydrate a list of {id, filepath, mtimeMs} candidates into full session
152
- // records by parsing each jsonl head. cliType is the discriminator in the
153
- // returned record.
154
- async function hydrateJsonl(candidates, cliType) {
155
- // codex prepends a big system preamble (<permissions instructions> +
156
- // <environment_context>, ~32KB) before the first user message, so 16KB
157
- // catches the cwd (line 1) but not the summary. Read a larger head for
158
- // codex; its dataset is tiny so the extra bytes are free. claude/copilot
159
- // keep the cheap 16KB.
160
- const headBytes = cliType === 'codex' ? 48 * 1024 : HEAD_BYTES;
161
- const parseTasks = candidates.map((c) => async () => {
162
- const { cwd, summary } = await parseJsonlHead(c.filepath, c.mtimeMs, headBytes);
163
- if (!cwd) return null;
164
- return {
165
- cliType,
166
- cliSessionId: c.id,
167
- cwd,
168
- mtime: c.mtimeMs,
169
- summary,
170
- };
171
- });
172
- const parsed = await pmap(parseTasks, CONCURRENCY);
173
- return parsed.filter(Boolean);
174
- }
175
-
176
- // Full-load variants (no pagination). Kept for back-compat callers /
177
- // codex+copilot where the dataset is small.
178
- async function listClaude() {
179
- return hydrateJsonl(await discoverClaude(), 'claude');
180
- }
181
- async function listCodex() {
182
- return hydrateJsonl(await discoverCodex(), 'codex');
183
- }
184
-
185
- async function listCopilot() {
186
- const root = path.join(os.homedir(), '.copilot', 'session-state');
187
- let dirs;
188
- try { dirs = await fsp.readdir(root, { withFileTypes: true }); }
189
- catch { return []; }
190
- const out = [];
191
- for (const d of dirs) {
192
- if (!d.isDirectory()) continue;
193
- const id = d.name;
194
- if (!/^[0-9a-f-]+$/i.test(id)) continue;
195
- const dirpath = path.join(root, id);
196
- let st; try { st = await fsp.stat(dirpath); } catch { continue; }
197
- const yaml = path.join(dirpath, 'workspace.yaml');
198
- let txt;
199
- try { txt = await fsp.readFile(yaml, 'utf8'); }
200
- catch { continue; }
201
- const cwd = (txt.match(/^\s*cwd\s*:\s*(.+?)\s*$/m) || [])[1] || null;
202
- const summary = (txt.match(/^\s*summary\s*:\s*(.+?)\s*$/m) || [])[1] || '';
203
- const updated = (txt.match(/^\s*updated_at\s*:\s*(.+?)\s*$/m) || [])[1];
204
- if (!cwd) continue;
205
- out.push({
206
- cliType: 'copilot',
207
- cliSessionId: id,
208
- cwd: cwd.trim(),
209
- mtime: updated ? Date.parse(updated) || st.mtimeMs : st.mtimeMs,
210
- summary: truncate(summary, SUMMARY_MAX),
211
- });
212
- }
213
- return out;
214
- }
215
-
216
- async function listForType(cliType) {
217
- if (cliType === 'claude') return listClaude();
218
- if (cliType === 'codex') return listCodex();
219
- if (cliType === 'copilot') return listCopilot();
220
- return [];
221
- }
222
-
223
- // ── Active-session detection ───────────────────────────────────────
224
- // "Active" = a cli process is currently running with this session loaded.
225
- // Claude: definitive — ~/.claude/sessions/<pid>.json has {pid, sessionId},
226
- // cross-check pid is alive via tasklist /FI "IMAGENAME eq claude.exe".
227
- // Codex / Copilot: no per-process session manifest we can read, so we
228
- // fall back to mtime heuristic (jsonl/yaml touched in the last
229
- // RECENT_MS = a session being actively written to is "active").
230
- const RECENT_MS = 5 * 60 * 1000;
231
-
232
- // tasklist is the expensive one — ~500ms on Windows for "list every
233
- // process named claude.exe". Strategy:
234
- //
235
- // 1. Module-level cache keyed by procName.
236
- // 2. A background refresh loop runs every LIVE_PIDS_REFRESH_MS while
237
- // anyone has asked for the pids in the recent past. The foreground
238
- // call ALWAYS returns the cached value immediately — it never waits
239
- // for tasklist. Stale-while-revalidate, in other words.
240
- // 3. First call ever (cache miss) blocks until tasklist returns once.
241
- //
242
- // Net effect: import-modal cold open shows the page in tens of ms, and
243
- // the "active" markers are at most LIVE_PIDS_REFRESH_MS old.
244
-
245
- const LIVE_PIDS_REFRESH_MS = 15_000;
246
- const livePidsByProc = new Map(); // procName → { pids: Set<pid>, ts: number }
247
- const livePidsRefresh = new Map(); // procName → setInterval handle
248
- const livePidsInflight = new Map(); // procName → Promise<Set<pid>>
249
-
250
- async function tasklistOnce(procName) {
251
- if (process.platform !== 'win32') return new Set();
252
- return new Promise((resolve) => {
253
- const { exec } = require('node:child_process');
254
- exec(`tasklist /FI "IMAGENAME eq ${procName}" /FO CSV /NH`,
255
- { windowsHide: true, maxBuffer: 4 * 1024 * 1024 },
256
- (err, stdout) => {
257
- const pids = new Set();
258
- if (!err && stdout) {
259
- for (const line of stdout.split(/\r?\n/)) {
260
- const m = line.match(/^"[^"]+","(\d+)"/);
261
- if (m) pids.add(Number(m[1]));
262
- }
263
- }
264
- resolve(pids);
265
- });
266
- });
267
- }
268
-
269
- function startRefreshLoop(procName) {
270
- if (livePidsRefresh.has(procName)) return;
271
- // unref so it doesn't keep the process alive.
272
- const handle = setInterval(async () => {
273
- try {
274
- const pids = await tasklistOnce(procName);
275
- livePidsByProc.set(procName, { pids, ts: Date.now() });
276
- } catch {}
277
- }, LIVE_PIDS_REFRESH_MS);
278
- if (typeof handle.unref === 'function') handle.unref();
279
- livePidsRefresh.set(procName, handle);
280
- }
281
-
282
- // Non-blocking lookup. Returns whatever the cache holds, even if empty.
283
- // If there's no fresh data, kicks off a refresh in the background — the
284
- // next request a few seconds later will see populated results. This is a
285
- // deliberate tradeoff: tasklist on this machine sometimes spikes to
286
- // 30+s, and we absolutely will not let import-modal cold-open inherit
287
- // that latency. Worst case the first paint has `active: false` on every
288
- // row and the second paint (after frontend re-fetches) has the correct
289
- // markers — at most LIVE_PIDS_REFRESH_MS stale.
290
- function getLivePids(procName) {
291
- startRefreshLoop(procName); // idempotent — keeps cache fresh
292
- const cached = livePidsByProc.get(procName);
293
- if (cached) return cached.pids;
294
-
295
- // Cache miss — kick off a tasklist if no one already has, but DON'T
296
- // await it. Return empty for now; future calls will see the populated
297
- // cache once it lands.
298
- if (!livePidsInflight.has(procName)) {
299
- const inflight = tasklistOnce(procName).then((pids) => {
300
- livePidsByProc.set(procName, { pids, ts: Date.now() });
301
- livePidsInflight.delete(procName);
302
- return pids;
303
- }).catch(() => { livePidsInflight.delete(procName); return new Set(); });
304
- livePidsInflight.set(procName, inflight);
305
- }
306
- return new Set(); // immediate, empty
307
- }
308
-
309
- // Prewarm — called from server boot so the first user request to the
310
- // import modal already hits the warm cache.
311
- function prewarmLivePids(procNames = ['claude.exe']) {
312
- for (const p of procNames) {
313
- getLivePids(p).catch(() => {});
314
- }
315
- }
316
-
317
- async function activeClaudeIds() {
318
- const dir = path.join(os.homedir(), '.claude', 'sessions');
319
- let files;
320
- try { files = await fsp.readdir(dir); }
321
- catch { return new Set(); }
322
- // Non-blocking — if tasklist cache is cold, returns empty Set and
323
- // schedules a background refresh. First-paint may miss live markers;
324
- // subsequent re-fetches pick them up.
325
- const livePids = getLivePids('claude.exe');
326
- const ids = new Set();
327
- await pmap(
328
- files.filter((f) => f.endsWith('.json')).map((f) => async () => {
329
- let raw; try { raw = await fsp.readFile(path.join(dir, f), 'utf8'); }
330
- catch { return; }
331
- try {
332
- const obj = JSON.parse(raw);
333
- if (obj && obj.sessionId && livePids.has(Number(obj.pid))) {
334
- ids.add(obj.sessionId);
335
- }
336
- } catch {}
337
- }),
338
- CONCURRENCY,
339
- );
340
- return ids;
341
- }
342
-
343
- // Compute per-type active set. Returns Set<cliSessionId>.
344
- async function getActiveIds(cliType) {
345
- if (cliType === 'claude') return activeClaudeIds();
346
- // codex / copilot: no manifest. Returning empty here; the caller falls
347
- // back to mtime-recency in listForTypeWithActive below.
348
- return new Set();
349
- }
350
-
351
- // Annotate listForType output with `active: bool`. Centralises the logic
352
- // so server.js doesn't have to know about per-CLI quirks.
353
- async function listForTypeWithActive(cliType) {
354
- const [items, activeIds] = await Promise.all([
355
- listForType(cliType),
356
- getActiveIds(cliType),
357
- ]);
358
- const now = Date.now();
359
- return items.map((it) => ({
360
- ...it,
361
- active: activeIds.has(it.cliSessionId)
362
- // Fallback: any session touched within RECENT_MS is treated as
363
- // active. Catches codex/copilot which don't expose a pid mapping.
364
- || (now - it.mtime) < RECENT_MS,
365
- }));
366
- }
367
-
368
- // Page-based list for the import modal. offset/limit is a window into the
369
- // full candidate set, ordered active-first then newest-first. Each call
370
- // returns exactly that page — the modal renders ‹ Prev · X–Y of Z · Next ›.
371
- //
372
- // Returns: { sessions, total, totalActive, totalNonActive, offset, limit, hasMore }
373
- async function listPaginated(cliType, { offset = 0, limit = 20 } = {}) {
374
- const now = Date.now();
375
-
376
- // copilot's "discover" is also the parse (no cheap jsonl head), so we
377
- // already have the full hydrated list — just slice the requested page.
378
- if (cliType === 'copilot') {
379
- const all = await listForTypeWithActive('copilot');
380
- all.sort((a, b) => (a.active !== b.active ? (a.active ? -1 : 1) : b.mtime - a.mtime));
381
- const totalActive = all.filter((x) => x.active).length;
382
- return {
383
- sessions: all.slice(offset, offset + limit),
384
- total: all.length,
385
- totalActive,
386
- totalNonActive: all.length - totalActive,
387
- offset, limit,
388
- hasMore: offset + limit < all.length,
389
- };
390
- }
391
-
392
- // claude / codex: two-phase — cheap discover (stat only), hydrate just the
393
- // page's slice.
394
- const discover = cliType === 'codex' ? discoverCodex : discoverClaude;
395
- const [candidates, activeIds] = await Promise.all([discover(), getActiveIds(cliType)]);
396
- const isActive = (c) => activeIds.has(c.id) || (now - c.mtimeMs) < RECENT_MS;
397
- // Active-first; discover already sorted mtime-desc so a stable partition
398
- // keeps newest-first within each group.
399
- const ordered = [...candidates.filter(isActive), ...candidates.filter((c) => !isActive(c))];
400
- const totalActive = ordered.length - candidates.filter((c) => !isActive(c)).length;
401
-
402
- const slice = ordered.slice(offset, offset + limit);
403
- const hydrated = await hydrateJsonl(slice, cliType);
404
- for (const s of hydrated) {
405
- s.active = activeIds.has(s.cliSessionId) || (now - s.mtime) < RECENT_MS;
406
- }
407
-
408
- return {
409
- sessions: hydrated,
410
- total: candidates.length,
411
- totalActive,
412
- totalNonActive: candidates.length - totalActive,
413
- offset, limit,
414
- hasMore: offset + limit < candidates.length,
415
- };
416
- }
417
-
418
- module.exports = {
419
- listForType,
420
- listForTypeWithActive,
421
- listPaginated,
422
- listClaude,
423
- listCodex,
424
- listCopilot,
425
- getActiveIds,
426
- prewarmLivePids,
427
- };
428
-
429
- // ── helpers ─────────────────────────────────────────────────────────
430
-
431
- async function walkFiles(root, visit) {
432
- let entries;
433
- try { entries = await fsp.readdir(root, { withFileTypes: true }); }
434
- catch { return; }
435
- const tasks = entries.map((e) => async () => {
436
- const p = path.join(root, e.name);
437
- if (e.isDirectory()) {
438
- await walkFiles(p, visit);
439
- } else {
440
- let st; try { st = await fsp.stat(p); } catch { return; }
441
- await visit(p, st);
442
- }
443
- });
444
- await pmap(tasks, CONCURRENCY);
445
- }
446
-
447
- function truncate(s, n) {
448
- if (!s) return '';
449
- const t = String(s).replace(/\s+/g, ' ').trim();
450
- return t.length > n ? t.slice(0, n - 1) + '…' : t;
451
- }
452
-
453
- // Returns { cwd, summary } from a claude/codex jsonl by reading just the
454
- // first 16KB directly. Way faster than readline+stream when scanning
455
- // hundreds of files. Cached by (filepath, mtimeMs) so a repeat scan of
456
- // unchanged files is O(1).
457
- //
458
- // cwd lives in the head of every jsonl (it's part of the per-message
459
- // envelope), so 16KB is more than enough. First user text usually too;
460
- // if it's beyond the head we just don't preview, that's fine.
461
- async function parseJsonlHead(filepath, mtimeMs, headBytes = HEAD_BYTES) {
462
- const cached = cacheGet(filepath, mtimeMs);
463
- if (cached) return cached;
464
-
465
- let fh;
466
- try { fh = await fsp.open(filepath, 'r'); }
467
- catch { return { cwd: null, summary: '' }; }
468
- const buf = Buffer.allocUnsafe(headBytes);
469
- let bytesRead = 0;
470
- try {
471
- const r = await fh.read(buf, 0, headBytes, 0);
472
- bytesRead = r.bytesRead || 0;
473
- } catch {
474
- /* leave bytesRead = 0 */
475
- } finally {
476
- try { await fh.close(); } catch {}
477
- }
478
- if (bytesRead === 0) {
479
- const v = { cwd: null, summary: '' };
480
- cachePut(filepath, mtimeMs, v);
481
- return v;
482
- }
483
-
484
- const text = buf.slice(0, bytesRead).toString('utf8');
485
- // Drop the trailing partial line — JSON.parse on it will fail anyway.
486
- const lines = text.split('\n');
487
- if (bytesRead === headBytes) lines.pop();
488
-
489
- let cwd = null;
490
- let summary = '';
491
- for (const line of lines) {
492
- if (cwd && summary) break;
493
- if (!line) continue;
494
- let obj;
495
- try { obj = JSON.parse(line); } catch { continue; }
496
- if (!obj) continue;
497
- // cwd —
498
- // claude · top-level { cwd: "..." } on every message envelope
499
- // codex · session_meta line { payload: { cwd: "..." } }
500
- if (!cwd) {
501
- if (typeof obj.cwd === 'string') cwd = obj.cwd;
502
- else if (obj.payload && typeof obj.payload.cwd === 'string') cwd = obj.payload.cwd;
503
- }
504
- // first user message (preview) —
505
- // claude · { type:'user', message:{ content:'...' } }
506
- // codex · { type:'event_msg', payload:{ type:'user_message', message:'...' } }
507
- if (!summary) {
508
- if (obj.type === 'user' && typeof obj.message?.content === 'string') {
509
- summary = truncate(obj.message.content, SUMMARY_MAX);
510
- } else if (obj.type === 'event_msg' && obj.payload?.type === 'user_message'
511
- && typeof obj.payload.message === 'string') {
512
- summary = truncate(obj.payload.message, SUMMARY_MAX);
513
- }
514
- }
515
- }
516
- const v = { cwd, summary };
517
- cachePut(filepath, mtimeMs, v);
518
- return v;
519
- }