diff --git a/scripts/lib/session-adapters/canonical-session.js b/scripts/lib/session-adapters/canonical-session.js index 4e0fe4f5..0ef73efd 100644 --- a/scripts/lib/session-adapters/canonical-session.js +++ b/scripts/lib/session-adapters/canonical-session.js @@ -36,6 +36,12 @@ function ensureString(value, fieldPath) { } } +function ensureStringAllowEmpty(value, fieldPath) { + if (typeof value !== 'string') { + throw new Error(`Canonical session snapshot requires ${fieldPath} to be a string`); + } +} + function ensureOptionalString(value, fieldPath) { if (value !== null && value !== undefined && typeof value !== 'string') { throw new Error(`Canonical session snapshot requires ${fieldPath} to be a string or null`); @@ -210,7 +216,7 @@ function validateCanonicalSnapshot(snapshot) { throw new Error(`Canonical session snapshot requires workers[${index}].intent to be an object`); } - ensureString(worker.intent.objective, `workers[${index}].intent.objective`); + ensureStringAllowEmpty(worker.intent.objective, `workers[${index}].intent.objective`); ensureArrayOfStrings(worker.intent.seedPaths, `workers[${index}].intent.seedPaths`); if (!isObject(worker.outputs)) { @@ -520,12 +526,119 @@ 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]) + }); +} + +function normalizeOpencodeSession(session, sourceTarget) { + const state = session.active ? 'active' : 'recorded'; + const objective = typeof session.objective === 'string' ? session.objective : ''; + const worker = { + id: session.sessionId, + label: session.title || session.sessionId, + state, + health: 'healthy', + branch: session.branch || null, + worktree: session.cwd || null, + runtime: { + kind: 'opencode-session', + command: 'opencode', + pid: null, + active: Boolean(session.active), + dead: !session.active, + }, + intent: { + objective, + seedPaths: [] + }, + outputs: { + summary: [], + validation: [], + remainingRisks: [] + }, + artifacts: { + sessionFile: session.sessionPath || null, + projectId: session.projectId || null, + version: session.version || null, + model: session.model || null, + provider: session.provider || null, + title: session.title || null, + createdAt: session.createdAt || null, + updatedAt: session.updatedAt || null, + messageCount: Number.isInteger(session.messageCount) ? session.messageCount : null + } + }; + + return validateCanonicalSnapshot({ + schemaVersion: SESSION_SCHEMA_VERSION, + adapterId: 'opencode', + session: { + id: session.sessionId, + kind: 'opencode', + state, + repoRoot: session.cwd || null, + sourceTarget + }, + workers: [worker], + aggregates: buildAggregates([worker]) + }); +} + module.exports = { SESSION_SCHEMA_VERSION, buildAggregates, getFallbackSessionRecordingPath, normalizeClaudeHistorySession, + normalizeCodexWorktreeSession, normalizeDmuxSnapshot, + normalizeOpencodeSession, persistCanonicalSnapshot, validateCanonicalSnapshot }; diff --git a/scripts/lib/session-adapters/codex-worktree.js b/scripts/lib/session-adapters/codex-worktree.js new file mode 100644 index 00000000..8b66d4d2 --- /dev/null +++ b/scripts/lib/session-adapters/codex-worktree.js @@ -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('') + || 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 +}; diff --git a/scripts/lib/session-adapters/opencode.js b/scripts/lib/session-adapters/opencode.js new file mode 100644 index 00000000..58f19fb5 --- /dev/null +++ b/scripts/lib/session-adapters/opencode.js @@ -0,0 +1,312 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +const { normalizeOpencodeSession, persistCanonicalSnapshot } = require('./canonical-session'); + +const OPENCODE_TARGET_PREFIXES = ['opencode:']; +const RECENT_ACTIVITY_THRESHOLD_MS = 5 * 60 * 1000; +const MAX_MESSAGE_SCAN = 40; + +function parseOpencodeTarget(target) { + if (typeof target !== 'string') { + return null; + } + + for (const prefix of OPENCODE_TARGET_PREFIXES) { + if (target.startsWith(prefix)) { + return target.slice(prefix.length).trim(); + } + } + + return null; +} + +function resolveStorageDir(options = {}, context = {}) { + const explicit = options.storageDir + || context.opencodeStorageDir + || process.env.OPENCODE_STORAGE_DIR; + + if (typeof explicit === 'string' && explicit.length > 0) { + return path.resolve(explicit); + } + + return path.join(os.homedir(), '.local', 'share', 'opencode', 'storage'); +} + +function isSessionInfoFile(filePath) { + const base = path.basename(filePath); + return base.startsWith('ses_') && base.endsWith('.json'); +} + +function isOpencodeSessionFileTarget(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() + && isSessionInfoFile(absoluteTarget) + && `${path.sep}session${path.sep}`.length > 0 + && absoluteTarget.includes(`${path.sep}session${path.sep}`); +} + +function listSessionInfoFiles(storageDir) { + const sessionDir = path.join(storageDir, 'session'); + if (!fs.existsSync(sessionDir) || !fs.statSync(sessionDir).isDirectory()) { + return []; + } + + const files = []; + const stack = [sessionDir]; + + 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() && isSessionInfoFile(entryPath)) { + files.push(entryPath); + } + } + } + + return files; +} + +function readSessionUpdatedMs(filePath) { + try { + const info = JSON.parse(fs.readFileSync(filePath, 'utf8')); + if (info && info.time && Number.isFinite(info.time.updated)) { + return info.time.updated; + } + } catch { + // fall through to file mtime + } + + try { + return fs.statSync(filePath).mtimeMs; + } catch { + return 0; + } +} + +function findLatestSessionInfo(storageDir) { + const files = listSessionInfoFiles(storageDir); + if (files.length === 0) { + return null; + } + + return files + .map(filePath => ({ filePath, updatedMs: readSessionUpdatedMs(filePath) })) + .sort((a, b) => b.updatedMs - a.updatedMs)[0].filePath; +} + +function findSessionInfoById(storageDir, sessionId) { + return listSessionInfoFiles(storageDir) + .find(filePath => path.basename(filePath, '.json') === sessionId) || null; +} + +function resolveSessionInfoPath(target, cwd, options, context) { + const explicitTarget = parseOpencodeTarget(target); + const storageDir = resolveStorageDir(options, context); + + if (explicitTarget) { + if (explicitTarget === 'latest') { + const latest = findLatestSessionInfo(storageDir); + if (!latest) { + throw new Error('No OpenCode sessions found'); + } + + return { sessionInfoPath: latest, sourceTarget: { type: 'opencode', value: 'latest' } }; + } + + const absoluteExplicit = path.resolve(cwd, explicitTarget); + if (fs.existsSync(absoluteExplicit) && isSessionInfoFile(absoluteExplicit)) { + return { sessionInfoPath: absoluteExplicit, sourceTarget: { type: 'opencode-session-file', value: absoluteExplicit } }; + } + + const byId = findSessionInfoById(storageDir, explicitTarget); + if (byId) { + return { sessionInfoPath: byId, sourceTarget: { type: 'opencode', value: explicitTarget } }; + } + + throw new Error(`OpenCode session not found: ${explicitTarget}`); + } + + if (isOpencodeSessionFileTarget(target, cwd)) { + const absoluteTarget = path.resolve(cwd, target); + return { sessionInfoPath: absoluteTarget, sourceTarget: { type: 'opencode-session-file', value: absoluteTarget } }; + } + + throw new Error(`Unsupported OpenCode session target: ${target}`); +} + +function readMessageFiles(messageDir) { + if (!fs.existsSync(messageDir) || !fs.statSync(messageDir).isDirectory()) { + return []; + } + + try { + return fs.readdirSync(messageDir) + .filter(name => name.startsWith('msg_') && name.endsWith('.json')) + .map(name => path.join(messageDir, name)); + } catch { + return []; + } +} + +function deriveModelFromMessages(messageFiles) { + for (const filePath of messageFiles.slice(0, MAX_MESSAGE_SCAN)) { + let message; + try { + message = JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch { + continue; + } + + if (message && message.role === 'assistant' && typeof message.modelID === 'string' && message.modelID.length > 0) { + return { + model: message.modelID, + provider: typeof message.providerID === 'string' ? message.providerID : null + }; + } + } + + return { model: null, provider: null }; +} + +function deriveObjective(title) { + if (typeof title !== 'string') { + return ''; + } + + const trimmed = title.trim(); + // OpenCode seeds an auto title ("New session - ") until the model + // renames it; treat that as no objective rather than noise. + if (trimmed.length === 0 || /^New session\b/i.test(trimmed)) { + return ''; + } + + return trimmed.length > 280 ? `${trimmed.slice(0, 277)}...` : trimmed; +} + +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 parseOpencodeSession(sessionInfoPath, options = {}) { + const storageDir = options.storageDir + ? path.resolve(options.storageDir) + : path.resolve(path.dirname(sessionInfoPath), '..', '..'); + const info = JSON.parse(fs.readFileSync(sessionInfoPath, 'utf8')); + + const sessionId = typeof info.id === 'string' && info.id.length > 0 + ? info.id + : path.basename(sessionInfoPath, '.json'); + const directory = typeof info.directory === 'string' && info.directory.length > 0 ? info.directory : null; + const updatedMs = info.time && Number.isFinite(info.time.updated) ? info.time.updated : null; + const createdMs = info.time && Number.isFinite(info.time.created) ? info.time.created : null; + + const messageFiles = readMessageFiles(path.join(storageDir, 'message', sessionId)); + const { model, provider } = deriveModelFromMessages(messageFiles); + + return { + sessionId, + sessionPath: sessionInfoPath, + cwd: directory, + branch: resolveGitBranch(directory, options.resolveBranchImpl), + objective: deriveObjective(info.title), + title: typeof info.title === 'string' ? info.title : null, + model, + provider, + version: typeof info.version === 'string' ? info.version : null, + projectId: typeof info.projectID === 'string' ? info.projectID : null, + createdAt: createdMs !== null ? new Date(createdMs).toISOString() : null, + updatedAt: updatedMs !== null ? new Date(updatedMs).toISOString() : null, + messageCount: messageFiles.length, + active: updatedMs !== null && (Date.now() - updatedMs) <= RECENT_ACTIVITY_THRESHOLD_MS + }; +} + +function createOpencodeAdapter(options = {}) { + const parseOpencodeSessionImpl = options.parseOpencodeSessionImpl || parseOpencodeSession; + const persistCanonicalSnapshotImpl = options.persistCanonicalSnapshotImpl || persistCanonicalSnapshot; + + return { + id: 'opencode', + description: 'OpenCode sessions normalized to ecc.session.v1', + targetTypes: ['opencode'], + canOpen(target, context = {}) { + if (context.adapterId && context.adapterId !== 'opencode') { + return false; + } + + if (context.adapterId === 'opencode') { + return true; + } + + const cwd = context.cwd || process.cwd(); + return parseOpencodeTarget(target) !== null || isOpencodeSessionFileTarget(target, cwd); + }, + open(target, context = {}) { + const cwd = context.cwd || process.cwd(); + + return { + adapterId: 'opencode', + getSnapshot() { + const { sessionInfoPath, sourceTarget } = resolveSessionInfoPath(target, cwd, options, context); + const session = parseOpencodeSessionImpl(sessionInfoPath, options); + const canonicalSnapshot = normalizeOpencodeSession(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 = { + createOpencodeAdapter, + parseOpencodeTarget, + parseOpencodeSession, + isOpencodeSessionFileTarget, + findLatestSessionInfo, + findSessionInfoById +}; diff --git a/scripts/lib/session-adapters/registry.js b/scripts/lib/session-adapters/registry.js index 28bc9298..bac7434f 100644 --- a/scripts/lib/session-adapters/registry.js +++ b/scripts/lib/session-adapters/registry.js @@ -2,13 +2,18 @@ const { createClaudeHistoryAdapter } = require('./claude-history'); const { createDmuxTmuxAdapter } = require('./dmux-tmux'); +const { createCodexWorktreeAdapter } = require('./codex-worktree'); +const { createOpencodeAdapter } = require('./opencode'); 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', + opencode: 'opencode' }); function buildDefaultAdapterOptions(options, adapterId) { @@ -30,7 +35,9 @@ 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')), + createOpencodeAdapter(buildDefaultAdapterOptions(options, 'opencode')) ]; } @@ -69,6 +76,20 @@ function normalizeStructuredTarget(target, context = {}) { }; } + if (type === 'codex-worktree' || type === 'codex') { + return { + target: `codex:${value}`, + context: nextContext + }; + } + + if (type === 'opencode') { + return { + target: `opencode:${value}`, + context: nextContext + }; + } + return { target: value, context: nextContext diff --git a/tests/lib/session-adapters-codex.test.js b/tests/lib/session-adapters-codex.test.js new file mode 100644 index 00000000..584af325 --- /dev/null +++ b/tests/lib/session-adapters-codex.test.js @@ -0,0 +1,208 @@ +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + createCodexWorktreeAdapter, + parseCodexTarget, + parseCodexRollout, + isCodexRolloutFileTarget, + findLatestRollout, + findRolloutById +} = 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/repo' }] + } }, + { 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'); +}); + + +// --- branch/error coverage --- + +function writeRollout(dir, name, lines) { + const fp = require('path').join(dir, name); + require('fs').writeFileSync(fp, lines.map(l => JSON.stringify(l)).join('\n') + '\n', 'utf8'); + return fp; +} + +test('parseCodexTarget handles non-string and unprefixed input', () => { + assert.strictEqual(parseCodexTarget(null), null); + assert.strictEqual(parseCodexTarget(42), null); + assert.strictEqual(parseCodexTarget('/abs/path.jsonl'), null); +}); + +test('adapter throws clear errors for missing sessions and unknown ids', () => { + const sessionsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-codex-empty-')); + const adapter = createCodexWorktreeAdapter({ sessionsDir, loadStateStoreImpl: () => null }); + assert.throws(() => adapter.open('codex:latest', { cwd: os.tmpdir() }).getSnapshot(), /No Codex rollout sessions found/); + assert.throws(() => adapter.open('codex:nope-not-real', { cwd: os.tmpdir() }).getSnapshot(), /not found/); + assert.throws(() => adapter.open('/not/a/rollout.txt', { cwd: os.tmpdir() }).getSnapshot(), /Unsupported Codex session target/); +}); + +test('findRolloutById + direct file target + isCodexRolloutFileTarget', () => { + const sessionsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-codex-byid-')); + const day = path.join(sessionsDir, '2026', '06', '02'); + fs.mkdirSync(day, { recursive: true }); + const now = new Date().toISOString(); + const fp = writeRollout(day, 'rollout-2026-06-02T03-00-00-019eUNIQUEID0001.jsonl', [ + { type: 'session_meta', timestamp: now, payload: { id: '019eUNIQUEID0001', cwd: sessionsDir } } + ]); + + assert.strictEqual(findRolloutById(sessionsDir, '019eUNIQUEID0001'), fp); + assert.ok(isCodexRolloutFileTarget(fp, os.tmpdir())); + assert.ok(!isCodexRolloutFileTarget('not-a-file.jsonl', os.tmpdir())); + + const adapter = createCodexWorktreeAdapter({ sessionsDir, loadStateStoreImpl: () => null, resolveBranchImpl: () => null }); + const byId = adapter.open('codex:019eUNIQUEID0001', { cwd: os.tmpdir() }).getSnapshot(); + assert.strictEqual(byId.session.id, '019eUNIQUEID0001'); + const byFile = adapter.open(fp, { cwd: os.tmpdir() }).getSnapshot(); + assert.strictEqual(byFile.session.id, '019eUNIQUEID0001'); +}); + +test('parseCodexRollout: model fallbacks, objective truncation, corrupt-line skip, mtime fallback', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-codex-parse-')); + const longObjective = 'x'.repeat(400); + const fp = path.join(dir, 'rollout-2026-06-02T03-00-00-019eMODELFALL0002.jsonl'); + // include a corrupt line, no turn_context (force meta.model_provider fallback), no timestamps (force mtime) + fs.writeFileSync(fp, [ + JSON.stringify({ type: 'session_meta', payload: { id: '019eMODELFALL0002', cwd: dir, model_provider: 'openai' } }), + '{ this is corrupt json', + JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'user', content: [{ type: 'text', text: longObjective }] } }) + ].join('\n') + '\n', 'utf8'); + + const parsed = parseCodexRollout(fp, { resolveBranchImpl: () => null }); + assert.strictEqual(parsed.model, 'openai', 'falls back to model_provider when no turn_context/model'); + assert.ok(parsed.objective.endsWith('...'), 'long objective is truncated'); + assert.ok(parsed.objective.length <= 280); + assert.strictEqual(parsed.active, true, 'no record timestamps => falls back to (recent) file mtime'); +}); + +test('resolveGitBranch returns null when cwd is not a git repo (real path)', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-codex-nogit-')); + const fp = path.join(dir, 'rollout-2026-06-02T03-00-00-019eNOGIT00003.jsonl'); + fs.writeFileSync(fp, JSON.stringify({ type: 'session_meta', payload: { id: '019eNOGIT00003', cwd: dir } }) + '\n', 'utf8'); + // no resolveBranchImpl => exercises the real execFileSync + catch path + const parsed = parseCodexRollout(fp, {}); + assert.strictEqual(parsed.branch, null); +}); + +console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`); +if (failed > 0) process.exit(1); diff --git a/tests/lib/session-adapters-opencode.test.js b/tests/lib/session-adapters-opencode.test.js new file mode 100644 index 00000000..edd9bc05 --- /dev/null +++ b/tests/lib/session-adapters-opencode.test.js @@ -0,0 +1,219 @@ +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + createOpencodeAdapter, + parseOpencodeTarget, + parseOpencodeSession, + isOpencodeSessionFileTarget, + findLatestSessionInfo, + findSessionInfoById +} = require('../../scripts/lib/session-adapters/opencode'); +const { + normalizeOpencodeSession, + validateCanonicalSnapshot +} = require('../../scripts/lib/session-adapters/canonical-session'); +const { createAdapterRegistry } = require('../../scripts/lib/session-adapters/registry'); + +console.log('=== Testing opencode 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 writeOpencodeFixture({ title = 'rebuild the basket trader rebalancer', updatedAgoMs = 0 } = {}) { + const storageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-opencode-store-')); + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-opencode-repo-')); + const projectHash = 'b43c6d2f5bbf6e71bc3d139c1656bf3afe1935aa'; + const sessionId = 'ses_66d5468bdffeVlx1Hy2KkdIshB'; + + const sessionDir = path.join(storageDir, 'session', projectHash); + const messageDir = path.join(storageDir, 'message', sessionId); + fs.mkdirSync(sessionDir, { recursive: true }); + fs.mkdirSync(messageDir, { recursive: true }); + + const updated = Date.now() - updatedAgoMs; + fs.writeFileSync(path.join(sessionDir, `${sessionId}.json`), JSON.stringify({ + id: sessionId, + version: '0.12.1', + projectID: projectHash, + directory: repoRoot, + title, + time: { created: updated - 10000, updated } + }), 'utf8'); + + // one user message + one assistant message carrying the model + fs.writeFileSync(path.join(messageDir, 'msg_user01.json'), JSON.stringify({ + id: 'msg_user01', sessionID: sessionId, role: 'user', time: { created: updated - 9000 } + }), 'utf8'); + fs.writeFileSync(path.join(messageDir, 'msg_asst01.json'), JSON.stringify({ + id: 'msg_asst01', sessionID: sessionId, role: 'assistant', + time: { created: updated - 8000, completed: updated - 7000 }, + modelID: 'claude-sonnet-4-5-20250929', providerID: 'anthropic' + }), 'utf8'); + + return { storageDir, repoRoot, sessionId, sessionInfoPath: path.join(sessionDir, `${sessionId}.json`) }; +} + +test('normalizeOpencodeSession produces a valid ecc.session.v1 snapshot', () => { + const snapshot = normalizeOpencodeSession({ + sessionId: 'ses_x', sessionPath: '/tmp/s.json', cwd: '/repo', branch: 'main', + objective: 'do the thing', title: 'do the thing', model: 'claude-sonnet-4-5-20250929', + provider: 'anthropic', version: '0.12.1', projectId: 'proj', createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:05:00Z', messageCount: 2, active: false + }, { type: 'opencode', value: 'ses_x' }); + + validateCanonicalSnapshot(snapshot); + assert.strictEqual(snapshot.adapterId, 'opencode'); + assert.strictEqual(snapshot.session.kind, 'opencode'); + assert.strictEqual(snapshot.workers[0].runtime.kind, 'opencode-session'); + assert.strictEqual(snapshot.workers[0].artifacts.provider, 'anthropic'); +}); + +test('parseOpencodeTarget strips the opencode prefix', () => { + assert.strictEqual(parseOpencodeTarget('opencode:latest'), 'latest'); + assert.strictEqual(parseOpencodeTarget('opencode:ses_abc'), 'ses_abc'); + assert.strictEqual(parseOpencodeTarget('codex:latest'), null); +}); + +test('adapter reads latest session, extracts model from messages, derives objective from title', () => { + const { storageDir, repoRoot, sessionInfoPath } = writeOpencodeFixture({ updatedAgoMs: 0 }); + const recordingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-opencode-rec-')); + + assert.strictEqual(findLatestSessionInfo(storageDir), sessionInfoPath); + + const adapter = createOpencodeAdapter({ + storageDir, recordingDir, loadStateStoreImpl: () => null, resolveBranchImpl: () => 'main' + }); + const snapshot = adapter.open('opencode:latest', { cwd: repoRoot }).getSnapshot(); + + assert.strictEqual(snapshot.adapterId, 'opencode'); + assert.strictEqual(snapshot.session.state, 'active'); + assert.strictEqual(snapshot.workers[0].worktree, repoRoot); + assert.strictEqual(snapshot.workers[0].branch, 'main'); + assert.strictEqual(snapshot.workers[0].artifacts.model, 'claude-sonnet-4-5-20250929'); + assert.strictEqual(snapshot.workers[0].artifacts.provider, 'anthropic'); + assert.strictEqual(snapshot.workers[0].artifacts.messageCount, 2); + assert.strictEqual(snapshot.workers[0].intent.objective, 'rebuild the basket trader rebalancer'); +}); + +test('auto-title "New session - ..." yields empty objective; stale session is recorded', () => { + const { storageDir, repoRoot } = writeOpencodeFixture({ + title: 'New session - 2025-09-28T23:32:22.978Z', + updatedAgoMs: 60 * 60 * 1000 + }); + const recordingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-opencode-rec2-')); + + const adapter = createOpencodeAdapter({ + storageDir, recordingDir, loadStateStoreImpl: () => null, resolveBranchImpl: () => null + }); + const snapshot = adapter.open('opencode:latest', { cwd: repoRoot }).getSnapshot(); + + assert.strictEqual(snapshot.workers[0].intent.objective, ''); + assert.strictEqual(snapshot.session.state, 'recorded'); + assert.strictEqual(snapshot.workers[0].runtime.dead, true); +}); + +test('registry routes structured opencode target and lists the adapter', () => { + const { storageDir, repoRoot } = writeOpencodeFixture(); + const recordingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-opencode-reg-')); + + const registry = createAdapterRegistry({ + recordingDir, + loadStateStoreImpl: () => null, + adapterOptions: { opencode: { storageDir, resolveBranchImpl: () => null } } + }); + + const typed = registry.open({ type: 'opencode', value: 'latest' }, { cwd: repoRoot }).getSnapshot(); + assert.strictEqual(typed.adapterId, 'opencode'); + + const listed = registry.listAdapters().map(a => a.id); + assert.ok(listed.includes('opencode'), 'registry lists opencode adapter'); + assert.ok(listed.includes('codex-worktree'), 'registry still lists codex-worktree adapter'); +}); + + +// --- branch/error coverage --- + +function writeSession(storageDir, projectHash, sessionId, info, messages) { + const sdir = path.join(storageDir, 'session', projectHash); + const mdir = path.join(storageDir, 'message', sessionId); + fs.mkdirSync(sdir, { recursive: true }); + fs.mkdirSync(mdir, { recursive: true }); + fs.writeFileSync(path.join(sdir, sessionId + '.json'), JSON.stringify(info), 'utf8'); + (messages || []).forEach((m, i) => fs.writeFileSync(path.join(mdir, 'msg_' + i + '.json'), JSON.stringify(m), 'utf8')); + return path.join(sdir, sessionId + '.json'); +} + +test('parseOpencodeTarget handles non-string and unprefixed input', () => { + assert.strictEqual(parseOpencodeTarget(null), null); + assert.strictEqual(parseOpencodeTarget('/abs/ses_x.json'), null); +}); + +test('adapter throws for empty store and unknown id; findLatest on empty => null', () => { + const storageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-oc-empty-')); + assert.strictEqual(findLatestSessionInfo(storageDir), null); + const adapter = createOpencodeAdapter({ storageDir, loadStateStoreImpl: () => null }); + assert.throws(() => adapter.open('opencode:latest', { cwd: os.tmpdir() }).getSnapshot(), /No OpenCode sessions found/); + assert.throws(() => adapter.open('opencode:ses_missing', { cwd: os.tmpdir() }).getSnapshot(), /not found/); +}); + +test('findSessionInfoById + direct file target + isOpencodeSessionFileTarget', () => { + const storageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-oc-byid-')); + const now = Date.now(); + const fp = writeSession(storageDir, 'projhash', 'ses_UNIQUE001', { + id: 'ses_UNIQUE001', directory: storageDir, title: 'real title', time: { created: now - 5000, updated: now - 5000 } + }, []); + + assert.strictEqual(findSessionInfoById(storageDir, 'ses_UNIQUE001'), fp); + assert.ok(isOpencodeSessionFileTarget(fp, os.tmpdir())); + assert.ok(!isOpencodeSessionFileTarget('/tmp/not-session.json', os.tmpdir())); + + const adapter = createOpencodeAdapter({ storageDir, loadStateStoreImpl: () => null, resolveBranchImpl: () => null }); + const byId = adapter.open('opencode:ses_UNIQUE001', { cwd: os.tmpdir() }).getSnapshot(); + assert.strictEqual(byId.session.id, 'ses_UNIQUE001'); + const byFile = adapter.open(fp, { cwd: os.tmpdir() }).getSnapshot(); + assert.strictEqual(byFile.session.id, 'ses_UNIQUE001'); +}); + +test('parseOpencodeSession: model from later assistant message, missing-time => recorded', () => { + const storageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-oc-parse-')); + const fp = writeSession(storageDir, 'ph', 'ses_MODEL01', { + id: 'ses_MODEL01', directory: storageDir, title: 'do work' + // no time block => updatedMs null => recorded/inactive + }, [ + { id: 'm0', role: 'user' }, + { id: 'm1', role: 'assistant', modelID: 'claude-sonnet-4-5-20250929', providerID: 'anthropic' } + ]); + const parsed = parseOpencodeSession(fp, { storageDir, resolveBranchImpl: () => null }); + assert.strictEqual(parsed.model, 'claude-sonnet-4-5-20250929'); + assert.strictEqual(parsed.provider, 'anthropic'); + assert.strictEqual(parsed.active, false); +}); + +test('resolveGitBranch real path returns null outside a repo', () => { + const storageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-oc-nogit-')); + const fp = writeSession(storageDir, 'ph', 'ses_NOGIT01', { + id: 'ses_NOGIT01', directory: storageDir, title: 't', time: { created: 1, updated: 1 } + }, []); + const parsed = parseOpencodeSession(fp, { storageDir }); // no resolveBranchImpl => real git path + assert.strictEqual(parsed.branch, null); +}); + +console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`); +if (failed > 0) process.exit(1);