'use strict'; const fs = require('fs'); const os = require('os'); const path = require('path'); const SESSION_SCHEMA_VERSION = 'ecc.session.v1'; const SESSION_RECORDING_SCHEMA_VERSION = 'ecc.session.recording.v1'; const DEFAULT_RECORDING_DIR = path.join(os.tmpdir(), 'ecc-session-recordings'); function isObject(value) { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } function sanitizePathSegment(value) { return String(value || 'unknown') .trim() .replace(/[^A-Za-z0-9._-]+/g, '_') .replace(/^_+|_+$/g, '') || 'unknown'; } function parseContextSeedPaths(context) { if (typeof context !== 'string' || context.trim().length === 0) { return []; } return context .split('\n') .map(line => line.trim()) .filter(Boolean); } function ensureString(value, fieldPath) { if (typeof value !== 'string' || value.length === 0) { throw new Error(`Canonical session snapshot requires ${fieldPath} to be a non-empty 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`); } } function ensureBoolean(value, fieldPath) { if (typeof value !== 'boolean') { throw new Error(`Canonical session snapshot requires ${fieldPath} to be a boolean`); } } function ensureArrayOfStrings(value, fieldPath) { if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) { throw new Error(`Canonical session snapshot requires ${fieldPath} to be an array of strings`); } } function ensureInteger(value, fieldPath) { if (!Number.isInteger(value) || value < 0) { throw new Error(`Canonical session snapshot requires ${fieldPath} to be a non-negative integer`); } } function buildAggregates(workers) { const states = workers.reduce((accumulator, worker) => { const state = worker.state || 'unknown'; accumulator[state] = (accumulator[state] || 0) + 1; return accumulator; }, {}); return { workerCount: workers.length, states }; } function summarizeRawWorkerStates(snapshot) { if (isObject(snapshot.workerStates)) { return snapshot.workerStates; } return (snapshot.workers || []).reduce((counts, worker) => { const state = worker && worker.status && worker.status.state ? worker.status.state : 'unknown'; counts[state] = (counts[state] || 0) + 1; return counts; }, {}); } function deriveDmuxSessionState(snapshot) { const workerStates = summarizeRawWorkerStates(snapshot); const totalWorkers = Number.isInteger(snapshot.workerCount) ? snapshot.workerCount : Object.values(workerStates).reduce((sum, count) => sum + count, 0); if (snapshot.sessionActive) { return 'active'; } if (totalWorkers === 0) { return 'missing'; } const failedCount = (workerStates.failed || 0) + (workerStates.error || 0); if (failedCount > 0) { return 'failed'; } const completedCount = (workerStates.completed || 0) + (workerStates.succeeded || 0) + (workerStates.success || 0) + (workerStates.done || 0); if (completedCount === totalWorkers) { return 'completed'; } return 'idle'; } function validateCanonicalSnapshot(snapshot) { if (!isObject(snapshot)) { throw new Error('Canonical session snapshot must be an object'); } ensureString(snapshot.schemaVersion, 'schemaVersion'); if (snapshot.schemaVersion !== SESSION_SCHEMA_VERSION) { throw new Error(`Unsupported canonical session schema version: ${snapshot.schemaVersion}`); } ensureString(snapshot.adapterId, 'adapterId'); if (!isObject(snapshot.session)) { throw new Error('Canonical session snapshot requires session to be an object'); } ensureString(snapshot.session.id, 'session.id'); ensureString(snapshot.session.kind, 'session.kind'); ensureString(snapshot.session.state, 'session.state'); ensureOptionalString(snapshot.session.repoRoot, 'session.repoRoot'); if (!isObject(snapshot.session.sourceTarget)) { throw new Error('Canonical session snapshot requires session.sourceTarget to be an object'); } ensureString(snapshot.session.sourceTarget.type, 'session.sourceTarget.type'); ensureString(snapshot.session.sourceTarget.value, 'session.sourceTarget.value'); if (!Array.isArray(snapshot.workers)) { throw new Error('Canonical session snapshot requires workers to be an array'); } snapshot.workers.forEach((worker, index) => { if (!isObject(worker)) { throw new Error(`Canonical session snapshot requires workers[${index}] to be an object`); } ensureString(worker.id, `workers[${index}].id`); ensureString(worker.label, `workers[${index}].label`); ensureString(worker.state, `workers[${index}].state`); ensureOptionalString(worker.branch, `workers[${index}].branch`); ensureOptionalString(worker.worktree, `workers[${index}].worktree`); if (!isObject(worker.runtime)) { throw new Error(`Canonical session snapshot requires workers[${index}].runtime to be an object`); } ensureString(worker.runtime.kind, `workers[${index}].runtime.kind`); ensureOptionalString(worker.runtime.command, `workers[${index}].runtime.command`); ensureBoolean(worker.runtime.active, `workers[${index}].runtime.active`); ensureBoolean(worker.runtime.dead, `workers[${index}].runtime.dead`); if (!isObject(worker.intent)) { throw new Error(`Canonical session snapshot requires workers[${index}].intent to be an object`); } ensureString(worker.intent.objective, `workers[${index}].intent.objective`); ensureArrayOfStrings(worker.intent.seedPaths, `workers[${index}].intent.seedPaths`); if (!isObject(worker.outputs)) { throw new Error(`Canonical session snapshot requires workers[${index}].outputs to be an object`); } ensureArrayOfStrings(worker.outputs.summary, `workers[${index}].outputs.summary`); ensureArrayOfStrings(worker.outputs.validation, `workers[${index}].outputs.validation`); ensureArrayOfStrings(worker.outputs.remainingRisks, `workers[${index}].outputs.remainingRisks`); if (!isObject(worker.artifacts)) { throw new Error(`Canonical session snapshot requires workers[${index}].artifacts to be an object`); } }); if (!isObject(snapshot.aggregates)) { throw new Error('Canonical session snapshot requires aggregates to be an object'); } ensureInteger(snapshot.aggregates.workerCount, 'aggregates.workerCount'); if (snapshot.aggregates.workerCount !== snapshot.workers.length) { throw new Error('Canonical session snapshot requires aggregates.workerCount to match workers.length'); } if (!isObject(snapshot.aggregates.states)) { throw new Error('Canonical session snapshot requires aggregates.states to be an object'); } for (const [state, count] of Object.entries(snapshot.aggregates.states)) { ensureString(state, 'aggregates.states key'); ensureInteger(count, `aggregates.states.${state}`); } return snapshot; } function resolveRecordingDir(options = {}) { if (typeof options.recordingDir === 'string' && options.recordingDir.length > 0) { return path.resolve(options.recordingDir); } if (typeof process.env.ECC_SESSION_RECORDING_DIR === 'string' && process.env.ECC_SESSION_RECORDING_DIR.length > 0) { return path.resolve(process.env.ECC_SESSION_RECORDING_DIR); } return DEFAULT_RECORDING_DIR; } function getFallbackSessionRecordingPath(snapshot, options = {}) { validateCanonicalSnapshot(snapshot); return path.join( resolveRecordingDir(options), sanitizePathSegment(snapshot.adapterId), `${sanitizePathSegment(snapshot.session.id)}.json` ); } function readExistingRecording(filePath) { if (!fs.existsSync(filePath)) { return null; } try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch { return null; } } function writeFallbackSessionRecording(snapshot, options = {}) { const filePath = getFallbackSessionRecordingPath(snapshot, options); const recordedAt = new Date().toISOString(); const existing = readExistingRecording(filePath); const snapshotChanged = !existing || JSON.stringify(existing.latest) !== JSON.stringify(snapshot); const payload = { schemaVersion: SESSION_RECORDING_SCHEMA_VERSION, adapterId: snapshot.adapterId, sessionId: snapshot.session.id, createdAt: existing && typeof existing.createdAt === 'string' ? existing.createdAt : recordedAt, updatedAt: recordedAt, latest: snapshot, history: Array.isArray(existing && existing.history) ? (snapshotChanged ? existing.history.concat([{ recordedAt, snapshot }]) : existing.history) : [{ recordedAt, snapshot }] }; fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, JSON.stringify(payload, null, 2) + '\n', 'utf8'); return { backend: 'json-file', path: filePath, recordedAt }; } function loadStateStore(options = {}) { if (options.stateStore) { return options.stateStore; } const loadStateStoreImpl = options.loadStateStoreImpl || (() => require('../state-store')); try { return loadStateStoreImpl(); } catch (error) { const missingRequestedModule = error && error.code === 'MODULE_NOT_FOUND' && typeof error.message === 'string' && error.message.includes('../state-store'); if (missingRequestedModule) { return null; } throw error; } } function resolveStateStoreWriter(stateStore) { if (!stateStore) { return null; } const candidates = [ { owner: stateStore, fn: stateStore.persistCanonicalSessionSnapshot }, { owner: stateStore, fn: stateStore.recordCanonicalSessionSnapshot }, { owner: stateStore, fn: stateStore.persistSessionSnapshot }, { owner: stateStore, fn: stateStore.recordSessionSnapshot }, { owner: stateStore, fn: stateStore.writeSessionSnapshot }, { owner: stateStore.sessions, fn: stateStore.sessions && stateStore.sessions.persistCanonicalSessionSnapshot }, { owner: stateStore.sessions, fn: stateStore.sessions && stateStore.sessions.recordCanonicalSessionSnapshot }, { owner: stateStore.sessions, fn: stateStore.sessions && stateStore.sessions.persistSessionSnapshot }, { owner: stateStore.sessions, fn: stateStore.sessions && stateStore.sessions.recordSessionSnapshot } ]; const writer = candidates.find(candidate => typeof candidate.fn === 'function'); return writer ? writer.fn.bind(writer.owner) : null; } function persistCanonicalSnapshot(snapshot, options = {}) { validateCanonicalSnapshot(snapshot); if (options.persist === false) { return { backend: 'skipped', path: null, recordedAt: null }; } const stateStore = loadStateStore(options); const writer = resolveStateStoreWriter(stateStore); if (stateStore && !writer) { // The loaded object is a factory module (e.g. has createStateStore but no // writer methods). Treat it the same as a missing state store and fall // through to the JSON-file recording path below. return writeFallbackSessionRecording(snapshot, options); } if (writer) { writer(snapshot, { adapterId: snapshot.adapterId, schemaVersion: snapshot.schemaVersion, sessionId: snapshot.session.id }); return { backend: 'state-store', path: null, recordedAt: null }; } return writeFallbackSessionRecording(snapshot, options); } function normalizeDmuxSnapshot(snapshot, sourceTarget) { const workers = (snapshot.workers || []).map(worker => ({ id: worker.workerSlug, label: worker.workerSlug, state: worker.status.state || 'unknown', branch: worker.status.branch || null, worktree: worker.status.worktree || null, runtime: { kind: 'tmux-pane', command: worker.pane ? worker.pane.currentCommand || null : null, pid: worker.pane ? worker.pane.pid || null : null, active: worker.pane ? Boolean(worker.pane.active) : false, dead: worker.pane ? Boolean(worker.pane.dead) : false, }, intent: { objective: worker.task.objective || '', seedPaths: Array.isArray(worker.task.seedPaths) ? worker.task.seedPaths : [] }, outputs: { summary: Array.isArray(worker.handoff.summary) ? worker.handoff.summary : [], validation: Array.isArray(worker.handoff.validation) ? worker.handoff.validation : [], remainingRisks: Array.isArray(worker.handoff.remainingRisks) ? worker.handoff.remainingRisks : [] }, artifacts: { statusFile: worker.files.status, taskFile: worker.files.task, handoffFile: worker.files.handoff } })); return validateCanonicalSnapshot({ schemaVersion: SESSION_SCHEMA_VERSION, adapterId: 'dmux-tmux', session: { id: snapshot.sessionName, kind: 'orchestrated', state: deriveDmuxSessionState(snapshot), repoRoot: snapshot.repoRoot || null, sourceTarget }, workers, aggregates: buildAggregates(workers) }); } function deriveClaudeWorkerId(session) { if (session.shortId && session.shortId !== 'no-id') { return session.shortId; } return path.basename(session.filename || session.sessionPath || 'session', '.tmp'); } function normalizeClaudeHistorySession(session, sourceTarget) { const metadata = session.metadata || {}; const workerId = deriveClaudeWorkerId(session); const worker = { id: workerId, label: metadata.title || session.filename || workerId, state: 'recorded', branch: metadata.branch || null, worktree: metadata.worktree || null, runtime: { kind: 'claude-session', command: 'claude', pid: null, active: false, dead: true, }, intent: { objective: metadata.inProgress && metadata.inProgress.length > 0 ? metadata.inProgress[0] : (metadata.title || ''), seedPaths: parseContextSeedPaths(metadata.context) }, outputs: { summary: Array.isArray(metadata.completed) ? metadata.completed : [], validation: [], remainingRisks: metadata.notes ? [metadata.notes] : [] }, artifacts: { sessionFile: session.sessionPath, context: metadata.context || null } }; return validateCanonicalSnapshot({ schemaVersion: SESSION_SCHEMA_VERSION, adapterId: 'claude-history', session: { id: workerId, kind: 'history', state: 'recorded', repoRoot: metadata.worktree || null, sourceTarget }, workers: [worker], aggregates: buildAggregates([worker]) }); } module.exports = { SESSION_SCHEMA_VERSION, buildAggregates, getFallbackSessionRecordingPath, normalizeClaudeHistorySession, normalizeDmuxSnapshot, persistCanonicalSnapshot, validateCanonicalSnapshot };