Files
platform/src/servers/api/chat/opencode-sessions.ts
T
pastilhasandClaude Opus 5 adaaba658c list opencode sessions from every project, not just the serve's own
An opencode session in a git directory never appeared in /chat. The list read GET /session,
which answers for ONE project — the one the request's directory resolves to, and with no
x-opencode-directory header that is the serve's own cwd, DATA_PATH/opencode_server. Not a git
checkout, so it resolves to the catch-all project `global`, along with every other non-git
directory. That is why the default chat dir listed fine and nothing looked broken: a cwd that
IS a checkout gets its own project, and chat pwds are checkouts.

Measured on the live serve before changing anything: /session returned 8 sessions, /api/session
13, the five missing ones being an old project's. A session created in a git directory came back
0 times from /session and 1 from /api/session.

/api/session spans projects, so that is now the list. The per-id reads stay on /session — they
answer for any session regardless of project, verified 200 with and without the header.

The trap, and the reason listSessions normalises rather than returning the response: the two
surfaces disagree in silence. /session carries the working directory as top-level `directory`,
/api/session as `location.directory` with no top-level field, inside a {data: …} envelope.
Swapping the endpoint without the mapping leaves `directory` undefined on every session, which
the cwd filter turns into an empty list — the same shape as the metadata.officer.cwd bug this
filter already had once.

Verified against the live serve: with the mapping, a session in a git directory and one in the
general chat dir both resolve to their cwd, and every session carries a directory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 02:41:24 +01:00

149 lines
6.6 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 = '';
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<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_');