You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 listenerreturnLayer.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"// ...exportconstwebHandler=lazy(()=>HttpRouter.toWebHandler(routes,{disableLogger: true,
memoMap,// module-singleton from @opencode-ai/coremiddleware: disposeMiddleware,}),)
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:
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:
constexisting=pending.get(input.requestID)if(!existing)return// ← no error, no log, HTTP 200 true
Reproduction
A minimal repro plugin (server-plugin.ts):
exportconstPlugin=async(ctx)=>{return{event: async({ event })=>{conste=eventas{type: string;properties?: any}if(e.type!=="permission.asked")returnconst{ id, sessionID }=e.properties// This call returns 200 true but does NOT resolve the deferred.awaitctx.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 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.
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(). 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.
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.
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:constreplyUrl=newURL(`/session/${encodeURIComponent(sessionId)}/permissions/${encodeURIComponent(permId)}`,ctx.serverUrl,)awaitfetch(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)
Description
Plugin replies to permission requests are silently dropped on v1.14.51+. The plugin sees its
client.postSessionIdPermissionsPermissionId(...)call return200 true, but thePermission.Service.replyit lands on is a different in-process instance than the one holding the pending permission. The pending Deferred never resolves, the bus eventpermission.repliedis never published, and the user sees the permission prompt forever in the web UI / TUI.Effectively: any plugin that subscribes to
permission.askedand 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 memoMapsThe TCP listener built by
Server.listen(...)and the in-processServer.Default()consumed bycreateOpencodeClient({ fetch: (...args) => Server.Default().app.fetch(...args) })use two separate EffectLayermemoMaps, so they hold independent copies of every singleton service — includingPermission.Serviceand itsInstanceState-backedpendingmap.In
packages/opencode/src/server/server.ts:In
packages/opencode/src/server/routes/instance/httpapi/server.ts:And
@opencode-ai/core/effect/memo-map.ts:Server.Default()is built fromHttpApiApp.webHandler(), which uses that module-singletonmemoMap. The TCP listener uses its own freshLayer.makeMemoMapUnsafe(). They never share state.In
packages/opencode/src/plugin/index.ts:128, the plugin SDK client is wired into the Default()'s handler:So when the user's tool call enters via the TCP listener and
Permission.askadds the entry to itspendingmap (memoMap A), the plugin's subsequentclient.post...({response:"once"})routes throughServer.Default()into memoMap B'sPermission.Service.reply, which looks at memoMap B's empty pending map and silently returns. The user-visiblePermission.replyhandler is:Reproduction
A minimal repro plugin (
server-plugin.ts):Steps:
opencode.json.opencode serve.bash: kubectl get pods).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
e4cc4e168)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 thatPermission.replyfrom a plugin actually resolves the pending Deferred set by the listener'sPermission.ask.Suggested fix
A few possible directions, in increasing invasiveness:
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.fetchtarget the real TCP listener URL (Server.url) over loopback. Simpler, no memoMap juggling, but adds a network hop per plugin call.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.clientfor state-mutating calls andfetchthe real TCP listener directly viactx.serverUrl. Thex-opencode-directoryheader (or?directory=query) still selects the right workspace. This avoids the in-process fast path and goes throughWorkspaceRoutingMiddleware → InstanceContextMiddlewareso the reply lands on the samePermission.Servicethat holds the pending entry.Environment
e4cc4e168)