// 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 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 // path that was replaced by `opencode run --dir` subprocesses and had no callers left. Removed // 2026-08-10; `docs/opencode-serve-path.md` records what it did and what a rebuild would want back, // because Phase 2 may want exactly this shape again. class ServerConnection { constructor(private readonly baseUrl: string) {} // ── 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}/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. */ async getSession(sessionId: string): Promise { const res = await fetch(`${this.baseUrl}/session/${sessionId}`); if (!res.ok) return null; return (await res.json()) as OpenCodeSessionInfo; } /* `/api/session/…/message`, NOT `/session/…/message`. Both paths exist and only one still works. The legacy one answers 200 with an empty array for a session that plainly has messages — verified 2026-08-27 against a session showing 4 on the `/api` path and 0 on the other. An empty transcript is indistinguishable from a new conversation, so reopening an OpenCode chat showed "Send a message to start" and every message the owner had sent was apparently gone. It also emptied the model, which is what made the composer fall back to Claude. Every other read this client does is fine on either path; this is the only one that moved. */ async getMessages(sessionId: string): Promise { const res = await fetch(`${this.baseUrl}/api/session/${sessionId}/message`); if (!res.ok) throw new Error(`opencode GET /api/session/${sessionId}/message → ${res.status}`); const body = (await res.json()) as { data?: OpenCodeStoredMessage[] } | OpenCodeStoredMessage[]; return Array.isArray(body) ? body : (body.data ?? []); } async deleteSession(sessionId: string): Promise { const res = await fetch(`${this.baseUrl}/session/${sessionId}`, { method: 'DELETE' }); return res.ok; } async renameSession(sessionId: string, title: string): Promise { const res = await fetch(`${this.baseUrl}/session/${sessionId}`, { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ title }), }); return res.ok; } } // 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; }; /** The model this conversation is on. Present before any assistant turn has landed. */ model?: { id?: string; modelID?: string; providerID?: string }; /** Absolute working directory the session belongs to. Top-level; there is no `location` wrapper. */ 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; tool?: string; callID?: string; state?: { input?: Record; output?: string; error?: string; status?: string }; /** Current build names a tool part here; the older one used `tool`. */ name?: string; /** Current build's id for a tool call, where the older one used `callID`. */ id?: string; }; /** * Two shapes, because two builds. * * The older one nests everything: `{ info: { role }, parts: [...] }`. The current one flattens it — * `{ type: 'user' | 'assistant', text, content: [...] }` — and puts a user message's text directly on * the message rather than in a part. Both are accepted rather than pinned to today's build: this reader * is the only thing standing between a working transcript and a chat that looks erased, and a host on a * different opencode should degrade to fewer features, never to an empty conversation. */ export type OpenCodeStoredMessage = { /** Wire order is newest-first, so the reader sorts on this. */ time?: { created?: number }; /** Current build: the role, flattened. */ type?: string; /** Current build: a user message's text, with no part wrapping it. */ text?: string; /** Current build: the assistant's parts. */ content?: OpenCodeStoredPart[]; /** Current build: `{ id, providerID }`. */ model?: { id?: string; modelID?: string; providerID?: string }; /** Older build. */ info?: { role?: string; modelID?: string; model?: { modelID?: string; providerID?: string } }; /** Older build. */ parts?: OpenCodeStoredPart[]; }; const connections = new Map(); export function getConnection(baseUrl: string): ServerConnection { let conn = connections.get(baseUrl); if (!conn) { conn = new ServerConnection(baseUrl); connections.set(baseUrl, conn); } return conn; } export type { ServerConnection };