chat: unified session history across Claude + OpenCode (Phase 3)

/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 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 16:56:20 +00:00
co-authored by Claude Opus 4.8
parent 3a2f3317b2
commit 059539a64a
7 changed files with 210 additions and 15 deletions
+39 -11
View File
@@ -9,8 +9,16 @@ import {
deleteClaudeSession, deleteClaudeSession,
renameClaudeSession, renameClaudeSession,
} from './claude-sessions'; } from './claude-sessions';
import {
listOpenCodeSessions,
loadOpenCodeSession,
deleteOpenCodeSession,
renameOpenCodeSession,
isOpenCodeSessionId,
} from './opencode-sessions';
import { listChatModels } from './list-models'; import { listChatModels } from './list-models';
import { logger } from './logger'; import { logger } from './logger';
import { getHomeDirForRole } from '../../data-path';
import { readSttConfig } from '../server-settings/stt'; import { readSttConfig } from '../server-settings/stt';
import { transcribeAudio } from '../stt/transcribe'; 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. // 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); 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. // GET /chat/pwds — the default /chat dir plus every directory that already has Claude sessions.
chatRouter.get('/pwds', (ctx) => { chatRouter.get('/pwds', (ctx) => {
const email = ctx.get('user').email; const email = ctx.get('user').email;
return ctx.json({ pwds: listClaudePwds(email), default: getClaudeSessionsCwd(email) }); return ctx.json({ pwds: listClaudePwds(email), default: getClaudeSessionsCwd(email) });
}); });
// GET /chat/sessions[?cwd=] — the conversations for a working directory, from Claude's transcripts. // GET /chat/sessions[?cwd=] — conversations for a working directory, merged across both harnesses
chatRouter.get('/sessions', (ctx) => { // (Claude transcripts + OpenCode's session store), newest first.
chatRouter.get('/sessions', async (ctx) => {
const email = ctx.get('user').email; 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. // GET /chat/sessions/:id[?cwd=] — one conversation's transcript. Routes by harness (ses_ = OpenCode).
chatRouter.get('/sessions/:id', (ctx) => { chatRouter.get('/sessions/:id', async (ctx) => {
const email = ctx.get('user').email; 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); if (!detail) return ctx.text('Not found', 404);
return ctx.json(detail); return ctx.json(detail);
}); });
// DELETE /chat/sessions/:id[?cwd=] — remove a conversation (deletes Claude's transcript file). // DELETE /chat/sessions/:id[?cwd=] — remove a conversation from the owning harness's store.
chatRouter.delete('/sessions/:id', (ctx) => { chatRouter.delete('/sessions/:id', async (ctx) => {
const email = ctx.get('user').email; 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); if (!ok) return ctx.text('Not found', 404);
return ctx.json({ ok: true }); 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) => { chatRouter.patch('/sessions/:id/title', async (ctx) => {
const email = ctx.get('user').email; 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 }>(); const { title } = await ctx.req.json<{ title?: string }>();
if (!title?.trim()) return ctx.text('title is required', 400); 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); if (!ok) return ctx.text('Not found', 404);
return ctx.json({ ok: true }); return ctx.json({ ok: true });
}); });
+1
View File
@@ -34,6 +34,7 @@ export type ClaudeSessionSummary = {
createdAt: string; // ISO, first entry createdAt: string; // ISO, first entry
updatedAt: string; // ISO, last entry updatedAt: string; // ISO, last entry
messageCount: number; // human + assistant turns 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). */ /** Pull the plain text out of a transcript entry's `message.content` (string or content-block array). */
+106
View File
@@ -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<ClaudeSessionSummary[]> {
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<ClaudeSessionDetail | null> {
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<boolean> {
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<boolean> {
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_');
+49
View File
@@ -121,8 +121,57 @@ class ServerConnection {
async abort(sessionId: string): Promise<void> { async abort(sessionId: string): Promise<void> {
await this.postJson(`/session/${sessionId}/abort`, {}).catch(() => {}); await this.postJson(`/session/${sessionId}/abort`, {}).catch(() => {});
} }
// ── Session history (REST; OpenCode's SQLite store is the source of truth) ──
async listSessions(): Promise<OpenCodeSessionInfo[]> {
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<OpenCodeStoredMessage[]> {
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<boolean> {
const res = await fetch(`${this.baseUrl}/session/${sessionId}`, { method: 'DELETE' });
return res.ok;
}
async renameSession(sessionId: string, title: string): Promise<boolean> {
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<string, unknown>; output?: string; error?: string };
};
export type OpenCodeStoredMessage = {
info?: { role?: string; modelID?: string; model?: { modelID?: string; providerID?: string } };
parts?: OpenCodeStoredPart[];
};
const connections = new Map<string, ServerConnection>(); const connections = new Map<string, ServerConnection>();
export function getConnection(baseUrl: string): ServerConnection { export function getConnection(baseUrl: string): ServerConnection {
+7 -3
View File
@@ -42,12 +42,16 @@ export async function sendOpenCodeStreaming(params: OpenCodeStreamingParams): Pr
const { baseUrl } = await ensureServer(cwd, home); const { baseUrl } = await ensureServer(cwd, home);
const conn = getConnection(baseUrl); const conn = getConnection(baseUrl);
// Reuse the OpenCode session for this live sessionKey, else create one bound to the cwd. // Resolve the OpenCode session: a known mapping, or — when resuming from history — the sessionKey is
let opencodeSessionId = getOpenCodeSession(params.sessionKey) ?? params.resumeSessionId; // 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) { if (!opencodeSessionId) {
opencodeSessionId = await conn.createSession(cwd); opencodeSessionId = await conn.createSession(cwd);
setOpenCodeSession(params.sessionKey, opencodeSessionId);
} }
setOpenCodeSession(params.sessionKey, opencodeSessionId);
const sessionId = opencodeSessionId; const sessionId = opencodeSessionId;
let unsub = () => {}; let unsub = () => {};
@@ -156,7 +156,13 @@ export const SessionList = () => {
})} })}
</span> </span>
<span>·</span> <span>·</span>
<span>{session.messageCount} msg{session.messageCount === 1 ? '' : 's'}</span> {session.harness === 'opencode' ? (
<span className="rounded bg-duck-teal/10 px-1.5 py-0.5 font-medium text-duck-teal">OpenCode</span>
) : (
<span>
{session.messageCount} msg{session.messageCount === 1 ? '' : 's'}
</span>
)}
</div> </div>
</div> </div>
</button> </button>
@@ -12,6 +12,7 @@ export type ClaudeSessionSummary = {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
messageCount: number; messageCount: number;
harness?: 'claude' | 'opencode'; // which harness produced it (absent = claude)
}; };
// Display-ready message, matching the frontend ChatMessage union (rebuilt from Claude's transcript). // Display-ready message, matching the frontend ChatMessage union (rebuilt from Claude's transcript).