|
| 1 | +/** |
| 2 | + * Codex Sessions Manager Module |
| 3 | + * |
| 4 | + * Lists Codex CLI session history for a project, the Codex counterpart of |
| 5 | + * claudeSessionsManager. Codex keeps one rollout file per session under |
| 6 | + * ~/.codex/sessions/YYYY/MM/DD/rollout-<timestamp>-<id>.jsonl — by date, not |
| 7 | + * by project — so every rollout's first record (`session_meta`, which carries |
| 8 | + * the session's `cwd`) decides whether it belongs to the project. |
| 9 | + * |
| 10 | + * Returns the same session shape and `{ sessions, reason }` envelope as the |
| 11 | + * Claude manager, so the Sessions panel renders both lists one way. |
| 12 | + */ |
| 13 | + |
| 14 | +const fs = require('fs'); |
| 15 | +const fsp = require('fs').promises; |
| 16 | +const path = require('path'); |
| 17 | +const os = require('os'); |
| 18 | +const readline = require('readline'); |
| 19 | +const { IPC } = require('../shared/ipcChannels'); |
| 20 | + |
| 21 | +/** |
| 22 | + * Where Codex keeps its data. `CODEX_HOME` is how Codex itself allows |
| 23 | + * relocating it; honouring it keeps Frame correct for users who set it and |
| 24 | + * lets the tests point this module at a fixture tree. Read per call. |
| 25 | + */ |
| 26 | +function sessionsDir() { |
| 27 | + const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), '.codex'); |
| 28 | + return path.join(codexHome, 'sessions'); |
| 29 | +} |
| 30 | + |
| 31 | +/** Longest first-prompt excerpt kept for a session title. */ |
| 32 | +const PROMPT_EXCERPT = 200; |
| 33 | + |
| 34 | +/** |
| 35 | + * Per-file scan cache, as in claudeSessionsManager: rollouts are append-only, |
| 36 | + * so a later scan reads only what was appended. Rollouts of other projects |
| 37 | + * are remembered too — their cwd never changes — so they are skipped without |
| 38 | + * being opened again. Keyed by absolute file path. |
| 39 | + * { offset, size, mtimeMs, session } |
| 40 | + */ |
| 41 | +const scanCache = new Map(); |
| 42 | + |
| 43 | +/** |
| 44 | + * The user's own words in a `response_item` message. |
| 45 | + * |
| 46 | + * Codex records injected context as user messages too: the |
| 47 | + * <environment_context> block, <user_instructions>, and the |
| 48 | + * "# AGENTS.md instructions for …" preamble. None of those are a title. |
| 49 | + */ |
| 50 | +function userPromptText(payload) { |
| 51 | + const parts = Array.isArray(payload.content) ? payload.content : []; |
| 52 | + const textPart = parts.find(part => part && part.type === 'input_text' && typeof part.text === 'string'); |
| 53 | + if (!textPart) return null; |
| 54 | + |
| 55 | + const text = textPart.text.trim(); |
| 56 | + if (!text) return null; |
| 57 | + if (text.startsWith('<')) return null; |
| 58 | + if (text.startsWith('# AGENTS.md instructions')) return null; |
| 59 | + return text.slice(0, PROMPT_EXCERPT); |
| 60 | +} |
| 61 | + |
| 62 | +/** |
| 63 | + * A subagent's thread (spawned by another Codex session) is part of its |
| 64 | + * parent's work, not a session the user started or would resume. |
| 65 | + */ |
| 66 | +function isSubagentMeta(payload) { |
| 67 | + if (payload.thread_source === 'subagent') return true; |
| 68 | + return !!(payload.source && typeof payload.source === 'object' && payload.source.subagent); |
| 69 | +} |
| 70 | + |
| 71 | +/** Fold one rollout record into the session being built. */ |
| 72 | +function applyRecord(session, record) { |
| 73 | + if (record.type === 'session_meta' && record.payload) { |
| 74 | + const meta = record.payload; |
| 75 | + session.sessionId = meta.id || meta.session_id || session.sessionId; |
| 76 | + session.cwd = typeof meta.cwd === 'string' ? meta.cwd : null; |
| 77 | + session.created = meta.timestamp || record.timestamp || session.created; |
| 78 | + session.gitBranch = (meta.git && meta.git.branch) || null; |
| 79 | + session.isSubagent = isSubagentMeta(meta); |
| 80 | + session.hasMeta = true; |
| 81 | + return; |
| 82 | + } |
| 83 | + |
| 84 | + if (record.timestamp) session.lastActivity = record.timestamp; |
| 85 | + |
| 86 | + if (record.type !== 'response_item' || !record.payload) return; |
| 87 | + const payload = record.payload; |
| 88 | + if (payload.type !== 'message') return; |
| 89 | + |
| 90 | + if (payload.role === 'assistant') { |
| 91 | + session.messageCount++; |
| 92 | + } else if (payload.role === 'user') { |
| 93 | + const text = userPromptText(payload); |
| 94 | + if (!text) return; // injected context, not a turn |
| 95 | + session.messageCount++; |
| 96 | + if (!session.firstPrompt) session.firstPrompt = text; |
| 97 | + } |
| 98 | +} |
| 99 | + |
| 100 | +function emptySession(file) { |
| 101 | + // rollout-2026-09-20T15-27-49-<uuid>.jsonl — the id is the trailing UUID; |
| 102 | + // session_meta overrides it when present. |
| 103 | + const match = path.basename(file, '.jsonl').match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i); |
| 104 | + return { |
| 105 | + sessionId: match ? match[1] : null, |
| 106 | + cwd: null, |
| 107 | + firstPrompt: null, |
| 108 | + messageCount: 0, |
| 109 | + created: null, |
| 110 | + lastActivity: null, |
| 111 | + gitBranch: null, |
| 112 | + isSubagent: false, |
| 113 | + hasMeta: false |
| 114 | + }; |
| 115 | +} |
| 116 | + |
| 117 | +/** |
| 118 | + * Stream the bytes of a rollout from `start` and fold them into `session`. |
| 119 | + * With `metaOnly`, stop after the first record: enough to learn the cwd of a |
| 120 | + * rollout that may belong to another project. |
| 121 | + */ |
| 122 | +async function foldRecords(file, start, session, metaOnly) { |
| 123 | + const stream = fs.createReadStream(file, { start, encoding: 'utf8' }); |
| 124 | + const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); |
| 125 | + try { |
| 126 | + for await (const line of rl) { |
| 127 | + if (!line) continue; |
| 128 | + let record; |
| 129 | + try { |
| 130 | + record = JSON.parse(line); |
| 131 | + } catch (_) { |
| 132 | + continue; // a half-written trailing line while a session is live |
| 133 | + } |
| 134 | + applyRecord(session, record); |
| 135 | + if (metaOnly) break; |
| 136 | + } |
| 137 | + } finally { |
| 138 | + rl.close(); |
| 139 | + stream.destroy(); |
| 140 | + } |
| 141 | +} |
| 142 | + |
| 143 | +function belongsTo(cwd, projectPath) { |
| 144 | + if (!cwd) return false; |
| 145 | + const root = path.resolve(projectPath); |
| 146 | + const dir = path.resolve(cwd); |
| 147 | + // The project itself or anything inside it — Frame's own worktrees under |
| 148 | + // .frame/worktrees/ included. |
| 149 | + return dir === root || dir.startsWith(root + path.sep); |
| 150 | +} |
| 151 | + |
| 152 | +/** |
| 153 | + * Read one rollout into a session record for `projectPath`, or null when it |
| 154 | + * belongs to another project. Reuses whatever a previous scan folded in. |
| 155 | + */ |
| 156 | +async function scanRollout(file, stat, projectPath) { |
| 157 | + const cached = scanCache.get(file); |
| 158 | + |
| 159 | + // A rollout's cwd never changes: one known to be elsewhere stays elsewhere. |
| 160 | + if (cached && cached.session.hasMeta && !belongsTo(cached.session.cwd, projectPath)) { |
| 161 | + return null; |
| 162 | + } |
| 163 | + |
| 164 | + if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs && cached.offset === stat.size) { |
| 165 | + return belongsTo(cached.session.cwd, projectPath) ? { ...cached.session } : null; |
| 166 | + } |
| 167 | + |
| 168 | + let session; |
| 169 | + let start; |
| 170 | + if (cached && stat.size >= cached.offset && cached.session.hasMeta) { |
| 171 | + session = { ...cached.session }; |
| 172 | + start = cached.offset; |
| 173 | + } else { |
| 174 | + // First look (or a rewritten file): read the meta line alone first. |
| 175 | + session = emptySession(file); |
| 176 | + await foldRecords(file, 0, session, true); |
| 177 | + if (session.hasMeta && !belongsTo(session.cwd, projectPath)) { |
| 178 | + scanCache.set(file, { offset: 0, size: stat.size, mtimeMs: stat.mtimeMs, session: { ...session } }); |
| 179 | + return null; |
| 180 | + } |
| 181 | + session = emptySession(file); |
| 182 | + start = 0; |
| 183 | + } |
| 184 | + |
| 185 | + await foldRecords(file, start, session, false); |
| 186 | + scanCache.set(file, { |
| 187 | + offset: stat.size, |
| 188 | + size: stat.size, |
| 189 | + mtimeMs: stat.mtimeMs, |
| 190 | + session: { ...session } |
| 191 | + }); |
| 192 | + |
| 193 | + return belongsTo(session.cwd, projectPath) ? session : null; |
| 194 | +} |
| 195 | + |
| 196 | +/** Every rollout file under sessions/YYYY/MM/DD/. */ |
| 197 | +async function listRollouts(root) { |
| 198 | + const out = []; |
| 199 | + async function walk(dir, depth) { |
| 200 | + let entries; |
| 201 | + try { |
| 202 | + entries = await fsp.readdir(dir, { withFileTypes: true }); |
| 203 | + } catch (_) { |
| 204 | + return; |
| 205 | + } |
| 206 | + for (const entry of entries) { |
| 207 | + const full = path.join(dir, entry.name); |
| 208 | + if (entry.isDirectory() && depth < 3) { |
| 209 | + await walk(full, depth + 1); |
| 210 | + } else if (entry.isFile() && entry.name.startsWith('rollout-') && entry.name.endsWith('.jsonl')) { |
| 211 | + out.push(full); |
| 212 | + } |
| 213 | + } |
| 214 | + } |
| 215 | + await walk(root, 0); |
| 216 | + return out; |
| 217 | +} |
| 218 | + |
| 219 | +/** |
| 220 | + * Get Codex sessions for a given project path. |
| 221 | + * Returns { sessions, reason } — reason distinguishes "zero sessions" from |
| 222 | + * "there is nothing to read here", so the panel can say why it's empty. |
| 223 | + */ |
| 224 | +async function getSessionsForProject(projectPath) { |
| 225 | + if (!projectPath) return { sessions: [], reason: 'no-project' }; |
| 226 | + const root = sessionsDir(); |
| 227 | + if (!fs.existsSync(root)) return { sessions: [], reason: 'no-codex-dir' }; |
| 228 | + |
| 229 | + let files; |
| 230 | + try { |
| 231 | + files = await listRollouts(root); |
| 232 | + } catch (err) { |
| 233 | + console.error('Error reading Codex sessions directory:', err); |
| 234 | + return { sessions: [], reason: 'read-error' }; |
| 235 | + } |
| 236 | + |
| 237 | + const sessions = []; |
| 238 | + let failures = 0; |
| 239 | + |
| 240 | + for (const file of files) { |
| 241 | + try { |
| 242 | + const stat = await fsp.stat(file); |
| 243 | + const session = await scanRollout(file, stat, projectPath); |
| 244 | + if (!session || !session.sessionId) continue; |
| 245 | + if (session.isSubagent) continue; |
| 246 | + // No user turn and no reply: a session opened and abandoned. Listing it |
| 247 | + // offers a resume that resumes nothing. |
| 248 | + if (session.messageCount === 0) continue; |
| 249 | + sessions.push({ |
| 250 | + sessionId: session.sessionId, |
| 251 | + summary: null, |
| 252 | + firstPrompt: session.firstPrompt, |
| 253 | + messageCount: session.messageCount, |
| 254 | + created: session.created || stat.birthtime.toISOString(), |
| 255 | + modified: session.lastActivity || stat.mtime.toISOString(), |
| 256 | + gitBranch: session.gitBranch, |
| 257 | + isSidechain: false, |
| 258 | + // Where the session ran — a folder inside the project (a worktree) |
| 259 | + // resumes there, not at the project root. |
| 260 | + cwd: session.cwd, |
| 261 | + projectPath |
| 262 | + }); |
| 263 | + } catch (err) { |
| 264 | + failures++; |
| 265 | + console.error('Error reading Codex rollout:', file, err.message); |
| 266 | + } |
| 267 | + } |
| 268 | + |
| 269 | + if (sessions.length === 0) { |
| 270 | + return { sessions: [], reason: failures > 0 ? 'read-error' : 'no-project-sessions' }; |
| 271 | + } |
| 272 | + |
| 273 | + sessions.sort((a, b) => new Date(b.modified) - new Date(a.modified)); |
| 274 | + return { sessions, reason: null }; |
| 275 | +} |
| 276 | + |
| 277 | +/** |
| 278 | + * Setup IPC handlers |
| 279 | + */ |
| 280 | +function setupIPC(ipcMain) { |
| 281 | + ipcMain.handle(IPC.LOAD_CODEX_SESSIONS, async (event, projectPath) => { |
| 282 | + return getSessionsForProject(projectPath); |
| 283 | + }); |
| 284 | +} |
| 285 | + |
| 286 | +module.exports = { |
| 287 | + setupIPC, |
| 288 | + getSessionsForProject, |
| 289 | + // exported for tests |
| 290 | + _resetCache: () => scanCache.clear() |
| 291 | +}; |
0 commit comments