Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -32,7 +32,8 @@
"LICENSE"
],
"dependencies": {
"@opencode-ai/plugin": "latest"
"@opencode-ai/plugin": "latest",
"@opencode/client": "latest"
},
"devDependencies": {
"@opencode-ai/sdk": "latest",
Expand Down
32 changes: 30 additions & 2 deletions src/index.exports-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})

Expand All @@ -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<string, unknown>
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})`)
}
Expand Down
10 changes: 9 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
}
107 changes: 107 additions & 0 deletions src/v2.ts
Original file line number Diff line number Diff line change
@@ -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<any> | undefined;

async function getClient(): Promise<any> {
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 () => {};
}