diff --git a/src/providers/codex.ts b/src/providers/codex.ts index dc21aa83..60b99993 100644 --- a/src/providers/codex.ts +++ b/src/providers/codex.ts @@ -1,4 +1,6 @@ -import { readdir, stat, open } from 'fs/promises' +import { readdir, stat } from 'fs/promises' +import { createReadStream } from 'fs' +import { createInterface } from 'readline' import { basename, join } from 'path' import { homedir } from 'os' @@ -70,21 +72,29 @@ function sanitizeProject(cwd: string): string { } async function readFirstLine(filePath: string): Promise { - let fh + // Codex CLI 0.128+ writes a session_meta line that can exceed 20 KB because + // it embeds the full base_instructions / system prompt. A fixed-size buffer + // would miss the trailing newline and reject the session as invalid. + // Stream the file via readline to read the first line regardless of length. + const stream = createReadStream(filePath, { encoding: 'utf-8' }) + const rl = createInterface({ input: stream, crlfDelay: Infinity }) + let firstLine: string | undefined try { - fh = await open(filePath, 'r') - const buf = Buffer.alloc(16384) - const { bytesRead } = await fh.read(buf, 0, 16384, 0) - if (bytesRead === 0) return null - const text = buf.toString('utf-8', 0, bytesRead) - const nl = text.indexOf('\n') - const line = nl >= 0 ? text.slice(0, nl) : text - if (!line.trim()) return null - return JSON.parse(line) as CodexEntry + for await (const line of rl) { + firstLine = line + break + } } catch { return null } finally { - await fh?.close() + rl.close() + stream.destroy() + } + if (!firstLine || !firstLine.trim()) return null + try { + return JSON.parse(firstLine) as CodexEntry + } catch { + return null } } diff --git a/tests/providers/codex.test.ts b/tests/providers/codex.test.ts index 92088111..cdf0dda8 100644 --- a/tests/providers/codex.test.ts +++ b/tests/providers/codex.test.ts @@ -123,6 +123,32 @@ describe('codex provider - session discovery', () => { expect(sessions).toHaveLength(1) }) + it('accepts session_meta lines larger than 16 KB (Codex CLI 0.128+)', async () => { + // Codex CLI 0.128+ embeds the full base_instructions / system prompt in the + // first session_meta line, often pushing it past 20 KB. Regression guard + // against a fixed-size buffer in readFirstLine. + const bigPayload = JSON.stringify({ + type: 'session_meta', + timestamp: '2026-05-02T00:00:00Z', + payload: { + cwd: '/Users/test/big', + originator: 'codex-tui', + session_id: 'sess-big', + model: 'gpt-5.5', + base_instructions: { text: 'x'.repeat(40_000) }, + }, + }) + await writeSession(tmpDir, '2026-05-02', 'rollout-big.jsonl', [ + bigPayload, + tokenCount({ last: { input: 100, output: 50 }, total: { total: 150 } }), + ]) + + const provider = createCodexProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(1) + expect(sessions[0]!.path).toContain('rollout-big.jsonl') + }) + it('skips files without codex session_meta', async () => { const [year, month, day] = '2026-04-14'.split('-') const sessionDir = join(tmpDir, 'sessions', year!, month!, day!)