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