From 22fe569b4265546fa579f25a663e52f3704ec349 Mon Sep 17 00:00:00 2001 From: Ryan Kelln Date: Mon, 9 Mar 2026 11:23:39 -0400 Subject: [PATCH 1/3] feat(plugin): add shutdown hook Adds a `shutdown` hook to the Hooks interface that is called during Instance dispose. Plugins can use this to gracefully clean up background processes (e.g. stop a Dolt SQL server) before OpenCode exits. The hook is invoked via the dispose callback registered on Plugin.state(), which is triggered by Instance.disposeAll() on the existing TUI and bootstrap shutdown paths. Closes #10524 Generated-by: github-copilot/claude-sonnet-4.6 --- packages/opencode/src/plugin/index.ts | 6 +++++- packages/plugin/src/index.ts | 6 ++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index e65d21bfd60..bafcf2c2062 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -101,10 +101,14 @@ export namespace Plugin { hooks, input, } + }, async ({ hooks }) => { + for (const hook of hooks) { + await hook["shutdown"]?.() + } }) 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/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 } From 1c8a1a5a37a5a0005f143865ed45ee077a931ff2 Mon Sep 17 00:00:00 2001 From: Ryan Kelln Date: Mon, 9 Mar 2026 11:30:57 -0400 Subject: [PATCH 2/3] test(plugin): add shutdown hook tests and fix error isolation Adds two tests for the shutdown hook: - Verifies shutdown is called during Instance.dispose() - Verifies a throwing shutdown hook does not prevent others from running The second test caught a real bug: the dispose callback had no per-hook error handling, so one failing plugin would skip all subsequent shutdown hooks. Fixed by adding .catch() around each hook invocation. Generated-by: github-copilot/claude-opus-4.6 --- packages/opencode/src/plugin/index.ts | 4 +- .../opencode/test/plugin/shutdown.test.ts | 124 ++++++++++++++++++ 2 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/plugin/shutdown.test.ts diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index bafcf2c2062..5c383625a61 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -103,7 +103,9 @@ export namespace Plugin { } }, async ({ hooks }) => { for (const hook of hooks) { - await hook["shutdown"]?.() + await hook["shutdown"]?.().catch((err: unknown) => { + log.error("plugin shutdown hook failed", { error: err }) + }) } }) 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) +}) From b2dc493fcc5e2cdfb4aa9fe1ab43e3187aa6c148 Mon Sep 17 00:00:00 2001 From: Ryan Kelln Date: Mon, 9 Mar 2026 12:01:18 -0400 Subject: [PATCH 3/3] docs(plugin): document shutdown hook in plugins.mdx Add shutdown hook to the Events reference list and add an Examples section showing how to use it for background process cleanup. Generated-by: claude-opus-4.6 --- packages/web/src/content/docs/plugins.mdx | 31 +++++++++++++++++++++++ 1 file changed, 31 insertions(+) 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.