Description
The TUI's event subscription filter at packages/opencode/src/cli/cmd/tui/context/event.ts:19 silently drops every server event whose event.project does not match project.project(). The TUI's project.project() is loaded asynchronously via sdk.client.project.current(...) and is undefined until that REST call resolves. The SSE stream (sdk.tsx:74-124 startSSE) is started in parallel from the same onMount, so events published by the server during the cold-start window race the project lookup and are silently dropped.
The most painful instance of this is permission.asked for external_directory. If the user's first prompt asks the model to read a file outside the worktree (very common: looking at sibling repos, or asking the agent to inspect a config under ~), the read tool calls assertExternalDirectoryEffect → ctx.ask({ permission: "external_directory", ... }) → permission.ask (permission/index.ts:161-195). The server publishes Event.Asked and then awaits an in-memory Deferred<void, RejectedError | CorrectedError> with no timeout. If the TUI dropped the asked event, the deferred is never resolved → the read tool hangs forever → and (independent bug) memory keeps growing because the AI SDK's result.fullStream.tee() buffer keeps reading model SSE chunks while opencode's consumer fiber is parked → the agent OOMs.
Symptom from the user's perspective: "I asked the model to read a file, the TUI just sits there with the tool call pending, no permission dialog ever appears, and after a while the process gets OOM-killed."
Reproduction
- Open the TUI in a project. (Don't run
permission.list() first — that doesn't happen on bootstrap.)
- Immediately on first prompt, ask the model to
read /some/absolute/path/outside/the/worktree. Best repro: ask in the very first model turn so the SSE event lands during the project-lookup race.
- Tool call sits in
running/pending state. No <PermissionPrompt> ever renders.
- RSS climbs at ~KB-MB/sec while the model continues to stream tokens that opencode can't consume past the parked tool fiber.
- Eventually OS kills the process (or with reasoning models, JS heap exhausts in seconds).
You can also reproduce more reliably by inserting a console.log("filter", event.project, project.project()) before the conditional at event.ts:19. You'll see one or more events where project.project() is undefined while event.project is a real id — those are the dropped ones.
Root cause walkthrough
// packages/opencode/src/cli/cmd/tui/context/event.ts:13-22
function subscribe(handler) {
return sdk.event.on("event", (event) => {
if (event.payload.type === "sync") return
if (event.directory === "global" || event.project === project.project()) {
handler(event.payload, { workspace: event.workspace })
}
// else: silently dropped
})
}
// packages/opencode/src/bus/index.ts:101-106 — server always tags events with the real project id
GlobalBus.emit("event", {
directory: dir,
project: context.project.id, // never undefined
workspace,
payload,
})
// packages/opencode/src/cli/cmd/tui/context/project.tsx:36-47
async function sync() {
const workspace = store.workspace.current
const [path, project] = await Promise.all([
sdk.client.path.get({ workspace }),
sdk.client.project.current({ workspace }),
])
batch(() => {
setStore("instance", "path", reconcile(path.data || defaultPath))
setStore("project", "id", project.data?.id) // <- undefined until this resolves
})
}
startSSE (sdk.tsx:74-124) and bootstrap → project.sync() (sync.tsx:378-479) both run from onMount. There is no ordering guarantee. Anything the server publishes between SSE attach and project resolution is filtered out by event.ts:19.
Why it doesn't recover
packages/opencode/src/cli/cmd/tui/context/sync.tsx bootstrap hydrates provider, agent, sessions, todos, mcp, formatter, status, lsp, command — but never calls sdk.client.permission.list(). The REST endpoint exists (server/routes/instance/httpapi/handlers/permission.ts), but the TUI relies entirely on the SSE stream. Once an event is dropped, the TUI cannot rebuild "what is currently pending" from the server's in-memory pending map.
Why it OOMs
Independent contributing bug, worth mentioning so the symptoms add up:
permission.ask at permission/index.ts:191 does Deferred.await(deferred) with no timeout.
- While the read tool's Effect fiber is parked on that deferred, ai-sdk's
streamText continues to drain the model SSE because result.fullStream is implemented via baseStream.tee() (ai@6.0.168 dist/index.mjs:7670-7697). tee() has no shared backpressure — the unread branch buffers every chunk without bound.
bus/index.ts:54,80 uses PubSub.unbounded; per-delta bus.publish(MessageV2.Event.PartDelta) (session/processor.ts:580-588) fans out to every slow subscriber's queue without bound.
So a single dropped permission event compounds into a full OOM.
Suggested fixes
In order of "smallest defensible change first":
A. Stop dropping events when project ID isn't loaded yet (1-line fix, smallest change):
// event.ts:19
const currentProject = project.project()
if (
event.directory === "global" ||
currentProject === undefined || // accept until project resolves
event.project === currentProject
) {
handler(event.payload, { workspace: event.workspace })
}
Trade-off: in attached / multi-project server scenarios the TUI may briefly receive cross-project events during the cold-start window. They re-tighten the moment project.project() resolves. This matches the existing fall-through for directory === "global" events that already cross the boundary.
B. Seed pending permissions on bootstrap (defense in depth):
In packages/opencode/src/cli/cmd/tui/context/sync.tsx bootstrap, after project.sync() resolves, call sdk.client.permission.list({ workspace }) and merge the response into store.permission. This recovers any permission ask that was missed by the SSE stream (cold start, reconnect, etc).
C. Bound the deferred await in permission.ask (defense in depth, separate bug):
// permission/index.ts:190-195
return yield* Effect.ensuring(
Deferred.await(deferred).pipe(
Effect.timeoutOrElse({
duration: "5 minutes",
orElse: () => Effect.fail(new RejectedError()),
}),
),
Effect.sync(() => { pending.delete(id) }),
)
Even if the dialog never appears (network problem, ACP relay race, future regression), the tool eventually fails instead of hanging the agent forever and pulling memory down with it.
Plugins
No response
OpenCode version
1.15.2 (also affects dev HEAD at f80651fa9 — none of event.ts:19, sync.tsx bootstrap, or permission/index.ts:191 have been touched since the bug landed).
Steps to reproduce
opencode tui in a project.
- First prompt: ask the model to read an absolute path outside the worktree. Example:
read /etc/hosts on macOS (or any sibling-repo file).
- Watch the tool sit pending; no permission dialog renders.
top -pid <pid> shows steadily climbing RSS.
- Process is OOM-killed after enough model output has been streamed into the unread
tee() branch.
Operating System
macOS 26.5 (arm64). Bug is platform-independent — pure logic race in the TUI event filter.
Terminal
Reproduces in any terminal — affects every TUI permission flow whose event arrives during the project-lookup race, regardless of terminal emulator.
Related
Description
The TUI's event subscription filter at
packages/opencode/src/cli/cmd/tui/context/event.ts:19silently drops every server event whoseevent.projectdoes not matchproject.project(). The TUI'sproject.project()is loaded asynchronously viasdk.client.project.current(...)and isundefineduntil that REST call resolves. The SSE stream (sdk.tsx:74-124 startSSE) is started in parallel from the sameonMount, so events published by the server during the cold-start window race the project lookup and are silently dropped.The most painful instance of this is
permission.askedforexternal_directory. If the user's first prompt asks the model to read a file outside the worktree (very common: looking at sibling repos, or asking the agent to inspect a config under~), the read tool callsassertExternalDirectoryEffect→ctx.ask({ permission: "external_directory", ... })→permission.ask(permission/index.ts:161-195). The server publishesEvent.Askedand then awaits an in-memoryDeferred<void, RejectedError | CorrectedError>with no timeout. If the TUI dropped the asked event, the deferred is never resolved → the read tool hangs forever → and (independent bug) memory keeps growing because the AI SDK'sresult.fullStream.tee()buffer keeps reading model SSE chunks while opencode's consumer fiber is parked → the agent OOMs.Symptom from the user's perspective: "I asked the model to read a file, the TUI just sits there with the tool call pending, no permission dialog ever appears, and after a while the process gets OOM-killed."
Reproduction
permission.list()first — that doesn't happen on bootstrap.)read /some/absolute/path/outside/the/worktree. Best repro: ask in the very first model turn so the SSE event lands during the project-lookup race.running/pendingstate. No<PermissionPrompt>ever renders.You can also reproduce more reliably by inserting a
console.log("filter", event.project, project.project())before the conditional atevent.ts:19. You'll see one or more events whereproject.project()isundefinedwhileevent.projectis a real id — those are the dropped ones.Root cause walkthrough
startSSE(sdk.tsx:74-124) andbootstrap → project.sync()(sync.tsx:378-479) both run fromonMount. There is no ordering guarantee. Anything the server publishes between SSE attach and project resolution is filtered out byevent.ts:19.Why it doesn't recover
packages/opencode/src/cli/cmd/tui/context/sync.tsxbootstrap hydrates provider, agent, sessions, todos, mcp, formatter, status, lsp, command — but never callssdk.client.permission.list(). The REST endpoint exists (server/routes/instance/httpapi/handlers/permission.ts), but the TUI relies entirely on the SSE stream. Once an event is dropped, the TUI cannot rebuild "what is currently pending" from the server's in-memorypendingmap.Why it OOMs
Independent contributing bug, worth mentioning so the symptoms add up:
permission.askatpermission/index.ts:191doesDeferred.await(deferred)with no timeout.streamTextcontinues to drain the model SSE becauseresult.fullStreamis implemented viabaseStream.tee()(ai@6.0.168 dist/index.mjs:7670-7697).tee()has no shared backpressure — the unread branch buffers every chunk without bound.bus/index.ts:54,80usesPubSub.unbounded; per-deltabus.publish(MessageV2.Event.PartDelta)(session/processor.ts:580-588) fans out to every slow subscriber's queue without bound.So a single dropped permission event compounds into a full OOM.
Suggested fixes
In order of "smallest defensible change first":
A. Stop dropping events when project ID isn't loaded yet (1-line fix, smallest change):
Trade-off: in attached / multi-project server scenarios the TUI may briefly receive cross-project events during the cold-start window. They re-tighten the moment
project.project()resolves. This matches the existing fall-through fordirectory === "global"events that already cross the boundary.B. Seed pending permissions on bootstrap (defense in depth):
In
packages/opencode/src/cli/cmd/tui/context/sync.tsxbootstrap, afterproject.sync()resolves, callsdk.client.permission.list({ workspace })and merge the response intostore.permission. This recovers any permission ask that was missed by the SSE stream (cold start, reconnect, etc).C. Bound the deferred await in
permission.ask(defense in depth, separate bug):Even if the dialog never appears (network problem, ACP relay race, future regression), the tool eventually fails instead of hanging the agent forever and pulling memory down with it.
Plugins
No response
OpenCode version
1.15.2(also affectsdevHEAD atf80651fa9— none ofevent.ts:19,sync.tsxbootstrap, orpermission/index.ts:191have been touched since the bug landed).Steps to reproduce
opencode tuiin a project.read /etc/hostson macOS (or any sibling-repo file).top -pid <pid>shows steadily climbing RSS.tee()branch.Operating System
macOS 26.5 (arm64). Bug is platform-independent — pure logic race in the TUI event filter.
Terminal
Reproduces in any terminal — affects every TUI permission flow whose event arrives during the project-lookup race, regardless of terminal emulator.
Related
readtool reads entire file regardless of MAX_BYTES limit (200× slowdown after #27155) #27864 — same family of "silent drop / unbounded growth caused by Effect migration cleanup"children()memo loses grandchild permission events (different break point, same family of "permission.asked silently swallowed")opencode serve+opencode attachif any permission is set to "ask" #16367 —opencode serve + attachpermission relay race (different scenario, same shape: deferred awaits forever with no UI surface)Permission.askDeferred has no timeout and blocks forever in headless contexts