Skip to content

feat(opencode): restore the slash commands on OpenCode 2 - #1434

Merged
backnotprop merged 3 commits into
mainfrom
feat/opencode2-native-commands
Aug 31, 2026
Merged

backnotprop merged 3 commits into
mainfrom
feat/opencode2-native-commands

Conversation

@backnotprop

@backnotprop backnotprop commented Aug 31, 2026

Copy link
Copy Markdown
Owner

What this does

Restores /plannotator-review, /plannotator-annotate and /plannotator-last on OpenCode 2, where they are currently dead.

Upstream lineage

OpenCode's V2 plugin API had no way for a plugin to own a slash command. That was raised as anomalyco/opencode issue #2185, drew a community PR (#44460), and was solved by maintainer PR #44765 (merged 2026-08-24 into their v2 branch): the command draft gained add({ name, description, execute }), where execute receives CommandInvocation { sessionID, prompt, delivery } and fully controls the invocation. It does not have to call ctx.session.prompt at all, which is exactly what a review command needs: the UI opens, the human decides, and only the result goes to the model.

Capability detection

The new draft ships on the beta and dev dist-tags of @opencode-ai/plugin; next and latest still carry the older one.

ctx.command.transform is not the signal. It exists on both generations. This repo's own pinned @opencode-ai/plugin@0.0.0-next-16775 declares CommandDraft as { list, get, update, remove } with no add, so probing the context reports a false positive on every stale-channel host. Capability is read from the draft handed to the callback instead:

await transform((draft) => {
  if (typeof draft?.add !== "function") return;
  ...
});

This matters because transforms are stored and replayed, not run once at registration. A TypeError from calling a missing add surfaces inside the batched reload flush and aborts it before commit, which can take the host's whole command registration down rather than just ours. The registration call is also wrapped in try/catch so no transform rejection can fail plugin setup: a slash command has a working fallback, the rest of the integration does not.

Nothing imports the new API. v2-client.ts describes the context with local duck-typed interfaces.

Command precedence, and why a reclaim is needed

On a native-capable host the plugin's definitions would still lose to the markdown stubs on any normal install. Verified against origin/v2:

  • Definitions live in a name-keyed Map and draft.add is Map.set (packages/core/src/command.ts), so the last transform to add a name wins.
  • Transforms replay in registration order (packages/core/src/state.ts: transforms = [...transforms, transform], walked by materialize).
  • Activation order is pre then packages then post, and OpenCode's own ConfigCommandPlugin is in post (packages/core/src/plugin/internal.ts:265-269, packages/core/src/plugin/supervisor.ts). It scans ~/.config/opencode/{command,commands}/**/*.md, which is exactly where scripts/install.sh and the package postinstall write the three stubs.

So a setup-time registration always replays first and is overwritten. The fix is to register the same transform once more after activation settles, putting it last in the replay order; it stays last afterwards because config only ever calls reload() and never re-registers. The explicit ctx.command.reload() after re-registering is load-bearing: each plugin's effect runs inside State.batch (packages/core/src/plugin.ts:52), so a late registration only adds its reload to an already-flushed batch (if (batch) batch.add(reload)) and would never materialize on its own.

Ownership is read back from ctx.command.list(), a direct read of committed state, on a bounded four-tick schedule. The event bus is deliberately not used: upstream #44788 reports it as unreliable on some V2 nightlies. Provenance comes from the description, which is why the native descriptions and the stub frontmatter are deliberately distinct, pinned by a test.

Failure mode if the reclaim cannot run (no list/reload, a scope that closed, a future OpenCode that reorders activation): the stubs keep the names and the commands still work through the model-mediated fallback. Degraded, never broken.

Host Slash commands Agent switching
draft has add (beta, dev) Native, executed by the same handleCliCommand machinery OpenCode 1 uses Applied via ctx.session.switchAgent
draft has no add (next, latest) Markdown stub fallback Warned in the server log, plan still approved

The fallback story

The three shared stubs now have bodies: short, imperative instructions telling the agent to run the plannotator CLI in the foreground with $ARGUMENTS and relay its stdout. That is what an OpenCode 2 user on a stale channel gets, and it is also what a native-capable host falls back to if the reclaim never runs. It costs a model turn and depends on the agent following the instruction.

Why the fallback is safe on OpenCode 1

On OpenCode 1 a command template's shell interpolation is evaluated before the V1 plugin's command.execute.before hook runs (prompt.ts:1397 vs :1461 upstream), so a bang template in these shared files would launch a second Plannotator session on every OC1 invocation, before the plugin could stop it. The bodies contain none, and a source-level test asserts they never will.

The rest of the OC1 path is unchanged: command.execute.before clears output.parts in place before the model sees anything (index.ts:448-456), so the new body text never reaches the OpenCode 1 model and the resolvePromptParts file-attachment problem from #713 stays fixed. That invariant now has a test, for all three commands in both plan-agent and manual mode, since interception lives on the always-built plugin object while shouldRegisterSubmitPlan only gates plugin.tool.

Also fixed

  • Agent switching on the command path. A failing switchAgent no longer costs the reviewer their feedback: it warns and delivers anyway, the same guarantee the approval path already gave.
  • Delivery mode. Feedback is delivered as "queue" rather than replaying the invocation's own delivery. That value was chosen when the user pressed enter; a review comes back minutes later, when a "steer" would land in the middle of whatever turn is running. Upstream's default is "steer", so this is set explicitly.
  • Agent-list shape. ctx.agent.list() is accepted as either the documented { location, data } envelope or a bare array. Reading .data unconditionally throws into a catch where the failure is invisible: an empty agent list silently disables subagent gating and agent-switch validation. (The earlier claim that new hosts return a bare array is not reproducible from origin/v2 and has been dropped from the comment; accepting both is still the right shape.)

Tests

bun test apps/opencode-plugin: 158 pass, 0 fail (118 on main).

Every new test was checked red-before / green-after by reverting the specific fix:

  • Pre-#44765 draft ({ list, get, update, remove }) registers nothing and throws nothing. Fails with a TypeError without the draft probe.
  • The shadowing contest, modelled on upstream's actual replay semantics (append-ordered transforms, Map.set add): the config stubs take the names, the reclaim takes them back, a later reload() keeps them, and the loop stops instead of piling on a transform per tick. All three fail without the reclaim.
  • A failing switchAgent still delivers feedback; feedback carries delivery: "queue". Both fail without their fixes.
  • OpenCode 1 interception empties output.parts in place for all three commands in both workflow modes. All six fail without the clearing.
  • A rejecting command.transform does not fail plugin setup.
  • Native descriptions differ from the stub frontmatter, which is what makes the ownership check meaningful.
  • Raw argument tail passes through unparsed; the stub bang-template ban; agent-list both shapes; V2 flat message translation.

bun run typecheck passes, plus a scoped tsc over apps/opencode-plugin with no errors in any touched file. bun run build:opencode succeeds (no UI changed, so the copied HTML is intentionally stale).

Smoke and manual verification

fixtures/v2-installed-smoke.ts now installs the three stubs into its sandbox config dir, so the contest between the config-loaded commands and the plugin's definitions actually happens there rather than in a shape no user has. It asserts the plannotator plugin entry is not status: "failed" (a failed plugin still lists, which is how a wrong probe could have passed the old smoke) and that all three commands resolve via /api/command, reporting which definition owns each name.

CI pins @opencode-ai/cli@0.0.0-next-16775, so that leg proves the stale-channel path only: the plugin registers nothing, the stubs win, and nothing fails. That is the expected outcome there.

scripts/opencode2-native-commands-smoke.sh is the dev-channel leg, run by a human: it installs @opencode-ai/cli@dev, packs the plugin, and runs the same fixture with PLANNOTATOR_SMOKE_EXPECT_NATIVE=1, which makes stub shadowing fatal. A dev-channel CI job is not proposed: those tags move daily and are not something to hang a required check on.

Still needs a human before release: run that script, then open the TUI on the same build and confirm /plannotator-review opens the UI without a model turn, arguments arrive intact, feedback returns to the session, and an agent switch from the review UI takes effect.

AI-assisted (Claude) under maintainer direction.

OpenCode's V2 plugin API gained native command execution upstream
(anomalyco/opencode issue #2185, PR #44765): ctx.command.transform lets a
plugin add a command whose execute callback fully owns the invocation. That
shape currently ships on the beta and dev dist-tags of @opencode-ai/plugin
while next and latest still carry the older context, so the capability is
duck-typed at runtime and never imported. On a host that exposes it the V2
adapter registers /plannotator-review, /plannotator-annotate and
/plannotator-last and runs the same handleCliCommand machinery OpenCode 1
uses, passing the raw argument tail straight through to the CLI. On a host
without it nothing new is registered and behavior is byte-identical to before.

Also wires ctx.session.switchAgent (same API generation, same probe) so an
agent switch chosen in the review UI is applied instead of only warned about,
and accepts both agent.list() response shapes: the HTTP client types it as a
{ location, data } envelope while the in-process plugin domain answers with a
bare array, where reading .data threw and silently emptied the agent list.

The shared command stubs get model-mediated fallback bodies for OpenCode 2
hosts on the stale channels. They carry no shell interpolation on purpose:
OpenCode 1 evaluates a template's !`...` before the V1 plugin's
command.execute.before hook can clear the parts, so a bang template there
would launch a second Plannotator session on every OC1 invocation. A source
level test pins that.

AI-assisted (Claude) under maintainer direction.
… stubs

Review found the capability probe was wrong in the direction that matters.
ctx.command.transform exists on pre-#44765 hosts too: our own pinned
@opencode-ai/plugin@0.0.0-next-16775 declares CommandDraft as
{ list, get, update, remove } with no add. The probe therefore returned true on
next and latest, draft.add was undefined, and because transforms are stored and
replayed the TypeError landed in the batched reload flush and aborted it before
commit, plausibly taking every command registration on the host down with it.
Capability is now read from the draft handed to the callback, which is the only
witness, and the registration call is wrapped so no transform rejection can fail
plugin setup.

The stubs also shadowed the native definitions on new hosts. Command definitions
land in a name-keyed map where add is Map.set, transforms replay in registration
order, and OpenCode's own ConfigCommandPlugin activates in the post group after
package plugins while scanning the exact directory the installer writes the
three stubs to. A setup-time registration is therefore always overwritten on a
normal install. The plugin now re-registers the same transform once activation
settles, so its definitions are last in the replay order, and calls
ctx.command.reload() explicitly because a late registration only adds its reload
to the already-flushed boot batch. Ownership is read back from
ctx.command.list() by description, which is why the native descriptions and the
stub frontmatter are deliberately distinct. If the reclaim cannot run the stubs
keep the names and the commands still work through their fallback bodies.

Also: a failing switchAgent no longer costs the reviewer their feedback on the
command path, feedback is delivered as "queue" rather than replaying the
invocation's admission mode minutes later when a steer would land mid-turn, and
the agent-list comment no longer asserts a bare-array response that could not be
reproduced upstream (accepting both shapes is still right, since reading .data
blindly throws into a catch that degrades silently).

Tests: the real old-host draft shape registers nothing and throws nothing, the
shadowing contest is modelled against upstream's replay semantics, the OpenCode 1
parts-clearing invariant is pinned for all three commands in both plan-agent and
manual mode now that the stubs carry real instructions, and the V2 smoke asserts
the plugin did not activate as failed and that all three commands resolve. The
smoke now also installs the stubs into its sandbox config dir so the contest
actually happens there. scripts/opencode2-native-commands-smoke.sh runs the same
smoke against a dev-channel build with native commands required; CI cannot,
because it pins a next build.

AI-assisted (Claude) under maintainer direction.
…failing setup

The reclaim ended the loop when the draft-probe flag read false, but that flag
only flips when the transform replays, which under boot batching is the flush
after every plugin has loaded. Plannotator loads before the post-group config
plugins, so the first tick legitimately reads false and the loop exited for
good: the reclaim was inert in exactly the shape production has. The tick is
skipped now instead, with a test that flips the flag between ticks.

The V1 entry called resolveBundledHtmlPath synchronously during plugin
construction, outside the .catch that was there to absorb a missing asset, so an
unbuilt checkout threw out of construction before any code path that needs the
HTML. The Test workflow runs bun test with no build step, so the new OpenCode 1
interception tests failed there. Both preloads are guarded; the lazy getters
still raise a clear error if something actually needs the file.

The smoke's failed-plugin guard read entry.state.status, but Plugin.Info carries
status and error at the top level, so a failed activation slipped through.
Reads the top level first and keeps the nested one as a fallback.

Comment corrections: State.batch clears its active flag before flushing, so a
late transform registration materializes on its own; the explicit reload() is
redundant-but-defensive rather than required. The reclaim schedule is a list of
deltas the loop awaits in turn, so the ticks land near 0.3s, 1.5s, 5.5s and
15.5s, not at the raw numbers.

AI-assisted (Claude) under maintainer direction.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant