A technical note for developers writing opencode plugins. It describes how plugin slash-command output works, what the relevant upstream code does, and the observable effect on plugins that try to display output without invoking the model. It sticks to verifiable behavior and cites the upstream commits/PRs; it does not speculate about anyone's intent.
On opencode 1.17.x, a plugin-registered slash command (config.command) is a
prompt command: when invoked, opencode unconditionally calls the prompt loop,
which sends a turn to the model. The command-execution path does not expose any
"don't reply" flag, so a config-registered command cannot render output without a
model turn.
One technique for emitting display-only output from a command was to throw from
the command.execute.before hook (after injecting a message separately). The
thrown error becomes an Effect defect (Die) that the command handler's
Effect.mapError does not catch, so it propagates and, in the TUI, surfaces as a
fatal error that breaks rendering. This surfaced as a v1.17.5 regression
(upstream #32253).
opencode's own display-only commands (/models, /themes, /help,
/sessions) are TUI commands, not server prompt commands — they run in the
TUI process and never enter the prompt loop. For a plugin, a TUI plugin
command that opens a dialog is the path that shows output with no model turn
and no propagating error.
A plugin registers a slash command by adding a template to the config.command
map during the config hook. When the command is invoked, opencode runs the
command.execute.before plugin hook and then calls prompt(...)
unconditionally. Current code, packages/opencode/src/session/prompt.ts:
// packages/opencode/src/session/prompt.ts (command execution)
yield* plugin.trigger(
"command.execute.before",
{ command: input.command, sessionID: input.sessionID, arguments: input.arguments },
{ parts },
)
const result = yield* prompt({ sessionID, messageID, model, agent, parts, variant })
// ^ always called; no "don't reply" flag is passed here
The command.execute.before hook was added by
bfd2f91d5 — "feat(hook): command execute before hook" (PR #9267).
That commit inserted the hook immediately before the prompt(...) call,
which already existed (the prompt(...) line was unchanged context in the diff,
not added by the commit):
+ await Plugin.trigger(
+ "command.execute.before",
+ { command: input.command, sessionID: input.sessionID, arguments: input.arguments },
+ { parts },
+ )
+
const result = (await prompt({
sessionID: input.sessionID,
/* … */Later commits moved/refactored this code —
c5442d418 (#19483) converted it from await to Effect yield*,
and 51d8219c4 (#22973) reorganized the namespace — and command
registration was reworked in
1ff19103a — "feat(core): add command registry" (#30624). Across
all of these, the hook remains immediately followed by an unconditional
prompt(...).
prompt() supports a noReply option: in prompt.ts, the PromptInput schema
declares noReply and prompt() early-returns when noReply === true
(if (input.noReply === true) return message). That option is reachable through
the session.prompt SDK/HTTP API.
It is not reachable from the command-execution path: that path's input has no
noReply field, and the prompt(...) call above does not pass one. So a
config-registered command always runs a model turn.
opencode invokes each hook inside Plugin.trigger
(packages/opencode/src/plugin/index.ts) without catching hook errors:
// packages/opencode/src/plugin/index.ts — the Plugin.trigger loop
for (const hook of s.hooks) {
const fn = hook[name]
if (!fn) continue
yield* Effect.promise(async () => fn(input, output)) // a throw here is not caught
}
This has held across versions: before the plugin service was effectified the loop
was a bare await fn(input, output) with no surrounding try/catch
(Plugin.trigger history: effectified in #19365; async invocation
adjusted in #19586). The command flow that calls the trigger
(prompt.ts, above) also does not wrap it. So a thrown command.execute.before
hook propagates out as an error.
Because the throw is synchronous inside Effect.promise(...), Effect treats it
as a defect (Die), not a typed failure (Fail). The command HTTP handler
maps errors with Effect.mapError(() => new HttpApiError.BadRequest({}))
(packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts):
return yield* promptSvc
.command({ ...ctx.payload, sessionID: ctx.params.sessionID })
.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
Effect.mapError only transforms typed Fail errors; it does not catch a
Die defect. So the thrown sentinel bypasses that mapping and reaches the TUI as
a raw error.
This surfaced as a regression in v1.17.5: upstream bug
#32253 reports that the same hook throws were silently handled in
v1.17.4 and started leaking to the TUI in v1.17.5, and bisects the change to
30b2544fe — "refactor(opencode): build server from layer nodes" (#32086),
a server-layer-composition refactor that (per that report) removed an error
boundary which previously caught these defects. The
Effect.mapError-doesn't-catch-Die mechanism above is verifiable in the
current source; the v1.17.4→v1.17.5 bisect is from #32253.
For a plugin slash command implemented as a server command, the available behaviors are:
- Run the command normally → its
partsgo intoprompt(...)and a model turn happens. There is no display-only outcome. - Inject a message via a separate
session.promptcall. A plugin can callclient.session.prompt({ noReply: true, parts: [{ type: "text", text, ignored: true }] })directly:noReply: trueskips the model turn for that call, andignored: truemarks the text part as display-only — opencode filters non-ignored, non-empty text parts (message-v2.ts:217:if (part.type === "text" && !part.ignored && part.text !== "")). This injects a visible part, but it does not stop the command's ownprompt(...)call. - Throw from
command.execute.beforeto stop the command'sprompt(...)→ the throw is not caught by the plugin layer and surfaces, on current opencode, as a fatal TUI error (see above).
There is no flag on the command path to suppress the model turn, and no non-throwing way to stop it. So a server slash command cannot render output without either a model turn or a propagating error.
opencode has a second command system that runs in the TUI process, separate from the server prompt loop:
| Server prompt command | TUI command | |
|---|---|---|
| Runs in | server process, in the prompt loop | TUI process, outside the prompt loop |
| Output | sent to prompt(...) (model input) |
rendered directly (dialog / toast) |
| Model turn | yes | none |
opencode's first-party display commands are implemented this way. In
packages/tui/src/app.tsx: /models → model.list opens <DialogModel/>;
/themes → theme.switch opens <DialogThemeList/>; /help → help.show
opens <DialogHelp/>; /sessions → session.list opens <DialogSessionList/>.
These dispatch through the TUI keymap/command layer and call dialog.replace(...);
none call session.prompt.
A TUI plugin command (registered in the TUI process) opens a dialog without entering the prompt loop: no model turn, and no thrown-hook error path. For plugin output that should not invoke the model, it is the available mechanism on current opencode.
There are filed upstream feature requests for first-class plugin command/output support:
- #10262 — "[FEATURE]: Allow plugins to register slash commands" — closed.
- #5305 — "[FEATURE]: Plugin Hook for Instant TUI Commands" — open.
Verified against opencode 1.17.x (
packages/opencode/package.jsonversion 1.17.7). Line numbers drift between releases; use the commits/PRs below rather than specific lines.
Repo: sst/opencode.
- PR #9267 (
bfd2f91d5) —feat(hook): command execute before hook— added thecommand.execute.beforehook before the already-existing unconditionalprompt(...)call. - PR #30624 (
1ff19103a) —feat(core): add command registry— reworked theconfig.commandregistration backing server slash commands. - PR #19483 (
c5442d418) —refactor(session): effectify SessionPrompt service— converted this code fromawaitto Effectyield*. - PR #22973 (
51d8219c4) —refactor: unwrap session/ tier-2 namespaces + self-reexport— reorganized the module. - PR #19365 —
effectify Plugin service internals— moved thePlugin.triggerhook loop onto Effect. - PR #19586 —
core: fix plugin hooks to properly handle async operations…— adjusted the hook invocation. - PR #32086 (
30b2544fe) —refactor(opencode): build server from layer nodes— server-layer-composition refactor that #32253 bisects the v1.17.5 regression to. - #32253 — "[BUG]
command.execute.beforehook errors leak to TUI since v1.17.5" — open; the upstream bug for this exact regression. - #10262 — "[FEATURE]: Allow plugins to register slash commands"
- #5305 — "[FEATURE]: Plugin Hook for Instant TUI Commands"
- opencode plugin docs: https://opencode.ai/docs/plugins/