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
SDK: @opencode-ai/sdk1.14.25 (npm) — same client surface on current latest (1.17.x); the relevant server code (packages/opencode/src/session/run-state.ts, packages/opencode/src/session/status.ts) is unchanged in structure as of this writing
Client: a plain Node.js/TypeScript client using @opencode-ai/sdk's generated client, calling session.prompt() then session.status() directly over HTTP — no TUI, no desktop app, no SolidJS store in the loop
OS: reproduced on Linux (WSL2); not OS-specific
Observed Behavior
After client.session.prompt({ path: { id: sessionID }, ... }) resolves (i.e., the request/response for the prompt has completed and the assistant's final message is already persisted and readable via session.messages()), an immediate follow-up call to GET /session/status (via client.session.status()) can still report that session as { type: "busy" }.
In the common case this clears within roughly one to two seconds. In rarer cases (not yet minimally reproduced, but observed repeatedly in production use over weeks of operation) the session stays busy in the status map indefinitely — with no further model activity and no way to distinguish "still working" from "done, but the status map hasn't been told."
Root cause (from reading the server source)
packages/opencode/src/session/status.ts's SessionStatus.Service is a simple in-memory Map<SessionID, Info>. Idle sessions are actively deleted from the map (status.set() calls data.delete(sessionID) when the new status is idle); busy sessions are just written. GET /session/status reflects this map directly — an absent entry means idle, a present entry means whatever state was last written.
The only place that writes idle back into that map for a normal run is packages/opencode/src/session/run-state.ts, inside SessionRunState's internal Runner:
onIdle fires when the internal Runner abstraction decides the run has settled — which is a separate, asynchronous signal from "the HTTP response body for session.prompt() finished streaming to the caller." There is a real gap between the two: the caller can observe the prompt as complete (response closed, final assistant message persisted and queryable) before onIdle has fired and updated the status map.
This is architecturally distinct from — but produces an identical externally-visible symptom to — the desktop app's known stale-busy bug (#17657, fix proposed in #32128): that bug is in the SolidJS client store failing to reconcile a setStore call, purely client-side. The behavior described here is server-side — the raw HTTP response of GET /session/status itself lags the true completion state, before any client store is involved.
A related, previously-proposed server-side fix along these lines was PR #14222 ("fix(session): concurrent prompt race, leaked callbacks, stale busy status"), which included moving the idle transition to "right after the while loop" instead of relying on post-loop prune() + stream read. That PR was closed without being merged (no comment explaining why), so as far as we can tell the underlying lag is still present.
Expected Behavior
Once a session.prompt() (or session.command()) call has returned to the caller with a completed result, GET /session/status for that session should reflect idle on the very next poll — with no unbounded window where the two disagree. At minimum, the busy-clearing signal (onIdle → status.set(..., { type: "idle" })) should never be able to lag indefinitely behind the response stream closing; today there appears to be no upper bound or recovery path if the onIdle callback is delayed or never fires.
Reproduction Sketch
opencode serve --port 4096
Create a session: POST /session (or SDK client.session.create())
Send a prompt: client.session.prompt({ path: { id: sessionID }, body: { parts: [...] } }) and await its resolution
Immediately call client.session.status() and inspect the entry for sessionID
Observe: the entry is frequently still { type: "busy" } for a short window after step 3 resolved, even though client.session.messages({ path: { id: sessionID } }) already shows the completed assistant message
(Harder to reproduce on demand) Under sustained/heavier use, the same entry can remain busy indefinitely with no further activity on the session
We do not yet have a minimal, deterministic repro for the "indefinite" case — only for the short (sub-2s) lag, which is consistently reproducible.
Impact
Any external orchestrator built directly on the HTTP API (not going through the TUI/desktop client) that uses GET /session/status as its source of truth for "is this session done yet" cannot trust a single poll immediately after a prompt completes. In practice this forces downstream tools into one of:
a fragile heuristic that gives up on busy after N consecutive polls and cross-checks message history to guess whether the session actually finished (workaround Roadmap & Existing Issues #2 below)
Both are compensating for the server not guaranteeing that the status map is consistent with the actual run state by the time a caller can observe it.
Real-world evidence — two independent workarounds in a downstream MCP server
Legate (an MCP server that wraps @opencode-ai/sdk to expose OpenCode sessions as Claude Code tools) currently carries two separate workarounds for this exact behavior, both in src/index.ts:
legate_run (around lines 334–345): after a prompt resolves, the tool polls session.status() for up to 2 seconds (250ms interval) purely so that an immediate follow-up status check from the caller doesn't see a stale busy:
// Grace delay: OpenCode's status map updates asynchronously after the stream closes.// Poll for up to 2s so that a legate_session_status call immediately after legate_run// returns sees idle rather than a stale busy.try{constgraceDeadline=Date.now()+2000;while(Date.now()<graceDeadline){const{ data }=awaitgetClient(serverUrl).session.status({query: dir ? {directory: dir} : undefined});conststatusEntry=(dataasRecord<string,{type: string}>|null)?.[sessionId];if(!statusEntry||statusEntry.type!=='busy')break;awaitnewPromise<void>((r)=>setTimeout(r,250));}}catch{/* best-effort — never block the return on status lag */}
legate_await (around lines 1329–1355): a polling loop that waits for a dispatched session to go idle escapes after 5 consecutive busy polls if the last message in the session is from the assistant, on the theory that the status map has simply failed to catch up:
// Stuck-busy escape: if status stays "busy" for STALE_BUSY_THRESHOLD consecutive polls,// cross-check against message history. If the last message is from the assistant the// agent has finished but OpenCode's status map has not caught up — break and treat as idle.constSTALE_BUSY_THRESHOLD=5;
...
if(statusEntry.type==='busy'){staleBusyCount++;if(staleBusyCount>=STALE_BUSY_THRESHOLD){constmsgResult=awaitgetClient(serverUrl).session.messages(...);if(!msgResult.error&&Array.isArray(msgResult.data)&&msgResult.data.length>0){constlastMsg=msgResult.data[msgResult.data.length-1]as{info: {role?: string}};if((lastMsg.infoas{role?: string}).role==='assistant'){// treat as idle — status map hasn't caught upbreak;}}staleBusyCount=0;}}
(We have since hardened workaround #2 to also require info.time.completed to be set on the last assistant message, because the message row exists — with role: "assistant" and zero parts — from the moment the prompt is accepted, so the role check alone misfired on any genuinely long-running task.)
Neither workaround is a real fix — they're both heuristics compensating for the status map's async catch-up delay, and the second one in particular risks masking a genuinely stuck session (see #27907 for a case where a session is legitimately, permanently busy due to a stuck tool part, which a naive "last message is assistant" check would not catch, but a longer busy-streak on a session with no trailing assistant message would).
Related / Prior Art (not exact duplicates)
#32506 — opencode run --format json --session hangs after final answer is persisted. Same class of problem (completion signal lags actual completion), but manifests in the CLI's own event-stream wait (finish() waiting on session.status: idle from the stream) rather than in a raw GET /session/status poll from an external client. Closest existing report; worth linking.
PR #14222 — "fix(session): concurrent prompt race, leaked callbacks, stale busy status." Proposed fixing stale busy status by setting idle immediately after the run loop instead of after post-loop pruning. Closed unmerged; no explanation given.
#17657 / PR #32128 — desktop app's SolidJS session_status store uses a bare setStore instead of reconcile, so stale busy entries the server no longer reports never get cleared client-side. Same symptom family ("stuck busy indicator") but a different, purely client-side bug — not the same code path as the server's raw HTTP response.
#27907 — a session can become genuinely, permanently busy (not just laggy) when a question tool part gets stuck in state.status: "running" and never resolves, locking out all assertNotBusy-guarded APIs including deleteMessage. Different root cause (a stuck tool, not status-map lag), but relevant background for anyone reasoning about the "indefinite busy" tail of this issue — a pure "N consecutive busy polls → assume idle" heuristic (as in workaround Roadmap & Existing Issues #2 above) is not safe to blindly copy, since it could mask a case like Bug: deleteMessage (and other assertNotBusy-guarded APIs) permanently fail when a question tool part is stuck in state.status=running #27907 where the session actually needs recovery, not to be treated as done.
None of the above appear to be a duplicate of the specific behavior described here (server-side GET /session/status returning busy for a window after the prompt response has already closed, observed by a plain SDK client with no TUI/desktop store involved) — filing as a new issue, with the above linked for context and to avoid overlap.
Environment
opencode serve(HTTP API mode)@opencode-ai/sdk1.14.25(npm) — same client surface on currentlatest(1.17.x); the relevant server code (packages/opencode/src/session/run-state.ts,packages/opencode/src/session/status.ts) is unchanged in structure as of this writing@opencode-ai/sdk's generated client, callingsession.prompt()thensession.status()directly over HTTP — no TUI, no desktop app, no SolidJS store in the loopObserved Behavior
After
client.session.prompt({ path: { id: sessionID }, ... })resolves (i.e., the request/response for the prompt has completed and the assistant's final message is already persisted and readable viasession.messages()), an immediate follow-up call toGET /session/status(viaclient.session.status()) can still report that session as{ type: "busy" }.In the common case this clears within roughly one to two seconds. In rarer cases (not yet minimally reproduced, but observed repeatedly in production use over weeks of operation) the session stays
busyin the status map indefinitely — with no further model activity and no way to distinguish "still working" from "done, but the status map hasn't been told."Root cause (from reading the server source)
packages/opencode/src/session/status.ts'sSessionStatus.Serviceis a simple in-memoryMap<SessionID, Info>. Idle sessions are actively deleted from the map (status.set()callsdata.delete(sessionID)when the new status isidle); busy sessions are just written.GET /session/statusreflects this map directly — an absent entry means idle, a present entry means whatever state was last written.idleback into that map for a normal run ispackages/opencode/src/session/run-state.ts, insideSessionRunState's internalRunner:onIdlefires when the internalRunnerabstraction decides the run has settled — which is a separate, asynchronous signal from "the HTTP response body forsession.prompt()finished streaming to the caller." There is a real gap between the two: the caller can observe the prompt as complete (response closed, final assistant message persisted and queryable) beforeonIdlehas fired and updated the status map.reconcileasetStorecall, purely client-side. The behavior described here is server-side — the raw HTTP response ofGET /session/statusitself lags the true completion state, before any client store is involved.prune()+ stream read. That PR was closed without being merged (no comment explaining why), so as far as we can tell the underlying lag is still present.Expected Behavior
Once a
session.prompt()(orsession.command()) call has returned to the caller with a completed result,GET /session/statusfor that session should reflectidleon the very next poll — with no unbounded window where the two disagree. At minimum, the busy-clearing signal (onIdle→status.set(..., { type: "idle" })) should never be able to lag indefinitely behind the response stream closing; today there appears to be no upper bound or recovery path if theonIdlecallback is delayed or never fires.Reproduction Sketch
opencode serve --port 4096POST /session(or SDKclient.session.create())client.session.prompt({ path: { id: sessionID }, body: { parts: [...] } })and await its resolutionclient.session.status()and inspect the entry forsessionID{ type: "busy" }for a short window after step 3 resolved, even thoughclient.session.messages({ path: { id: sessionID } })already shows the completed assistant messagebusyindefinitely with no further activity on the sessionWe do not yet have a minimal, deterministic repro for the "indefinite" case — only for the short (sub-2s) lag, which is consistently reproducible.
Impact
Any external orchestrator built directly on the HTTP API (not going through the TUI/desktop client) that uses
GET /session/statusas its source of truth for "is this session done yet" cannot trust a single poll immediately after a prompt completes. In practice this forces downstream tools into one of:busyafter N consecutive polls and cross-checks message history to guess whether the session actually finished (workaround Roadmap & Existing Issues #2 below)Both are compensating for the server not guaranteeing that the status map is consistent with the actual run state by the time a caller can observe it.
Real-world evidence — two independent workarounds in a downstream MCP server
Legate (an MCP server that wraps
@opencode-ai/sdkto expose OpenCode sessions as Claude Code tools) currently carries two separate workarounds for this exact behavior, both insrc/index.ts:legate_run(around lines 334–345): after a prompt resolves, the tool pollssession.status()for up to 2 seconds (250ms interval) purely so that an immediate follow-up status check from the caller doesn't see a stalebusy:legate_await(around lines 1329–1355): a polling loop that waits for a dispatched session to go idle escapes after 5 consecutivebusypolls if the last message in the session is from the assistant, on the theory that the status map has simply failed to catch up:(We have since hardened workaround #2 to also require
info.time.completedto be set on the last assistant message, because the message row exists — withrole: "assistant"and zero parts — from the moment the prompt is accepted, so the role check alone misfired on any genuinely long-running task.)Neither workaround is a real fix — they're both heuristics compensating for the status map's async catch-up delay, and the second one in particular risks masking a genuinely stuck session (see #27907 for a case where a session is legitimately, permanently busy due to a stuck tool part, which a naive "last message is assistant" check would not catch, but a longer busy-streak on a session with no trailing assistant message would).
Related / Prior Art (not exact duplicates)
opencode run --format json --sessionhangs after final answer is persisted. Same class of problem (completion signal lags actual completion), but manifests in the CLI's own event-stream wait (finish()waiting onsession.status: idlefrom the stream) rather than in a rawGET /session/statuspoll from an external client. Closest existing report; worth linking.session_statusstore uses a baresetStoreinstead ofreconcile, so stalebusyentries the server no longer reports never get cleared client-side. Same symptom family ("stuck busy indicator") but a different, purely client-side bug — not the same code path as the server's raw HTTP response.questiontool part gets stuck instate.status: "running"and never resolves, locking out allassertNotBusy-guarded APIs includingdeleteMessage. Different root cause (a stuck tool, not status-map lag), but relevant background for anyone reasoning about the "indefinite busy" tail of this issue — a pure "N consecutive busy polls → assume idle" heuristic (as in workaround Roadmap & Existing Issues #2 above) is not safe to blindly copy, since it could mask a case like Bug: deleteMessage (and other assertNotBusy-guarded APIs) permanently fail when a question tool part is stuck in state.status=running #27907 where the session actually needs recovery, not to be treated as done.None of the above appear to be a duplicate of the specific behavior described here (server-side
GET /session/statusreturningbusyfor a window after the prompt response has already closed, observed by a plain SDK client with no TUI/desktop store involved) — filing as a new issue, with the above linked for context and to avoid overlap.