548 lines
14 KiB
TypeScript
548 lines
14 KiB
TypeScript
// @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<void> | 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<void> {
|
|
if (this.client) return;
|
|
this.startPromise ??= this.doStart().catch((err) => {
|
|
this.startPromise = null;
|
|
throw err;
|
|
});
|
|
return this.startPromise;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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<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 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<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 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<AISession> {
|
|
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<void> {
|
|
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<AIMessage> {
|
|
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<string, unknown> | undefined;
|
|
if (!props) continue;
|
|
|
|
// Filter: only events for our session
|
|
const eventSessionId =
|
|
(props.sessionID as string) ??
|
|
((props.info as Record<string, unknown>)?.sessionID as string) ??
|
|
((props.part as Record<string, unknown>)?.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<string, unknown>,
|
|
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<string, unknown>;
|
|
if (!part) return [];
|
|
|
|
const partType = part.type as string;
|
|
|
|
if (partType === "tool") {
|
|
const state = part.state as Record<string, unknown>;
|
|
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<string, unknown>) ?? {},
|
|
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<string, unknown>) ?? {};
|
|
|
|
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<string, unknown>;
|
|
if (status?.type === "idle") {
|
|
return [
|
|
{
|
|
type: "result",
|
|
sessionId,
|
|
success: true,
|
|
},
|
|
];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
case "session.error": {
|
|
const error = props.error as Record<string, unknown>;
|
|
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<string, unknown>;
|
|
if (!info) return [];
|
|
|
|
const msgError = info.error as Record<string, unknown>;
|
|
if (msgError) {
|
|
const errorData = msgError.data as Record<string, unknown>;
|
|
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),
|
|
);
|