@obtoai/agent-bridge 0.1.0-beta.2 → 0.1.0-beta.20

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.
@@ -0,0 +1,546 @@
1
+ 'use strict';
2
+
3
+ // Phase 6.1 — External Thread Discovery (scanner half).
4
+ //
5
+ // Scans the local filesystem for AI coding sessions started OUTSIDE the bridge
6
+ // and returns a flat list of session records the daemon can POST to
7
+ // /api/bridge/external/sync. The bridge UI then renders them alongside
8
+ // bridge-owned threads — single pane of glass over all the user's AI work.
9
+ //
10
+ // Sources scanned:
11
+ // - Claude Code (CLI + VSCode Claude extension): both write JSONL session
12
+ // files at ~/.claude/projects/<encoded-projectdir>/<sessionId>.jsonl
13
+ // - Codex CLI: ~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<sessionId>.jsonl
14
+ // - opencode is NOT scanned — its SDK is server-bound, no shared JSONL store
15
+ // - Web tools (claude.ai chat, ChatGPT) are out of reach by design
16
+ //
17
+ // Privacy: we extract metadata + the LAST message preview only (1–2 lines,
18
+ // capped at 200 chars). Full transcripts NEVER leave the user's machine.
19
+ // The daemon POSTs the extracted records; the bridge stores them in the
20
+ // agent_bridge_external_sessions Mongo collection.
21
+
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+ const os = require('os');
25
+
26
+ const CLAUDE_DIR = path.join(os.homedir(), '.claude', 'projects');
27
+ const CODEX_DIR = path.join(os.homedir(), '.codex', 'sessions');
28
+ // Claude Desktop app's local-agent mode stores session JSONLs under
29
+ // ~/Library/Application Support/Claude/local-agent-mode-sessions/<uuid>/<uuid>/local_<uuid>/.claude/projects/<encoded>/<sid>.jsonl
30
+ // The file format is identical to Claude Code CLI's — same queue-operation
31
+ // preamble, same user/assistant message shape. Just a different root.
32
+ // Phase 6.2.5 — beta.19: surface these so the bridge sees every Claude
33
+ // session on disk, not just the CLI/extension subset.
34
+ const CLAUDE_DESKTOP_BASE = path.join(
35
+ os.homedir(),
36
+ 'Library', 'Application Support', 'Claude', 'local-agent-mode-sessions',
37
+ );
38
+
39
+ const PREVIEW_MAX_CHARS = 200;
40
+ const TITLE_MAX_CHARS = 80;
41
+ const TAIL_READ_BYTES = 16384; // fixed-budget tail (cheap; used for ai-title + lastMessage)
42
+ const TAIL_STREAM_MAX_BYTES = 524288;// streaming tail cap — 512KB max read for recents (Phase 6.2.4)
43
+ const TAIL_STREAM_CHUNK = 65536; // 64KB chunk size for the backward stream
44
+ const TAIL_STREAM_MIN_MESSAGES = 10; // stop reading once we have this many text-bearing lines
45
+ const TITLE_MAX_LINES = 40; // scan up to this many lines for a real first message
46
+ const TITLE_MAX_BYTES = 65536; // hard ceiling per file even if MAX_LINES never reached
47
+
48
+ // Read the tail of a (potentially large) JSONL file without slurping the
49
+ // whole thing into memory. Returns a string (UTF-8) or '' on any failure.
50
+ const readTail = (filePath, maxBytes = TAIL_READ_BYTES) => {
51
+ let fd = null;
52
+ try {
53
+ const stat = fs.statSync(filePath);
54
+ const size = stat.size;
55
+ const start = Math.max(0, size - maxBytes);
56
+ const len = size - start;
57
+ if (len <= 0) return '';
58
+ fd = fs.openSync(filePath, 'r');
59
+ const buf = Buffer.alloc(len);
60
+ fs.readSync(fd, buf, 0, len, start);
61
+ return buf.toString('utf8');
62
+ } catch (_) {
63
+ return '';
64
+ } finally {
65
+ if (fd !== null) { try { fs.closeSync(fd); } catch (_) {} }
66
+ }
67
+ };
68
+
69
+ // Phase 6.2.4 — streaming tail. For sessions where individual JSONL records
70
+ // exceed the fixed 16KB budget (long assistant messages with lots of inline
71
+ // code), the fixed tail returns 0 parseable lines. This reads backward in
72
+ // 64KB chunks until we either have ≥TAIL_STREAM_MIN_MESSAGES text-bearing
73
+ // user/assistant lines OR hit TAIL_STREAM_MAX_BYTES (512KB) — whichever
74
+ // comes first. The first partial line at the buffer boundary is dropped
75
+ // because it won't JSON.parse cleanly; that's fine, the next chunk will
76
+ // pick it up complete.
77
+ const readTailUntilMessages = (filePath) => {
78
+ let fd = null;
79
+ try {
80
+ const stat = fs.statSync(filePath);
81
+ if (stat.size === 0) return '';
82
+ fd = fs.openSync(filePath, 'r');
83
+ let buffer = '';
84
+ let pos = stat.size;
85
+ while (pos > 0 && buffer.length < TAIL_STREAM_MAX_BYTES) {
86
+ const readSize = Math.min(TAIL_STREAM_CHUNK, pos);
87
+ pos -= readSize;
88
+ const chunk = Buffer.alloc(readSize);
89
+ fs.readSync(fd, chunk, 0, readSize, pos);
90
+ buffer = chunk.toString('utf8') + buffer;
91
+ // Cheap completeness check: count parseable user/assistant lines with text.
92
+ const lines = buffer.split(/\r?\n/);
93
+ let count = 0;
94
+ for (const line of lines) {
95
+ if (!line.trim()) continue;
96
+ let obj;
97
+ try { obj = JSON.parse(line); } catch (_) { continue; }
98
+ const role = (obj && (obj.role || (obj.message && obj.message.role))) || null;
99
+ if (role !== 'user' && role !== 'assistant') continue;
100
+ const raw = (obj.message && obj.message.content) || obj.content;
101
+ if (!raw) continue;
102
+ if (typeof raw === 'string') {
103
+ if (raw.trim()) count++;
104
+ } else if (Array.isArray(raw)) {
105
+ for (const p of raw) {
106
+ if (p && (p.type === 'text' || typeof p.text === 'string') && String(p.text || '').trim()) {
107
+ count++; break;
108
+ }
109
+ }
110
+ }
111
+ }
112
+ if (count >= TAIL_STREAM_MIN_MESSAGES) break;
113
+ }
114
+ return buffer;
115
+ } catch (_) {
116
+ return '';
117
+ } finally {
118
+ if (fd !== null) { try { fs.closeSync(fd); } catch (_) {} }
119
+ }
120
+ };
121
+
122
+ // Pull the last N user/assistant *logical turns* from the tail, oldest-first.
123
+ // Phase 6.2.3 — beta.17 fix: Claude Code streams a single assistant reply as
124
+ // MULTIPLE JSONL lines (text → tool_use → tool_result → text → …), each its
125
+ // own JSONL record. The earlier extractor took one line per turn and
126
+ // fragmented responses into single-shard previews. Now we walk forward,
127
+ // parse every text-bearing user/assistant line, and **coalesce consecutive
128
+ // same-role lines into one logical turn** before slicing the last N. Result:
129
+ // a real conversation, not a single mid-sentence excerpt.
130
+ const RECENT_MESSAGE_BODY_MAX = 3000;
131
+ const RECENT_TURN_COUNT = 10;
132
+
133
+ const extractRecentMessages = (jsonlTail, n = RECENT_TURN_COUNT) => {
134
+ if (!jsonlTail) return [];
135
+ const lines = jsonlTail.split(/\r?\n/);
136
+ const parsed = [];
137
+ for (const lineRaw of lines) {
138
+ const line = lineRaw.trim();
139
+ if (!line) continue;
140
+ let obj;
141
+ try { obj = JSON.parse(line); } catch (_) { continue; }
142
+ let role = null, raw = null;
143
+ if (obj && obj.message && (obj.message.role || obj.type)) {
144
+ role = obj.message.role || (obj.type === 'user' ? 'user' : 'assistant');
145
+ raw = obj.message.content;
146
+ } else if (obj && obj.role && (obj.content || obj.text)) {
147
+ role = obj.role;
148
+ raw = obj.content != null ? obj.content : obj.text;
149
+ }
150
+ if (role !== 'user' && role !== 'assistant') continue;
151
+ if (raw == null) continue;
152
+ let text = '';
153
+ if (typeof raw === 'string') text = raw;
154
+ else if (Array.isArray(raw)) {
155
+ text = raw
156
+ .filter((p) => p && (p.type === 'text' || typeof p.text === 'string'))
157
+ .map((p) => String(p.text || ''))
158
+ .join('\n');
159
+ }
160
+ // Phase 6.2.6 — keep newlines so the bridge UI can render markdown
161
+ // (tables, lists, code blocks, paragraphs). Collapsing every \s+ to a
162
+ // single space was stripping all the formatting before storage. We
163
+ // still collapse runs of horizontal whitespace and excessive blank
164
+ // lines, but real line breaks survive.
165
+ text = text.replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim();
166
+ if (!text) continue; // skip pure tool_use / tool_result lines (no text body)
167
+ parsed.push({ role, text, ts: obj.timestamp || null });
168
+ }
169
+
170
+ // Coalesce consecutive same-role lines (one logical turn split across JSONL
171
+ // records). This is the actual fix — without it, assistant turns with mid-
172
+ // reply tool calls fragment into the first text shard only. Joining with
173
+ // \n\n preserves paragraph boundaries between coalesced chunks so markdown
174
+ // renders correctly.
175
+ const coalesced = [];
176
+ for (const p of parsed) {
177
+ const last = coalesced[coalesced.length - 1];
178
+ if (last && last.role === p.role) {
179
+ last.text = (last.text + '\n\n' + p.text).replace(/\n{3,}/g, '\n\n').trim();
180
+ last.ts = p.ts || last.ts;
181
+ } else {
182
+ coalesced.push({ role: p.role, text: p.text, ts: p.ts });
183
+ }
184
+ }
185
+
186
+ // Filter user turns that are pure platform-injection noise. Assistant turns
187
+ // are never filtered — they're always real Claude/Codex output.
188
+ const filtered = coalesced.filter((m) => {
189
+ if (m.role !== 'user') return true;
190
+ return !isInjectionMessage(m.text);
191
+ });
192
+
193
+ const sliced = filtered.slice(-n);
194
+ return sliced.map((m) => ({
195
+ role: m.role,
196
+ body: m.text.length > RECENT_MESSAGE_BODY_MAX ? m.text.slice(0, RECENT_MESSAGE_BODY_MAX) : m.text,
197
+ ts: m.ts,
198
+ }));
199
+ };
200
+
201
+ // Claude Code writes an LLM-generated title as a `type: "ai-title"` JSONL
202
+ // record near the end of each session file (this is the same title VSCode's
203
+ // session list shows — "Analyze MongoDB MCP server architecture" style).
204
+ // If we find one, it beats anything we could extract from the user's first
205
+ // raw prompt. Scan the tail backwards to hit it fast.
206
+ const extractAiTitleFromTail = (jsonlTail) => {
207
+ if (!jsonlTail) return '';
208
+ const lines = jsonlTail.split(/\r?\n/);
209
+ for (let i = lines.length - 1; i >= 0; i--) {
210
+ const line = lines[i];
211
+ // Cheap pre-filter so we only JSON.parse candidate lines.
212
+ if (line.indexOf('ai-title') === -1 && line.indexOf('aiTitle') === -1) continue;
213
+ try {
214
+ const obj = JSON.parse(line);
215
+ if (obj && obj.type === 'ai-title' && typeof obj.aiTitle === 'string') {
216
+ const t = obj.aiTitle.trim();
217
+ if (t) return t.length > TITLE_MAX_CHARS ? t.slice(0, TITLE_MAX_CHARS) : t;
218
+ }
219
+ } catch (_) { /* not a JSON line — keep walking */ }
220
+ }
221
+ return '';
222
+ };
223
+
224
+
225
+ // "user" messages BEFORE the human's first real prompt — system reminders,
226
+ // IDE state, untrusted-metadata wrappers, command blocks, etc. These are
227
+ // noise from a label perspective. Filter them so the first PLAIN user
228
+ // prompt is what we use as the thread title.
229
+ const isInjectionMessage = (text) => {
230
+ const s = String(text || '').trim();
231
+ if (!s) return true;
232
+ if (/^<[a-zA-Z!]/.test(s)) return true; // <system-reminder>, <ide_opened_file>, etc.
233
+ if (/^Sender \(untrusted metadata\)/i.test(s)) return true;
234
+ if (/^Conversation info \(untrusted metadata\)/i.test(s)) return true;
235
+ if (/^untrusted metadata/i.test(s)) return true;
236
+ if (/^Caveat:/i.test(s)) return true;
237
+ if (/^Claude was launched/i.test(s)) return true;
238
+ if (/^```json/i.test(s)) return true;
239
+ if (/^`<command-name>/.test(s)) return true;
240
+ if (/^This session is being continued/i.test(s)) return true; // /resume preamble
241
+ return false;
242
+ };
243
+
244
+ // Walk a JSONL file line-by-line until we find a plain user message (skipping
245
+ // metadata injections) or we've checked TITLE_MAX_LINES / TITLE_MAX_BYTES.
246
+ // Streaming so big files don't get fully slurped. Returns a clean ≤80-char
247
+ // title or '' if nothing meaningful was found in the bound.
248
+ const extractTitleFromFile = (filePath) => {
249
+ let fd = null;
250
+ try {
251
+ fd = fs.openSync(filePath, 'r');
252
+ const CHUNK = 16 * 1024;
253
+ const buf = Buffer.alloc(CHUNK);
254
+ let carry = '';
255
+ let bytesRead = 0;
256
+ let linesChecked = 0;
257
+ while (bytesRead < TITLE_MAX_BYTES && linesChecked < TITLE_MAX_LINES) {
258
+ const n = fs.readSync(fd, buf, 0, CHUNK, bytesRead);
259
+ if (n === 0) break;
260
+ bytesRead += n;
261
+ carry += buf.toString('utf8', 0, n);
262
+ const lines = carry.split(/\r?\n/);
263
+ carry = lines.pop() || '';
264
+ for (const line of lines) {
265
+ if (linesChecked >= TITLE_MAX_LINES) break;
266
+ if (!line.trim()) continue;
267
+ linesChecked++;
268
+ let obj;
269
+ try { obj = JSON.parse(line); } catch (_) { continue; }
270
+ let role = null, raw = null;
271
+ if (obj && obj.message && (obj.message.role || obj.type)) {
272
+ role = obj.message.role || (obj.type === 'user' ? 'user' : 'assistant');
273
+ raw = obj.message.content;
274
+ } else if (obj && obj.role && (obj.content || obj.text)) {
275
+ role = obj.role;
276
+ raw = obj.content != null ? obj.content : obj.text;
277
+ }
278
+ if (role !== 'user' || raw == null) continue;
279
+ let text = '';
280
+ if (typeof raw === 'string') text = raw;
281
+ else if (Array.isArray(raw)) {
282
+ text = raw
283
+ .filter((p) => p && (p.type === 'text' || typeof p.text === 'string'))
284
+ .map((p) => String(p.text || ''))
285
+ .join('\n');
286
+ }
287
+ text = text.replace(/\s+/g, ' ').trim();
288
+ if (!text) continue;
289
+ if (isInjectionMessage(text)) continue;
290
+ return text.length > TITLE_MAX_CHARS ? text.slice(0, TITLE_MAX_CHARS) : text;
291
+ }
292
+ }
293
+ return '';
294
+ } catch (_) {
295
+ return '';
296
+ } finally {
297
+ if (fd !== null) { try { fs.closeSync(fd); } catch (_) {} }
298
+ }
299
+ };
300
+
301
+ // Walk a JSONL tail backwards, parse each non-empty line as JSON, return the
302
+ // first one we can extract a message from. Tolerant of multiple shapes —
303
+ // Claude and Codex write slightly different envelopes and the formats have
304
+ // drifted across SDK versions.
305
+ const extractLastMessage = (jsonlTail) => {
306
+ if (!jsonlTail) return null;
307
+ const lines = jsonlTail.split(/\r?\n/).filter((l) => l.trim().length > 0);
308
+ for (let i = lines.length - 1; i >= 0; i--) {
309
+ let obj;
310
+ try {
311
+ obj = JSON.parse(lines[i]);
312
+ } catch (_) {
313
+ continue;
314
+ }
315
+
316
+ // ── Claude Code session JSONL shapes ─────────────────────────────────
317
+ // Common:
318
+ // { type: 'user', message: { role: 'user', content: '...' } }
319
+ // { type: 'assistant', message: { role: 'assistant', content: [{type:'text', text:'...'}, ...] } }
320
+ if (obj && obj.message && (obj.message.role || obj.type)) {
321
+ const role = obj.message.role || (obj.type === 'user' ? 'user' : 'assistant');
322
+ let raw = obj.message.content;
323
+ let text = '';
324
+ if (typeof raw === 'string') text = raw;
325
+ else if (Array.isArray(raw)) {
326
+ text = raw
327
+ .filter((p) => p && (p.type === 'text' || typeof p.text === 'string'))
328
+ .map((p) => String(p.text || ''))
329
+ .join('\n');
330
+ }
331
+ text = text.trim();
332
+ if (text) return { author: role === 'user' ? 'user' : 'assistant', preview: text.slice(0, PREVIEW_MAX_CHARS) };
333
+ }
334
+
335
+ // ── Codex SDK rollout shapes ────────────────────────────────────────
336
+ // { record_type: 'message', role: 'assistant', content: [{type:'text', text:'...'}] }
337
+ // { type: 'message', role: 'user', content: '...' }
338
+ // { event: 'output_text', text: '...', role: 'assistant' }
339
+ if (obj && (obj.role || obj.event) && (obj.content || obj.text)) {
340
+ const role = obj.role || (obj.event === 'input_text' ? 'user' : 'assistant');
341
+ let raw = obj.content != null ? obj.content : obj.text;
342
+ let text = '';
343
+ if (typeof raw === 'string') text = raw;
344
+ else if (Array.isArray(raw)) {
345
+ text = raw
346
+ .filter((p) => p && (p.type === 'text' || typeof p.text === 'string'))
347
+ .map((p) => String(p.text || ''))
348
+ .join('\n');
349
+ }
350
+ text = text.trim();
351
+ if (text) return { author: role === 'user' ? 'user' : 'assistant', preview: text.slice(0, PREVIEW_MAX_CHARS) };
352
+ }
353
+ }
354
+ return null;
355
+ };
356
+
357
+ // Decode Claude's project-dir filename encoding back to a path-like string.
358
+ // Claude turns `/Users/divyansh/foo` → `-Users-divyansh-foo`. We can't
359
+ // perfectly reverse it (project names with literal `-` are ambiguous), but
360
+ // for display purposes leading-dash → leading-slash + dashes → slashes is
361
+ // usually close enough. The BridgeExternal stores both the raw encoded
362
+ // projectDir AND a decoded label; the view route picks the friendlier one.
363
+ const decodeClaudeProjectDir = (encoded) => {
364
+ if (!encoded) return '';
365
+ if (encoded.startsWith('-')) {
366
+ return '/' + encoded.slice(1).replace(/-/g, '/');
367
+ }
368
+ return encoded.replace(/-/g, '/');
369
+ };
370
+
371
+ // Discover all Claude Desktop "local-agent-mode" project roots.
372
+ // Structure is 3 levels deep before we hit `.claude/projects/`:
373
+ // <BASE>/<accountUuid>/<workspaceUuid>/local_<sessionUuid>/.claude/projects/
374
+ // We collect every leaf `.claude/projects` dir, then walk each like we walk
375
+ // the CLI's ~/.claude/projects.
376
+ const findClaudeDesktopProjectRoots = () => {
377
+ const out = [];
378
+ let l1;
379
+ try { l1 = fs.readdirSync(CLAUDE_DESKTOP_BASE); } catch (_) { return out; }
380
+ for (const a of l1) {
381
+ const aPath = path.join(CLAUDE_DESKTOP_BASE, a);
382
+ let aStat; try { aStat = fs.statSync(aPath); } catch (_) { continue; }
383
+ if (!aStat.isDirectory()) continue;
384
+ let l2; try { l2 = fs.readdirSync(aPath); } catch (_) { continue; }
385
+ for (const b of l2) {
386
+ const bPath = path.join(aPath, b);
387
+ let bStat; try { bStat = fs.statSync(bPath); } catch (_) { continue; }
388
+ if (!bStat.isDirectory()) continue;
389
+ let l3; try { l3 = fs.readdirSync(bPath); } catch (_) { continue; }
390
+ for (const c of l3) {
391
+ if (!c.startsWith('local_')) continue;
392
+ const projects = path.join(bPath, c, '.claude', 'projects');
393
+ try { if (fs.statSync(projects).isDirectory()) out.push(projects); } catch (_) {}
394
+ }
395
+ }
396
+ }
397
+ return out;
398
+ };
399
+
400
+ // Walk a single Claude projects root (works for both ~/.claude/projects and
401
+ // each of the Desktop app's local-agent-mode project roots — file format is
402
+ // identical, only the path differs).
403
+ const walkClaudeProjectsRoot = (root) => {
404
+ const out = [];
405
+ let topEntries;
406
+ try { topEntries = fs.readdirSync(root); } catch (_) { return out; }
407
+
408
+ for (const entry of topEntries) {
409
+ const projectPath = path.join(root, entry);
410
+ let projectStat;
411
+ try { projectStat = fs.statSync(projectPath); } catch (_) { continue; }
412
+ if (!projectStat.isDirectory()) continue;
413
+
414
+ let sessionFiles;
415
+ try { sessionFiles = fs.readdirSync(projectPath); } catch (_) { continue; }
416
+
417
+ for (const file of sessionFiles) {
418
+ if (!file.endsWith('.jsonl')) continue;
419
+ const sessionId = file.slice(0, -'.jsonl'.length);
420
+ const filePath = path.join(projectPath, file);
421
+ let stat;
422
+ try { stat = fs.statSync(filePath); } catch (_) { continue; }
423
+ const tail = readTail(filePath);
424
+ const lastMsg = extractLastMessage(tail);
425
+ let title = extractAiTitleFromTail(tail);
426
+ if (!title) title = extractTitleFromFile(filePath);
427
+ const recentMessages = extractRecentMessages(readTailUntilMessages(filePath));
428
+ out.push({
429
+ source: 'claude',
430
+ sessionId,
431
+ projectDir: entry,
432
+ projectName: decodeClaudeProjectDir(entry),
433
+ title: title,
434
+ recentMessages: recentMessages,
435
+ lastActivityAt: stat.mtimeMs,
436
+ lastMessagePreview: lastMsg ? lastMsg.preview : '',
437
+ lastMessageAuthor: lastMsg ? lastMsg.author : null,
438
+ });
439
+ }
440
+ }
441
+ return out;
442
+ };
443
+
444
+ // Scan all Claude session storage on this machine — CLI + VSCode extension
445
+ // (~/.claude/projects) AND every Claude Desktop local-agent-mode subdir
446
+ // (~/Library/Application Support/Claude/local-agent-mode-sessions/.../).
447
+ const scanClaude = () => {
448
+ const roots = [CLAUDE_DIR].concat(findClaudeDesktopProjectRoots());
449
+ const out = [];
450
+ for (const root of roots) {
451
+ out.push(...walkClaudeProjectsRoot(root));
452
+ }
453
+ return out;
454
+ };
455
+
456
+ // Scan ~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<sessionId>.jsonl
457
+ // The first JSONL line for a Codex rollout is a session-meta record that
458
+ // contains the working directory; we read it once for projectDir.
459
+ const scanCodex = () => {
460
+ const out = [];
461
+ let years;
462
+ try { years = fs.readdirSync(CODEX_DIR); } catch (_) { return out; }
463
+
464
+ for (const y of years) {
465
+ if (!/^\d{4}$/.test(y)) continue;
466
+ const yPath = path.join(CODEX_DIR, y);
467
+ let months;
468
+ try { months = fs.readdirSync(yPath); } catch (_) { continue; }
469
+ for (const m of months) {
470
+ if (!/^\d{2}$/.test(m)) continue;
471
+ const mPath = path.join(yPath, m);
472
+ let days;
473
+ try { days = fs.readdirSync(mPath); } catch (_) { continue; }
474
+ for (const d of days) {
475
+ if (!/^\d{2}$/.test(d)) continue;
476
+ const dPath = path.join(mPath, d);
477
+ let files;
478
+ try { files = fs.readdirSync(dPath); } catch (_) { continue; }
479
+ for (const f of files) {
480
+ if (!f.startsWith('rollout-') || !f.endsWith('.jsonl')) continue;
481
+ // session id is the last hex/uuid block before .jsonl
482
+ const sidMatch = f.match(/-([0-9a-f-]{8,})\.jsonl$/i);
483
+ if (!sidMatch) continue;
484
+ const sessionId = sidMatch[1];
485
+ const filePath = path.join(dPath, f);
486
+ let stat;
487
+ try { stat = fs.statSync(filePath); } catch (_) { continue; }
488
+
489
+ // Read the first KB to pull the session-meta's working directory.
490
+ let projectDir = '';
491
+ let fd = null;
492
+ try {
493
+ fd = fs.openSync(filePath, 'r');
494
+ const headBuf = Buffer.alloc(Math.min(2048, stat.size));
495
+ fs.readSync(fd, headBuf, 0, headBuf.length, 0);
496
+ const firstLine = headBuf.toString('utf8').split(/\r?\n/)[0] || '';
497
+ try {
498
+ const meta = JSON.parse(firstLine);
499
+ projectDir = String(
500
+ meta?.cwd ||
501
+ meta?.workingDirectory ||
502
+ meta?.working_directory ||
503
+ meta?.session_meta?.cwd ||
504
+ meta?.payload?.cwd ||
505
+ ''
506
+ );
507
+ } catch (_) { /* not a meta line — leave projectDir blank */ }
508
+ } catch (_) {} finally {
509
+ if (fd !== null) { try { fs.closeSync(fd); } catch (_) {} }
510
+ }
511
+
512
+ const tail = readTail(filePath);
513
+ const lastMsg = extractLastMessage(tail);
514
+ const title = extractTitleFromFile(filePath);
515
+ // Recent messages get the streaming tail (handles huge per-message
516
+ // assistant turns that overflow the 16KB fixed budget).
517
+ const recentMessages = extractRecentMessages(readTailUntilMessages(filePath));
518
+ out.push({
519
+ source: 'codex',
520
+ sessionId,
521
+ projectDir: projectDir || `${y}/${m}/${d}`,
522
+ projectName: projectDir || null,
523
+ title: title,
524
+ recentMessages: recentMessages,
525
+ lastActivityAt: stat.mtimeMs,
526
+ lastMessagePreview: lastMsg ? lastMsg.preview : '',
527
+ lastMessageAuthor: lastMsg ? lastMsg.author : null,
528
+ });
529
+ }
530
+ }
531
+ }
532
+ }
533
+ return out;
534
+ };
535
+
536
+ // Public entry: returns a flat list of every external session found.
537
+ // Synchronous on purpose — the daemon calls this on a 30s timer and the
538
+ // total IO is dominated by readdir + a single readSync per file. Async
539
+ // would only complicate retry/cancel semantics with no real benefit.
540
+ const scanAll = () => {
541
+ const claude = scanClaude();
542
+ const codex = scanCodex();
543
+ return claude.concat(codex);
544
+ };
545
+
546
+ module.exports = { scanAll, scanClaude, scanCodex, extractLastMessage, decodeClaudeProjectDir };