From 54468a8c1c9ff6af75faa54daec96ae0e2e8a23b Mon Sep 17 00:00:00 2001 From: Somdutta Roy Date: Tue, 27 Oct 2026 16:39:40 -0700 Subject: [PATCH] fix(cli): support OpenCode v2 servers in the OpenCode agent The OpenCode integration could not launch or talk to OpenCode v2.x servers, so tapping OpenCode in the app always failed. Three separate v2 incompatibilities are addressed: 1. Ready-line banner. `createOpencodeServer()` from the bundled `@opencode-ai/sdk` waits for a line starting with "opencode server listening". v2 prints "server listening on ", so the match never fired and the SDK killed the server after the 15s timeout ("Timeout waiting for server to start after 15000ms"). We now discover and reuse an already-running server instead of relying on that banner: - `OPENCODE_SERVER_URL` (explicit override) - probe the default port (4096) - read `~/.config/opencode/service.json` written by `opencode serve --service`, including its password Servers discovered this way are marked external and are never killed on `destroy()`; only servers we spawned are closed. 2. Authentication. v2 servers generate a per-server password. The CLI built its client with a random Basic header that v2 rejects with 401. The password read from `service.json` is now used for Basic auth. 3. Session list endpoint and payload shape. The SDK's `experimental.session.list` requests `/experimental/session`, which v2.1.4 serves as the web UI (text/html) rather than JSON, so the SDK threw "Request is not supported by this version of OpenCode Server (Server responded with text/html)". We now fall back to a direct request against `/api/session`, and normalize the v2 payload into the v1 shape the app expects: `directory` comes from `location.directory`, `slug` is derived when absent, `version` comes from `/api/info`, and `project` is reconstructed. v1-shaped responses pass through unchanged. Fixes #117 --- cli/src/agents/opencode.ts | 263 +++++++++++++++++++++++++++++++++++-- 1 file changed, 251 insertions(+), 12 deletions(-) diff --git a/cli/src/agents/opencode.ts b/cli/src/agents/opencode.ts index 34327f6..2852cc9 100644 --- a/cli/src/agents/opencode.ts +++ b/cli/src/agents/opencode.ts @@ -1,4 +1,7 @@ import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import type * as acp from "@agentclientprotocol/sdk"; import { @@ -8,6 +11,7 @@ import { import { z } from "zod"; import { config } from "@/config"; +import { logger } from "@/logger"; import { BUILTIN_AGENT_DESCRIPTORS } from "./agents"; import { ACP } from "./base"; import { type AcpTranscriptOptions, acpSessionToAiSession } from "./events"; @@ -16,6 +20,93 @@ import { shouldSkipOpenCodeReadReplayContent, } from "./replay-normalization"; +/** A discovered running OpenCode server and its password (if known). */ +interface DiscoveredOpenCodeServer { + url: string; + password: string | null; + /** True when we did not spawn this server and must not kill it on destroy. */ + external: true; +} + +/** Handle for a server we spawned ourselves (SDK-provided close handle). */ +interface SpawnedOpenCodeServer { + url: string; + password: string | null; + external: false; + close: () => void; +} + +type OpenCodeServer = DiscoveredOpenCodeServer | SpawnedOpenCodeServer; + +/** Probe whether an OpenCode HTTP server is alive at the given base URL. */ +async function probeOpenCodeServer(url: string): Promise { + try { + const response = await fetch(`${url}/api/info`, { + signal: AbortSignal.timeout(2000), + }); + // Any HTTP response (even 401) means a server is alive there. + return response.status < 500; + } catch { + return false; + } +} + +/** + * Detect an already-running OpenCode server (from a previous `opencode serve` + * or `opencode serve --service`) and reuse it instead of spawning a fresh one. + * + * Resolution order: + * 1. `OPENCODE_SERVER_URL` environment variable (explicit override). + * 2. Probe the well-known default port (4096) on loopback. + * 3. Read `~/.config/opencode/service.json` written by `opencode serve --service`. + * + * Returns the base URL and password (if known), otherwise null. + */ +async function discoverRunningOpenCodeServer(): Promise { + const envUrl = process.env.OPENCODE_SERVER_URL; + if (envUrl) { + return { url: envUrl.replace(/\/+$/, ""), password: null, external: true }; + } + + // Probe the well-known default port used by `opencode serve` (v2). + const defaultPort = Number(process.env.OPENCODE_PORT || 4096); + const candidate = `http://127.0.0.1:${defaultPort}`; + if (await probeOpenCodeServer(candidate)) { + return { url: candidate, password: null, external: true }; + } + + // Read service.json written by `opencode serve --service`. + const serviceConfigPath = path.join( + os.homedir(), + ".config", + "opencode", + "service.json", + ); + try { + const raw = await fs.promises.readFile(serviceConfigPath, "utf8"); + const serviceConfig = JSON.parse(raw) as { + hostname?: string; + port?: number; + password?: string; + }; + if (serviceConfig?.port) { + const hostname = serviceConfig.hostname || "127.0.0.1"; + const url = `http://${hostname === "0.0.0.0" ? "127.0.0.1" : hostname}:${serviceConfig.port}`; + if (await probeOpenCodeServer(url)) { + return { + url, + password: serviceConfig.password ?? null, + external: true, + }; + } + } + } catch { + // No service.json or unreadable — continue with the spawn fallback. + } + + return null; +} + const OpenCodeSessionSchema = z.object({ id: z.string(), slug: z.string(), @@ -50,6 +141,72 @@ const OpenCodeSessionListSchema = z.union([ z.array(OpenCodeSessionSchema), ]); +/** + * Normalize an OpenCode v2 server session object into the v1 shape the + * Shellular app expects (a v2→v1 adapter). + * + * v2 moves `directory` under `location`, drops `slug`/`version`, and adds + * extra fields (agent, model, tokens, outcome) that the v1 schema ignores. + * v1-shaped sessions are passed through unchanged, so this also works with + * older servers that already speak the v1 shape. + */ +function normalizeOpenCodeSession( + session: unknown, + serverVersion: string, +): unknown { + if (typeof session !== "object" || session === null) return session; + const s = session as Record; + const location = s.location as Record | undefined; + const directory = + typeof s.directory === "string" + ? s.directory + : typeof location?.directory === "string" + ? location.directory + : undefined; + if (typeof directory !== "string") { + return session; + } + + const title = typeof s.title === "string" ? s.title : ""; + const slug = + typeof s.slug === "string" && s.slug + ? s.slug + : title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") || s.id; + + return { + ...s, + directory, + slug, + version: typeof s.version === "string" ? s.version : serverVersion, + project: { id: s.projectID, worktree: directory }, + }; +} + +/** + * Wrap a v2 session-list response into the v1 shape, given the server's + * reported version. Returns the input unchanged for v1-shaped responses. + */ +function wrapOpenCodeSessionsV2ToV1( + response: unknown, + serverVersion: string, +): unknown { + if (typeof response !== "object" || response === null) return response; + const sessions = Array.isArray(response) + ? response + : (response as Record).data; + if (!Array.isArray(sessions)) return response; + + const normalized = sessions.map((session) => + normalizeOpenCodeSession(session, serverVersion), + ); + return Array.isArray(response) + ? normalized + : { ...response, data: normalized }; +} + /** * OpenCode ACP client with custom session listing using opencode sdk. * @@ -68,9 +225,9 @@ const OpenCodeSessionListSchema = z.union([ */ export class OpenCode extends ACP { #ocClient: ReturnType | null = null; - private ocServer: Awaited> | null = - null; + private ocServer: OpenCodeServer | null = null; private ocAuthHeader: string | null = null; + private ocServerVersion: string | null = null; static create() { return new OpenCode(); @@ -78,11 +235,22 @@ export class OpenCode extends ACP { constructor() { super(BUILTIN_AGENT_DESCRIPTORS.opencode); + // Default auth header (used when we spawn our own server or no password + // is known). v2 servers generate a per-server password, so this is a + // placeholder that gets replaced in `init()` when a server is + // discovered or spawned with a known password. const opencodeUsername = config.NAME; const opencodePassword = crypto.randomBytes(32).toString("base64url"); this.ocAuthHeader = `Basic ${Buffer.from(`${opencodeUsername}:${opencodePassword}`).toString("base64")}`; } + private setAuthHeaderFor(password: string | null) { + if (!password) { + return; + } + this.ocAuthHeader = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`; + } + protected override transcriptOptions(): AcpTranscriptOptions { return { shouldSkipUserReplayContent: shouldSkipOpenCodeReadReplayContent, @@ -94,21 +262,78 @@ export class OpenCode extends ACP { const result = await super.init(); if (!this.ocServer) { - this.ocServer = await createOpencodeServer({ - hostname: "127.0.0.1", - port: 0, - timeout: 15000, - }); + // Prefer reusing an already-running OpenCode server. + const discovered = await discoverRunningOpenCodeServer(); + if (discovered) { + logger.log(`reusing running opencode server at ${discovered.url}`); + this.ocServer = discovered; + this.setAuthHeaderFor(discovered.password); + } else { + const spawned = await createOpencodeServer({ + hostname: "127.0.0.1", + port: 0, + timeout: 15000, + }); + this.ocServer = { + url: spawned.url, + password: null, + external: false, + close: spawned.close, + }; + } + this.#ocClient = createOpencodeClient({ baseUrl: this.ocServer.url, headers: { Authorization: this.ocAuthHeader }, directory: undefined, }); + + // Ask the server for its version (used by the v2→v1 session + // wrapper below). Best-effort — falls back to a placeholder. + this.ocServerVersion = await this.fetchServerVersion(); } return result; } + private async fetchServerVersion(): Promise { + try { + const response = await fetch(`${this.ocServer?.url}/api/info`, { + headers: { Authorization: this.ocAuthHeader ?? "" }, + signal: AbortSignal.timeout(2000), + }); + if (!response.ok) return null; + const info = (await response.json()) as { version?: string }; + return typeof info.version === "string" ? info.version : null; + } catch { + return null; + } + } + + /** + * Fetch the session list directly from the v2 HTTP API (/api/session). + * The SDK's experimental.session.list points at /experimental/session, + * which OpenCode v2.x serves as the web UI (HTML), not JSON. + */ + private async fetchSessionsViaHttp(): Promise<{ data: unknown[] }> { + if (!this.ocServer?.url) { + throw new Error("OpenCode server not initialized"); + } + + const response = await fetch(`${this.ocServer.url}/api/session`, { + headers: { Authorization: this.ocAuthHeader ?? "" }, + signal: AbortSignal.timeout(10000), + }); + if (!response.ok) { + throw new Error(`OpenCode session list failed: HTTP ${response.status}`); + } + const body = (await response.json()) as { data?: unknown[] }; + if (!Array.isArray(body.data)) { + throw new Error("OpenCode session list returned unexpected shape"); + } + return { data: body.data }; + } + get ocClient() { if (!this.#ocClient) { throw new Error("OpenCode client not initialized"); @@ -122,10 +347,21 @@ export class OpenCode extends ACP { ): Promise { await this.init(); - const response = await this.ocClient.experimental.session.list({ - ...(params.cwd ? { directory: params.cwd } : { roots: true }), - }); - const parsed = OpenCodeSessionListSchema.parse(response); + const response = await this.ocClient.experimental.session + .list({ + ...(params.cwd ? { directory: params.cwd } : { roots: true }), + }) + .catch(async () => { + // OpenCode v2.1.4 serves the experimental session list at + // /api/session; the SDK's /experimental/session path returns the + // web UI (HTML), so fall back to the direct HTTP API. + return await this.fetchSessionsViaHttp(); + }); + const wrapped = wrapOpenCodeSessionsV2ToV1( + response, + this.ocServerVersion ?? "2.0.0", + ); + const parsed = OpenCodeSessionListSchema.parse(wrapped); const openCodeSessions = Array.isArray(parsed) ? parsed : parsed.data; const sessions = openCodeSessions.map((session): acp.SessionInfo => { @@ -164,7 +400,10 @@ export class OpenCode extends ACP { this.ocAuthHeader = null; this.#ocClient = null; if (this.ocServer) { - this.ocServer.close(); + if (!this.ocServer.external) { + // Only kill servers we spawned ourselves. + this.ocServer.close(); + } this.ocServer = null; } }