# OpenCode 2.0 rewrites the plugin API: what breaks, what still works, and how to tell

OpenCode's 2.0 train renames @opencode-ai/plugin to @opencode/plugin, replaces the hooks object with Plugin.define, and drops permission.ask and tool.execute.* entirely. A 1.x plugin loads as nothing. Here is what we measured, the old-to-new hook map, and the two-line check that tells you whether your governance plugin is actually running.

<div style="margin:8px 0 28px;padding:20px 22px;border:1px solid var(--line-2);border-radius:12px;background:var(--color-accent-light,#f0effe);">
  <p style="margin:0 0 12px;font-size:15px;line-height:1.6;color:var(--acp-text);"><strong>The short version.</strong> OpenCode 2.0 (tags <code>v2.0.22+</code>, not yet on <code>latest</code>) replaces the plugin contract. A 1.x plugin does not load. Model calls through a configured provider keep working, so the session looks the same. The check is two lines: run a call your policy denies, then look for its audit row.</p>
  <p style="margin:0;font-size:13px;color:var(--acp-text-dim);"><a href="/blog/opencode-hooks-reference" data-track="OpenCode2: Hooks Reference" style="font-weight:600;">1.x hooks reference →</a> &nbsp;·&nbsp; <a href="/blog/opencode-permissions-reference" data-track="OpenCode2: Permissions Reference">1.x permissions reference →</a> &nbsp;·&nbsp; <a href="/integrations/opencode" data-track="OpenCode2: Install Guide">install guide →</a></p>
</div>

We run a daily canary that installs the current release of every harness we support, installs our plugin through the live installer, pushes one governed call through it, and asserts the audit row landed. On 13 October it installed `opencode v2.0.27` and reported this:

```
ACP · session governed: 3 model calls · 2 tool calls · 2.6¢
FAIL no audit row for opencode since 2026-10-13T04:52:21.000Z within 120s
```

Three model calls priced. Two tool calls run. Zero audit rows. The session receipt said governed; the tool boundary said nothing. Ten hours later the same canary installed 1.19.0 and went green, because upstream had pointed its installer back at a v1 branch. That window is the whole story of this post: a harness release can switch governance off without a single visible error, and the only thing that caught it was a scheduled call with an assertion on the other side.

## What is actually in the 2.0 train

Everything below is read from tag `v2.0.29` on `sst/opencode` (tagged 14 October) against `v1.19.0`. There are no release notes to quote; the release body is the string `release: v2.0.29`.

**The repo is restructured.** `packages/opencode` is gone. The runtime is now `@opencode/core`, `@opencode/cli` and `@opencode/server`. Thirty-six packages where there were a handful.

**The plugin package is renamed and rewritten.** `@opencode-ai/plugin` becomes `@opencode/plugin`, published on npm at 2.0.29 alongside `@opencode/schema`, `@opencode/client` and `@opencode/sdk`. The 1.x shape was a function that returned an object of named hooks. The 2.0 shape is a definition with a setup function that registers hooks imperatively against domains:

```ts
import { Plugin } from "@opencode/plugin"

export default Plugin.define({
  id: "example",
  setup: async (ctx) => {
    await ctx.permission.hook("evaluate", (e) => {
      // e.effect is "allow" | "deny" | "ask", already computed from the user's rules
    })
    await ctx.tool.hook("execute.before", (e) => { /* e.tool, e.id, e.input */ })
    await ctx.tool.hook("execute.after", (e) => { /* e.status, e.result | e.error */ })
    await ctx.session.hook("http.request", (e) => { e.request.headers.set("X-Example", "1") })
    return () => { /* cleanup on unload */ }
  },
})
```

**The 1.x hook names do not exist.** A search of `packages/core/src` at `v2.0.29` for `permission.ask`, `tool.execute.before`, `tool.execute.after`, `chat.headers`, `event` and `shell.env` finds nothing. They were not renamed with a compatibility alias. They are gone.

**The loader rejects a 1.x export.** `packages/core/src/plugin/module.ts` validates the default export and fails with:

```
Plugin must export a default definition with an id and an effect or setup function.
```

**Config migrates.** `plugin: [...]` is read as legacy and folded into the new `plugins` list. The v1 `permission` block and the v1 `provider` block each have a migration under `packages/core/src/v1/config/`. Your `opencode.json` keeps working. That is the part that makes the failure quiet.

## Old to new, hook by hook

| 1.x (`@opencode-ai/plugin`) | 2.0 (`@opencode/plugin`) |
|---|---|
| `export default async (input, options) => ({ ...hooks })` | `export default Plugin.define({ id, setup(ctx) })` |
| `"permission.ask"(input, output)` sets `output.status` | `ctx.permission.hook("evaluate", e => { e.effect = … })` |
| `"tool.execute.before"(input, output)`, throw to block | `ctx.tool.hook("execute.before", e => …)`, throw to block |
| `"tool.execute.after"(input, output)` | `ctx.tool.hook("execute.after", e => …)` with `status: "completed" \| "error"` |
| `"chat.headers"(input, output)` | `ctx.session.hook("http.request", e => e.request.headers.set(…))` |
| `"event"` | `ctx.event.subscribe(…)` |
| `"shell.env"` | `ctx.shell.hook("create.before", e => { e.env … })` |
| second argument `options` | `ctx.options` |

Two details worth knowing if you port something. First, the permission event now carries `effect` already resolved from the user's rules, so a plugin can see an upstream `deny` and leave it alone. In 1.x the hook only fired for `ask`, and the plugin could not tell whether the user had denied the call themselves. Second, the permission request is addressed by `action` and `resources`, not by tool name and arguments. `edit` and `write` both evaluate as action `edit` with the target path as the resource; `webfetch` evaluates with the URL. The tool's full input still arrives at `execute.before`, keyed by the same call id that the permission event carries in `source.id`, so a plugin that wants arguments for its decision caches them there and reads them back in `evaluate`.

## Why nothing tells you

A session on 2.0 with a 1.x plugin still does three things that look like governance. The provider block routes model calls through whatever proxy you configured, so they are priced and show up in a cost receipt. The permission block still prompts for `ask` rules, so you still see native approvals. And the plugin's own session-start line never prints, which is the kind of absence nobody notices.

What stops happening: the pre-call check against workspace policy, the pre-approval that skips the prompt when policy says allow, and the audit row per tool call. Those three are the plugin. The receipt is the proxy.

So the check is not "do I see cost" or "do I see prompts". It is:

1. Run one tool call your workspace policy denies. If it executes, no plugin is deciding.
2. Open the audit log for that session. If the model calls are there and the tool calls are not, you are looking at exactly what our canary saw.

## What we changed in acp-opencode

OpenCode 2.0 resolves a plugin package by trying `<package>/server` before `<package>`. So one npm package can carry both entries: the root export stays the 1.x hooks object, and a `./server` export subpath carries the 2.0 definition. 1.x never asks for the subpath; 2.0 asks for it first.

The 2.0 entry maps the same contract onto the new hooks. `evaluate` posts the call to the workspace, sets `effect` to `allow` or `deny`, leaves `ask` for OpenCode's own gate, and never loosens an upstream `deny`. `execute.before` is the deny-only backstop for tools that never reach a permission rule. `execute.after` writes the audit row. `http.request` stamps the session id so proxy-side spend joins hook-side audit rows. Kill switch, credentials and fail posture are unchanged from 1.x. Not ported yet: the local SQLite metering (the 2.0 event bus has a different shape) and the TUI toasts.

The config you already have keeps working on both:

```json
{
  "$schema": "https://opencode.ai/config.json",
  "plugin": ["acp-opencode"],
  "permission": { "bash": "ask", "edit": "ask", "webfetch": "ask" }
}
```

Two honest caveats. The 2.0 entry is tested against the contract read from the tag and a fake context, not against a 2.0 binary, because the 2.0 releases ship source archives only and `opencode-ai` on npm has no 2.x. And the API is a nightly train with no notes; the hook names above are what `v2.0.29` has, and a later tag can move them. The canary will tell us when it does.

## The general point

Every harness in this space is going to do this at least once. The plugin API is the youngest part of each of them, and the harness authors owe you nothing about it. The failure mode when it changes is not a crash. It is a session that looks identical to yesterday's with the control layer missing.

Two things hold against that. A scheduled call through the real installed harness with an assertion on the audit side, so the gap is measured the same day and not discovered in a postmortem. And a decision point that sits outside the harness, so the harness changing its plugin surface changes how you attach, not what you enforce.
