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
After loading an existing ACP session with a different cwd than the one it was created
with (session/new at dir A → session/load same sessionId at dir B), the agent enters a
split-brain state:
Prompts still execute in the context rooted at the creation cwd (dir A) — e.g. a read of a file inside the loaded cwd (dir B) triggers an external_directory
permission request, which only makes sense relative to dir A.
The client's session/request_permissionreply is silently dropped: the response is
delivered on the wire (correct JSON-RPC id, correct {"outcome": {"outcome": "selected", "optionId": "always"}} shape), opencode never
processes it, the tool call never resolves, and session/prompt hangs indefinitely.
No error is surfaced anywhere. Replying once behaves identically.
The identical ask→reply exchange works fine (reply processed in ~10 ms, tool runs, turn
completes) when the session is used at the cwd it was created with — so this is specific to
the load-with-changed-cwd path, not to permission handling in general.
ACP session/load accepting a cwd parameter suggests rebinding is intended to be
supported; if it isn't, an error from session/load would also be fine — anything but a
silent hang.
Where we believe it breaks (from reading the v1.17.3 source, commit 8c80113):
packages/opencode/src/acp/session.ts — ACP-side load stores the session with the new
cwd, but the server-side session keeps executing in the instance rooted at its creation
directory.
packages/opencode/src/acp/permission.ts — Handler.process() forwards the client's
reply via sdk.permission.reply({requestID, reply, directory: session.cwd}), i.e. the new cwd.
packages/sdk/js/src/v2/gen/sdk.gen.ts — permission.reply sends directory as a query
param, which workspace-routing / instance-context middleware resolve to the new
cwd's instance.
packages/opencode/src/permission/index.ts — pending permissions live in per-instance InstanceState; the reply lands in the wrong instance, pending.get(requestID) misses,
and Permission.NotFoundError is raised…
…and swallowed: Handler.handle() in acp/permission.ts wraps processing in .catch(() => {}), so the failure is invisible and the asking fiber's Deferred waits
forever.
Suggested fix directions (any one of these would resolve it; listed by how targeted they
are):
Reply with the directory the ask originated in rather than the ACP session's current
cwd — e.g. surface directory on the permission.asked event properties (the server-side
bus envelope already carries it) and use it in Handler.reply(). This is exactly what
merged PR fix(tui): route permission replies to session directory #30851 ("fix(tui): route permission replies to session directory") did for the
TUI prompt — the ACP handler needs the analogous change.
Resolve /permission/:requestID/reply across instances — request IDs are unique, so the
directory-scoped lookup is only load-bearing in the broken case.
Make ACP session/load actually rebind the server-side session to the new cwd, or reject
the call loudly when it can't.
Independent hardening: don't .catch(() => {}) around permission processing — log and
reject the pending tool call so a routing failure fails the turn instead of hanging it.
Related issues (same root-cause class — permission reply landing on a different
instance/directory than the pending ask — on other surfaces; none covers the ACP path):
Stuck on "Permission Required" after opening in new directory #30344 (open): "Stuck on Permission Required after opening in new directory" — same
user-visible symptom via TUI/opencode run -s <id> from a different directory; a comment
there shows a dir-A/dir-B repro hanging after the ask, like this one.
1.17.3 (Homebrew). Worked on pre-ACP-rewrite versions (e.g. 1.2.x); first broken version not bisected — suspected introduced with the ACP "next" implementation promotion (#29929).
Steps to reproduce
Self-contained ACP client (Python 3, stdlib only). It spawns opencode acp, creates a
session in temp dir A, loads it with cwd = dir B, then prompts a read of a file inside B and
auto-replies "always" to the permission request:
Observed transcript of the broken run (trimmed; exactly this script against opencode 1.17.3,
dirs realpath'd so the symlink artifact below is not a factor):
SEND session/new cwd=/private/var/.../T/repro-a-peg8w3dl -> ses_1474bbb77ffe...
SEND session/load sessionId=ses_1474bbb77ffe... cwd=/private/var/.../T/repro-b-km2vipwu -> ok
SEND session/prompt "read /private/var/.../T/repro-b-km2vipwu/docs/prd.md"
RECV tool_call read (pending), then in_progress
RECV session/request_permission id=0 title=external_directory
filepath=/private/var/.../T/repro-b-km2vipwu/docs/prd.md
# NB: the file is INSIDE the loaded cwd — "external" only
# relative to the session's CREATION cwd (dir A)
SEND {"jsonrpc":"2.0","id":0,"result":{"outcome":{"outcome":"selected","optionId":"always"}}}
... nothing. 120 s later the client gives up; the tool never executes, the prompt never returns.
Control (direct mode, session created at dir B): the same read asks no permission at
all and the prompt completes. Pointing the read at a file outside dir B instead (one-line
change) produces the same external_directory ask, and the "always" reply is processed in
~10 ms with the tool then running — so the ask→reply mechanism itself is healthy; only the
load-with-changed-cwd path misroutes the reply.
Also observed in our real client (Zed-style ACP integration) on macOS and in a linux/amd64
container — same wire pattern, including the case of two parallel reads producing permission
requests id 0 and id 1, both answered, both dropped.
Unrelated minor observation noticed while reducing this: if the session/new cwd is
passed un-realpath'd through a symlink (macOS /var/folders/...), reads of in-project files
via their realpath also trigger external_directory — path containment appears to compare a
realpath'd instance directory against the raw tool path. Looks like the macOS flavor of #27601 (external_directory not resolving symlinked directories).
Screenshot and/or share link
N/A — headless ACP client; full wire transcripts above.
Operating System
macOS 26 (Darwin 25.5.0), arm64; also reproduced in a linux/amd64 container (Cloud Run).
Description
After loading an existing ACP session with a different
cwdthan the one it was createdwith (
session/newat dir A →session/loadsame sessionId at dir B), the agent enters asplit-brain state:
readof a file inside the loaded cwd (dir B) triggers anexternal_directorypermission request, which only makes sense relative to dir A.
session/request_permissionreply is silently dropped: the response isdelivered on the wire (correct JSON-RPC id, correct
{"outcome": {"outcome": "selected", "optionId": "always"}}shape), opencode neverprocesses it, the tool call never resolves, and
session/prompthangs indefinitely.No error is surfaced anywhere. Replying
oncebehaves identically.The identical ask→reply exchange works fine (reply processed in ~10 ms, tool runs, turn
completes) when the session is used at the cwd it was created with — so this is specific to
the load-with-changed-cwd path, not to permission handling in general.
ACP
session/loadaccepting acwdparameter suggests rebinding is intended to besupported; if it isn't, an error from
session/loadwould also be fine — anything but asilent hang.
Where we believe it breaks (from reading the v1.17.3 source, commit
8c80113):packages/opencode/src/acp/session.ts— ACP-sideloadstores the session with the newcwd, but the server-side session keeps executing in the instance rooted at its creation
directory.
packages/opencode/src/acp/permission.ts—Handler.process()forwards the client'sreply via
sdk.permission.reply({requestID, reply, directory: session.cwd}), i.e. thenew cwd.
packages/sdk/js/src/v2/gen/sdk.gen.ts—permission.replysendsdirectoryas a queryparam, which
workspace-routing/instance-contextmiddleware resolve to the newcwd's instance.
packages/opencode/src/permission/index.ts— pending permissions live in per-instanceInstanceState; the reply lands in the wrong instance,pending.get(requestID)misses,and
Permission.NotFoundErroris raised…Handler.handle()inacp/permission.tswraps processing in.catch(() => {}), so the failure is invisible and the asking fiber'sDeferredwaitsforever.
Suggested fix directions (any one of these would resolve it; listed by how targeted they
are):
cwd — e.g. surface
directoryon thepermission.askedevent properties (the server-sidebus envelope already carries it) and use it in
Handler.reply(). This is exactly whatmerged PR fix(tui): route permission replies to session directory #30851 ("fix(tui): route permission replies to session directory") did for the
TUI prompt — the ACP handler needs the analogous change.
/permission/:requestID/replyacross instances — request IDs are unique, so thedirectory-scoped lookup is only load-bearing in the broken case.
session/loadactually rebind the server-side session to the new cwd, or rejectthe call loudly when it can't.
.catch(() => {})around permission processing — log andreject the pending tool call so a routing failure fails the turn instead of hanging it.
Related issues (same root-cause class — permission reply landing on a different
instance/directory than the pending ask — on other surfaces; none covers the ACP path):
directory instead of falling back to the TUI process cwd.
user-visible symptom via TUI/
opencode run -s <id>from a different directory; a commentthere shows a dir-A/dir-B repro hanging after the ask, like this one.
(separate layer memoMaps) but the same failure shape: reply hits a
Permission.Serviceinstance that doesn't hold the pending entry, and nothing surfaces the miss.
POST /permission/{requestID}/replyreturns 200/true for non-existent permission IDs #15386 (open):/permission/{requestID}/replyreturns 200/true for non-existent IDs —compounding hardening gap for all of the above.
Plugins
None
OpenCode version
1.17.3 (Homebrew). Worked on pre-ACP-rewrite versions (e.g. 1.2.x); first broken version not bisected — suspected introduced with the ACP "next" implementation promotion (#29929).
Steps to reproduce
Self-contained ACP client (Python 3, stdlib only). It spawns
opencode acp, creates asession in temp dir A, loads it with cwd = dir B, then prompts a read of a file inside B and
auto-replies "always" to the permission request:
Observed transcript of the broken run (trimmed; exactly this script against opencode 1.17.3,
dirs realpath'd so the symlink artifact below is not a factor):
Control (
directmode, session created at dir B): the same read asks no permission atall and the prompt completes. Pointing the read at a file outside dir B instead (one-line
change) produces the same
external_directoryask, and the "always" reply is processed in~10 ms with the tool then running — so the ask→reply mechanism itself is healthy; only the
load-with-changed-cwd path misroutes the reply.
Also observed in our real client (Zed-style ACP integration) on macOS and in a linux/amd64
container — same wire pattern, including the case of two parallel reads producing permission
requests id 0 and id 1, both answered, both dropped.
Unrelated minor observation noticed while reducing this: if the
session/newcwd ispassed un-realpath'd through a symlink (macOS
/var/folders/...), reads of in-project filesvia their realpath also trigger
external_directory— path containment appears to compare arealpath'd instance directory against the raw tool path. Looks like the macOS flavor of
#27601 (
external_directorynot resolving symlinked directories).Screenshot and/or share link
N/A — headless ACP client; full wire transcripts above.
Operating System
macOS 26 (Darwin 25.5.0), arm64; also reproduced in a linux/amd64 container (Cloud Run).
Terminal
N/A (programmatic ACP client over stdio).