// @generated — DO NOT EDIT. Source: packages/ai/providers/opencode-sdk.ts /** * OpenCode provider — bridges Plannotator's AI layer with OpenCode's agent server. * * 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 { AIMessage, AIProvider, AIProviderCapabilities, AISession, CreateSessionOptions, OpenCodeConfig, } from "../types.ts"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const PROVIDER_NAME = "opencode-sdk"; // --------------------------------------------------------------------------- // SDK import cache — resolve once, reuse across all sessions // --------------------------------------------------------------------------- // biome-ignore lint/suspicious/noExplicitAny: SDK types not available at compile time let sdk: any = null; async function getSDK() { if (!sdk) { sdk = await import("@opencode-ai/sdk"); } return sdk; } // --------------------------------------------------------------------------- // Provider // --------------------------------------------------------------------------- export class OpenCodeProvider implements AIProvider { readonly name = PROVIDER_NAME; readonly capabilities: AIProviderCapabilities = { fork: true, resume: true, streaming: true, tools: true, }; models?: Array<{ id: string; label: string; default?: boolean }>; private config: OpenCodeConfig; // biome-ignore lint/suspicious/noExplicitAny: SDK types not available at compile time private server: { url: string; close: () => void } | null = null; private client: OpencodeClient | null = null; private startPromise: Promise | null = null; private lastAttachError: string | null = null; constructor(config: OpenCodeConfig) { this.config = config; } /** Attach to an existing OpenCode server or spawn one if needed. */ async ensureServer(): Promise { if (this.client) return; this.startPromise ??= this.doStart().catch((err) => { this.startPromise = null; throw err; }); return this.startPromise; } private async doStart(): Promise { this.lastAttachError = null; const { createOpencodeServer, createOpencodeClient } = await getSDK(); const attachedClient = await this.tryAttachExistingServer(createOpencodeClient); if (attachedClient) { this.client = attachedClient; return; } 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, directory: this.config.cwd ?? process.cwd(), }); } 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 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, model: options.model, parentSessionId: null, }); return session; } 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 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, model: options.model, parentSessionId: parentId, }); } async resumeSession(sessionId: string): Promise { await this.ensureServer(); const client = this.getClient(); // Verify session exists await client.session.get({ path: { id: sessionId } }); return new OpenCodeSession({ sessionId, systemPrompt: null, client, model: undefined, parentSessionId: null, }); } dispose(): void { if (this.server) { this.server.close(); this.server = 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 client.provider.list({ query: { directory: this.config.cwd ?? process.cwd() }, }); const data = result.data; 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: `${provider.id}/${model.id}`, label: model.name ?? model.id, }); } } if (models.length > 0) { // Mark first model as default models[0].default = true; this.models = models; } } catch { // OpenCode not configured or no models available } } } // --------------------------------------------------------------------------- // Session // --------------------------------------------------------------------------- interface SessionConfig { sessionId: string; systemPrompt: string | null; // biome-ignore lint/suspicious/noExplicitAny: SDK types not available at compile time client: any; /** Model in "providerID/modelID" format. */ model?: string; parentSessionId: string | null; } class OpenCodeSession extends BaseSession { private config: SessionConfig; constructor(config: SessionConfig) { super({ parentSessionId: config.parentSessionId, initialId: config.sessionId, }); this.config = config; this._resolvedId = config.sessionId; } async *query(prompt: string): AsyncIterable { const started = this.startQuery(); if (!started) { yield BaseSession.BUSY_ERROR; return; } const { gen } = started; try { // Build model param if specified let modelParam: { providerID: string; modelID: string } | undefined; if (this.config.model) { const [providerID, ...rest] = this.config.model.split("/"); const modelID = rest.join("/"); if (providerID && modelID) { modelParam = { providerID, modelID }; } } // Subscribe to SSE events const { stream } = await this.config.client.event.subscribe(); try { // Send prompt asynchronously try { await this.config.client.session.promptAsync({ path: { id: this.config.sessionId }, body: { ...(!this._firstQuerySent && this.config.systemPrompt && { system: this.config.systemPrompt, }), ...(modelParam && { model: modelParam }), parts: [{ type: "text", text: prompt }], }, }); } catch (err) { yield { type: "error", error: `OpenCode rejected prompt: ${err instanceof Error ? err.message : String(err)}`, code: "opencode_prompt_rejected", }; return; } this._firstQuerySent = true; // Drain SSE events filtered by session ID for await (const event of stream) { const eventType = event.type as string; const props = event.properties as Record | undefined; if (!props) continue; // Filter: only events for our session const eventSessionId = (props.sessionID as string) ?? ((props.info as Record)?.sessionID as string) ?? ((props.part as Record)?.sessionID as string); if (eventSessionId && eventSessionId !== this.config.sessionId) continue; const mapped = mapOpenCodeEvent(eventType, props, this.id); for (const msg of mapped) { yield msg; if (msg.type === "result" || (msg.type === "error" && isTerminalEvent(eventType))) { return; } } } } finally { stream.return?.(); } } catch (err) { yield { type: "error", error: err instanceof Error ? err.message : String(err), code: "provider_error", }; } finally { this.endQuery(gen); } } abort(): void { this.config.client.session .abort({ path: { id: this.config.sessionId } }) .catch(() => {}); super.abort(); } respondToPermission( requestId: string, allow: boolean, _message?: string, ): void { this.config.client .postSessionIdPermissionsPermissionId({ path: { id: this.config.sessionId, permissionID: requestId }, body: { response: allow ? "once" : "reject" }, }) .catch(() => {}); } } // --------------------------------------------------------------------------- // Event mapping // --------------------------------------------------------------------------- /** Returns true for events that should terminate the query when mapped to an error. */ function isTerminalEvent(eventType: string): boolean { return eventType === "session.error" || eventType === "session.status"; } /** * Map an OpenCode SSE event to AIMessage[]. * * Key events: * message.part.delta → text_delta (streaming text) * message.part.updated → tool_use / tool_result (tool lifecycle) * permission.updated → permission_request * session.status → result (when idle) * message.updated → error (when message has error) */ export function mapOpenCodeEvent( eventType: string, props: Record, sessionId: string, ): AIMessage[] { switch (eventType) { case "message.part.delta": { const field = props.field as string; const delta = props.delta as string; if (field === "text" && delta) { return [{ type: "text_delta", delta }]; } return []; } case "message.part.updated": { const part = props.part as Record; if (!part) return []; const partType = part.type as string; if (partType === "tool") { const state = part.state as Record; if (!state) return []; const status = state.status as string; const callID = (part.callID as string) ?? (part.id as string); const toolName = part.tool as string; switch (status) { case "running": return [ { type: "tool_use", toolName: toolName ?? "unknown", toolInput: (state.input as Record) ?? {}, toolUseId: callID, }, ]; case "completed": { const output = (state.output as string) ?? ""; return [ { type: "tool_result", toolUseId: callID, result: output, }, ]; } case "error": { const error = (state.error as string) ?? "Tool execution failed"; return [ { type: "tool_result", toolUseId: callID, result: `[Error] ${error}`, }, ]; } default: return []; } } return []; } case "permission.updated": { const id = props.id as string; const permType = props.type as string; const title = props.title as string; const callID = props.callID as string; const metadata = (props.metadata as Record) ?? {}; return [ { type: "permission_request", requestId: id, toolName: permType ?? "unknown", toolInput: metadata, title: title ?? permType, toolUseId: callID ?? id, }, ]; } case "session.status": { const status = props.status as Record; if (status?.type === "idle") { return [ { type: "result", sessionId, success: true, }, ]; } return []; } case "session.error": { const error = props.error as Record; const message = (error?.message as string) ?? (props.message as string) ?? "Session error"; return [ { type: "error", error: message, code: "opencode_session_error", }, ]; } case "message.updated": { const info = props.info as Record; if (!info) return []; const msgError = info.error as Record; if (msgError) { const errorData = msgError.data as Record; const message = (errorData?.message as string) ?? (msgError.name as string) ?? "Message error"; return [ { type: "error", error: message, code: "opencode_message_error", }, ]; } return []; } default: return []; } } // --------------------------------------------------------------------------- // Factory registration // --------------------------------------------------------------------------- import { registerProviderFactory } from "../provider.ts"; registerProviderFactory( PROVIDER_NAME, async (config) => new OpenCodeProvider(config as OpenCodeConfig), );