list opencode sessions at all, and give a resumed one its directory back

Two defects, one cause: the session type declared two fields opencode 1.17.9 does not return.

`GET /session` returns `directory` at the top level. There is no `location` object and no `metadata`.
Re-verified by reading the live server rather than the type.

So `metadata.officer.cwd` was compared against `undefined` for every session and the list filter matched
nothing — and since `cwdOf` substitutes a default when no `?cwd=` is given, the `!cwd` escape never fired
either. There was no configuration in which an OpenCode session appeared in /chat. Confirmed against the
running server: 7 sessions present, 0 returned, and the `OpenCode` badge in SessionList was unreachable
code. Now 1 of 7 is listed under the default chat dir, the other 6 correctly filtered to their own
directories.

And `location?.directory ?? ''` was likewise always '', so resuming a session reported no cwd and
relocated the conversation to the default chat dir — which matters because OpenCode rebuilds its
working-directory system prompt every turn. Detail now reads the session's own record via a new
`getSession`, alongside the transcript.

`officerMeta` and the `metadata` tag are gone rather than fixed: the only writer of that tag
(`client.createSession`) has no callers, because the sidecar creates sessions with `opencode run --dir`.
Tagging would have been a second source of truth for something `directory` already answers.

docs/opencode-parity.md B1 and B3. Its suggested fix — derive from `location.directory` — was written
against a field that does not exist; the doc asked for the version to be checked first, and this is why.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 01:59:19 +00:00
co-authored by Claude Opus 5
parent 52bac070ce
commit 22bcd7d4b9
2 changed files with 50 additions and 16 deletions
+23 -8
View File
@@ -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<ClaudeSessionSummary[]> {
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<ClaudeSessionS
export async function loadOpenCodeSession(sessionId: string): Promise<ClaudeSessionDetail | null> {
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<ClaudeSess
}
}
return { id: sessionId, model: modelId ? `opencode/${modelId}` : 'opencode', cwd: '', messages };
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;
+27 -8
View File
@@ -146,6 +146,13 @@ class ServerConnection {
return (await res.json()) as OpenCodeSessionInfo[];
}
/** One session's own record. The only place its working directory can be read on resume. */
async getSession(sessionId: string): Promise<OpenCodeSessionInfo | null> {
const res = await fetch(`${this.baseUrl}/session/${sessionId}`);
if (!res.ok) return null;
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}`);
@@ -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<string, unknown>;
/** 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<string, unknown>): OfficerSessionMeta =>
(m?.officer as OfficerSessionMeta) ?? {};
export type OpenCodeStoredPart = {
type?: string;
text?: string;