The serve is now the only way an opencode turn runs. runner.ts and its tests are gone, and
so is the OPENCODE_TURNS switch — there is no fallback engine any more, and the recovery for
a bad day is git rather than a config flag. Deliberate, and cheap right now precisely because
nothing depends on opencode yet.
What goes with it: mapRunLine and its NDJSON fixtures, the temp-file spill for --file image
attachments, the supersede-and-kill dance, the process watchdogs, the pidfile-adjacent child
tracking, and stopAllOpenCodeTurns. All of it existed to work around stdin being /dev/null.
Verified after deletion, with no env var set at all: tool call, tool result, 5 streaming
deltas, text and cost, through the real chat socket.
Also corrected the comments the deletion falsified rather than leaving them to mislead — the
module header, the wire contract description of opencode:run-streaming, and serve-runner own
header, which still announced itself as off by default.
One difference worth stating: shutdown no longer kills anything. Turns run inside the serve,
which is a separate process that survives us, so officer stops routing them and says so in
the transcript. When a subprocess ran the turn, failing to kill it orphaned it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
runOpenCodeTurnOnServe ignored params.images entirely, which is defect B4 rebuilt on the new
path: the image renders in your own bubble and the model never receives it, with nothing
reporting a loss. Fixed before the path is switched on for anyone rather than after.
data: URIs, not file://, and that is measured — the wrong choice is accepted with a 200 and
then dies inside the turn with "Anthropic Messages media must contain valid base64". The
data URI round-trips and the model describes the image.
Strictly better than the subprocess path here: no temp file to spill and nothing to clean up,
because the bytes travel in the request.
Both prompt paths carry them — an ordinary send and a mid-turn injection. Verified end to end
through the chat socket with the serve engine on: a red png came back "**Red**", with deltas
streaming.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase C server half, and a real flaw in phase B.
On the subprocess path a second message could only supersede — kill the process, start again,
lose the turn — because opencode run has no input channel. The serve takes another prompt
into the running turn, so a message arriving mid-turn is handed over with delivery steer and
the existing turn is left exactly as it is.
Keeping the same turn object is the load-bearing part. Phase B retired it and registered a
replacement, which stops officer routing events the serve is still producing while the serve
carries on regardless: output goes nowhere and the turn looks hung.
Verified end to end through the chat socket — sent a count to 50, injected a change of plan
eight seconds in, and BANANA INJECTED came back inside the same turn with deltas streaming
throughout.
No client change was needed. Officer composer already sends while generating; the difference
is only what the sidecar does with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
OPENCODE_TURNS=serve picks the new engine; unset keeps the subprocess, which is the default
and stays the default until this has been lived with. A bad evening should cost one restart,
not a revert. Claude is a different sidecar and is untouched.
Verified end to end through the real chat socket:
session:init -> tool:start(bash) -> tool:result -> assistant:delta x3 -> assistant:text
-> result, cost in=304 out=73
Those deltas are the first token streaming an opencode turn has ever produced in officer.
Stop is now an INTERRUPT: the turn ends and the session survives — verified by sending a
second prompt to the same session afterwards and getting an answer, which killing a
subprocess could never do.
Reads the LIVE global stream rather than the durable per-session one, because it is a strict
superset — same tool.called, tool.success, step.ended, text.ended, plus the deltas that are
the whole point. Global means one socket carries every session, so everything filters on
sessionID; one subscription is shared for the process rather than one per turn.
A turn ends on step.ended with finish != tool-calls. tool-calls is a step boundary MID-turn,
and treating it as terminal would cut every tool-using conversation in half.
delivery is stated explicitly as queue because it DEFAULTS to steer, which injects into a
running turn — wrong for an ordinary send, where two quick messages would merge into one.
Wiring steer to the button that means it is phase C.
What phase B does not do: read the durable stream. The sidecar still commits every event to
chat_session_events as it arrives, so durability is unchanged, but recovering a turn this
process never saw needs the ?after= cursor and is its own change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>