diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index e65d21bfd60..5c383625a61 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -101,10 +101,16 @@ export namespace Plugin { hooks, input, } + }, async ({ hooks }) => { + for (const hook of hooks) { + await hook["shutdown"]?.().catch((err: unknown) => { + log.error("plugin shutdown hook failed", { error: err }) + }) + } }) export async function trigger< - Name extends Exclude, "auth" | "event" | "tool">, + Name extends Exclude, "auth" | "event" | "tool" | "shutdown">, Input = Parameters[Name]>[0], Output = Parameters[Name]>[1], >(name: Name, input: Input, output: Output): Promise { diff --git a/packages/opencode/test/plugin/shutdown.test.ts b/packages/opencode/test/plugin/shutdown.test.ts new file mode 100644 index 00000000000..b5aa2e1c9f3 --- /dev/null +++ b/packages/opencode/test/plugin/shutdown.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import fs from "fs/promises" +import { tmpdir } from "../fixture/fixture" +import { Instance } from "../../src/project/instance" +import { Plugin } from "../../src/plugin" + +describe("plugin.shutdown", () => { + test("shutdown hook is called on instance dispose", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const pluginDir = path.join(dir, ".opencode", "plugin") + await fs.mkdir(pluginDir, { recursive: true }) + + // Write a plugin that creates a marker file when shutdown is called + const marker = path.join(dir, "shutdown-called") + await Bun.write( + path.join(pluginDir, "shutdown-test.ts"), + [ + `import fs from "fs"`, + `export default async () => ({`, + ` async shutdown() {`, + ` fs.writeFileSync(${JSON.stringify(marker)}, "shutdown")`, + ` },`, + `})`, + ``, + ].join("\n"), + ) + return { marker } + }, + }) + + const marker = tmp.extra.marker + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Initialize plugins so the state is populated (and dispose callback registered) + const hooks = await Plugin.list() + expect(hooks.length).toBeGreaterThan(0) + expect(hooks.some((h) => typeof h.shutdown === "function")).toBe(true) + + // Verify marker doesn't exist yet + const existsBefore = await fs + .stat(marker) + .then(() => true) + .catch(() => false) + expect(existsBefore).toBe(false) + + // Trigger dispose — this should call shutdown hooks + await Instance.dispose() + + // Verify the shutdown hook wrote the marker file + const contents = await fs.readFile(marker, "utf-8") + expect(contents).toBe("shutdown") + }, + }) + }, 30000) + + test("shutdown hook errors do not prevent other hooks from running", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const pluginDir = path.join(dir, ".opencode", "plugin") + await fs.mkdir(pluginDir, { recursive: true }) + + const marker1 = path.join(dir, "first-shutdown-called") + const marker2 = path.join(dir, "second-shutdown-called") + + // Plugin aaa loads first (alphabetical), writes marker, then throws + await Bun.write( + path.join(pluginDir, "aaa-shutdown-error.ts"), + [ + `import fs from "fs"`, + `export default async () => ({`, + ` async shutdown() {`, + ` fs.writeFileSync(${JSON.stringify(marker1)}, "first")`, + ` throw new Error("shutdown failed on purpose")`, + ` },`, + `})`, + ``, + ].join("\n"), + ) + + // Plugin zzz loads second (alphabetical), writes marker + await Bun.write( + path.join(pluginDir, "zzz-shutdown-ok.ts"), + [ + `import fs from "fs"`, + `export default async () => ({`, + ` async shutdown() {`, + ` fs.writeFileSync(${JSON.stringify(marker2)}, "second")`, + ` },`, + `})`, + ``, + ].join("\n"), + ) + + return { marker1, marker2 } + }, + }) + + const { marker1, marker2 } = tmp.extra + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Initialize plugins + const hooks = await Plugin.list() + expect(hooks.length).toBeGreaterThan(0) + + // Trigger dispose + await Instance.dispose() + + // The first hook ran (wrote its marker before throwing) + const first = await fs.readFile(marker1, "utf-8") + expect(first).toBe("first") + + // The second hook should also have run despite the first throwing + const second = await fs.readFile(marker2, "utf-8") + expect(second).toBe("second") + }, + }) + }, 30000) +}) diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 76370d1d5a7..ae12469faeb 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -231,4 +231,10 @@ export interface Hooks { * Modify tool definitions (description and parameters) sent to LLM */ "tool.definition"?: (input: { toolID: string }, output: { description: string; parameters: any }) => Promise + /** + * Called when the OpenCode instance is shutting down. Use this hook to + * perform cleanup (e.g. gracefully stop background processes) before the + * process exits. + */ + "shutdown"?: () => Promise } diff --git a/packages/web/src/content/docs/plugins.mdx b/packages/web/src/content/docs/plugins.mdx index a8be798217a..392f1bbf38c 100644 --- a/packages/web/src/content/docs/plugins.mdx +++ b/packages/web/src/content/docs/plugins.mdx @@ -207,6 +207,10 @@ Plugins can subscribe to events as seen below in the Examples section. Here is a - `tui.command.execute` - `tui.toast.show` +#### Lifecycle Hooks + +- `shutdown` — called during process exit for cleanup + --- ## Examples @@ -387,3 +391,30 @@ Format as a structured prompt that a new agent can use to resume work. ``` When `output.prompt` is set, it completely replaces the default compaction prompt. The `output.context` array is ignored in this case. + +--- + +### Shutdown hooks + +Run cleanup logic when OpenCode exits: + +```ts title=".opencode/plugins/cleanup.ts" +import type { Plugin } from "@opencode-ai/plugin" + +export const CleanupPlugin: Plugin = async ({ $ }) => { + // Start a background process during init + const proc = Bun.spawn(["my-server", "--port", "9000"]) + + return { + shutdown: async () => { + // Gracefully stop the background process before exit + proc.kill() + await proc.exited + }, + } +} +``` + +The `shutdown` hook fires during `Ctrl+C` or normal exit, before the process terminates. Use it to stop background services, flush buffers, or release resources that your plugin started. + +Unlike other hooks, `shutdown` takes no arguments and returns nothing. Each plugin's shutdown runs independently — if one plugin's shutdown throws, the remaining plugins still get a chance to clean up.