Skip to content

Instantly share code, notes, and snippets.

@rmk40
Last active June 18, 2026 21:42
Show Gist options
  • Select an option

  • Save rmk40/6dcd0a2c40a7f3bd6ae91160575386b8 to your computer and use it in GitHub Desktop.

Select an option

Save rmk40/6dcd0a2c40a7f3bd6ae91160575386b8 to your computer and use it in GitHub Desktop.
opencode plugin output: server commands vs TUI commands (why display-only plugin output requires a TUI plugin)

Plugin Output in opencode: Server Commands vs TUI Commands

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.

TL;DR

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.

How a server slash command executes

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(...).

noReply is not reachable from the command path

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.

What a thrown command.execute.before hook does

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.

Observable effect on plugins

For a plugin slash command implemented as a server command, the available behaviors are:

  • Run the command normally → its parts go into prompt(...) and a model turn happens. There is no display-only outcome.
  • Inject a message via a separate session.prompt call. A plugin can call client.session.prompt({ noReply: true, parts: [{ type: "text", text, ignored: true }] }) directly: noReply: true skips the model turn for that call, and ignored: true marks 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 own prompt(...) call.
  • Throw from command.execute.before to stop the command's prompt(...) → 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.

TUI commands

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: /modelsmodel.list opens <DialogModel/>; /themestheme.switch opens <DialogThemeList/>; /helphelp.show opens <DialogHelp/>; /sessionssession.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.

Status

There are filed upstream feature requests for first-class plugin command/output support:

Verified against opencode 1.17.x (packages/opencode/package.json version 1.17.7). Line numbers drift between releases; use the commits/PRs below rather than specific lines.

References

Repo: sst/opencode.

See also

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment