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
This commit is contained in:
Orestis Ioannou
2026-04-15 18:22:55 +03:00
committed by GitHub
parent 4139999526
commit 14bff361f8
5 changed files with 117 additions and 42 deletions
+23 -4
View File
@@ -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;
+1 -1
View File
@@ -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": {
+83 -27
View File
@@ -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<void> | 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<void> {
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<void> {
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<OpencodeClient | null> {
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<AISession> {
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<AISession> {
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<AISession> {
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<void> {
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<string, { id: string; providerID: string; name: string }>;
}>;
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,
});
}
+1 -1
View File
@@ -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;
}
+9 -9
View File
@@ -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 ""