diff --git a/src/servers/api/chat/opencode-sessions.ts b/src/servers/api/chat/opencode-sessions.ts index 2597f851..0877277f 100644 --- a/src/servers/api/chat/opencode-sessions.ts +++ b/src/servers/api/chat/opencode-sessions.ts @@ -1,6 +1,6 @@ import type { ClaudeSessionSummary, ClaudeSessionDetail, ClaudeChatMessage } from './claude-sessions'; import { ensureServer } from './opencode/server-manager'; -import { getConnection, officerMeta } from './opencode/client'; +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 @@ -9,23 +9,29 @@ import { logger } from './logger'; // tagged harness:'opencode', so chat.ts can merge both harnesses transparently. /** - * List OpenCode sessions tagged with the given logical cwd (via `metadata.officer.cwd`), 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. + * 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 when the runner starts it + * with `--dir`. 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. */ export async function listOpenCodeSessions(cwd?: string): Promise { try { const { baseUrl } = await ensureServer(); const sessions = await getConnection(baseUrl).listSessions(); return sessions - .filter((s) => !cwd || officerMeta(s.metadata).cwd === cwd) + .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: s.title || '(untitled)', - cwd: s.location?.directory ?? '', + 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 @@ -42,7 +48,11 @@ export async function listOpenCodeSessions(cwd?: string): Promise { try { const { baseUrl } = await ensureServer(); - const stored = await getConnection(baseUrl).getMessages(sessionId); + 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 = ''; @@ -81,7 +91,12 @@ export async function loadOpenCodeSession(sessionId: string): Promise { + const res = await fetch(`${this.baseUrl}/session/${sessionId}`); + if (!res.ok) return null; + return (await res.json()) as OpenCodeSessionInfo; + } + async getMessages(sessionId: string): Promise { const res = await fetch(`${this.baseUrl}/session/${sessionId}/message`); if (!res.ok) throw new Error(`opencode GET /session/${sessionId}/message → ${res.status}`); @@ -167,20 +174,32 @@ class ServerConnection { } } -// Shapes returned by the `/session/*` read endpoints (verified against opencode 1.17.9). +// Shapes returned by the `/session/*` read endpoints. +// +// Re-verified against the running opencode 1.17.9 on 2026-08-10 by reading `GET /session` directly. The +// keys it actually returns are: +// +// id, slug, projectID, directory, path, summary, cost, tokens, title, agent, model, version, time, +// permission +// +// Two fields this type used to declare are NOT among them, and both were load-bearing: +// +// - `location.directory` — there is no `location` object. The working directory is top-level +// `directory`. Every read of `location?.directory ?? ''` therefore produced '', which is why a +// resumed OpenCode session lost its cwd. +// - `metadata` — never returned, and nothing writes it. The session list filtered on +// `metadata.officer.cwd`, so it matched nothing and no OpenCode session was ever listed. +// +// Kept deliberately narrow: only the fields we read. Adding one means confirming it against a live +// server first — the previous pair were plausible and wrong, and cost two user-visible defects. export type OpenCodeSessionInfo = { id: string; title?: string; time?: { created?: number; updated?: number }; - location?: { directory?: string | null }; - metadata?: Record; + /** Absolute working directory the session belongs to. Top-level; there is no `location` wrapper. */ + directory?: string | null; }; -// Our own namespaced session metadata (stored under the free-form `metadata.officer` key). -export type OfficerSessionMeta = { cwd?: string }; -export const officerMeta = (m?: Record): OfficerSessionMeta => - (m?.officer as OfficerSessionMeta) ?? {}; - export type OpenCodeStoredPart = { type?: string; text?: string;