Skip to content

Plugin ctx.client session.abort silently no-ops when opencode is run as a server and driven over the SDK #29894

@remorses

Description

@remorses

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

  1. Run opencode as a server and drive a session via the SDK (not the interactive TUI).
  2. Start a turn that runs a long tool call, e.g. bash with sleep 100.
  3. From a plugin hook, call ctx.client.session.abort({ ... }) for that session.
  4. Observe: session.status becomes idle, but the sleep keeps running and events for the "aborted" turn still arrive.
  5. 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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions