From 4d63c06f4663d2329f07839b5d4a013d06f56ef8 Mon Sep 17 00:00:00 2001 From: OpenCode Bot Date: Sun, 4 Oct 2026 21:35:33 -0600 Subject: [PATCH] feat: add V2 plugin entrypoint (index-v2.ts) for OpenCode v2 compatibility - Add src/index-v2.ts with V2 {id, setup} schema - Adapt V1 function-form plugin to V2 context API - Export via package.json exports field (./v2) - Build outputs to dist/v2.js - Shim V1 client.session.*, client.tool.ids, client.app.log, etc. to V2 equivalents - Register V1 hooks (tool.execute.before/after, chat.message, event) to V2 hook domains --- bun.lock | 46 +++++++ package.json | 14 +- src/index-v2.ts | 346 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 402 insertions(+), 4 deletions(-) create mode 100644 bun.lock create mode 100644 src/index-v2.ts diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..e574062 --- /dev/null +++ b/bun.lock @@ -0,0 +1,46 @@ +{ + "lockfileVersion": 1, + "configVersion": 0, + "workspaces": { + "": { + "name": "opencode-auto-resume", + "dependencies": { + "@opencode-ai/plugin": "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=="], + + "@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=="], + + "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 5ad650d..2625f86 100644 --- a/package.json +++ b/package.json @@ -21,13 +21,19 @@ "author": "Daniele \"mte90\" Scasciafratte", "type": "module", "main": "dist/index.js", -"scripts": { - "build": "bun build src/index.ts --outdir dist --target bun", + "exports": { + ".": "./dist/index.js", + "./v2": "./dist/v2.js" + }, + "scripts": { + "build": "bun build src/index.ts --outdir dist --target bun && bun build src/index-v2.ts --outfile dist/v2.js --target bun", "dev": "bun build src/index.ts --outdir dist --target bun --watch", "test": "bun test", "prepublishOnly": "bun run build" -}, "files": [ + }, + "files": [ "dist/index.js", + "dist/v2.js", "README.md", "LICENSE" ], @@ -39,4 +45,4 @@ "@types/bun": "latest", "typescript": "latest" } -} +} \ No newline at end of file diff --git a/src/index-v2.ts b/src/index-v2.ts new file mode 100644 index 0000000..76c1d2c --- /dev/null +++ b/src/index-v2.ts @@ -0,0 +1,346 @@ +// V2 wrapper for opencode-auto-resume +// This provides OpenCode v2 compatibility by adapting the V1 plugin factory +// to the V2 { id, setup } plugin schema. + +import { Database } from "bun:sqlite"; +import { AutoResumePlugin } from "./index.js"; + +const V1_OPTIONS_DEFAULTS = { + chunkTimeoutMs: 180_000, + gracePeriodMs: 3_000, + maxRetries: 3, + debug: false, + subagentWaitMs: 60_000, + loopMaxContinues: 3, + loopWindowMs: 10 * 60_000, + contextSaturationThreshold: 0.85, + subagentNativeCompactionEnabled: false, + activeUserWindowMs: 5 * 60_000, + continuePrompt: "continue", + streamingFailureErrorNames: [ + "ProviderError", + "APIError", + "StreamError", + "ConnectionError", + "TimeoutError", + ], + streamingFailureMessagePatterns: [ + "streaming response failed", + "stream.*fail", + "connection.*reset", + "connection.*closed", + "aborted due to timeout", + ], + resumeOnActionIntent: true, + actionIntentPrompt: "continue", + toolTextRecoveryPrompt: + "Your last message contained a raw tool call printed as text instead of being executed. " + + "Please use the proper tool calling mechanism to execute it.", + thinkingToolRecoveryPrompt: + "I noticed you have a tool call generated in your thinking/reasoning. " + + "Please execute it using the proper tool calling mechanism instead of keeping it in reasoning.", + doneWithoutWorkPrompt: + "I need you to verify more carefully that you have actually completed all the required tasks. " + + "Your response indicated you're done, but no work was detected. Please check your todo list " + + "and complete any remaining work.", + doneWithoutDetailsPrompt: + "Your last response claimed the task is complete but contained no work description. This is not acceptable. " + + "You MUST respond now with a full, detailed report of everything you did: " + + "for each file you modified, state the full path and the exact changes; " + + "list every command you ran to verify and its result; state the final outcome. " + + "Do NOT reply with 'done', 'task completed', or any short acknowledgment — " + + "your ONLY acceptable response right now is this detailed report. Write it now.", + silentDeadStreamMinTokens: 200, + busyStallStrategy: "continue", + toolTextCheckDelayMs: 3_000, + maxRecoveryRetries: 2, + minActivityGapMs: 1_000, + warmupMs: 15_000, + discoveryDelayMs: 5_000, +}; + +function makeV1Context(ctx, options) { + const { session, tool, event, provider, command, model, storage, location, app, generate, rpc, permission, skill, shell, websearch, vcs, worktree, integration, mcp, aisdk, agent, experimental } = ctx; + + const log = async (level, message) => { + const msg = `[auto-resume] ${message}`; + console[level === "error" ? "error" : level === "warn" ? "warn" : "log"](msg); + try { if (rpc && typeof rpc.call === "function") { await rpc.call({ rpcID: "opencode", method: "log", input: { level, message: msg } }); } } catch {} + return; + }; + + const dbg = options.debug ? log.bind(null, "debug") : () => {}; + const short = (sid) => sid && typeof sid === "string" ? sid.slice(0, 12) : String(sid); + + async function getSessionMessages(sid) { + try { + const msgs = await session.context({ sessionID: sid }); + return Array.isArray(msgs) ? msgs : []; + } catch (e) { + await log("warn", `Failed to fetch messages for ${short(sid)}: ${e.message}`); + return []; + } + } + + async function promptSession(sid, text, agent, model) { + try { + return await session.prompt({ + sessionID: sid, + text: String(text), + agent, + model, + }); + } catch (e) { + await log("warn", `prompt failed for ${short(sid)}: ${e.message}`); + throw e; + } + } + + async function abortSession(sid) { + try { + return await session.interrupt({ sessionID: sid, resume: false }); + } catch (e) { + await log("warn", `interrupt failed for ${short(sid)}: ${e.message}`); + throw e; + } + } + + async function getSessionStatusMap() { + try { + return {}; + } catch (e) { + await log("warn", `session status map error: ${e.message}`); + return {}; + } + } + + async function getSessionTodos(sid) { + return []; + } + + async function summarizeSession(sid) { + try { + return await session.command({ sessionID: sid, name: "compact" }); + } catch (e) { + await log("warn", `compact failed for ${short(sid)}: ${e.message}`); + throw e; + } + } + + async function discoverSessions() { + try { + const db = new Database("/data/data/com.termux/files/home/.local/share/opencode/opencode.db", { readonly: true }); + const rows = db.query("SELECT id, directory, project_id as project, '' as status, time_created as created_at FROM session_v2 ORDER BY time_created DESC LIMIT 100").all(); + db.close(); + return rows.map(r => ({ id: r.id, directory: r.directory, project: r.project, status: r.status, created_at: r.created_at })); + } catch (e) { + await log("warn", `session list via sqlite error: ${e.message}`); + return []; + } + } + + async function getAvailableToolIds() { + try { + const tools = await tool.list(); + return Array.isArray(tools) ? tools.map(t => t.id).filter(Boolean) : []; + } catch (e) { + await log("warn", `tool.list error: ${e.message}`); + return []; + } + } + + const appLog = async (input) => { + const { body } = input || {}; + if (body && body.message) { + await log(body.level || "info", body.message); + } + }; + + async function getProviderInfo(providerID) { + try { + const providers = await provider.list(); + const data = Array.isArray(providers) ? providers : (providers?.data || []); + return data.find(p => p.id === providerID) || null; + } catch (e) { + await log("warn", `provider.get error: ${e.message}`); + return null; + } + } + + async function getConfig() { + return { + plugin: [], + agent: [], + model: [], + provider: [], + mcp: [], + experimental: {}, + }; + } + + const eventSubscribe = (opts = {}) => { + try { + const iterable = event.subscribe(opts.signal ? { signal: opts.signal } : {}); + return iterable; + } catch (e) { + dbg(`event.subscribe error: ${e.message}`); + return { + [Symbol.asyncIterator]() { + return { + next: async () => ({ done: true, value: null }) + }; + } + }; + } + }; + + const v1ctx = { + direction: "incoming", + sessionID: null, + async: (p) => p, + + client: { + app: { log: appLog }, + session: { + prompt: ({ path, body }) => promptSession(path?.id, body?.parts?.[0]?.text || body?.text, body?.agent, body?.model), + messages: ({ path }) => getSessionMessages(path?.id), + abort: ({ path }) => abortSession(path?.id), + status: () => getSessionStatusMap(), + todo: ({ path }) => getSessionTodos(path?.id), + summarize: ({ path }) => summarizeSession(path?.id), + list: discoverSessions, + command: ({ path, body }) => session.command({ sessionID: path?.id, name: body?.command, text: body?.arguments }), + }, + tool: { + ids: getAvailableToolIds, + }, + provider: { + get: () => ({ data: getProviderInfo() }), + }, + config: { + get: getConfig, + }, + }, + + event: { + subscribe: (opts) => eventSubscribe(opts), + }, + + directory: location?.directory, + skipChecks: false, + seen: new Set(), + jitless: false, + }; + + return v1ctx; +} + +export default { + id: "opencode-auto-resume", + async setup(ctx) { + const options = { ...V1_OPTIONS_DEFAULTS, ...(ctx.options || {}) }; + console.log(`[auto-resume] V2 wrapper starting with options:`, JSON.stringify(options)); + + const v1ctx = makeV1Context(ctx, options); + let hooks; + try { + hooks = await AutoResumePlugin(v1ctx, options); + console.log("[auto-resume] V1 plugin initialized, hooks:", Object.keys(hooks || {})); + } catch (e) { + console.error("[auto-resume] V1 plugin initialization failed:", e.message); + return; + } + + if (!hooks || typeof hooks !== "object") { + console.warn("[auto-resume] V1 plugin returned no hooks object"); + return; + } + + const registrations = []; + + if (typeof hooks["tool.execute.before"] === "function") { + try { + const reg = await ctx.tool.hook("execute.before", async (ev) => { + const input = { + tool: ev.tool, + args: ev.input, + sessionID: ev.sessionID, + }; + return hooks["tool.execute.before"](input, undefined); + }); + registrations.push(reg); + console.log("[auto-resume] registered tool.execute.before"); + } catch (e) { + console.error("[auto-resume] tool.execute.before hook failed:", e.message); + } + } + + if (typeof hooks["tool.execute.after"] === "function") { + try { + const reg = await ctx.tool.hook("execute.after", async (ev) => { + const input = { + tool: ev.tool, + args: ev.input, + sessionID: ev.sessionID, + }; + return hooks["tool.execute.after"](input); + }); + registrations.push(reg); + console.log("[auto-resume] registered tool.execute.after"); + } catch (e) { + console.error("[auto-resume] tool.execute.after hook failed:", e.message); + } + } + + if (typeof hooks["chat.message"] === "function") { + try { + const reg = await ctx.session.hook("prompt", async (ev) => { + const input = { sessionID: ev.sessionID }; + return hooks["chat.message"](input); + }); + registrations.push(reg); + console.log("[auto-resume] registered chat.message (as session.hook prompt)"); + } catch (e) { + console.error("[auto-resume] chat.message hook failed:", e.message); + } + } + + if (typeof hooks["command.execute.before"] === "function") { + console.log("[auto-resume] command.execute.before skipped (no V2 equivalent)"); + } + + if (typeof hooks.event === "function") { + try { + const iterable = ctx.event.subscribe(); + (async () => { + try { + for await (const event of iterable) { + await hooks.event({ event }); + } + } catch (e) { + console.error("[auto-resume] event loop error:", e.message); + } + })(); + console.log("[auto-resume] event subscription started"); + } catch (e) { + console.error("[auto-resume] event subscribe failed:", e.message); + } + } + + if (typeof hooks.config === "function") { + try { + await hooks.config(); + console.log("[auto-resume] config handler executed"); + } catch (e) { + console.error("[auto-resume] config handler error:", e.message); + } + } + + return async () => { + console.log("[auto-resume] cleaning up..."); + for (const reg of registrations) { + try { await reg.dispose(); } catch {} + } + }; + }, +}; \ No newline at end of file