Repository navigation
Plugin permission replies via SDK client are silently dropped on v1.14.51+ (Server.Default and TCP listener use different memoMaps) #28037
Description
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 trueReproduction
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:
- Register the plugin in
opencode.json. - Start
opencode serve. - From the web UI / TUI, run any tool call that triggers a permission ask (e.g.
bash: kubectl get pods). - Observe: server log shows
permission.asked publishingbut neverpermission.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:
- Share the listener's memoMap with
Server.Default(). WhenServer.listen(...)runs, capture theMemoMapand rebuildwebHandleragainst it (replace the module-singleton). All in-process plugin clients then hit the live listener's services. - Drop the in-process fast path entirely. Have the plugin SDK
fetchtarget the real TCP listener URL (Server.url) over loopback. Simpler, no memoMap juggling, but adds a network hop per plugin call. - Plumb
MemoMapas an explicit service soServer.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
Activity
github-actions commentedon May 17, 2026
This issue might be a duplicate of (or closely related to) existing issues. Please check:
POST /permission/{requestID}/replyreturns 200/true for non-existent permission IDs #15386: Describes the same symptom —POST /permission/{requestID}/replyreturns 200/true but does nothing. Your issue provides the root cause (separate memoMaps) that explains whyPOST /permission/{requestID}/replyreturns 200/true for non-existent permission IDs #15386 occurs in the plugin fast-path scenario.CaffeinatedFeline commentedon Jun 4, 2026
I am also affected — CodeNomad user, OpenCode v1.15.13, Debian 13 / Proxmox VE
My setup
@neuralnomads/codenomad), running ascodenomad.servicevia systemdhttps://127.0.0.1:9898opencode serve --port 0 --print-logs --log-level DEBUGas child processesSymptom
When a tool triggers a permission prompt with
permission: "external_directory"andpatterns: ["/proc/self/*"], the CodeNomad web UI displays the prompt correctly. Clicking "Approve" (or "Always") returns the error "permission.reply failed" in the UI. The pending Deferred is never resolved, the tool call hangs, and the permission prompt reappears on next attempt.The general permission mechanism works for other paths — I can see successful
permission.asked → permission.repliedevent pairs in the OpenCode log files forbashpermissions. The failure is specific to the HTTP-proxied permission reply flow that CodeNomad uses.This used to work. It broke after updating OpenCode past v1.14.50 — I did not change my CodeNomad configuration or permission settings.
What I traced
The error text "permission.reply failed" is not a literal string in any log file or config. It is constructed dynamically by the OpenCode SDK HTTP client when
sdk.permission.reply({requestID, reply})throws — most likely an HTTP 404 (requestID not found in thependingMap) wrapped as<methodName> failed: <statusText>.The relevant code path in CodeNomad's architecture is a transparent HTTP proxy:
No permission logic lives in CodeNomad itself — it delegates entirely to the managed
opencode serveinstances.Why this is consistent with the memoMap split
The original issue's root cause analysis is the most plausible explanation for what I am seeing. The TCP listener (
buildWithMemoMap) and the request handler (Server.Default()/toWebHandler) use separate Effect memoMaps. IfPermission.Serviceis instantiated in both memoMaps, the tool execution context (which stores the pending Deferred in the listener's memoMap) and the HTTP reply handler (which looks it up via the web handler's memoMap) resolve different service instances — hence the requestID is never found.The reason my case differs from the plugin SDK case described in the original issue is that CodeNomad sends genuine HTTP requests to the TCP listener, not in-process calls through
Server.Default().app.fetch(). This suggests the memoMap split is broader than originally scoped: it affects the HTTP service resolution path within the listener's own request pipeline, not just the separate in-process fast path.Why existing workarounds do not help me
The original issue's workaround (replacing the SDK client call with
fetch(ctx.serverUrl, ...)) works for plugin authors who control their own HTTP client. In CodeNomad's architecture, the permission reply originates from the browser-side SDK bundled in the CodeNomad web SPA — I cannot modify that SDK's routing.A fix that ensures the HTTP request handler resolves
Permission.Servicefrom the same memoMap as the tool execution context (your Option 1 or Option 3) would resolve this for all external HTTP clients, not just plugins.Additional context
ShellTool.ask()issues two sequentialPermission.ask()calls — firstexternal_directory, thenbash. If the first fails, the second is never reached. This means a single failed external_directory reply blocks the entire tool call silently.Versions verified
npm list -g opencode-aicodenomad --versionrekram1-node commentedon Jun 15, 2026
Should be fixed:
87c33b3