diff --git a/README.md b/README.md index fff9978..513488f 100644 --- a/README.md +++ b/README.md @@ -376,6 +376,19 @@ With options: } ``` +### OpenCode 2 + +Works on both OpenCode 1 and OpenCode 2 — no separate install needed. On OpenCode 2 the config key is `plugins`: + +```jsonc +{ + "$schema": "https://opencode.ai/config.json", + "plugins": ["opencode-auto-resume"] +} +``` + +The package ships a dual-shape entrypoint: OpenCode 1 (>= 1.3.4) loads the classic V1 plugin via the `server` field, while OpenCode 2 calls the new `setup` entrypoint (see `src/v2.ts`), which bridges the same features onto the V2 APIs (tool hooks, event bus, `task_complete` tool registration). + ## Configuration ```json diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..20b55c3 --- /dev/null +++ b/bun.lock @@ -0,0 +1,57 @@ +{ + "lockfileVersion": 2, + "configVersion": 0, + "workspaces": { + "": { + "name": "opencode-auto-resume", + "dependencies": { + "@opencode-ai/plugin": "latest", + "@opencode/client": "latest", + }, + "devDependencies": { + "@opencode-ai/sdk": "latest", + "@types/bun": "latest", + "typescript": "latest", + }, + }, + }, + "packages": { + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.4.0", "", { "dependencies": { "@opencode-ai/sdk": "1.4.0", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.1.97", "@opentui/solid": ">=0.1.97" }, "optionalPeers": ["@opentui/core", "@opentui/solid"] }, "sha512-VFIff6LHp/RVaJdrK3EQ1ijx0K1tV5i1DY5YJ+pRqwC6trunPHbvqSN0GHSTZX39RdnSc+XuzCTZQCy1W2qNOg=="], + + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.4.0", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-mfa3MzhqNM+Az4bgPDDXL3NdG+aYOHClXmT6/4qLxf2ulyfPpMNHqb9Dfmo4D8UfmrDsPuJHmbune73/nUQnuw=="], + + "@opencode/client": ["@opencode/client@2.1.2", "", { "dependencies": { "@opencode/protocol": "2.1.2", "@opencode/schema": "2.1.2" }, "peerDependencies": { "effect": "4.0.1", "solid-js": ">=1.9.0" }, "optionalPeers": ["effect", "solid-js"] }, "sha512-c7FqI/zMy6HrdVGHynzouiz3y1smokGuimh2+Is2iVWz8I8VzJEty9eTrvQkQzXG1Hj5K2FL1K5fytQ7yJEd9w=="], + + "@opencode/protocol": ["@opencode/protocol@2.1.2", "", { "dependencies": { "@opencode/schema": "2.1.2", "effect": "4.0.1" } }, "sha512-dNJuZrJlcUnc3mP7GFoawXECpUoUA+1TEbXvXkFKteq9tpZbO5Qsi1AWqocRJH2cIfG8v5rddyBTyX4+0ax9Yw=="], + + "@opencode/schema": ["@opencode/schema@2.1.2", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "effect": "4.0.1" } }, "sha512-gYbneOINd0Tt41krlC79OWjcXMPlW9EjIR+tmG3EYD8SEA4xCdZh0WhZLrFDlLMKDcb6d9xeHTFwhqGACt0IRg=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + + "@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="], + + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "effect": ["effect@4.0.1", "", {}, "sha512-b1VlQG9g8fwxE5QnIZuPoOP/0MsqHJSRKxUijjoM80E5q95FXrX5yWtY5XI8G+GJsBQAVSDbLS458z9a++uVvw=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], + } +} diff --git a/package.json b/package.json index f0e53a9..299d6af 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-auto-resume", - "version": "1.1.21", + "version": "1.2.0", "description": "OpenCode plugin that automatically resumes stalled LLM sessions when thinking/streaming freezes mid-generation.", "keywords": [ "opencode", @@ -32,7 +32,8 @@ "LICENSE" ], "dependencies": { - "@opencode-ai/plugin": "latest" + "@opencode-ai/plugin": "latest", + "@opencode/client": "latest" }, "devDependencies": { "@opencode-ai/sdk": "latest", diff --git a/src/index.exports-guard.test.ts b/src/index.exports-guard.test.ts index 0902bd0..eead057 100644 --- a/src/index.exports-guard.test.ts +++ b/src/index.exports-guard.test.ts @@ -37,8 +37,12 @@ describe("REGRESSION: non-Plugin exports crash OpenCode host", () => { expect(SOURCE).toMatch(/^export\s+const\s+AutoResumePlugin/m) }) - test("plugin source must have a default export", () => { - expect(SOURCE).toMatch(/^export\s+default\s+AutoResumePlugin/m) + test("plugin source must have a dual-shape default export (V1 server + V2 setup)", () => { + // OpenCode 1 (>=1.3.4) calls `default.server`; OpenCode 2 calls `default.setup`. + expect(SOURCE).toMatch(/export\s+default\s+\{/m) + expect(SOURCE).toMatch(/id:\s*"opencode-auto-resume"/m) + expect(SOURCE).toMatch(/server:\s*AutoResumePlugin/m) + expect(SOURCE).toMatch(/setup:\s*setupV2/m) }) }) @@ -60,6 +64,30 @@ describe("REGRESSION: bundled dist must only export Plugin-shaped values (no nul } for (const [name, fn] of Object.entries(mod)) { + if (name === "default") { + // Dual-shape default export: { id, server (V1), setup (V2) } + const def = fn as unknown as Record + if (typeof def !== "object" || def === null) { + throw new Error(`Export "default" is not an object (got ${typeof def})`) + } + if (typeof def.id !== "string") { + throw new Error(`Export "default".id must be a string`) + } + if (typeof def.server !== "function") { + throw new Error(`Export "default".server must be a function (V1 Plugin)`) + } + if (typeof def.setup !== "function") { + throw new Error(`Export "default".setup must be a function (V2 Plugin setup)`) + } + const result = await (def.server as Function)(fakeCtx, {}) + if (result === null || typeof result !== "object") { + throw new Error( + `REGRESSION BROKEN: export "default".server returned ${result} when called as Plugin. ` + + `OpenCode 1's loader expects the V1 plugin to return a Hooks object.`, + ) + } + continue + } if (typeof fn !== "function") { throw new Error(`Export "${name}" is not a function (got ${typeof fn})`) } diff --git a/src/index.ts b/src/index.ts index 562da69..e169b64 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import type { Plugin } from "@opencode-ai/plugin" import { tool } from "@opencode-ai/plugin" import type { SessionPromptResponses } from "@opencode-ai/sdk" +import { setup as setupV2 } from "./v2" interface Todo { content: string @@ -3125,4 +3126,11 @@ export const AutoResumePlugin: Plugin = async (ctx, options) => { } } -export default AutoResumePlugin +// Dual-shape plugin export: OpenCode 1 (>=1.3.4) calls `server`, OpenCode 2 +// calls `setup` (see src/v2.ts). The named `AutoResumePlugin` export above +// stays unchanged for V1 hosts that import it directly. +export default { + id: "opencode-auto-resume", + server: AutoResumePlugin, + setup: setupV2, +} diff --git a/src/v2.ts b/src/v2.ts new file mode 100644 index 0000000..2a3feaf --- /dev/null +++ b/src/v2.ts @@ -0,0 +1,107 @@ +/** + * V2 shim for opencode-auto-resume (Mte90) — the upstream plugin exports a V1 + * function that OpenCode 2 rejects ("must export a default definition with an + * id and a setup function"). + * + * This shim default-exports a V2 { id, setup } plugin, instantiates the V1 + * implementation with a synthesized OpenCodeClient (V2 Context has no + * `client` field), and bridges the returned V1 hooks onto V2 APIs: + * - tool.execute.before/after -> ctx.tool.hook("execute.before"/"execute.after") + * - event + chat.message -> ctx.event.subscribe() pump + * - tool.task_complete -> ctx.tool.transform (direct tool) + * - config -> V1-only, skipped + */ +import { AutoResumePlugin } from "./index"; + +let clientPromise: Promise | undefined; + +async function getClient(): Promise { + if (!clientPromise) { + clientPromise = (async () => { + try { + const mod: any = await import("@opencode/client"); + const create = + mod.createOpencodeClient ?? mod.default?.createOpencodeClient; + if (typeof create === "function") { + // The client performs local service discovery on its own. + return await create(); + } + } catch {} + return undefined; + })(); + } + return clientPromise; +} + +export async function setup(ctx: any) { + const client = (ctx as any).client ?? (await getClient()); + const hooks: any = await AutoResumePlugin({ ...(ctx as any), client }, undefined); + + await ctx.tool.hook("execute.before", (ev: any) => + hooks["tool.execute.before"]?.( + { sessionID: ev.sessionID, tool: ev.tool, args: ev.input }, + { args: ev.input }, + ), + ); + + await ctx.tool.hook("execute.after", (ev: any) => + hooks["tool.execute.after"]?.({ sessionID: ev.sessionID, tool: ev.tool }), + ); + + const tc = hooks.tool?.task_complete; + if (tc) { + await ctx.tool.transform((editor: any) => { + editor.add({ + name: "task_complete", + description: + tc.description ?? + "Signal that the current task is fully complete.", + input: { type: "object", properties: {}, additionalProperties: false }, + async execute(input: any) { + try { + const out = await tc.execute(input ?? {}); + return { + content: + typeof out === "string" ? out : JSON.stringify(out ?? ""), + }; + } catch (e) { + return { content: `task_complete error: ${String(e)}` }; + } + }, + }); + }); + } + + const consume = async () => { + try { + for await (const ev of ctx.event.subscribe() as any) { + try { + if (ev?.type === "chat.message") { + const sid = + ev.properties?.info?.sessionID ?? + ev.properties?.sessionID ?? + ev.properties?.message?.sessionID; + if (sid) await hooks["chat.message"]?.({ sessionID: sid }); + } + // V1 event hook receives the raw bus event; V1/V2 share the + // { type, properties } shape. + await hooks.event?.({ event: ev }); + } catch {} + } + } catch (e) { + try { + await client?.app?.log?.({ + body: { + service: "auto-resume-shim", + level: "error", + message: `event pump died: ${String(e)}`, + }, + }); + } catch {} + } + }; + consume(); + + // V1-only: config hook and command.execute.before have no V2 equivalent. + return () => {}; +}