mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-11 02:33:10 +08:00
feat: extend session-adapter layer with codex-worktree + opencode adapters (#2145)
* feat: add codex-worktree session adapter Adds the third session adapter (after dmux-tmux and claude-history), normalizing Codex rollout sessions into the harness-neutral ecc.session.v1 snapshot. Reads ~/.codex/sessions rollout JSONL, derives objective (skipping the AGENTS.md preamble + leading message UUID), model, originator, worktree cwd, and best-effort git branch. This is step 1 of ECC-2.0-SESSION-ADAPTER-DISCOVERY (move the abstraction beyond tmux + Claude-history) and supports the wrap/adapt control-pane strategy: ECC reads sessions from any harness rather than owning one UX. - scripts/lib/session-adapters/codex-worktree.js: adapter + rollout parser - canonical-session.js: normalizeCodexWorktreeSession - registry.js: register adapter, codex/codex-worktree target types - tests/lib/session-adapters-codex.test.js: 4 tests (unit + registry routing) * feat: add opencode session adapter + allow empty intent objective Adds the fourth session adapter (after dmux-tmux, claude-history, codex-worktree), normalizing OpenCode sessions into ecc.session.v1. Reads ~/.local/share/opencode/storage: session/<project>/ses_*.json for metadata (id, directory, title, version, projectID, time) and message/<session>/msg_*.json to extract the model (modelID/providerID from the first assistant message). Derives objective from the session title, treating the auto-generated "New session - <date>" title as no objective. Recency-based active/recorded state. Schema: relax intent.objective from non-empty to allow empty string (ensureStringAllowEmpty). Sessions legitimately have no objective yet (fresh/auto-titled), and claude-history already emitted "" via metadata.title fallback. This fixes a latent over-strict validation. - scripts/lib/session-adapters/opencode.js: adapter + storage parser - canonical-session.js: normalizeOpencodeSession + ensureStringAllowEmpty - registry.js: register adapter + opencode target type - tests/lib/session-adapters-opencode.test.js: 5 tests Tests: opencode 5/0, codex 4/0, session-adapters 14/0, control-pane-state 10/0, session-inspect 8/0, control-pane 12/0. Smoke-tested on a real OpenCode session (140 messages, gpt-5.3-codex). * test: cover error/fallback branches for codex-worktree + opencode adapters Lift global branch coverage past the 80% gate (was 79.53%). Adds error and fallback path tests: missing-session/unknown-id throws, findRolloutById/ findSessionInfoById, direct file targets, objective truncation, model fallbacks, corrupt-line skip, mtime activity fallback, and the real resolveGitBranch path outside a repo. codex-worktree.js branch 52.8%->78.3%; global branch 80.04%.
This commit is contained in:
@@ -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
|
||||
};
|
||||
|
||||
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 { 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
|
||||
|
||||
Reference in New Issue
Block a user