import { readdir, stat } from 'fs/promises' import { existsSync } from 'fs' import { basename, join } from 'path' import { homedir } from 'os' import { readSessionLines } from './fs-utils.js' import { estimateTokensFromChars } from './token-estimate.js' import { add, estimateTokens, IMAGE_TOKEN_FALLBACK, lineToText, newAcc, readChunk, snapshot, type Acc, type ContextTreeResult, type SessionRef, type TitledSessionRef, } from './context-tree.js' // Codex rollout counterpart of the Claude Code context tree. Rollouts carry // full response items plus token_count events with exact totals: the last // token_count gives the live context size and model_context_window, and the // cumulative reasoning_output_tokens total prices reasoning exactly (reasoning // item text is encrypted). `compacted` entries mark compactions and include // the replacement_history the next window starts from. type CodexItem = { type?: string role?: string content?: unknown name?: string arguments?: unknown input?: unknown action?: unknown output?: unknown } type CodexEntry = { type?: string payload?: { type?: string role?: string model?: string cwd?: string id?: string base_instructions?: { text?: unknown } | null message?: unknown replacement_history?: unknown info?: { total_token_usage?: { reasoning_output_tokens?: number } last_token_usage?: { input_tokens?: number; output_tokens?: number; total_tokens?: number } model_context_window?: number } | null } & CodexItem } // Injected harness content: any tag-shaped block that isn't an image marker, // plus the AGENTS.md / mentioned-files preambles Codex prepends to turns. function isCodexMetaText(text: string): boolean { const t = text.trimStart() if (t.startsWith('<')) return !t.startsWith('" markers; the // pixels never hit the file, so charge a flat estimate per marker. if (b.text.trimStart().startsWith(' { const full = newAcc() let segment = newAcc() let compactions = 0 let model = 'unknown' let systemTokens = 0 let contextWindow: number | null = null let lastTotalReasoning = 0 let segmentStartReasoning = 0 let lastUsage: { input_tokens?: number; output_tokens?: number; total_tokens?: number } | null = null for await (const line of readSessionLines(session.filePath, undefined, { largeLineAsBuffer: true })) { const text = lineToText(line) if (!text || text.charCodeAt(0) !== 123) continue let entry: CodexEntry try { entry = JSON.parse(text) as CodexEntry } catch { continue } const payload = entry.payload if (!payload) continue if (entry.type === 'session_meta') { const instructions = payload.base_instructions?.text if (typeof instructions === 'string') systemTokens = estimateTokens(instructions) } else if (entry.type === 'turn_context') { if (typeof payload.model === 'string' && payload.model) model = payload.model } else if (entry.type === 'compacted') { compactions += 1 segment = newAcc() segmentStartReasoning = lastTotalReasoning if (typeof payload.message === 'string' && payload.message) { add(segment.userCompactSummary, estimateTokens(payload.message)) } // The originals already landed in `full`, so the replacement history // seeds only the new live window. if (Array.isArray(payload.replacement_history)) { for (const item of payload.replacement_history) { if (item != null && typeof item === 'object') addCodexItem([segment], item as CodexItem) } } } else if (entry.type === 'response_item') { addCodexItem([full, segment], payload) } else if (entry.type === 'event_msg' && payload.type === 'token_count') { const info = payload.info const totalReasoning = info?.total_token_usage?.reasoning_output_tokens if (typeof totalReasoning === 'number') lastTotalReasoning = totalReasoning if (info?.last_token_usage) lastUsage = info.last_token_usage if (typeof info?.model_context_window === 'number' && info.model_context_window > 0) { contextWindow = info.model_context_window } } } full.assistantReasoning.tokens = lastTotalReasoning segment.assistantReasoning.tokens = Math.max(0, lastTotalReasoning - segmentStartReasoning) if (systemTokens > 0) { add(full.system, systemTokens) add(segment.system, systemTokens) } let reported: ContextTreeResult['reported'] = null if (lastUsage) { const context = lastUsage.total_tokens ?? (lastUsage.input_tokens ?? 0) + (lastUsage.output_tokens ?? 0) if (context > 0) { // No guessing for OpenAI windows: without model_context_window the // percentage is omitted rather than computed against a wrong constant. reported = { context, window: contextWindow } } } return { session, model, compactions, reported, effective: snapshot(segment), full: snapshot(full), } } const ROLLOUT_RE = /^rollout-.{19}-(.+)\.jsonl$/ // Mirrors the CODEX_HOME handling of providers/codex.ts. function codexSessionsRoot(): string { return join(process.env['CODEX_HOME'] ?? join(homedir(), '.codex'), 'sessions') } type RolloutFile = { filePath: string; sessionId: string } async function listRolloutFiles(): Promise { const root = codexSessionsRoot() if (!existsSync(root)) return [] let files: string[] try { files = await readdir(root, { recursive: true }) } catch { return [] } const rollouts: RolloutFile[] = [] for (const rel of files) { const match = ROLLOUT_RE.exec(basename(rel)) if (match) rollouts.push({ filePath: join(root, rel), sessionId: match[1] }) } return rollouts } async function statRef(file: RolloutFile): Promise { try { const info = await stat(file.filePath) if (!info.isFile() || info.size === 0) return null return { ...file, project: '', mtimeMs: info.mtimeMs, sizeBytes: info.size } } catch { return null } } function newestFirst(refs: Array): SessionRef[] { return refs.filter((r): r is SessionRef => r !== null).sort((a, b) => b.mtimeMs - a.mtimeMs) } export async function listCodexSessionRefs(): Promise { const files = await listRolloutFiles() return newestFirst(await Promise.all(files.map(statRef))) } // Id lookups match filenames directly so only the matching files get stated. export async function findCodexSession(idPrefix: string): Promise { const matches = (await listRolloutFiles()).filter((f) => f.sessionId.startsWith(idPrefix)) return newestFirst(await Promise.all(matches.map(statRef)))[0] ?? null } // Codex stores no session name; use the head chunk for the cwd (project) and // the first real user message as a stand-in title. async function readCodexHeadInfo(ref: SessionRef): Promise<{ project: string; title: string }> { let chunk: string try { chunk = await readChunk(ref.filePath, 0, 262_144) } catch { return { project: '', title: '' } } let project = '' let title = '' for (const line of chunk.split('\n')) { if (project && title) break let entry: CodexEntry try { entry = JSON.parse(line) as CodexEntry } catch { continue } const payload = entry.payload if (!payload) continue if (!project && entry.type === 'session_meta' && typeof payload.cwd === 'string' && payload.cwd) { project = basename(payload.cwd) } if (!title && entry.type === 'response_item' && payload.type === 'message' && payload.role === 'user' && Array.isArray(payload.content)) { for (const block of payload.content) { const b = block as { type?: string; text?: unknown } if ((b.type === 'input_text' || b.type === 'text') && typeof b.text === 'string' && b.text.trim() && !isCodexMetaText(b.text)) { title = b.text.replace(/\s+/g, ' ').trim().slice(0, 80) break } } } } return { project, title } } export async function listRecentCodexSessions(limit = 15): Promise { const refs = (await listCodexSessionRefs()).slice(0, limit) return Promise.all( refs.map(async (ref) => { const info = await readCodexHeadInfo(ref) return { ...ref, project: info.project, title: info.title } }), ) }