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