mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-10 18:23:12 +08:00
Compare commits
3 Commits
v2.0.0
...
product/co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59ee1042c5 | ||
|
|
f4af79ace4 | ||
|
|
e391419026 |
@@ -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) {
|
function ensureOptionalString(value, fieldPath) {
|
||||||
if (value !== null && value !== undefined && typeof value !== 'string') {
|
if (value !== null && value !== undefined && typeof value !== 'string') {
|
||||||
throw new Error(`Canonical session snapshot requires ${fieldPath} to be a string or null`);
|
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`);
|
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`);
|
ensureArrayOfStrings(worker.intent.seedPaths, `workers[${index}].intent.seedPaths`);
|
||||||
|
|
||||||
if (!isObject(worker.outputs)) {
|
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 = {
|
module.exports = {
|
||||||
SESSION_SCHEMA_VERSION,
|
SESSION_SCHEMA_VERSION,
|
||||||
buildAggregates,
|
buildAggregates,
|
||||||
getFallbackSessionRecordingPath,
|
getFallbackSessionRecordingPath,
|
||||||
normalizeClaudeHistorySession,
|
normalizeClaudeHistorySession,
|
||||||
|
normalizeCodexWorktreeSession,
|
||||||
normalizeDmuxSnapshot,
|
normalizeDmuxSnapshot,
|
||||||
|
normalizeOpencodeSession,
|
||||||
persistCanonicalSnapshot,
|
persistCanonicalSnapshot,
|
||||||
validateCanonicalSnapshot
|
validateCanonicalSnapshot
|
||||||
};
|
};
|
||||||
|
|||||||
348
scripts/lib/session-adapters/codex-worktree.js
Normal file
348
scripts/lib/session-adapters/codex-worktree.js
Normal 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
|
||||||
|
};
|
||||||
312
scripts/lib/session-adapters/opencode.js
Normal file
312
scripts/lib/session-adapters/opencode.js
Normal file
@@ -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 - <ISO date>") 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
|
||||||
|
};
|
||||||
@@ -2,13 +2,18 @@
|
|||||||
|
|
||||||
const { createClaudeHistoryAdapter } = require('./claude-history');
|
const { createClaudeHistoryAdapter } = require('./claude-history');
|
||||||
const { createDmuxTmuxAdapter } = require('./dmux-tmux');
|
const { createDmuxTmuxAdapter } = require('./dmux-tmux');
|
||||||
|
const { createCodexWorktreeAdapter } = require('./codex-worktree');
|
||||||
|
const { createOpencodeAdapter } = require('./opencode');
|
||||||
|
|
||||||
const TARGET_TYPE_TO_ADAPTER_ID = Object.freeze({
|
const TARGET_TYPE_TO_ADAPTER_ID = Object.freeze({
|
||||||
plan: 'dmux-tmux',
|
plan: 'dmux-tmux',
|
||||||
session: 'dmux-tmux',
|
session: 'dmux-tmux',
|
||||||
'claude-history': 'claude-history',
|
'claude-history': 'claude-history',
|
||||||
'claude-alias': '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) {
|
function buildDefaultAdapterOptions(options, adapterId) {
|
||||||
@@ -30,7 +35,9 @@ function buildDefaultAdapterOptions(options, adapterId) {
|
|||||||
function createDefaultAdapters(options = {}) {
|
function createDefaultAdapters(options = {}) {
|
||||||
return [
|
return [
|
||||||
createClaudeHistoryAdapter(buildDefaultAdapterOptions(options, 'claude-history')),
|
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 {
|
return {
|
||||||
target: value,
|
target: value,
|
||||||
context: nextContext
|
context: nextContext
|
||||||
|
|||||||
208
tests/lib/session-adapters-codex.test.js
Normal file
208
tests/lib/session-adapters-codex.test.js
Normal file
@@ -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<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');
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// --- 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);
|
||||||
219
tests/lib/session-adapters-opencode.test.js
Normal file
219
tests/lib/session-adapters-opencode.test.js
Normal file
@@ -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);
|
||||||
Reference in New Issue
Block a user