Skip to content

Plugin permission replies via SDK client are silently dropped on v1.14.51+ (Server.Default and TCP listener use different memoMaps) #28037

@va3093

Description

@va3093

Description

Plugin replies to permission requests are silently dropped on v1.14.51+. The plugin sees its client.postSessionIdPermissionsPermissionId(...) call return 200 true, but the Permission.Service.reply it lands on is a different in-process instance than the one holding the pending permission. The pending Deferred never resolves, the bus event permission.replied is never published, and the user sees the permission prompt forever in the web UI / TUI.

Effectively: any plugin that subscribes to permission.asked and tries to reply via the SDK client is broken on v1.14.51+. Pre-1.14.51 this worked because the Hono backend was a single shared in-process app instance.

Root cause: Server.Default() and the TCP listener use independent memoMaps

The TCP listener built by Server.listen(...) and the in-process Server.Default() consumed by createOpencodeClient({ fetch: (...args) => Server.Default().app.fetch(...args) }) use two separate Effect Layer memoMaps, so they hold independent copies of every singleton service — including Permission.Service and its InstanceState-backed pending map.

In packages/opencode/src/server/server.ts:

// startListener — fresh memoMap per listener
return Layer.buildWithMemoMap(listenerLayer(opts, port), Layer.makeMemoMapUnsafe(), scope).pipe(...)

In packages/opencode/src/server/routes/instance/httpapi/server.ts:

import { memoMap } from "@opencode-ai/core/effect/memo-map"
// ...
export const webHandler = lazy(() =>
  HttpRouter.toWebHandler(routes, {
    disableLogger: true,
    memoMap,                 // module-singleton from @opencode-ai/core
    middleware: disposeMiddleware,
  }),
)

And @opencode-ai/core/effect/memo-map.ts:

import { Layer } from "effect"
export const memoMap = Layer.makeMemoMapUnsafe()

Server.Default() is built from HttpApiApp.webHandler(), which uses that module-singleton memoMap. The TCP listener uses its own fresh Layer.makeMemoMapUnsafe(). They never share state.

In packages/opencode/src/plugin/index.ts:128, the plugin SDK client is wired into the Default()'s handler:

const client = createOpencodeClient({
  baseUrl: "http://localhost:4096",
  directory: ctx.directory,
  headers: ServerAuth.headers(),
  fetch: async (...args) => Server.Default().app.fetch(...args),
})

So when the user's tool call enters via the TCP listener and Permission.ask adds the entry to its pending map (memoMap A), the plugin's subsequent client.post...({response:"once"}) routes through Server.Default() into memoMap B's Permission.Service.reply, which looks at memoMap B's empty pending map and silently returns. The user-visible Permission.reply handler is:

const existing = pending.get(input.requestID)
if (!existing) return  // ← no error, no log, HTTP 200 true

Reproduction

A minimal repro plugin (server-plugin.ts):

export const Plugin = async (ctx) => {
  return {
    event: async ({ event }) => {
      const e = event as { type: string; properties?: any }
      if (e.type !== "permission.asked") return
      const { id, sessionID } = e.properties
      // This call returns 200 true but does NOT resolve the deferred.
      await ctx.client.postSessionIdPermissionsPermissionId({
        body: { response: "once" },
        path: { id: sessionID, permissionID: id },
      })
    },
  }
}

Steps:

  1. Register the plugin in opencode.json.
  2. Start opencode serve.
  3. From the web UI / TUI, run any tool call that triggers a permission ask (e.g. bash: kubectl get pods).
  4. Observe: server log shows permission.asked publishing but never permission.replied publishing. GET /permission?directory=<dir> keeps showing the permission as pending. Prompt is never auto-resolved.

Confirming it's the memoMap split (not the SDK / directory routing): replacing the SDK call with a raw fetch(ctx.serverUrl + "/session/<id>/permissions/<permID>", ...) to the running TCP listener resolves the permission correctly. Same JSON payload, same path, same headers — only the routing target differs.

Affected versions

  • First broken release: v1.14.51 (commit 195f592 "refactor(server): simplify listener lifecycle" refactor(server): simplify listener lifecycle #27413, 2026-05-14, which removed the Hono backend and exposed the existing memoMap split)
  • Still broken on current dev (verified at e4cc4e168)
  • Last working release: v1.14.50

Before v1.14.51 the default backend was Hono, a single shared in-process app instance, so plugin replies and the TCP listener naturally hit the same Permission.Service. The Effect HttpApi backend has always had this memoMap split, but it wasn't the default for plugins until #27413.

Expected behaviour

Server.Default().app.fetch(...) should resolve plugin-issued mutations against the same in-process services as the running TCP listener, so that Permission.reply from a plugin actually resolves the pending Deferred set by the listener's Permission.ask.

Suggested fix

A few possible directions, in increasing invasiveness:

  1. Share the listener's memoMap with Server.Default(). When Server.listen(...) runs, capture the MemoMap and rebuild webHandler against it (replace the module-singleton). All in-process plugin clients then hit the live listener's services.
  2. Drop the in-process fast path entirely. Have the plugin SDK fetch target the real TCP listener URL (Server.url) over loopback. Simpler, no memoMap juggling, but adds a network hop per plugin call.
  3. Plumb MemoMap as an explicit service so Server.Default() resolves to "whatever listener is currently active" rather than a separate static handler.

Happy to send a PR for (1) or (2) if there's a preferred direction.

Workaround (for plugin authors hitting this today)

Bypass ctx.client for state-mutating calls and fetch the real TCP listener directly via ctx.serverUrl. The x-opencode-directory header (or ?directory= query) still selects the right workspace. This avoids the in-process fast path and goes through WorkspaceRoutingMiddleware → InstanceContextMiddleware so the reply lands on the same Permission.Service that holds the pending entry.

// In a plugin: don't use ctx.client.postSessionIdPermissionsPermissionId(...).
// Instead:
const replyUrl = new URL(
  `/session/${encodeURIComponent(sessionId)}/permissions/${encodeURIComponent(permId)}`,
  ctx.serverUrl,
)
await fetch(replyUrl, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    ...(ctx.directory ? { "x-opencode-directory": encodeURIComponent(ctx.directory) } : {}),
  },
  body: JSON.stringify({ response: "once" }),
})

Environment

  • OpenCode v1.15.3 (also reproduced on dev tip e4cc4e168)
  • macOS 26 (Apple Silicon), Bun 1.3.x
  • Web UI and TUI both affected

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