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).
This commit is contained in:
Affaan Mustafa
2026-06-04 16:51:25 -04:00
parent e391419026
commit f4af79ace4
4 changed files with 534 additions and 3 deletions

View File

@@ -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)) {
@@ -571,6 +577,60 @@ function normalizeCodexWorktreeSession(session, sourceTarget) {
});
}
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,
@@ -578,6 +638,7 @@ module.exports = {
normalizeClaudeHistorySession,
normalizeCodexWorktreeSession,
normalizeDmuxSnapshot,
normalizeOpencodeSession,
persistCanonicalSnapshot,
validateCanonicalSnapshot
};

View 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
};

View File

@@ -3,6 +3,7 @@
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',
@@ -11,7 +12,8 @@ const TARGET_TYPE_TO_ADAPTER_ID = Object.freeze({
'claude-alias': 'claude-history',
'session-file': 'claude-history',
'codex-worktree': 'codex-worktree',
codex: 'codex-worktree'
codex: 'codex-worktree',
opencode: 'opencode'
});
function buildDefaultAdapterOptions(options, adapterId) {
@@ -34,7 +36,8 @@ function createDefaultAdapters(options = {}) {
return [
createClaudeHistoryAdapter(buildDefaultAdapterOptions(options, 'claude-history')),
createDmuxTmuxAdapter(buildDefaultAdapterOptions(options, 'dmux-tmux')),
createCodexWorktreeAdapter(buildDefaultAdapterOptions(options, 'codex-worktree'))
createCodexWorktreeAdapter(buildDefaultAdapterOptions(options, 'codex-worktree')),
createOpencodeAdapter(buildDefaultAdapterOptions(options, 'opencode'))
];
}
@@ -80,6 +83,13 @@ function normalizeStructuredTarget(target, context = {}) {
};
}
if (type === 'opencode') {
return {
target: `opencode:${value}`,
context: nextContext
};
}
return {
target: value,
context: nextContext

View File

@@ -0,0 +1,148 @@
'use strict';
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const {
createOpencodeAdapter,
parseOpencodeTarget,
findLatestSessionInfo
} = 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');
});
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
if (failed > 0) process.exit(1);