Repository navigation
Plugin ctx.client session.abort silently no-ops when opencode is run as a server and driven over the SDK #29894
Description
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.
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.- 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.
bashwithsleep 100. - From a plugin hook, call
ctx.client.session.abort({ ... })for that session. - Observe:
session.statusbecomesidle, but thesleepkeeps running and events for the "aborted" turn still arrive. - Swap
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 opencode TUI.
Operating System
macOS 26
Activity
github-actions commentedon May 29, 2026
This issue might be a duplicate of (or closely related to) an existing issue. Please check:
Server.Default()and the TCP listener use different memoMaps/in-process instances, so pluginctx.clientcalls land on a different service instance than the one holding the running session state. The samectx.serverUrl-based workaround is documented there. The fix for Plugin permission replies via SDK client are silently dropped on v1.14.51+ (Server.Default and TCP listener use different memoMaps) #28037 would likely resolve this issue as well.If this is indeed the same underlying bug, it may be worth adding your findings as additional context on #28037 rather than tracking separately.
rekram1-node commentedon Jun 15, 2026
Should be fixed:
87c33b3