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:
@@ -1,6 +1,6 @@
|
|||||||
import type { ClaudeSessionSummary, ClaudeSessionDetail, ClaudeChatMessage } from './claude-sessions';
|
import type { ClaudeSessionSummary, ClaudeSessionDetail, ClaudeChatMessage } from './claude-sessions';
|
||||||
import { ensureServer } from './opencode/server-manager';
|
import { ensureServer } from './opencode/server-manager';
|
||||||
import { getConnection, officerMeta } from './opencode/client';
|
import { getConnection } from './opencode/client';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
|
|
||||||
// The OpenCode analog of claude-sessions.ts. OpenCode's own store is the source of truth, read via the
|
// 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.
|
// 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
|
* List OpenCode sessions for a working directory, so each context (/chat pwd, email account, project)
|
||||||
* context (/chat pwd, email account, project) sees only its own. With no cwd, returns all. Never
|
* sees only its own. With no cwd, returns all. Never throws — returns [] if the server is unavailable.
|
||||||
* 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[]> {
|
export async function listOpenCodeSessions(cwd?: string): Promise<ClaudeSessionSummary[]> {
|
||||||
try {
|
try {
|
||||||
const { baseUrl } = await ensureServer();
|
const { baseUrl } = await ensureServer();
|
||||||
const sessions = await getConnection(baseUrl).listSessions();
|
const sessions = await getConnection(baseUrl).listSessions();
|
||||||
return sessions
|
return sessions
|
||||||
.filter((s) => !cwd || officerMeta(s.metadata).cwd === cwd)
|
.filter((s) => !cwd || s.directory === cwd)
|
||||||
.map((s) => {
|
.map((s) => {
|
||||||
const created = s.time?.created ?? Date.now();
|
const created = s.time?.created ?? Date.now();
|
||||||
const updated = s.time?.updated ?? created;
|
const updated = s.time?.updated ?? created;
|
||||||
return {
|
return {
|
||||||
id: s.id,
|
id: s.id,
|
||||||
title: s.title || '(untitled)',
|
title: s.title || '(untitled)',
|
||||||
cwd: s.location?.directory ?? '',
|
cwd: s.directory ?? '',
|
||||||
createdAt: new Date(created).toISOString(),
|
createdAt: new Date(created).toISOString(),
|
||||||
updatedAt: new Date(updated).toISOString(),
|
updatedAt: new Date(updated).toISOString(),
|
||||||
messageCount: 0, // the session list endpoint doesn't include a turn count
|
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> {
|
export async function loadOpenCodeSession(sessionId: string): Promise<ClaudeSessionDetail | null> {
|
||||||
try {
|
try {
|
||||||
const { baseUrl } = await ensureServer();
|
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[] = [];
|
const messages: ClaudeChatMessage[] = [];
|
||||||
let modelId = '';
|
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) {
|
} catch (err) {
|
||||||
logger.warn('Failed to load OpenCode session', { sessionId, error: String(err) });
|
logger.warn('Failed to load OpenCode session', { sessionId, error: String(err) });
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -146,6 +146,13 @@ class ServerConnection {
|
|||||||
return (await res.json()) as OpenCodeSessionInfo[];
|
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[]> {
|
async getMessages(sessionId: string): Promise<OpenCodeStoredMessage[]> {
|
||||||
const res = await fetch(`${this.baseUrl}/session/${sessionId}/message`);
|
const res = await fetch(`${this.baseUrl}/session/${sessionId}/message`);
|
||||||
if (!res.ok) throw new Error(`opencode GET /session/${sessionId}/message → ${res.status}`);
|
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 = {
|
export type OpenCodeSessionInfo = {
|
||||||
id: string;
|
id: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
time?: { created?: number; updated?: number };
|
time?: { created?: number; updated?: number };
|
||||||
location?: { directory?: string | null };
|
/** Absolute working directory the session belongs to. Top-level; there is no `location` wrapper. */
|
||||||
metadata?: Record<string, unknown>;
|
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 = {
|
export type OpenCodeStoredPart = {
|
||||||
type?: string;
|
type?: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user