Description
When a plugin calls ctx.client.session.abort(...) from inside a hook, the abort does not actually cancel the running turn if opencode is running as a server that is driven over the SDK (rather than the interactive TUI).
Concretely:
- A long-running tool call (e.g.
bash running sleep 100) keeps executing after ctx.client.session.abort() resolves.
session.status flips to idle (so any UI that trusts status shows the session as stopped), but the underlying turn is still alive.
- Follow-up user messages then look "queued" in the UI, yet the original turn keeps running in the background and eventually produces output.
This only reproduces when opencode is run as a server + driven over the SDK. It does not reproduce in a plain interactive opencode TUI session.
Probable root cause (from reading the source)
The plugin-provided ctx.client short-circuits HTTP. In packages/opencode/src/plugin/index.ts:
const client = createOpencodeClient({
baseUrl: "http://localhost:4096",
directory: ctx.directory,
// ...
fetch: async (...args) => Server.Default().fetch(...args), // in-process app, not a real HTTP request
})
Server.Default = lazy(() => createApp({})) is a separate, lazily-created app instance.
The abort route handler calls SessionPrompt.cancel, and the running-session state it consults is Instance-scoped. In packages/opencode/src/session/prompt.ts:
const state = Instance.state(() => {
const data: Record<string, { abort: AbortController; callbacks: ... }> = {}
// ...
})
export async function cancel(sessionID) {
const s = state()
const match = s[sessionID]
if (!match) {
await SessionStatus.set(sessionID, { type: "idle" }) // <-- status flips idle...
return
}
match.abort.abort() // <-- ...but this never runs when the wrong Instance is resolved
delete s[sessionID]
await SessionStatus.set(sessionID, { type: "idle" })
}
When the prompt is started through the real server pipeline but cancel() runs via the plugin client's in-process Server.Default().fetch, cancel() resolves a different Instance.state() than the one that holds the running turn's AbortController. So s[sessionID] is empty: it sets status to idle (which is why UIs think it stopped) but never calls match.abort.abort(), leaving the tool/turn running.
In a plain TUI everything shares the same Instance, so the in-process client resolves the same state() and abort works. The mismatch only appears when the server and the SDK-driven session bind to different Instances.
Workaround
Build your own SDK client inside the plugin from ctx.serverUrl instead of using ctx.client. That routes through the real HTTP server pipeline (which resolves the correct Instance from directory), and abort works:
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
const plugin: Plugin = async (ctx) => {
const client = createOpencodeClient({
baseUrl: ctx.serverUrl.toString().replace(/\/$/, ""),
directory: ctx.directory,
})
// client.session.abort(...) now actually cancels the running turn.
// We confirmed the fix by polling client.session.status(...) until idle:
// OpenCode's cancel() sets status idle synchronously, and with the
// serverUrl-based client the AbortController genuinely fires.
}
This same workaround was needed for other plugin calls too (e.g. session.status, session.messages, auth.set), which suggests it is not abort-specific but a general property of the plugin-provided ctx.client short-circuiting through Server.Default().fetch.
Plugins
Custom plugin that calls client.session.abort(...) from a chat.message / event hook to interrupt a busy session and replay a queued message. Discovered while building kimaki, which spawns opencode as a server and drives sessions over the SDK rather than using the TUI.
OpenCode version
opencode 1.15.12, @opencode-ai/sdk / @opencode-ai/plugin 1.15.11.
Steps to reproduce
- Run opencode as a server and drive a session via the SDK (not the interactive TUI).
- Start a turn that runs a long tool call, e.g.
bash with sleep 100.
- From a plugin hook, call
ctx.client.session.abort({ ... }) for that session.
- Observe:
session.status becomes idle, but the sleep keeps running and events for the "aborted" turn still arrive.
- Swap
ctx.client for a client built from ctx.serverUrl (see workaround) and repeat: the abort now actually cancels the turn.
Does not reproduce in a plain interactive opencode TUI.
Operating System
macOS 26
Description
When a plugin calls
ctx.client.session.abort(...)from inside a hook, the abort does not actually cancel the running turn if opencode is running as a server that is driven over the SDK (rather than the interactive TUI).Concretely:
bashrunningsleep 100) keeps executing afterctx.client.session.abort()resolves.session.statusflips toidle(so any UI that trusts status shows the session as stopped), but the underlying turn is still alive.This only reproduces when opencode is run as a server + driven over the SDK. It does not reproduce in a plain interactive
opencodeTUI session.Probable root cause (from reading the source)
The plugin-provided
ctx.clientshort-circuits HTTP. Inpackages/opencode/src/plugin/index.ts:Server.Default = lazy(() => createApp({}))is a separate, lazily-created app instance.The abort route handler calls
SessionPrompt.cancel, and the running-session state it consults is Instance-scoped. Inpackages/opencode/src/session/prompt.ts:When the prompt is started through the real server pipeline but
cancel()runs via the plugin client's in-processServer.Default().fetch,cancel()resolves a differentInstance.state()than the one that holds the running turn'sAbortController. Sos[sessionID]is empty: it sets status toidle(which is why UIs think it stopped) but never callsmatch.abort.abort(), leaving the tool/turn running.In a plain TUI everything shares the same Instance, so the in-process client resolves the same
state()and abort works. The mismatch only appears when the server and the SDK-driven session bind to different Instances.Workaround
Build your own SDK client inside the plugin from
ctx.serverUrlinstead of usingctx.client. That routes through the real HTTP server pipeline (which resolves the correct Instance fromdirectory), and abort works:This same workaround was needed for other plugin calls too (e.g.
session.status,session.messages,auth.set), which suggests it is not abort-specific but a general property of the plugin-providedctx.clientshort-circuiting throughServer.Default().fetch.Plugins
Custom plugin that calls
client.session.abort(...)from achat.message/eventhook to interrupt a busy session and replay a queued message. Discovered while building kimaki, which spawns opencode as a server and drives sessions over the SDK rather than using the TUI.OpenCode version
opencode
1.15.12,@opencode-ai/sdk/@opencode-ai/plugin1.15.11.Steps to reproduce
bashwithsleep 100.ctx.client.session.abort({ ... })for that session.session.statusbecomesidle, but thesleepkeeps running and events for the "aborted" turn still arrive.ctx.clientfor a client built fromctx.serverUrl(see workaround) and repeat: the abort now actually cancels the turn.Does not reproduce in a plain interactive
opencodeTUI.Operating System
macOS 26