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() + } + }, +}