From 14bff361f8db5b02573908512df6a11db775b945 Mon Sep 17 00:00:00 2001 From: Orestis Ioannou Date: Wed, 15 Apr 2026 18:22:55 +0300 Subject: [PATCH] fix(opencode): reuse local server for review flows (#567) * fix(opencode): reuse local server for review flows Try the default local OpenCode server before spawning a new one, and resolve bundled assets and command paths correctly when the plugin is loaded from source during local testing. * Fix typecheck after narrowing the opencode type to the sdk --- apps/opencode-plugin/index.ts | 27 +++++- apps/opencode-plugin/package.json | 2 +- packages/ai/providers/opencode-sdk.ts | 110 +++++++++++++++++++------ packages/ai/types.ts | 2 +- tests/manual/local/sandbox-opencode.sh | 18 ++-- 5 files changed, 117 insertions(+), 42 deletions(-) diff --git a/apps/opencode-plugin/index.ts b/apps/opencode-plugin/index.ts index 233989e1..fbf46499 100644 --- a/apps/opencode-plugin/index.ts +++ b/apps/opencode-plugin/index.ts @@ -68,13 +68,32 @@ import { let _planHtml: string | null = null; let _reviewHtml: string | null = null; +function resolveBundledHtmlPath(filename: string): string { + const candidates = [ + path.join(import.meta.dir, filename), + path.join(import.meta.dir, "..", filename), + ]; + + for (const candidate of candidates) { + if (existsSync(candidate)) { + return candidate; + } + } + + throw new Error(`Could not find bundled HTML asset: ${filename}`); +} + +function readBundledHtml(filename: string): string { + return readFileSync(resolveBundledHtmlPath(filename), "utf-8"); +} + function getPlanHtml(): string { - if (!_planHtml) _planHtml = readFileSync(path.join(import.meta.dir, "..", "plannotator.html"), "utf-8"); + if (!_planHtml) _planHtml = readBundledHtml("plannotator.html"); return _planHtml; } function getReviewHtml(): string { - if (!_reviewHtml) _reviewHtml = readFileSync(path.join(import.meta.dir, "..", "review-editor.html"), "utf-8"); + if (!_reviewHtml) _reviewHtml = readBundledHtml("review-editor.html"); return _reviewHtml; } @@ -153,8 +172,8 @@ Only write and submit a plan once you have sufficient context. export const PlannotatorPlugin: Plugin = async (ctx) => { // Preload HTML in background — populates the sync cache before first use - Bun.file(path.join(import.meta.dir, "..", "plannotator.html")).text().then(h => { _planHtml = h; }); - Bun.file(path.join(import.meta.dir, "..", "review-editor.html")).text().then(h => { _reviewHtml = h; }); + Bun.file(resolveBundledHtmlPath("plannotator.html")).text().then(h => { _planHtml = h; }); + Bun.file(resolveBundledHtmlPath("review-editor.html")).text().then(h => { _reviewHtml = h; }); let cachedAgents: any[] | null = null; diff --git a/apps/opencode-plugin/package.json b/apps/opencode-plugin/package.json index 75f937e7..7d34b97e 100644 --- a/apps/opencode-plugin/package.json +++ b/apps/opencode-plugin/package.json @@ -31,7 +31,7 @@ ], "scripts": { "build": "cp ../hook/dist/index.html ./plannotator.html && cp ../review/dist/index.html ./review-editor.html && bun build index.ts --outfile dist/index.js --target bun --external @opencode-ai/plugin", - "postinstall": "mkdir -p ${XDG_CONFIG_HOME:-$HOME/.config}/opencode/command && cp ./commands/*.md ${XDG_CONFIG_HOME:-$HOME/.config}/opencode/command/ 2>/dev/null || true", + "postinstall": "mkdir -p ${XDG_CONFIG_HOME:-$HOME/.config}/opencode/commands && cp ./commands/*.md ${XDG_CONFIG_HOME:-$HOME/.config}/opencode/commands/ 2>/dev/null || true", "prepublishOnly": "bun run build" }, "dependencies": { diff --git a/packages/ai/providers/opencode-sdk.ts b/packages/ai/providers/opencode-sdk.ts index 12e8d9c7..0430af24 100644 --- a/packages/ai/providers/opencode-sdk.ts +++ b/packages/ai/providers/opencode-sdk.ts @@ -1,11 +1,13 @@ /** * OpenCode provider — bridges Plannotator's AI layer with OpenCode's agent server. * - * Uses @opencode-ai/sdk to spawn `opencode serve` and communicate via HTTP + SSE. - * One server per provider, shared across all sessions. The user must have the - * `opencode` CLI installed and authenticated. + * Uses @opencode-ai/sdk to connect to an existing `opencode serve` first and + * only spawns a new server when nothing is reachable. One server is shared + * across all sessions. The user must have the `opencode` CLI installed and + * authenticated. */ +import type { OpencodeClient } from "@opencode-ai/sdk"; import { BaseSession } from "../base-session.ts"; import { buildSystemPrompt } from "../context.ts"; import type { @@ -54,17 +56,17 @@ export class OpenCodeProvider implements AIProvider { private config: OpenCodeConfig; // biome-ignore lint/suspicious/noExplicitAny: SDK types not available at compile time private server: { url: string; close: () => void } | null = null; - // biome-ignore lint/suspicious/noExplicitAny: SDK types not available at compile time - private client: any = null; + private client: OpencodeClient | null = null; private startPromise: Promise | null = null; + private lastAttachError: string | null = null; constructor(config: OpenCodeConfig) { this.config = config; } - /** Lazy-spawn the OpenCode server and create the HTTP client. */ + /** Attach to an existing OpenCode server or spawn one if needed. */ async ensureServer(): Promise { - if (this.server && this.client) return; + if (this.client) return; this.startPromise ??= this.doStart().catch((err) => { this.startPromise = null; throw err; @@ -73,13 +75,27 @@ export class OpenCodeProvider implements AIProvider { } private async doStart(): Promise { + this.lastAttachError = null; const { createOpencodeServer, createOpencodeClient } = await getSDK(); + const attachedClient = await this.tryAttachExistingServer(createOpencodeClient); + if (attachedClient) { + this.client = attachedClient; + return; + } - this.server = await createOpencodeServer({ - hostname: this.config.hostname ?? "127.0.0.1", - ...(this.config.port != null && { port: this.config.port }), - timeout: 15_000, - }); + try { + this.server = await createOpencodeServer({ + hostname: this.config.hostname ?? "127.0.0.1", + ...(this.config.port != null && { port: this.config.port }), + timeout: 15_000, + }); + } catch (err) { + const spawnMessage = err instanceof Error ? err.message : String(err); + if (this.lastAttachError) { + throw new Error(`${this.lastAttachError}\nFallback startup also failed: ${spawnMessage}`); + } + throw err; + } this.client = createOpencodeClient({ baseUrl: this.server!.url, @@ -87,18 +103,52 @@ export class OpenCodeProvider implements AIProvider { }); } + private async tryAttachExistingServer( + createOpencodeClient: (config?: { baseUrl?: string; directory?: string }) => OpencodeClient, + ): Promise { + const cwd = this.config.cwd ?? process.cwd(); + const baseUrl = `http://${this.config.hostname ?? "127.0.0.1"}:${this.config.port ?? 4096}`; + const client = createOpencodeClient({ + baseUrl, + directory: cwd, + }); + + try { + await client.config.get({ + throwOnError: true, + signal: AbortSignal.timeout(1_000), + }); + return client; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.lastAttachError = `Failed to attach to existing OpenCode server at ${baseUrl}: ${message}`; + return null; + } + } + + private getClient(): OpencodeClient { + if (!this.client) { + throw new Error("OpenCode client is not initialized."); + } + return this.client; + } + async createSession(options: CreateSessionOptions): Promise { await this.ensureServer(); + const client = this.getClient(); - const result = await this.client.session.create({ + const result = await client.session.create({ query: { directory: options.cwd ?? this.config.cwd ?? process.cwd() }, }); const sessionData = result.data; + if (!sessionData) { + throw new Error("OpenCode did not return session data."); + } const session = new OpenCodeSession({ sessionId: sessionData.id, systemPrompt: buildSystemPrompt(options.context), - client: this.client, + client, model: options.model, parentSessionId: null, }); @@ -107,21 +157,25 @@ export class OpenCodeProvider implements AIProvider { async forkSession(options: CreateSessionOptions): Promise { await this.ensureServer(); + const client = this.getClient(); const parentId = options.context.parent?.sessionId; if (!parentId) { throw new Error("Fork requires a parent session ID."); } - const result = await this.client.session.fork({ + const result = await client.session.fork({ path: { id: parentId }, }); const sessionData = result.data; + if (!sessionData) { + throw new Error("OpenCode did not return forked session data."); + } return new OpenCodeSession({ sessionId: sessionData.id, systemPrompt: buildSystemPrompt(options.context), - client: this.client, + client, model: options.model, parentSessionId: parentId, }); @@ -129,14 +183,15 @@ export class OpenCodeProvider implements AIProvider { async resumeSession(sessionId: string): Promise { await this.ensureServer(); + const client = this.getClient(); // Verify session exists - await this.client.session.get({ path: { id: sessionId } }); + await client.session.get({ path: { id: sessionId } }); return new OpenCodeSession({ sessionId, systemPrompt: null, - client: this.client, + client, model: undefined, parentSessionId: null, }); @@ -146,32 +201,33 @@ export class OpenCodeProvider implements AIProvider { if (this.server) { this.server.close(); this.server = null; - this.client = null; - this.startPromise = null; } + this.client = null; + this.startPromise = null; } /** Fetch available models from OpenCode. Call before registering the provider. */ async fetchModels(): Promise { try { await this.ensureServer(); + const client = this.getClient(); - const result = await this.client.provider.list({ + const result = await client.provider.list({ query: { directory: this.config.cwd ?? process.cwd() }, }); const data = result.data; - const connected = new Set(data.connected as string[]); - const allProviders = data.all as Array<{ - id: string; - models: Record; - }>; + if (!data) { + return; + } + const connected = new Set(data.connected ?? []); + const allProviders = data.all ?? []; const models: Array<{ id: string; label: string; default?: boolean }> = []; for (const provider of allProviders) { if (!connected.has(provider.id)) continue; for (const model of Object.values(provider.models)) { models.push({ - id: `${model.providerID}/${model.id}`, + id: `${provider.id}/${model.id}`, label: model.name ?? model.id, }); } diff --git a/packages/ai/types.ts b/packages/ai/types.ts index 13c0fbd9..9353c2e3 100644 --- a/packages/ai/types.ts +++ b/packages/ai/types.ts @@ -364,6 +364,6 @@ export interface OpenCodeConfig extends AIProviderConfig { type: "opencode-sdk"; /** Hostname for the OpenCode server. Default: "127.0.0.1". */ hostname?: string; - /** Port for the OpenCode server. Default: random. */ + /** Port for the OpenCode server. Default: 4096. */ port?: number; } diff --git a/tests/manual/local/sandbox-opencode.sh b/tests/manual/local/sandbox-opencode.sh index fa6e8c45..0de4ceb1 100755 --- a/tests/manual/local/sandbox-opencode.sh +++ b/tests/manual/local/sandbox-opencode.sh @@ -1538,22 +1538,22 @@ echo "" # Set up local plugin via loader file echo "Setting up local plugin..." -mkdir -p .opencode/plugin +mkdir -p .opencode/plugins # Create a loader file that re-exports from the source -# OpenCode only loads top-level .ts/.js files in the plugin directory -cat > .opencode/plugin/plannotator.ts << EOF +# OpenCode only loads top-level .ts/.js files in the plugins directory +cat > .opencode/plugins/plannotator.ts << EOF // Loader for local Plannotator plugin development export * from "$PLUGIN_DIR/index.ts"; EOF -# Copy command files to local .opencode/command -mkdir -p .opencode/command -cp "$PLUGIN_DIR/commands/"*.md .opencode/command/ +# Copy command files to local .opencode/commands +mkdir -p .opencode/commands +cp "$PLUGIN_DIR/commands/"*.md .opencode/commands/ -# Also install to global command directory (some OpenCode versions need this) -mkdir -p ~/.config/opencode/command -cp "$PLUGIN_DIR/commands/"*.md ~/.config/opencode/command/ 2>/dev/null || true +# Also install to global commands directory (some OpenCode versions need this) +mkdir -p ~/.config/opencode/commands +cp "$PLUGIN_DIR/commands/"*.md ~/.config/opencode/commands/ 2>/dev/null || true echo ""