import type { ClaudeSessionSummary, ClaudeSessionDetail, ClaudeChatMessage } from './claude-sessions'; import { ensureServer } from './opencode/server-manager'; import { getConnection } from './opencode/client'; import { logger } from './logger'; // The OpenCode analog of claude-sessions.ts. OpenCode's own store is the source of truth, read via the // fixed pm2-managed server's HTTP API (never the DB directly). Returns the same shapes as the Claude // reader, tagged harness:'opencode', so chat.ts can merge both harnesses transparently. // // Sessions do NOT all live in one project. Each records the directory its turn ran in — the serve takes // it per request as `x-opencode-directory` — and one serve holds sessions for many. That is why the cwd // filter has to read `directory` rather than assume, and why the list has to come from a read that spans // projects (`client.listSessions`, which is where the project-scoping trap is written up). /** * List OpenCode sessions for a working directory, so each context (/chat pwd, email account, project) * sees only its own. With no cwd, returns all. Never throws — returns [] if the server is unavailable. * * Filters on the session's own `directory`, which is what OpenCode records from the directory the turn * ran in. It used to filter on `metadata.officer.cwd`, a tag whose only writer * (`client.createSession`) has no callers — so the comparison was against `undefined` for every session * and the list was ALWAYS empty. `cwdOf` in chat.ts substitutes a default when no `?cwd=` is given, so * the `!cwd` escape never fired either and there was no configuration in which an OpenCode session * appeared in /chat. Verified against the live server: 7 sessions present, 0 returned. */ /** * OpenCode's own placeholder name for a session it has not titled yet. * * It titles a session from the conversation, but ASYNCHRONOUSLY and after there is something to read — * a finished turn ends up called "Single color in oc-red2.png", which is better than anything we would * generate. Until then the session is literally named `New session - 2026-08-10T15:44:17.178Z`. * * That window is exactly when a session is most visible: `/chat/live` shows turns that are RUNNING, so * the placeholder is what the panel catches. Treating it as "not yet titled" lets the good name arrive * on its own. Forcing `--title` on the run instead would fix the transient case by permanently * replacing opencode's title with a truncated prompt — worse where it lasts longest. */ export const isPlaceholderOpenCodeTitle = (title: string | undefined): boolean => { const trimmed = title?.trim(); return !trimmed || /^New session - \d{4}-\d{2}-\d{2}T[\d:.]+Z?$/.test(trimmed); }; export async function listOpenCodeSessions(cwd?: string): Promise { try { const { baseUrl } = await ensureServer(); const sessions = await getConnection(baseUrl).listSessions(); return sessions .filter((s) => !cwd || s.directory === cwd) .map((s) => { const created = s.time?.created ?? Date.now(); const updated = s.time?.updated ?? created; return { id: s.id, title: isPlaceholderOpenCodeTitle(s.title) ? '(untitled)' : (s.title ?? '(untitled)'), cwd: s.directory ?? '', createdAt: new Date(created).toISOString(), updatedAt: new Date(updated).toISOString(), messageCount: 0, // the session list endpoint doesn't include a turn count harness: 'opencode', } satisfies ClaudeSessionSummary; }); } catch (err) { logger.warn('Failed to list OpenCode sessions', { error: String(err) }); return []; } } /** Load one OpenCode session's transcript, rebuilt into the shared display message shape. */ export async function loadOpenCodeSession(sessionId: string): Promise { try { const { baseUrl } = await ensureServer(); const conn = getConnection(baseUrl); // Both reads, together: the transcript, and the session record that carries its directory. OpenCode // needs the cwd on EVERY turn (it rebuilds its working-directory system prompt each time), so a // resume that reports '' silently relocates the conversation to the default chat dir. const [stored, info] = await Promise.all([conn.getMessages(sessionId), conn.getSession(sessionId)]); const messages: ClaudeChatMessage[] = []; let modelId = ''; for (const m of stored) { const role = m.info?.role; const parts = m.parts ?? []; if (role === 'user') { const text = parts .filter((p) => p.type === 'text') .map((p) => p.text ?? '') .join('') .trim(); if (text) messages.push({ role: 'user', text }); continue; } if (role === 'assistant') { if (!modelId) modelId = m.info?.model?.modelID ?? m.info?.modelID ?? ''; for (const p of parts) { if (p.type === 'text' && p.text) { messages.push({ role: 'assistant', id: `${sessionId}-${messages.length}`, text: p.text }); } else if (p.type === 'tool' && p.callID) { messages.push({ role: 'tool', toolName: p.tool ?? 'tool', toolInput: p.state?.input ?? {}, toolCallId: p.callID, output: p.state?.output, isError: Boolean(p.state?.error), }); } // skip reasoning/step-* parts (parity with the streaming delta filter). } } } return { id: sessionId, model: modelId ? `opencode/${modelId}` : 'opencode', cwd: info?.directory ?? '', messages, }; } catch (err) { logger.warn('Failed to load OpenCode session', { sessionId, error: String(err) }); return null; } } export async function deleteOpenCodeSession(sessionId: string): Promise { try { const { baseUrl } = await ensureServer(); return await getConnection(baseUrl).deleteSession(sessionId); } catch (err) { logger.warn('Failed to delete OpenCode session', { sessionId, error: String(err) }); return false; } } export async function renameOpenCodeSession(sessionId: string, title: string): Promise { try { const { baseUrl } = await ensureServer(); return await getConnection(baseUrl).renameSession(sessionId, title); } catch (err) { logger.warn('Failed to rename OpenCode session', { sessionId, error: String(err) }); return false; } } /** OpenCode session ids are prefixed `ses_`; Claude ids are bare UUIDs. */ export const isOpenCodeSessionId = (id: string): boolean => id.startsWith('ses_');