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:
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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_');
|
||||
@@ -121,8 +121,57 @@ class ServerConnection {
|
||||
async abort(sessionId: string): Promise<void> {
|
||||
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>();
|
||||
|
||||
export function getConnection(baseUrl: string): ServerConnection {
|
||||
|
||||
@@ -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 = () => {};
|
||||
|
||||
@@ -156,7 +156,13 @@ export const SessionList = () => {
|
||||
})}
|
||||
</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>
|
||||
</button>
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user