diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts index afdac3d7..ea0d890f 100644 --- a/src/servers/api/chat/chat.ts +++ b/src/servers/api/chat/chat.ts @@ -32,7 +32,8 @@ export const chatRouter = createRouter(); // The working directory a request operates on: an explicit ?cwd= (a chosen pwd), else the default // general_chat_sessions dir. Claude groups sessions by cwd, so this selects which project group we read. -// (OpenCode sessions all live in the one fixed server and ignore cwd.) +// OpenCode runs on one fixed serve, but each session records the directory its turn ran in, so cwd +// selects there too. const cwdOf = (ctx: Context, email: string): string => ctx.req.query('cwd')?.trim() || getGeneralChatSessionsCwd(email); // GET /chat/pwds — the default /chat dir plus every directory that already has Claude sessions. diff --git a/src/servers/api/chat/opencode-sessions.ts b/src/servers/api/chat/opencode-sessions.ts index b265974d..229dd1e9 100644 --- a/src/servers/api/chat/opencode-sessions.ts +++ b/src/servers/api/chat/opencode-sessions.ts @@ -7,18 +7,17 @@ import { logger } from './logger'; // 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. That claim was true when turns went through the serve and -// inherited its directory; turns are now `opencode run --dir ` subprocesses, so each session -// records the directory it ran in and one serve happily lists sessions across many. Verified: a single -// serve returned 7 sessions spread over several directories, which is also why the cwd filter has to -// read `directory` rather than assume. +// 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 when the runner starts it - * with `--dir`. It used to filter on `metadata.officer.cwd`, a tag whose only writer + * 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 diff --git a/src/servers/api/chat/opencode/client.ts b/src/servers/api/chat/opencode/client.ts index 7ac41dd9..92b461f9 100644 --- a/src/servers/api/chat/opencode/client.ts +++ b/src/servers/api/chat/opencode/client.ts @@ -1,5 +1,7 @@ // One connection per `opencode serve` base URL, for the REST reads the chat list and transcript need. -// The SDK-style `/session/*` route family is used (feature-complete, incl. DELETE). +// The SDK-style `/session/*` route family is used for the per-id reads and writes (feature-complete, +// incl. DELETE) — those answer for any session regardless of project, verified 200 with and without a +// directory header. The LIST is the exception and goes through `/api/session`; see `listSessions`. // // This used to also hold a shared SSE subscription (`GET /event`) demultiplexed per session, plus // `createSession`, `postMessage`, `abort` and `isServerHealthy` — the client half of a serve-based turn @@ -12,10 +14,30 @@ class ServerConnection { // ── Session history (REST; OpenCode's SQLite store is the source of truth) ── + /** + * Every session the serve knows, across every project. + * + * `GET /session` — the endpoint this used — answers for ONE project: the one the request's directory + * resolves to, which with no `x-opencode-directory` header is the serve's own cwd + * (`DATA_PATH/opencode_server`). That directory is not a git checkout, so it resolves to the catch-all + * project `global`, and so does every other non-git directory — which is why the general chat dir + * listed fine and nothing looked wrong. A cwd that IS a git checkout gets its own project and its + * sessions were simply never returned. Measured on the live serve: `/session` 8, `/api/session` 13, + * and a session created in a git directory came back 0 times from `/session` and 1 from `/api/session`. + * + * Chat pwds are project directories, so that is the ordinary case, not the edge one. + * + * The two surfaces disagree about the shape, and the difference is silent: `/session` carries the + * working directory as top-level `directory`, `/api/session` as `location.directory` with no top-level + * field at all, wrapped in `{data: …}`. Normalised here so callers keep reading `directory` — reading + * the wrong one yields `undefined` for every session, which filters the list down to nothing. + */ async listSessions(): Promise { - const res = await fetch(`${this.baseUrl}/session`); - if (!res.ok) throw new Error(`opencode GET /session → ${res.status}`); - return (await res.json()) as OpenCodeSessionInfo[]; + const res = await fetch(`${this.baseUrl}/api/session`); + if (!res.ok) throw new Error(`opencode GET /api/session → ${res.status}`); + const body = (await res.json()) as { data?: ApiSessionInfo[] } | ApiSessionInfo[]; + const sessions = Array.isArray(body) ? body : (body.data ?? []); + return sessions.map((s) => ({ ...s, directory: s.directory ?? s.location?.directory ?? null })); } /** One session's own record. The only place its working directory can be read on resume. */ @@ -72,6 +94,9 @@ export type OpenCodeSessionInfo = { directory?: string | null; }; +/** `/api/session`'s shape: the same record, with the directory one level down. Normalised by `listSessions`. */ +type ApiSessionInfo = OpenCodeSessionInfo & { location?: { directory?: string | null } }; + export type OpenCodeStoredPart = { type?: string; text?: string;