three complaints, one root cause and two small ones. THE TRANSCRIPT CAME BACK EMPTY because the client read `/session/<id>/message` and this build answers that with `[]` — 200, well-formed, and wrong. the same session returns 4 messages on `/api/session/<id>/message`. an empty transcript is indistinguishable from a new conversation, so reopening a chat said "send a message to start" and every message the owner had sent looked deleted. that also explains the third complaint. the model was read from the assistant turns, so no messages meant no model, so the detail reported a bare `opencode`, which the composer cannot match — and it fell back to claude code and sonnet. it now also reads the model off the session record, which is populated before any assistant turn lands. the message shape moved with the endpoint: `type` where it was `info.role`, `content` where it was `parts`, and a user message's text sits on the message rather than in a part. both shapes are accepted rather than pinned to today's build — this reader is the only thing between a working transcript and a chat that looks erased, and a host on a different opencode should lose features, never its history. AND THE ORDER WAS BACKWARDS. the endpoint returns newest first and nothing downstream re-sorted, so the answer sat above the question that produced it. sorted here, because this is the only place that still has timestamps. NAMING: opencode is meant to title a session from its contents and does not, on this surface — sessions stay at `New session - <timestamp>` through a finished turn and past it, which is why the list was a column of "(untitled)". `isPlaceholderOpenCodeTitle` exists precisely because that name leaks into the UI. so the sidecar names a NEW session from the first line of its first prompt, which is what the claude side shows. a resumed session keeps what it has, and if opencode ever does title one it writes after us and wins. verified live on the session that prompted this: 4 messages, in order, model opencode/kimi-k3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
168 lines
7.8 KiB
TypeScript
168 lines
7.8 KiB
TypeScript
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<ClaudeSessionSummary[]> {
|
|
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<ClaudeSessionDetail | null> {
|
|
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 = '';
|
|
|
|
/*
|
|
OLDEST FIRST. The endpoint returns newest first, and nothing downstream re-sorts — so the
|
|
conversation rendered backwards: the answer above the question that produced it, and the opening
|
|
message at the bottom. Sorting here rather than at the call site because this is the only place
|
|
that knows the wire order, and the display shape it returns has no timestamps left to sort by.
|
|
*/
|
|
const ordered = [...stored].sort((a, b) => (a.time?.created ?? 0) - (b.time?.created ?? 0));
|
|
|
|
for (const m of ordered) {
|
|
// `type` is the current build, `info.role` the older one. See OpenCodeStoredMessage.
|
|
const role = m.type ?? m.info?.role;
|
|
const parts = m.content ?? m.parts ?? [];
|
|
|
|
if (role === 'user') {
|
|
// The current build hands a user message's text over directly; the older one wraps it in parts.
|
|
const text = (
|
|
m.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.model?.id ?? m.model?.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') {
|
|
const callId = p.callID ?? p.id;
|
|
if (!callId) continue;
|
|
messages.push({
|
|
role: 'tool',
|
|
toolName: p.name ?? p.tool ?? 'tool',
|
|
toolInput: p.state?.input ?? {},
|
|
toolCallId: callId,
|
|
output: p.state?.output,
|
|
isError: Boolean(p.state?.error),
|
|
});
|
|
}
|
|
// skip reasoning/step-* parts (parity with the streaming delta filter).
|
|
}
|
|
}
|
|
}
|
|
|
|
// The session record carries the model too, and it is the only source for a conversation whose
|
|
// assistant turns have not landed yet. Without it the detail reports a bare `opencode`, the composer
|
|
// cannot match that to anything, and reopening the chat silently switches the harness back to Claude.
|
|
if (!modelId) modelId = info?.model?.id ?? info?.model?.modelID ?? '';
|
|
|
|
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<boolean> {
|
|
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<boolean> {
|
|
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_');
|