Skip to content
Open
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
8 changes: 7 additions & 1 deletion packages/opencode/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<keyof Required<Hooks>, "auth" | "event" | "tool">,
Name extends Exclude<keyof Required<Hooks>, "auth" | "event" | "tool" | "shutdown">,
Input = Parameters<Required<Hooks>[Name]>[0],
Output = Parameters<Required<Hooks>[Name]>[1],
>(name: Name, input: Input, output: Output): Promise<Output> {
Expand Down
124 changes: 124 additions & 0 deletions packages/opencode/test/plugin/shutdown.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
6 changes: 6 additions & 0 deletions packages/plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>
/**
* 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<void>
}
31 changes: 31 additions & 0 deletions packages/web/src/content/docs/plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Loading