Skip to content

Commit 689a6cd

Browse files
author
Kaan Özhan
committed
feat(sessions): Codex sessions beside Claude Code's, one tab each
The Sessions panel listed only Claude Code transcripts. It now has a Claude Code | Codex tab pair (the specs filter chips, with counts; the choice is remembered). codexSessionsManager reads ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl (CODEX_HOME honoured). Rollouts are filed by date, so each one's session_meta cwd decides the project: the project or a folder inside it. Titles skip Codex's injected context, subagent threads and empty sessions are left out, and an append-only cache skips other projects' rollouts without reopening them. Resume goes through resumeAgentSession('codex'), in the session's own folder when it still exists.
1 parent ba38bb0 commit 689a6cd

8 files changed

Lines changed: 846 additions & 63 deletions

File tree

‎.frame/STRUCTURE.json‎

Lines changed: 241 additions & 30 deletions
Large diffs are not rendered by default.

‎index.html‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -594,6 +594,11 @@ <h2>Sessions</h2>
594594
</button>
595595
</div>
596596
</div>
597+
<!-- One list per CLI: Claude Code transcripts and Codex rollouts. -->
598+
<div id="sessions-tabs" class="sessions-tabs" role="tablist" aria-label="Session source">
599+
<button type="button" class="specs-filter-chip phase-implementing sessions-tab active" role="tab" data-tool="claude" aria-selected="true" tabindex="-1">Claude Code<span class="specs-filter-count" data-count-for="claude"></span></button>
600+
<button type="button" class="specs-filter-chip phase-implementing sessions-tab" role="tab" data-tool="codex" aria-selected="false" tabindex="-1">Codex<span class="specs-filter-count" data-count-for="codex"></span></button>
601+
</div>
597602
<div id="sessions-content">
598603
<!-- Sessions will be populated here -->
599604
</div>

‎src/main/codexSessionsManager.js‎

Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
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+
};

‎src/main/index.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const projectInsights = require('./projectInsights');
3434
const gitBranchesManager = require('./gitBranchesManager');
3535
const aiToolManager = require('./aiToolManager');
3636
const claudeSessionsManager = require('./claudeSessionsManager');
37+
const codexSessionsManager = require('./codexSessionsManager');
3738
const updateChecker = require('./updateChecker');
3839
const userSettings = require('./userSettings');
3940
const uiZoom = require('./uiZoom');
@@ -226,6 +227,7 @@ function setupAllIPC() {
226227
projectInsights.setupIPC(ipcMain);
227228
gitBranchesManager.setupIPC(ipcMain);
228229
claudeSessionsManager.setupIPC(ipcMain);
230+
codexSessionsManager.setupIPC(ipcMain);
229231
updateChecker.setupIPC();
230232

231233
// User settings (renderer-side preferences persisted to userData JSON)

0 commit comments

Comments
 (0)