feat: add codex-worktree session adapter

Adds the third session adapter (after dmux-tmux and claude-history),
normalizing Codex rollout sessions into the harness-neutral
ecc.session.v1 snapshot. Reads ~/.codex/sessions rollout JSONL,
derives objective (skipping the AGENTS.md preamble + leading message
UUID), model, originator, worktree cwd, and best-effort git branch.

This is step 1 of ECC-2.0-SESSION-ADAPTER-DISCOVERY (move the
abstraction beyond tmux + Claude-history) and supports the
wrap/adapt control-pane strategy: ECC reads sessions from any
harness rather than owning one UX.

- scripts/lib/session-adapters/codex-worktree.js: adapter + rollout parser
- canonical-session.js: normalizeCodexWorktreeSession
- registry.js: register adapter, codex/codex-worktree target types
- tests/lib/session-adapters-codex.test.js: 4 tests (unit + registry routing)
This commit is contained in:
Affaan Mustafa
2026-06-04 16:20:57 -04:00
parent bc8e12bb80
commit e391419026
4 changed files with 548 additions and 2 deletions

View File

@@ -520,11 +520,63 @@ function normalizeClaudeHistorySession(session, sourceTarget) {
});
}
function normalizeCodexWorktreeSession(session, sourceTarget) {
const state = session.active ? 'active' : 'recorded';
const objective = typeof session.objective === 'string' ? session.objective : '';
const worker = {
id: session.sessionId,
label: session.sessionId,
state,
health: 'healthy',
branch: session.branch || null,
worktree: session.cwd || null,
runtime: {
kind: 'codex-session',
command: 'codex',
pid: null,
active: Boolean(session.active),
dead: !session.active,
},
intent: {
objective,
seedPaths: []
},
outputs: {
summary: [],
validation: [],
remainingRisks: []
},
artifacts: {
sessionFile: session.sessionPath || null,
model: session.model || null,
originator: session.originator || null,
cliVersion: session.cliVersion || null,
startedAt: session.startedAt || null,
recordCount: Number.isInteger(session.recordCount) ? session.recordCount : null
}
};
return validateCanonicalSnapshot({
schemaVersion: SESSION_SCHEMA_VERSION,
adapterId: 'codex-worktree',
session: {
id: session.sessionId,
kind: 'codex-worktree',
state,
repoRoot: session.cwd || null,
sourceTarget
},
workers: [worker],
aggregates: buildAggregates([worker])
});
}
module.exports = {
SESSION_SCHEMA_VERSION,
buildAggregates,
getFallbackSessionRecordingPath,
normalizeClaudeHistorySession,
normalizeCodexWorktreeSession,
normalizeDmuxSnapshot,
persistCanonicalSnapshot,
validateCanonicalSnapshot

View File

@@ -0,0 +1,348 @@
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync } = require('child_process');
const { normalizeCodexWorktreeSession, persistCanonicalSnapshot } = require('./canonical-session');
const CODEX_TARGET_PREFIXES = ['codex-worktree:', 'codex:'];
const ROLLOUT_PREFIX = 'rollout-';
const RECENT_ACTIVITY_THRESHOLD_MS = 5 * 60 * 1000;
function parseCodexTarget(target) {
if (typeof target !== 'string') {
return null;
}
for (const prefix of CODEX_TARGET_PREFIXES) {
if (target.startsWith(prefix)) {
return target.slice(prefix.length).trim();
}
}
return null;
}
function resolveSessionsDir(options = {}, context = {}) {
const explicit = options.sessionsDir
|| context.codexSessionsDir
|| process.env.CODEX_SESSIONS_DIR;
if (typeof explicit === 'string' && explicit.length > 0) {
return path.resolve(explicit);
}
return path.join(os.homedir(), '.codex', 'sessions');
}
function isRolloutFile(filePath) {
const base = path.basename(filePath);
return base.startsWith(ROLLOUT_PREFIX) && base.endsWith('.jsonl');
}
function isCodexRolloutFileTarget(target, cwd) {
if (typeof target !== 'string' || target.length === 0) {
return false;
}
const absoluteTarget = path.resolve(cwd, target);
return fs.existsSync(absoluteTarget)
&& fs.statSync(absoluteTarget).isFile()
&& isRolloutFile(absoluteTarget);
}
function listRolloutFiles(sessionsDir) {
if (!fs.existsSync(sessionsDir) || !fs.statSync(sessionsDir).isDirectory()) {
return [];
}
const files = [];
const stack = [sessionsDir];
while (stack.length > 0) {
const current = stack.pop();
let entries;
try {
entries = fs.readdirSync(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const entryPath = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(entryPath);
} else if (entry.isFile() && isRolloutFile(entryPath)) {
files.push(entryPath);
}
}
}
return files;
}
function findLatestRollout(sessionsDir) {
const files = listRolloutFiles(sessionsDir);
if (files.length === 0) {
return null;
}
return files
.map(filePath => ({ filePath, mtimeMs: fs.statSync(filePath).mtimeMs }))
.sort((a, b) => b.mtimeMs - a.mtimeMs)[0].filePath;
}
function findRolloutById(sessionsDir, sessionId) {
return listRolloutFiles(sessionsDir)
.find(filePath => path.basename(filePath).includes(sessionId)) || null;
}
function resolveRolloutPath(target, cwd, options, context) {
const explicitTarget = parseCodexTarget(target);
const sessionsDir = resolveSessionsDir(options, context);
if (explicitTarget) {
if (explicitTarget === 'latest') {
const latest = findLatestRollout(sessionsDir);
if (!latest) {
throw new Error('No Codex rollout sessions found');
}
return { rolloutPath: latest, sourceTarget: { type: 'codex-worktree', value: 'latest' } };
}
const absoluteExplicit = path.resolve(cwd, explicitTarget);
if (fs.existsSync(absoluteExplicit) && isRolloutFile(absoluteExplicit)) {
return { rolloutPath: absoluteExplicit, sourceTarget: { type: 'codex-rollout-file', value: absoluteExplicit } };
}
const byId = findRolloutById(sessionsDir, explicitTarget);
if (byId) {
return { rolloutPath: byId, sourceTarget: { type: 'codex-worktree', value: explicitTarget } };
}
throw new Error(`Codex rollout session not found: ${explicitTarget}`);
}
if (isCodexRolloutFileTarget(target, cwd)) {
const absoluteTarget = path.resolve(cwd, target);
return { rolloutPath: absoluteTarget, sourceTarget: { type: 'codex-rollout-file', value: absoluteTarget } };
}
throw new Error(`Unsupported Codex session target: ${target}`);
}
function readJsonLines(filePath) {
const raw = fs.readFileSync(filePath, 'utf8');
const records = [];
for (const line of raw.split('\n')) {
const trimmed = line.trim();
if (trimmed.length === 0) {
continue;
}
try {
records.push(JSON.parse(trimmed));
} catch {
// Rollout logs are append-only; skip partial/corrupt trailing lines.
}
}
return records;
}
function extractText(content) {
if (typeof content === 'string') {
return content;
}
if (Array.isArray(content)) {
return content
.map(part => (part && typeof part.text === 'string' ? part.text : ''))
.join('')
.trim();
}
return '';
}
function stripLeadingMessageId(text) {
// Codex rollouts sometimes prepend a message UUID directly onto the user
// text (e.g. "019e52db-...please continue"). Drop it for a clean objective.
return text.replace(/^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}/i, '').trim();
}
function isPreambleText(text) {
// The first user record in a Codex rollout is the injected harness preamble
// (AGENTS.md / environment context), not the operator's actual objective.
return text.startsWith('#')
|| text.startsWith('<')
|| text.includes('<cwd>')
|| text.includes('AGENTS.md instructions');
}
function deriveObjective(records) {
for (const record of records) {
const payload = record && record.payload;
if (!payload || payload.type !== 'message' || payload.role !== 'user') {
continue;
}
const text = stripLeadingMessageId(extractText(payload.content).trim());
if (text.length === 0 || isPreambleText(text)) {
continue;
}
return text.length > 280 ? `${text.slice(0, 277)}...` : text;
}
return '';
}
function recordTimestampMs(record) {
const ts = record && record.timestamp;
if (typeof ts !== 'string') {
return null;
}
const ms = Date.parse(ts);
return Number.isNaN(ms) ? null : ms;
}
function deriveLastActivityMs(records, fallbackPath) {
for (let index = records.length - 1; index >= 0; index -= 1) {
const ms = recordTimestampMs(records[index]);
if (ms !== null) {
return ms;
}
}
try {
return fs.statSync(fallbackPath).mtimeMs;
} catch {
return null;
}
}
function deriveModel(meta, records) {
for (const record of records) {
if (record && record.type === 'turn_context' && record.payload) {
if (typeof record.payload.model === 'string' && record.payload.model.length > 0) {
return record.payload.model;
}
}
}
if (meta && typeof meta.model === 'string' && meta.model.length > 0) {
return meta.model;
}
if (meta && typeof meta.model_provider === 'string' && meta.model_provider.length > 0) {
return meta.model_provider;
}
return null;
}
function resolveGitBranch(cwd, resolveBranchImpl) {
if (typeof resolveBranchImpl === 'function') {
return resolveBranchImpl(cwd);
}
if (typeof cwd !== 'string' || cwd.length === 0 || !fs.existsSync(cwd)) {
return null;
}
try {
const branch = execFileSync('git', ['-C', cwd, 'rev-parse', '--abbrev-ref', 'HEAD'], {
stdio: ['ignore', 'pipe', 'ignore'],
encoding: 'utf8'
}).trim();
return branch.length > 0 ? branch : null;
} catch {
return null;
}
}
function parseCodexRollout(rolloutPath, options = {}) {
const records = readJsonLines(rolloutPath);
const metaRecord = records.find(record => record && record.type === 'session_meta');
const meta = (metaRecord && metaRecord.payload) || {};
const cwd = typeof meta.cwd === 'string' && meta.cwd.length > 0 ? meta.cwd : null;
const lastActivityMs = deriveLastActivityMs(records, rolloutPath);
const isRecent = lastActivityMs !== null && (Date.now() - lastActivityMs) <= RECENT_ACTIVITY_THRESHOLD_MS;
return {
sessionId: typeof meta.id === 'string' && meta.id.length > 0
? meta.id
: path.basename(rolloutPath, '.jsonl'),
sessionPath: rolloutPath,
cwd,
branch: resolveGitBranch(cwd, options.resolveBranchImpl),
objective: deriveObjective(records),
model: deriveModel(meta, records),
originator: typeof meta.originator === 'string' ? meta.originator : null,
cliVersion: typeof meta.cli_version === 'string' ? meta.cli_version : null,
startedAt: typeof meta.timestamp === 'string' ? meta.timestamp : null,
recordCount: records.length,
active: isRecent
};
}
function createCodexWorktreeAdapter(options = {}) {
const parseCodexRolloutImpl = options.parseCodexRolloutImpl || parseCodexRollout;
const persistCanonicalSnapshotImpl = options.persistCanonicalSnapshotImpl || persistCanonicalSnapshot;
return {
id: 'codex-worktree',
description: 'Codex rollout sessions running in git worktrees, normalized to ecc.session.v1',
targetTypes: ['codex-worktree', 'codex'],
canOpen(target, context = {}) {
if (context.adapterId && context.adapterId !== 'codex-worktree') {
return false;
}
if (context.adapterId === 'codex-worktree') {
return true;
}
const cwd = context.cwd || process.cwd();
return parseCodexTarget(target) !== null || isCodexRolloutFileTarget(target, cwd);
},
open(target, context = {}) {
const cwd = context.cwd || process.cwd();
return {
adapterId: 'codex-worktree',
getSnapshot() {
const { rolloutPath, sourceTarget } = resolveRolloutPath(target, cwd, options, context);
const session = parseCodexRolloutImpl(rolloutPath, options);
const canonicalSnapshot = normalizeCodexWorktreeSession(session, sourceTarget);
persistCanonicalSnapshotImpl(canonicalSnapshot, {
loadStateStoreImpl: options.loadStateStoreImpl,
persist: context.persistSnapshots !== false && options.persistSnapshots !== false,
recordingDir: context.recordingDir || options.recordingDir,
stateStore: options.stateStore
});
return canonicalSnapshot;
}
};
}
};
}
module.exports = {
createCodexWorktreeAdapter,
parseCodexTarget,
parseCodexRollout,
isCodexRolloutFileTarget,
findLatestRollout,
findRolloutById
};

View File

@@ -2,13 +2,16 @@
const { createClaudeHistoryAdapter } = require('./claude-history');
const { createDmuxTmuxAdapter } = require('./dmux-tmux');
const { createCodexWorktreeAdapter } = require('./codex-worktree');
const TARGET_TYPE_TO_ADAPTER_ID = Object.freeze({
plan: 'dmux-tmux',
session: 'dmux-tmux',
'claude-history': 'claude-history',
'claude-alias': 'claude-history',
'session-file': 'claude-history'
'session-file': 'claude-history',
'codex-worktree': 'codex-worktree',
codex: 'codex-worktree'
});
function buildDefaultAdapterOptions(options, adapterId) {
@@ -30,7 +33,8 @@ function buildDefaultAdapterOptions(options, adapterId) {
function createDefaultAdapters(options = {}) {
return [
createClaudeHistoryAdapter(buildDefaultAdapterOptions(options, 'claude-history')),
createDmuxTmuxAdapter(buildDefaultAdapterOptions(options, 'dmux-tmux'))
createDmuxTmuxAdapter(buildDefaultAdapterOptions(options, 'dmux-tmux')),
createCodexWorktreeAdapter(buildDefaultAdapterOptions(options, 'codex-worktree'))
];
}
@@ -69,6 +73,13 @@ function normalizeStructuredTarget(target, context = {}) {
};
}
if (type === 'codex-worktree' || type === 'codex') {
return {
target: `codex:${value}`,
context: nextContext
};
}
return {
target: value,
context: nextContext

View File

@@ -0,0 +1,135 @@
'use strict';
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const {
createCodexWorktreeAdapter,
parseCodexTarget,
findLatestRollout
} = require('../../scripts/lib/session-adapters/codex-worktree');
const {
normalizeCodexWorktreeSession,
validateCanonicalSnapshot
} = require('../../scripts/lib/session-adapters/canonical-session');
const { createAdapterRegistry } = require('../../scripts/lib/session-adapters/registry');
console.log('=== Testing codex-worktree session adapter ===\n');
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
passed += 1;
console.log(` ok - ${name}`);
} catch (error) {
failed += 1;
console.log(` FAIL - ${name}`);
console.log(` ${error && error.message}`);
}
}
function writeRolloutFixture() {
const sessionsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-codex-sessions-'));
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-codex-worktree-'));
const dayDir = path.join(sessionsDir, '2026', '06', '02');
fs.mkdirSync(dayDir, { recursive: true });
const now = new Date().toISOString();
const rolloutPath = path.join(dayDir, 'rollout-2026-06-02T03-01-58-019etest-codex-0001.jsonl');
const lines = [
{ type: 'session_meta', timestamp: now, payload: {
id: '019etest-codex-0001', timestamp: now, cwd: repoRoot,
originator: 'Codex Desktop', cli_version: '0.136.0', source: 'vscode', model_provider: 'openai'
} },
{ type: 'turn_context', timestamp: now, payload: { model: 'gpt-5.5-codex' } },
{ type: 'response_item', timestamp: now, payload: {
type: 'message', role: 'user',
content: [{ type: 'text', text: '# AGENTS.md instructions for /repo\n<cwd>/repo</cwd>' }]
} },
{ type: 'response_item', timestamp: now, payload: {
type: 'message', role: 'user',
content: [{ type: 'text', text: 'continue our ecc 2.0 session and build the codex-worktree adapter' }]
} }
];
fs.writeFileSync(rolloutPath, lines.map(line => JSON.stringify(line)).join('\n') + '\n', 'utf8');
return { sessionsDir, repoRoot, rolloutPath };
}
test('normalizeCodexWorktreeSession produces a valid ecc.session.v1 snapshot', () => {
const snapshot = normalizeCodexWorktreeSession({
sessionId: 'abc', sessionPath: '/tmp/r.jsonl', cwd: '/repo', branch: 'feat/x',
objective: 'do the thing', model: 'gpt-5.5-codex', originator: 'Codex Desktop',
cliVersion: '0.136.0', startedAt: '2026-06-02T03:01:58Z', recordCount: 4, active: true
}, { type: 'codex-worktree', value: 'abc' });
validateCanonicalSnapshot(snapshot);
assert.strictEqual(snapshot.adapterId, 'codex-worktree');
assert.strictEqual(snapshot.session.kind, 'codex-worktree');
assert.strictEqual(snapshot.session.state, 'active');
assert.strictEqual(snapshot.workers[0].runtime.kind, 'codex-session');
assert.strictEqual(snapshot.workers[0].branch, 'feat/x');
assert.strictEqual(snapshot.workers[0].artifacts.model, 'gpt-5.5-codex');
});
test('parseCodexTarget strips codex prefixes', () => {
assert.strictEqual(parseCodexTarget('codex:latest'), 'latest');
assert.strictEqual(parseCodexTarget('codex-worktree:019eabc'), '019eabc');
assert.strictEqual(parseCodexTarget('/some/path.jsonl'), null);
});
test('adapter reads latest rollout, skips preamble, derives objective + model', () => {
const { sessionsDir, repoRoot, rolloutPath } = writeRolloutFixture();
const recordingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-codex-rec-'));
assert.strictEqual(findLatestRollout(sessionsDir), rolloutPath);
const adapter = createCodexWorktreeAdapter({
sessionsDir, recordingDir, loadStateStoreImpl: () => null, resolveBranchImpl: () => null
});
const snapshot = adapter.open('codex:latest', { cwd: repoRoot }).getSnapshot();
assert.strictEqual(snapshot.adapterId, 'codex-worktree');
assert.strictEqual(snapshot.session.id, '019etest-codex-0001');
assert.strictEqual(snapshot.session.state, 'active');
assert.strictEqual(snapshot.workers.length, 1);
assert.strictEqual(snapshot.workers[0].worktree, repoRoot);
assert.strictEqual(snapshot.workers[0].runtime.command, 'codex');
assert.strictEqual(snapshot.workers[0].runtime.active, true);
assert.strictEqual(snapshot.workers[0].artifacts.model, 'gpt-5.5-codex');
assert.strictEqual(
snapshot.workers[0].intent.objective,
'continue our ecc 2.0 session and build the codex-worktree adapter'
);
assert.strictEqual(snapshot.aggregates.workerCount, 1);
assert.strictEqual(snapshot.aggregates.states.active, 1);
});
test('registry routes structured codex-worktree target and direct rollout path', () => {
const { sessionsDir, repoRoot, rolloutPath } = writeRolloutFixture();
const recordingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-codex-reg-'));
const registry = createAdapterRegistry({
recordingDir,
loadStateStoreImpl: () => null,
adapterOptions: { 'codex-worktree': { sessionsDir, resolveBranchImpl: () => null } }
});
const typed = registry.open({ type: 'codex-worktree', value: 'latest' }, { cwd: repoRoot }).getSnapshot();
assert.strictEqual(typed.adapterId, 'codex-worktree');
assert.strictEqual(typed.session.id, '019etest-codex-0001');
const byPath = registry.open(rolloutPath, { cwd: repoRoot }).getSnapshot();
assert.strictEqual(byPath.adapterId, 'codex-worktree');
const listed = registry.listAdapters().map(a => a.id);
assert.ok(listed.includes('codex-worktree'), 'registry lists codex-worktree adapter');
});
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
if (failed > 0) process.exit(1);