From c5023c62c3550f5f1ac2604df2a2a101e86a8d25 Mon Sep 17 00:00:00 2001 From: roboalchemist Date: Thu, 8 Oct 2026 07:15:11 -0700 Subject: [PATCH] Add OpenCode v2 support via ./server adapter entrypoint OpenCode 2.x loads plugins through the v2 plugin API ({id, setup(context)}) and prefers the ./server export of an npm package (issue #33). This adds a v2 entrypoint (src/server.ts) that adapts the existing v1 recovery engine to the v2 API: - builds a v1-shaped client bridge on top of the v2 plugin context - invokes the unchanged v1 plugin (src/index.ts) with it - re-registers the returned v1 hooks (event, config, tool, chat.message, tool.execute.before/after, command.execute.before) into the v2 domain API - translates v2 bus events into the v1 event shape The recovery engine is untouched, so v1 and v2 hosts automatically run the same logic and stay in sync with future v1 changes. OpenCode 1.x keeps loading main (dist/index.js); 2.x loads ./server. Also adds zod (used by the adapter) to dependencies and builds both entries in the build script. --- README.md | 13 +++ package.json | 10 ++- src/server.ts | 225 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 src/server.ts diff --git a/README.md b/README.md index fff9978..ab86456 100644 --- a/README.md +++ b/README.md @@ -376,6 +376,19 @@ With options: } ``` +### OpenCode 2.x + +OpenCode 2.x loads plugins through the new plugin API (`{ id, setup(context) }`) and +prefers the `./server` export of an npm package. This package ships a v2 entrypoint +(`src/server.ts`, exposed as `./server`) that adapts the same v1 recovery engine to the +v2 API — the `plugin` config above works unchanged on both host versions: OpenCode 2.x +loads the `./server` export automatically, OpenCode 1.x keeps using the v1 entrypoint. + +The adapter bridges the v1 plugin onto the v2 domain API (event subscription, tool +registration, prompt/tool/command hooks) and translates v2 bus events into the v1 event +shape. The recovery logic itself is untouched, so both host versions run the exact same +engine. + ## Configuration ```json diff --git a/package.json b/package.json index f0e53a9..d7d06d2 100644 --- a/package.json +++ b/package.json @@ -21,18 +21,24 @@ "author": "Daniele \"mte90\" Scasciafratte", "type": "module", "main": "dist/index.js", + "exports": { + ".": "./dist/index.js", + "./server": "./src/server.ts" + }, "scripts": { - "build": "bun build src/index.ts --outdir dist --target bun", + "build": "bun build src/index.ts src/server.ts --outdir dist --target bun", "dev": "bun build src/index.ts --outdir dist --target bun --watch", "test": "bun test", "prepublishOnly": "bun run build" }, "files": [ "dist/index.js", + "src/server.ts", "README.md", "LICENSE" ], "dependencies": { - "@opencode-ai/plugin": "latest" + "@opencode-ai/plugin": "latest", + "zod": "^4.1.8" }, "devDependencies": { "@opencode-ai/sdk": "latest", diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..e864cee --- /dev/null +++ b/src/server.ts @@ -0,0 +1,225 @@ +// OpenCode v2 entrypoint for opencode-auto-resume. +// +// OpenCode 2.x loads plugins that export `{ id, setup(context) }` (the v2 +// plugin API) and prefers the `./server` export of an npm plugin. The v1 +// engine in `src/index.ts` expects the legacy `Plugin(ctx, options)` shape +// with v1 hooks (`event`, `config`, `tool`, `chat.message`, +// `tool.execute.before/after`, `command.execute.before`). +// +// This adapter bridges the two: it builds a v1-shaped `client` on top of the +// v2 plugin context, invokes the real v1 plugin with it, and re-registers the +// returned v1 hooks into the v2 domain API. The recovery engine itself is +// untouched — all stall/recovery logic keeps coming from `src/index.ts`, so +// v1 and v2 stay automatically in sync. +import { z } from "zod" +import AutoResumePlugin from "./index" + +type LegacyEvent = { type: string; properties?: Record } + +// Translate v2 bus events into the v1 event names the engine's +// handleEvent() understands ({type, properties:{sessionID, ...}}). +function translateEvent(ev: any): LegacyEvent[] { + const type: string = ev?.type ?? "" + const data: Record = ev?.data ?? {} + const sid: string | undefined = data.sessionID + const props = { ...data, sessionID: sid } + + switch (type) { + case "session.execution.started": + return [{ type: "session.status", properties: { sessionID: sid, status: { type: "busy" } } }] + case "session.execution.succeeded": + return [ + { type: "session.status", properties: { sessionID: sid, status: { type: "idle" } } }, + { type: "session.idle", properties: { sessionID: sid } }, + ] + case "session.execution.failed": + return [ + { type: "session.error", properties: { sessionID: sid, error: data.error } }, + { type: "session.status", properties: { sessionID: sid, status: { type: "idle" } } }, + ] + case "session.execution.interrupted": + return [{ type: "session.interrupted", properties: { sessionID: sid } }] + case "session.created": + return [{ type: "session.created", properties: { sessionID: sid, parentID: data.parentID, session: data } }] + case "session.updated": + case "session.renamed": + return [{ type: "session.updated", properties: { sessionID: sid } }] + default: + if (sid) return [{ type: "message.updated", properties: props }] + return [] + } +} + +export default { + id: "opencode-auto-resume-v2", + setup: async (context: any) => { + const abort = new AbortController() + + // v1-shaped client bridged onto the v2 context. + const bridgeClient = { + app: { + log: async ({ body }: any) => { + try { + console.error(`[auto-resume] ${body?.level ?? "info"}: ${body?.message ?? ""}`) + } catch {} + }, + }, + session: { + prompt: async (input: any) => { + const body = input?.body ?? {} + const text = Array.isArray(body.parts) + ? body.parts.map((p: any) => p?.text ?? "").join("") + : (body.text ?? "") + const req: any = { sessionID: input?.path?.id ?? input?.sessionID, text } + const agent = body.agent ?? body.agents?.[0]?.name + if (agent) req.agents = [{ name: agent }] + return await context.session.prompt(req) + }, + abort: async (input: any) => context.session.interrupt({ sessionID: input?.path?.id ?? input?.sessionID }), + interrupt: async (input: any) => context.session.interrupt({ sessionID: input?.path?.id ?? input?.sessionID }), + messages: async (input: any) => ({ data: await context.session.context({ sessionID: input?.path?.id ?? input?.sessionID }) }), + context: async (input: any) => ({ data: await context.session.context({ sessionID: input?.path?.id ?? input?.sessionID }) }), + status: async () => ({ data: {} }), + list: async () => ({ data: [] }), + todo: async () => ({ data: [] }), + summarize: async () => undefined, + command: async (input: any) => + context.session.command({ + sessionID: input?.path?.id ?? input?.sessionID, + command: input?.body?.command ?? "", + text: input?.body?.arguments ?? input?.body?.text ?? "", + }), + }, + config: { get: async () => ({ data: {} }) }, + provider: { get: async () => ({ data: {} }) }, + } + + const legacyCtx = { + client: bridgeClient, + app: {}, + directory: context.location?.directory ?? process.cwd(), + worktree: context.location?.directory ?? process.cwd(), + serverUrl: undefined, + $: undefined, + } + + let hooks: any + try { + hooks = await (AutoResumePlugin as any)(legacyCtx, context.options ?? {}) + } catch (e) { + console.error("[auto-resume-v2] construction failed:", e) + return () => {} + } + + try { + await hooks?.config?.() + } catch {} + + // v1 tool definitions -> v2 tool registry. + if (hooks?.tool) { + try { + await context.tool.transform((draft: any) => { + for (const [name, def] of Object.entries(hooks.tool)) { + let input: any + try { + input = z.toJSONSchema(z.object(def.args ?? {})) + } catch { + input = { type: "object", properties: {}, additionalProperties: true } + } + draft.add({ + name, + description: def.description, + input, + execute: async (args: any, toolContext: any) => { + try { + const r = await def.execute(args, { sessionID: toolContext?.sessionID }) + return { content: typeof r === "string" ? r : "" } + } catch (e) { + return { content: `auto-resume tool error: ${String((e as any)?.message ?? e)}` } + } + }, + }) + } + }) + } catch (e) { + console.error("[auto-resume-v2] tool registration failed:", e) + } + } + + if (hooks?.["chat.message"]) { + try { + await context.session.hook("prompt", (input: any) => hooks["chat.message"]({ sessionID: input?.sessionID })) + } catch (e) { + console.error("[auto-resume-v2] prompt hook failed:", e) + } + } + + if (hooks?.["tool.execute.before"]) { + try { + await context.tool.hook("execute.before", (input: any) => + hooks["tool.execute.before"]({ sessionID: input?.sessionID, tool: input?.tool, args: input?.input }, { args: input?.input }), + ) + } catch (e) { + console.error("[auto-resume-v2] tool.execute.before hook failed:", e) + } + } + + if (hooks?.["tool.execute.after"]) { + try { + await context.tool.hook("execute.after", (input: any) => + hooks["tool.execute.after"]({ sessionID: input?.sessionID }), + ) + } catch (e) { + console.error("[auto-resume-v2] tool.execute.after hook failed:", e) + } + } + + if (hooks?.["command.execute.before"]) { + try { + const commandDomain = context.command ?? (context as any).commands + if (typeof commandDomain?.hook === "function") { + await commandDomain.hook("execute.before", (input: any) => + hooks["command.execute.before"]({ sessionID: input?.sessionID, command: input?.command }), + ) + } else { + console.error("[auto-resume-v2] command.execute.before: no command domain in this OpenCode build; skipping") + } + } catch (e) { + console.error("[auto-resume-v2] command.execute.before hook failed:", e) + } + } + + // Feed translated v2 events into the v1 engine's event hook. + ;(async () => { + try { + const sub: any = context.event.subscribe({ signal: abort.signal }) + const it = sub[Symbol.asyncIterator]() + while (true) { + const { done, value } = await it.next() + if (done) break + let ev = value + if (typeof ev === "string") { + try { + ev = JSON.parse(ev) + } catch { + continue + } + } + for (const legacyEvent of translateEvent(ev)) { + try { + await hooks?.event?.({ event: legacyEvent }) + } catch (e) { + console.error("[auto-resume-v2] event handling failed:", e) + } + } + } + } catch (e) { + if (!abort.signal.aborted) console.error("[auto-resume-v2] event loop stopped:", e) + } + })() + + return () => { + abort.abort() + } + }, +}