#18559 · @kldzj · opened Mar 21, 2026 at 10:01 PM UTC · last updated Mar 21, 2026 at 10:02 PM UTC

feat(plugin): add cancellation support for `command.execute.before` hook

appfeat
76
+633 files

Score breakdown

Impact

9.0

Clarity

9.0

Urgency

7.0

Ease Of Review

9.0

Guidelines

9.0

Readiness

9.0

Size

10.0

Trust

5.0

Traction

4.0

Summary

This PR introduces a cancelled boolean to the command.execute.before hook output, enabling plugins to cleanly prevent subsequent LLM invocation. This change resolves a critical user experience issue where plugin command cancellations previously resulted in a 500 error in the web app.

Open in GitHub

Description

Issue for this PR

Closes #18554 and #9306

Type of change

  • [ ] Bug fix
  • [x] New feature
  • [ ] Refactor / code improvement
  • [ ] Documentation

What does this PR do?

Adds a cancelled boolean to the command.execute.before hook output type. Plugins can set output.cancelled = true to prevent the subsequent prompt() call and Command.Event.Executed publish from running.

Previously the only way to prevent LLM invocation from a plugin was to throw, which surfaced as a 500 in the web app. Now the hook can cleanly signal cancellation via the same mutable-output pattern every other hook already uses.

How did you verify your code works?

Reviewed all callers of SessionPrompt.command() to confirm they handle undefined returns.

Checklist

  • [x] I have tested my changes locally
  • [x] I have not included unrelated changes in this PR

Linked Issues

#18554 [FEATURE]: Add cancellation support for `command.execute.before` hook

View issue

Comments

No comments.

Changed Files

packages/opencode/src/server/routes/session.ts

+10
@@ -884,6 +884,7 @@ export const SessionRoutes = lazy(() =>
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const msg = await SessionPrompt.command({ ...body, sessionID })
if (!msg) return c.body(null, 204)
return c.json(msg)
},
)

packages/opencode/src/session/prompt.ts

+42
@@ -1892,22 +1892,24 @@ NOTE: At any point in time through this workflow you should feel free to ask the
: await lastModel(input.sessionID)
: taskModel
const output = { parts, cancelled: false }
await Plugin.trigger(
"command.execute.before",
{
command: input.command,
sessionID: input.sessionID,
arguments: input.arguments,
},
{ parts },
output,
)
if (output.cancelled) return
const result = (await prompt({
sessionID: input.sessionID,
messageID: input.messageID,
model: userModel,
agent: userAgent,
parts,
parts: output.parts,
variant: input.variant,
})) as MessageV2.WithParts

packages/plugin/src/index.ts

+11
@@ -193,7 +193,7 @@ export interface Hooks {
"permission.ask"?: (input: Permission, output: { status: "ask" | "deny" | "allow" }) => Promise<void>
"command.execute.before"?: (
input: { command: string; sessionID: string; arguments: string },
output: { parts: Part[] },
output: { parts: Part[]; cancelled: boolean },
) => Promise<void>
"tool.execute.before"?: (
input: { tool: string; sessionID: string; callID: string },