|
| 1 | +import { readdirSync, statSync, existsSync, readFileSync, openSync, readSync, closeSync, fstatSync } from 'node:fs'; |
| 2 | +import { join } from 'node:path'; |
| 3 | +import type { SessionMeta } from '../shared/types'; |
| 4 | +import { codexCwd, codexFirstUserMessage, codexLastUserMessage } from '../shared/codexParse'; |
| 5 | + |
| 6 | +const HEAD_BYTES = 64 * 1024; |
| 7 | +// Codex rollouts can be multi-MB and the last user message often sits hundreds of |
| 8 | +// KB before EOF (a long autonomous agent turn follows it), so scan the tail backward |
| 9 | +// in chunks and stop as soon as a user message is found, up to a hard cap — mirroring |
| 10 | +// the Claude reader in sessions.ts. |
| 11 | +const TAIL_CHUNK = 1024 * 1024; |
| 12 | +const TAIL_MAX = 8 * 1024 * 1024; |
| 13 | +const SESSION_ID_RE = /^[0-9a-fA-F][0-9a-fA-F-]{7,}$/; |
| 14 | + |
| 15 | +function readHead(file: string): string { |
| 16 | + try { |
| 17 | + const fd = openSync(file, 'r'); |
| 18 | + try { const buf = Buffer.alloc(HEAD_BYTES); const n = readSync(fd, buf, 0, HEAD_BYTES, 0); return buf.toString('utf8', 0, n); } |
| 19 | + finally { closeSync(fd); } |
| 20 | + } catch { try { return readFileSync(file, 'utf8'); } catch { return ''; } } |
| 21 | +} |
| 22 | + |
| 23 | +/** Last user message of a rollout, scanning backward in chunks up to TAIL_MAX. */ |
| 24 | +function lastUserMessageInFile(file: string): string | null { |
| 25 | + let fd: number; |
| 26 | + try { fd = openSync(file, 'r'); } catch { return null; } |
| 27 | + try { |
| 28 | + const size = fstatSync(fd).size; |
| 29 | + let pos = size; |
| 30 | + let acc = Buffer.alloc(0); |
| 31 | + while (pos > 0 && size - pos < TAIL_MAX) { |
| 32 | + const readLen = Math.min(TAIL_CHUNK, pos); |
| 33 | + pos -= readLen; |
| 34 | + const buf = Buffer.alloc(readLen); |
| 35 | + readSync(fd, buf, 0, readLen, pos); |
| 36 | + acc = Buffer.concat([buf, acc]); // contiguous byte range [pos, size) — safe to decode whole |
| 37 | + const text = acc.toString('utf8'); |
| 38 | + // Drop the leading partial line until we've read the very start of the file. |
| 39 | + const body = pos === 0 ? text : text.slice(text.indexOf('\n') + 1); |
| 40 | + const found = codexLastUserMessage(body); |
| 41 | + if (found) return found; |
| 42 | + } |
| 43 | + return null; |
| 44 | + } catch { return null; } |
| 45 | + finally { closeSync(fd); } |
| 46 | +} |
| 47 | + |
| 48 | +/** Recursively collect rollout-*.jsonl files under the sessions dir. */ |
| 49 | +function rolloutFiles(dir: string): string[] { |
| 50 | + const out: string[] = []; |
| 51 | + let entries; |
| 52 | + try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; } |
| 53 | + for (const e of entries) { |
| 54 | + const full = join(dir, e.name); |
| 55 | + if (e.isDirectory()) out.push(...rolloutFiles(full)); |
| 56 | + else if (e.name.startsWith('rollout-') && e.name.endsWith('.jsonl')) out.push(full); |
| 57 | + } |
| 58 | + return out; |
| 59 | +} |
| 60 | + |
| 61 | +interface CodexHead { id: string | null; cwd: string | null; mtimeMs: number; path: string; } |
| 62 | +const _headCache = new Map<string, CodexHead>(); // key: path + ':' + mtimeMs |
| 63 | + |
| 64 | +function head(file: string): CodexHead { |
| 65 | + let mtimeMs = 0; |
| 66 | + try { mtimeMs = statSync(file).mtimeMs; } catch { /* ignore */ } |
| 67 | + const key = file + ':' + mtimeMs; |
| 68 | + const cached = _headCache.get(key); |
| 69 | + if (cached) return cached; |
| 70 | + const text = readHead(file); |
| 71 | + const metaLine = text.split('\n', 1)[0] ?? ''; |
| 72 | + let id: string | null = null; |
| 73 | + try { id = (JSON.parse(metaLine)?.payload?.id ?? null) as string | null; } catch { /* ignore */ } |
| 74 | + const h: CodexHead = { id, cwd: codexCwd(text), mtimeMs, path: file }; |
| 75 | + _headCache.set(key, h); |
| 76 | + return h; |
| 77 | +} |
| 78 | + |
| 79 | +export function codexAvailable(codexSessionsDir: string): boolean { |
| 80 | + return existsSync(codexSessionsDir); |
| 81 | +} |
| 82 | + |
| 83 | +/** Codex sessions for a project (by cwd), newest-first, with first-message previews. */ |
| 84 | +export function listCodexSessions(projectPath: string, codexSessionsDir: string, limit = 5): SessionMeta[] { |
| 85 | + const matches = rolloutFiles(codexSessionsDir) |
| 86 | + .map(head) |
| 87 | + .filter((h) => h.cwd === projectPath && h.id) |
| 88 | + .sort((a, b) => b.mtimeMs - a.mtimeMs) |
| 89 | + .slice(0, limit); |
| 90 | + return matches.map((h) => ({ |
| 91 | + id: h.id as string, |
| 92 | + mtimeMs: h.mtimeMs, |
| 93 | + firstMessage: codexFirstUserMessage(readHead(h.path)), |
| 94 | + })); |
| 95 | +} |
| 96 | + |
| 97 | +/** Last user message of a specific Codex session (by id, under a cwd). Null if absent. */ |
| 98 | +export function lastUserMessageForCodexSession(projectPath: string, sessionId: string, codexSessionsDir: string): string | null { |
| 99 | + if (!SESSION_ID_RE.test(sessionId)) return null; |
| 100 | + const match = rolloutFiles(codexSessionsDir).map(head).find((h) => h.id === sessionId && h.cwd === projectPath); |
| 101 | + if (!match) return null; |
| 102 | + return lastUserMessageInFile(match.path); |
| 103 | +} |
0 commit comments