Skip to content

Commit 14bff36

Browse files
authored
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
1 parent 4139999 commit 14bff36

5 files changed

Lines changed: 117 additions & 42 deletions

File tree

apps/opencode-plugin/index.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,13 +68,32 @@ import {
6868
let _planHtml: string | null = null;
6969
let _reviewHtml: string | null = null;
7070

71+
function resolveBundledHtmlPath(filename: string): string {
72+
const candidates = [
73+
path.join(import.meta.dir, filename),
74+
path.join(import.meta.dir, "..", filename),
75+
];
76+
77+
for (const candidate of candidates) {
78+
if (existsSync(candidate)) {
79+
return candidate;
80+
}
81+
}
82+
83+
throw new Error(`Could not find bundled HTML asset: ${filename}`);
84+
}
85+
86+
function readBundledHtml(filename: string): string {
87+
return readFileSync(resolveBundledHtmlPath(filename), "utf-8");
88+
}
89+
7190
function getPlanHtml(): string {
72-
if (!_planHtml) _planHtml = readFileSync(path.join(import.meta.dir, "..", "plannotator.html"), "utf-8");
91+
if (!_planHtml) _planHtml = readBundledHtml("plannotator.html");
7392
return _planHtml;
7493
}
7594

7695
function getReviewHtml(): string {
77-
if (!_reviewHtml) _reviewHtml = readFileSync(path.join(import.meta.dir, "..", "review-editor.html"), "utf-8");
96+
if (!_reviewHtml) _reviewHtml = readBundledHtml("review-editor.html");
7897
return _reviewHtml;
7998
}
8099

@@ -153,8 +172,8 @@ Only write and submit a plan once you have sufficient context.
153172

154173
export const PlannotatorPlugin: Plugin = async (ctx) => {
155174
// Preload HTML in background — populates the sync cache before first use
156-
Bun.file(path.join(import.meta.dir, "..", "plannotator.html")).text().then(h => { _planHtml = h; });
157-
Bun.file(path.join(import.meta.dir, "..", "review-editor.html")).text().then(h => { _reviewHtml = h; });
175+
Bun.file(resolveBundledHtmlPath("plannotator.html")).text().then(h => { _planHtml = h; });
176+
Bun.file(resolveBundledHtmlPath("review-editor.html")).text().then(h => { _reviewHtml = h; });
158177

159178
let cachedAgents: any[] | null = null;
160179

apps/opencode-plugin/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
],
3232
"scripts": {
3333
"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",
34-
"postinstall": "mkdir -p ${XDG_CONFIG_HOME:-$HOME/.config}/opencode/command && cp ./commands/*.md ${XDG_CONFIG_HOME:-$HOME/.config}/opencode/command/ 2>/dev/null || true",
34+
"postinstall": "mkdir -p ${XDG_CONFIG_HOME:-$HOME/.config}/opencode/commands && cp ./commands/*.md ${XDG_CONFIG_HOME:-$HOME/.config}/opencode/commands/ 2>/dev/null || true",
3535
"prepublishOnly": "bun run build"
3636
},
3737
"dependencies": {

packages/ai/providers/opencode-sdk.ts

Lines changed: 83 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
/**
22
* OpenCode provider — bridges Plannotator's AI layer with OpenCode's agent server.
33
*
4-
* Uses @opencode-ai/sdk to spawn `opencode serve` and communicate via HTTP + SSE.
5-
* One server per provider, shared across all sessions. The user must have the
6-
* `opencode` CLI installed and authenticated.
4+
* Uses @opencode-ai/sdk to connect to an existing `opencode serve` first and
5+
* only spawns a new server when nothing is reachable. One server is shared
6+
* across all sessions. The user must have the `opencode` CLI installed and
7+
* authenticated.
78
*/
89

10+
import type { OpencodeClient } from "@opencode-ai/sdk";
911
import { BaseSession } from "../base-session.ts";
1012
import { buildSystemPrompt } from "../context.ts";
1113
import type {
@@ -54,17 +56,17 @@ export class OpenCodeProvider implements AIProvider {
5456
private config: OpenCodeConfig;
5557
// biome-ignore lint/suspicious/noExplicitAny: SDK types not available at compile time
5658
private server: { url: string; close: () => void } | null = null;
57-
// biome-ignore lint/suspicious/noExplicitAny: SDK types not available at compile time
58-
private client: any = null;
59+
private client: OpencodeClient | null = null;
5960
private startPromise: Promise<void> | null = null;
61+
private lastAttachError: string | null = null;
6062

6163
constructor(config: OpenCodeConfig) {
6264
this.config = config;
6365
}
6466

65-
/** Lazy-spawn the OpenCode server and create the HTTP client. */
67+
/** Attach to an existing OpenCode server or spawn one if needed. */
6668
async ensureServer(): Promise<void> {
67-
if (this.server && this.client) return;
69+
if (this.client) return;
6870
this.startPromise ??= this.doStart().catch((err) => {
6971
this.startPromise = null;
7072
throw err;
@@ -73,32 +75,80 @@ export class OpenCodeProvider implements AIProvider {
7375
}
7476

7577
private async doStart(): Promise<void> {
78+
this.lastAttachError = null;
7679
const { createOpencodeServer, createOpencodeClient } = await getSDK();
80+
const attachedClient = await this.tryAttachExistingServer(createOpencodeClient);
81+
if (attachedClient) {
82+
this.client = attachedClient;
83+
return;
84+
}
7785

78-
this.server = await createOpencodeServer({
79-
hostname: this.config.hostname ?? "127.0.0.1",
80-
...(this.config.port != null && { port: this.config.port }),
81-
timeout: 15_000,
82-
});
86+
try {
87+
this.server = await createOpencodeServer({
88+
hostname: this.config.hostname ?? "127.0.0.1",
89+
...(this.config.port != null && { port: this.config.port }),
90+
timeout: 15_000,
91+
});
92+
} catch (err) {
93+
const spawnMessage = err instanceof Error ? err.message : String(err);
94+
if (this.lastAttachError) {
95+
throw new Error(`${this.lastAttachError}\nFallback startup also failed: ${spawnMessage}`);
96+
}
97+
throw err;
98+
}
8399

84100
this.client = createOpencodeClient({
85101
baseUrl: this.server!.url,
86102
directory: this.config.cwd ?? process.cwd(),
87103
});
88104
}
89105

106+
private async tryAttachExistingServer(
107+
createOpencodeClient: (config?: { baseUrl?: string; directory?: string }) => OpencodeClient,
108+
): Promise<OpencodeClient | null> {
109+
const cwd = this.config.cwd ?? process.cwd();
110+
const baseUrl = `http://${this.config.hostname ?? "127.0.0.1"}:${this.config.port ?? 4096}`;
111+
const client = createOpencodeClient({
112+
baseUrl,
113+
directory: cwd,
114+
});
115+
116+
try {
117+
await client.config.get({
118+
throwOnError: true,
119+
signal: AbortSignal.timeout(1_000),
120+
});
121+
return client;
122+
} catch (err) {
123+
const message = err instanceof Error ? err.message : String(err);
124+
this.lastAttachError = `Failed to attach to existing OpenCode server at ${baseUrl}: ${message}`;
125+
return null;
126+
}
127+
}
128+
129+
private getClient(): OpencodeClient {
130+
if (!this.client) {
131+
throw new Error("OpenCode client is not initialized.");
132+
}
133+
return this.client;
134+
}
135+
90136
async createSession(options: CreateSessionOptions): Promise<AISession> {
91137
await this.ensureServer();
138+
const client = this.getClient();
92139

93-
const result = await this.client.session.create({
140+
const result = await client.session.create({
94141
query: { directory: options.cwd ?? this.config.cwd ?? process.cwd() },
95142
});
96143
const sessionData = result.data;
144+
if (!sessionData) {
145+
throw new Error("OpenCode did not return session data.");
146+
}
97147

98148
const session = new OpenCodeSession({
99149
sessionId: sessionData.id,
100150
systemPrompt: buildSystemPrompt(options.context),
101-
client: this.client,
151+
client,
102152
model: options.model,
103153
parentSessionId: null,
104154
});
@@ -107,36 +157,41 @@ export class OpenCodeProvider implements AIProvider {
107157

108158
async forkSession(options: CreateSessionOptions): Promise<AISession> {
109159
await this.ensureServer();
160+
const client = this.getClient();
110161

111162
const parentId = options.context.parent?.sessionId;
112163
if (!parentId) {
113164
throw new Error("Fork requires a parent session ID.");
114165
}
115166

116-
const result = await this.client.session.fork({
167+
const result = await client.session.fork({
117168
path: { id: parentId },
118169
});
119170
const sessionData = result.data;
171+
if (!sessionData) {
172+
throw new Error("OpenCode did not return forked session data.");
173+
}
120174

121175
return new OpenCodeSession({
122176
sessionId: sessionData.id,
123177
systemPrompt: buildSystemPrompt(options.context),
124-
client: this.client,
178+
client,
125179
model: options.model,
126180
parentSessionId: parentId,
127181
});
128182
}
129183

130184
async resumeSession(sessionId: string): Promise<AISession> {
131185
await this.ensureServer();
186+
const client = this.getClient();
132187

133188
// Verify session exists
134-
await this.client.session.get({ path: { id: sessionId } });
189+
await client.session.get({ path: { id: sessionId } });
135190

136191
return new OpenCodeSession({
137192
sessionId,
138193
systemPrompt: null,
139-
client: this.client,
194+
client,
140195
model: undefined,
141196
parentSessionId: null,
142197
});
@@ -146,32 +201,33 @@ export class OpenCodeProvider implements AIProvider {
146201
if (this.server) {
147202
this.server.close();
148203
this.server = null;
149-
this.client = null;
150-
this.startPromise = null;
151204
}
205+
this.client = null;
206+
this.startPromise = null;
152207
}
153208

154209
/** Fetch available models from OpenCode. Call before registering the provider. */
155210
async fetchModels(): Promise<void> {
156211
try {
157212
await this.ensureServer();
213+
const client = this.getClient();
158214

159-
const result = await this.client.provider.list({
215+
const result = await client.provider.list({
160216
query: { directory: this.config.cwd ?? process.cwd() },
161217
});
162218
const data = result.data;
163-
const connected = new Set(data.connected as string[]);
164-
const allProviders = data.all as Array<{
165-
id: string;
166-
models: Record<string, { id: string; providerID: string; name: string }>;
167-
}>;
219+
if (!data) {
220+
return;
221+
}
222+
const connected = new Set(data.connected ?? []);
223+
const allProviders = data.all ?? [];
168224

169225
const models: Array<{ id: string; label: string; default?: boolean }> = [];
170226
for (const provider of allProviders) {
171227
if (!connected.has(provider.id)) continue;
172228
for (const model of Object.values(provider.models)) {
173229
models.push({
174-
id: `${model.providerID}/${model.id}`,
230+
id: `${provider.id}/${model.id}`,
175231
label: model.name ?? model.id,
176232
});
177233
}

packages/ai/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,6 @@ export interface OpenCodeConfig extends AIProviderConfig {
364364
type: "opencode-sdk";
365365
/** Hostname for the OpenCode server. Default: "127.0.0.1". */
366366
hostname?: string;
367-
/** Port for the OpenCode server. Default: random. */
367+
/** Port for the OpenCode server. Default: 4096. */
368368
port?: number;
369369
}

tests/manual/local/sandbox-opencode.sh

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1538,22 +1538,22 @@ echo ""
15381538

15391539
# Set up local plugin via loader file
15401540
echo "Setting up local plugin..."
1541-
mkdir -p .opencode/plugin
1541+
mkdir -p .opencode/plugins
15421542

15431543
# Create a loader file that re-exports from the source
1544-
# OpenCode only loads top-level .ts/.js files in the plugin directory
1545-
cat > .opencode/plugin/plannotator.ts << EOF
1544+
# OpenCode only loads top-level .ts/.js files in the plugins directory
1545+
cat > .opencode/plugins/plannotator.ts << EOF
15461546
// Loader for local Plannotator plugin development
15471547
export * from "$PLUGIN_DIR/index.ts";
15481548
EOF
15491549

1550-
# Copy command files to local .opencode/command
1551-
mkdir -p .opencode/command
1552-
cp "$PLUGIN_DIR/commands/"*.md .opencode/command/
1550+
# Copy command files to local .opencode/commands
1551+
mkdir -p .opencode/commands
1552+
cp "$PLUGIN_DIR/commands/"*.md .opencode/commands/
15531553

1554-
# Also install to global command directory (some OpenCode versions need this)
1555-
mkdir -p ~/.config/opencode/command
1556-
cp "$PLUGIN_DIR/commands/"*.md ~/.config/opencode/command/ 2>/dev/null || true
1554+
# Also install to global commands directory (some OpenCode versions need this)
1555+
mkdir -p ~/.config/opencode/commands
1556+
cp "$PLUGIN_DIR/commands/"*.md ~/.config/opencode/commands/ 2>/dev/null || true
15571557

15581558
echo ""
15591559

0 commit comments

Comments
 (0)