From 059539a64aa991c6ded540e67705bc768413a6fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 24 Jul 2026 16:56:20 +0000 Subject: [PATCH] chat: unified session history across Claude + OpenCode (Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /chat's session list, transcript load, delete, and rename now span both harnesses. - opencode-sessions.ts — REST-backed reader (OpenCode's SQLite via its HTTP API, never the DB): listOpenCodeSessions / loadOpenCodeSession / delete / rename, returning the same shapes as the Claude reader, tagged harness:'opencode'. A serve is directory- scoped, so listing a cwd = asking the serve rooted there. Transcript rebuild maps user/assistant/tool parts and drops reasoning (parity with the delta filter). - client.ts — adds listSessions/getMessages/deleteSession/renameSession over /session/*. - chat.ts — /sessions merges both (newest first); /sessions/:id, DELETE, and /title route by id shape (ses_ = OpenCode). ClaudeSessionSummary gains an optional `harness` tag. - send-opencode.ts — resuming from history: when the sessionKey is itself a ses_ id, reuse that OpenCode session instead of creating a new one. - SessionList.tsx — shows an "OpenCode" badge for OpenCode sessions. Verified end-to-end against a live serve: list (5 sessions, tagged), load (transcript rebuilt, reasoning filtered), and rename all work. Phases 1-3 complete; needs a restart. Co-Authored-By: Claude Opus 4.8 --- src/servers/api/chat/chat.ts | 50 +++++++-- src/servers/api/chat/claude-sessions.ts | 1 + src/servers/api/chat/opencode-sessions.ts | 106 ++++++++++++++++++ src/servers/api/chat/opencode/client.ts | 49 ++++++++ src/servers/channels/send-opencode.ts | 10 +- .../src/apps/ChatHistory/SessionList.tsx | 8 +- src/workspaces/state/src/useClaudeSessions.ts | 1 + 7 files changed, 210 insertions(+), 15 deletions(-) create mode 100644 src/servers/api/chat/opencode-sessions.ts diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts index 92aa89ef..c0955352 100644 --- a/src/servers/api/chat/chat.ts +++ b/src/servers/api/chat/chat.ts @@ -9,8 +9,16 @@ import { deleteClaudeSession, renameClaudeSession, } from './claude-sessions'; +import { + listOpenCodeSessions, + loadOpenCodeSession, + deleteOpenCodeSession, + renameOpenCodeSession, + isOpenCodeSessionId, +} from './opencode-sessions'; import { listChatModels } from './list-models'; import { logger } from './logger'; +import { getHomeDirForRole } from '../../data-path'; import { readSttConfig } from '../server-settings/stt'; import { transcribeAudio } from '../stt/transcribe'; @@ -20,40 +28,60 @@ export const chatRouter = createRouter(); // claude_sessions dir. Claude groups sessions by cwd, so this selects which project group we read. const cwdOf = (ctx: Context, email: string): string => ctx.req.query('cwd')?.trim() || getClaudeSessionsCwd(email); +// The home dir an OpenCode `serve` runs under (so it reads the user's ~/.local/share/opencode auth). +const homeOf = (ctx: Context): string => getHomeDirForRole(ctx.get('user').email, ctx.get('user').role); + // GET /chat/pwds — the default /chat dir plus every directory that already has Claude sessions. chatRouter.get('/pwds', (ctx) => { const email = ctx.get('user').email; return ctx.json({ pwds: listClaudePwds(email), default: getClaudeSessionsCwd(email) }); }); -// GET /chat/sessions[?cwd=] — the conversations for a working directory, from Claude's transcripts. -chatRouter.get('/sessions', (ctx) => { +// GET /chat/sessions[?cwd=] — conversations for a working directory, merged across both harnesses +// (Claude transcripts + OpenCode's session store), newest first. +chatRouter.get('/sessions', async (ctx) => { const email = ctx.get('user').email; - return ctx.json({ sessions: listClaudeSessions(email, cwdOf(ctx, email)) }); + const cwd = cwdOf(ctx, email); + const claude = listClaudeSessions(email, cwd).map((s) => ({ ...s, harness: 'claude' as const })); + const opencode = await listOpenCodeSessions(cwd, homeOf(ctx)); + const sessions = [...claude, ...opencode].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + return ctx.json({ sessions }); }); -// GET /chat/sessions/:id[?cwd=] — one conversation's full transcript, parsed into display messages. -chatRouter.get('/sessions/:id', (ctx) => { +// GET /chat/sessions/:id[?cwd=] — one conversation's transcript. Routes by harness (ses_ = OpenCode). +chatRouter.get('/sessions/:id', async (ctx) => { const email = ctx.get('user').email; - const detail = loadClaudeSession(email, cwdOf(ctx, email), ctx.req.param('id')); + const id = ctx.req.param('id'); + const cwd = cwdOf(ctx, email); + const detail = isOpenCodeSessionId(id) + ? await loadOpenCodeSession(cwd, homeOf(ctx), id) + : loadClaudeSession(email, cwd, id); if (!detail) return ctx.text('Not found', 404); return ctx.json(detail); }); -// DELETE /chat/sessions/:id[?cwd=] — remove a conversation (deletes Claude's transcript file). -chatRouter.delete('/sessions/:id', (ctx) => { +// DELETE /chat/sessions/:id[?cwd=] — remove a conversation from the owning harness's store. +chatRouter.delete('/sessions/:id', async (ctx) => { const email = ctx.get('user').email; - const ok = deleteClaudeSession(email, cwdOf(ctx, email), ctx.req.param('id')); + const id = ctx.req.param('id'); + const cwd = cwdOf(ctx, email); + const ok = isOpenCodeSessionId(id) + ? await deleteOpenCodeSession(cwd, homeOf(ctx), id) + : deleteClaudeSession(email, cwd, id); if (!ok) return ctx.text('Not found', 404); return ctx.json({ ok: true }); }); -// PATCH /chat/sessions/:id/title[?cwd=] — rename by writing a summary entry into Claude's transcript. +// PATCH /chat/sessions/:id/title[?cwd=] — rename in the owning harness's store. chatRouter.patch('/sessions/:id/title', async (ctx) => { const email = ctx.get('user').email; + const id = ctx.req.param('id'); + const cwd = cwdOf(ctx, email); const { title } = await ctx.req.json<{ title?: string }>(); if (!title?.trim()) return ctx.text('title is required', 400); - const ok = renameClaudeSession(email, cwdOf(ctx, email), ctx.req.param('id'), title.trim()); + const ok = isOpenCodeSessionId(id) + ? await renameOpenCodeSession(cwd, homeOf(ctx), id, title.trim()) + : renameClaudeSession(email, cwd, id, title.trim()); if (!ok) return ctx.text('Not found', 404); return ctx.json({ ok: true }); }); diff --git a/src/servers/api/chat/claude-sessions.ts b/src/servers/api/chat/claude-sessions.ts index d2ec4776..d08ef905 100644 --- a/src/servers/api/chat/claude-sessions.ts +++ b/src/servers/api/chat/claude-sessions.ts @@ -34,6 +34,7 @@ export type ClaudeSessionSummary = { createdAt: string; // ISO, first entry updatedAt: string; // ISO, last entry messageCount: number; // human + assistant turns + harness?: 'claude' | 'opencode'; // which harness produced it (defaults to claude when absent) }; /** Pull the plain text out of a transcript entry's `message.content` (string or content-block array). */ diff --git a/src/servers/api/chat/opencode-sessions.ts b/src/servers/api/chat/opencode-sessions.ts new file mode 100644 index 00000000..9ca8d38e --- /dev/null +++ b/src/servers/api/chat/opencode-sessions.ts @@ -0,0 +1,106 @@ +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 SQLite store is the source of truth, read +// via its HTTP API (never the DB directly). A `serve` is directory-scoped, so listing sessions for a +// cwd just means asking the serve rooted at that cwd. Returns the same shapes as the Claude reader, +// tagged harness:'opencode', so chat.ts can merge both harnesses transparently. + +/** List OpenCode sessions for a working directory. Never throws — returns [] if OpenCode is unavailable. */ +export async function listOpenCodeSessions(cwd: string, home: string): Promise { + try { + const { baseUrl } = await ensureServer(cwd, home); + const sessions = await getConnection(baseUrl).listSessions(); + return sessions.map((s) => { + const created = s.time?.created ?? Date.now(); + const updated = s.time?.updated ?? created; + return { + id: s.id, + title: s.title || '(untitled)', + cwd, + 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', { cwd, error: String(err) }); + return []; + } +} + +/** Load one OpenCode session's transcript, rebuilt into the shared display message shape. */ +export async function loadOpenCodeSession(cwd: string, home: string, sessionId: string): Promise { + try { + const { baseUrl } = await ensureServer(cwd, home); + const stored = await getConnection(baseUrl).getMessages(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), + }); + } + // text-part deltas already excluded reasoning; skip reasoning/step-* parts here too. + } + } + } + + return { id: sessionId, model: modelId ? `opencode/${modelId}` : 'opencode', cwd, messages }; + } catch (err) { + logger.warn('Failed to load OpenCode session', { sessionId, error: String(err) }); + return null; + } +} + +export async function deleteOpenCodeSession(cwd: string, home: string, sessionId: string): Promise { + try { + const { baseUrl } = await ensureServer(cwd, home); + 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(cwd: string, home: string, sessionId: string, title: string): Promise { + try { + const { baseUrl } = await ensureServer(cwd, home); + 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_'); diff --git a/src/servers/api/chat/opencode/client.ts b/src/servers/api/chat/opencode/client.ts index 4100242f..1150fce6 100644 --- a/src/servers/api/chat/opencode/client.ts +++ b/src/servers/api/chat/opencode/client.ts @@ -121,8 +121,57 @@ class ServerConnection { async abort(sessionId: string): Promise { await this.postJson(`/session/${sessionId}/abort`, {}).catch(() => {}); } + + // ── Session history (REST; OpenCode's SQLite store is the source of truth) ── + + async listSessions(): Promise { + const res = await fetch(`${this.baseUrl}/session`); + if (!res.ok) throw new Error(`opencode GET /session → ${res.status}`); + return (await res.json()) as OpenCodeSessionInfo[]; + } + + async getMessages(sessionId: string): Promise { + const res = await fetch(`${this.baseUrl}/session/${sessionId}/message`); + if (!res.ok) throw new Error(`opencode GET /session/${sessionId}/message → ${res.status}`); + return (await res.json()) as OpenCodeStoredMessage[]; + } + + async deleteSession(sessionId: string): Promise { + const res = await fetch(`${this.baseUrl}/session/${sessionId}`, { method: 'DELETE' }); + return res.ok; + } + + async renameSession(sessionId: string, title: string): Promise { + const res = await fetch(`${this.baseUrl}/session/${sessionId}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ title }), + }); + return res.ok; + } } +// Shapes returned by the `/session/*` read endpoints (verified against opencode 1.17.9). +export type OpenCodeSessionInfo = { + id: string; + title?: string; + time?: { created?: number; updated?: number }; + location?: { directory?: string | null }; +}; + +export type OpenCodeStoredPart = { + type?: string; + text?: string; + tool?: string; + callID?: string; + state?: { input?: Record; output?: string; error?: string }; +}; + +export type OpenCodeStoredMessage = { + info?: { role?: string; modelID?: string; model?: { modelID?: string; providerID?: string } }; + parts?: OpenCodeStoredPart[]; +}; + const connections = new Map(); export function getConnection(baseUrl: string): ServerConnection { diff --git a/src/servers/channels/send-opencode.ts b/src/servers/channels/send-opencode.ts index 589af6ca..b2f690ff 100644 --- a/src/servers/channels/send-opencode.ts +++ b/src/servers/channels/send-opencode.ts @@ -42,12 +42,16 @@ export async function sendOpenCodeStreaming(params: OpenCodeStreamingParams): Pr const { baseUrl } = await ensureServer(cwd, home); const conn = getConnection(baseUrl); - // Reuse the OpenCode session for this live sessionKey, else create one bound to the cwd. - let opencodeSessionId = getOpenCodeSession(params.sessionKey) ?? params.resumeSessionId; + // Resolve the OpenCode session: a known mapping, or — when resuming from history — the sessionKey is + // itself the OpenCode session id (`ses_…`); otherwise create one bound to the cwd. + let opencodeSessionId = + getOpenCodeSession(params.sessionKey) ?? + (params.sessionKey.startsWith('ses_') ? params.sessionKey : undefined) ?? + params.resumeSessionId; if (!opencodeSessionId) { opencodeSessionId = await conn.createSession(cwd); - setOpenCodeSession(params.sessionKey, opencodeSessionId); } + setOpenCodeSession(params.sessionKey, opencodeSessionId); const sessionId = opencodeSessionId; let unsub = () => {}; diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx index 2eceb158..ee474a5a 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx @@ -156,7 +156,13 @@ export const SessionList = () => { })} · - {session.messageCount} msg{session.messageCount === 1 ? '' : 's'} + {session.harness === 'opencode' ? ( + OpenCode + ) : ( + + {session.messageCount} msg{session.messageCount === 1 ? '' : 's'} + + )} diff --git a/src/workspaces/state/src/useClaudeSessions.ts b/src/workspaces/state/src/useClaudeSessions.ts index b69b321c..87d91edd 100644 --- a/src/workspaces/state/src/useClaudeSessions.ts +++ b/src/workspaces/state/src/useClaudeSessions.ts @@ -12,6 +12,7 @@ export type ClaudeSessionSummary = { createdAt: string; updatedAt: string; messageCount: number; + harness?: 'claude' | 'opencode'; // which harness produced it (absent = claude) }; // Display-ready message, matching the frontend ChatMessage union (rebuilt from Claude's transcript).