The mapping half of the serve migration, written and pinned before anything depends on it,
so the switch-over is not also the moment the parsing turns out to be wrong. Nothing routes
through this — turns are still opencode run subprocesses, and the claude path is untouched.
The finding that matters: the serve publishes each turn TWICE, and reading the wrong one
makes it look like it cannot stream at all.
/api/session/{id}/event?after= durable, per session, replayable, durable.seq on every
event, whole values only, NO deltas
/api/event live, GLOBAL, ephemeral, carries text.delta and
tool.input.delta, no cursor
Same turn: 13 events durable, 21 live, the difference being 3 text.delta and 5
tool.input.delta. I probed the per-session one first and nearly recorded "no streaming" as
a fact — it would have removed the main reason to migrate. The split maps exactly onto what
officer already does for claude: durable to chat_session_events, live to UI deltas. The cost
is that the live stream is global, so a consumer must filter on sessionID.
tool:start is emitted on tool.called, not tool.input.started, because only tool.called has
the resolved input object — the input arrives as JSON fragments ({"comman) and a tool row
rendered with half-parsed arguments is worse than one that appears a moment later.
step.ended with finish tool-calls is a step boundary MID-turn, not the end of the turn, so
nothing terminal is emitted for it. Treating it as the end would cut every tool-using
conversation in half.
Fixtures are verbatim captures from 1.18.16. Replaying both real streams through the mapper
reconstructs the turn identically from each, with the reassembled deltas exactly equal to
the committed text and identical cost, and zero unrecognised events.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6.5 KiB
Moving OpenCode turns onto the serve — the plan
Written 2026-08-10, after the fork was unblocked (docs/opencode-fork-decision.md). Nothing here is
implemented. It exists so the work can start from verified facts rather than from the API docs, which
have been wrong or misleading three times on this path.
Andre should read "What changes for the user" and "The risk I would not take blind" before this starts.
What we are moving from and to
Today every turn is opencode run --dir <cwd> --format json, a subprocess with stdin: 'ignore'. It
works, it is verified end to end, and its limits are all consequences of that one closed pipe.
The serve's /api/session/* surface offers, and I have run each of these against 1.18.16:
| Capability | How | Verified |
|---|---|---|
| Mid-turn injection | POST /prompt {delivery: "steer"} |
yes — steered a running turn |
| Queue behind a turn | POST /prompt {delivery: "queue"} |
yes — "ONE" then "TWO", no errors |
| Token streaming | GET /api/event (GLOBAL, live) — text.delta |
yes — deltas reassemble to the committed text |
| Reconnect + replay | GET /api/session/{id}/event?after=<seq> (durable) |
yes — replayed a finished session |
| Interrupt, session lives | POST /interrupt → 204 |
endpoint only, not exercised |
| Model selection | POST /model → 204 |
yes — runs on the chosen model |
| Images | prompt.files |
not exercised (we have images via --file already) |
There are TWO streams, and this is the thing to get right
Corrected after Phase A; the table above originally implied one. The serve publishes each turn twice:
GET /api/session/{id}/event?after=<seq>— durable, per session, replayable, every event carryingdurable.seq. Whole values only (text.endedwith the full text). No deltas.GET /api/event— live, global, ephemeral. Carriestext.deltaandtool.input.delta. No cursor.
Measured on one real turn: 13 events durable, 21 live, the difference being 3 text.delta and 5
tool.input.delta. Reading only the per-session stream — which is what I did first — makes it look
like the serve cannot stream at all, and would have quietly removed the main reason to migrate.
The split maps exactly onto what officer already does for Claude: durable → chat_session_events, live →
UI deltas. The cost is that the live stream is GLOBAL, so a consumer must filter on sessionID and
cannot assume it owns the socket.
Facts that will bite whoever implements this
Each of these cost time to find. None is in the API docs.
- The location is per REQUEST, not per session.
x-opencode-directory: <cwd>header, or?location[directory]=as a deepObject query. A session created withlocationin the body and then prompted without the header does not behave. - Responses wrap in
{"data": …}on this surface; the legacy/session/*returns bare objects. Readingbody.idinstead ofbody.data.idyieldsundefinedsilently. deliverydefaults to"steer". Omitting it injects into a running turn, which is NOT the safe default for an ordinary "send" — it must be set explicitly per intent.- A model with no connected credential fails silently. Prompt admitted,
prompt.admittedandpromptedemitted, then nothing, forever. The sidecar now connects the credential at boot (connect-credential.ts), and this failure mode is why that exists. - The event names are
session.next.*—step.started,text.started,text.ended,tool.called,tool.success,step.ended,step.failed. Not the shapesmapRunLinehandles.
Shape of the work
Phase A — read the stream without depending on it. Add a serve-based reader alongside the existing
runner: subscribe to /api/session/{id}/event, map session.next.* → ChatEvent, and prove the mapping
against real turns. Do not route any user traffic through it. This is where mapRunLine's successor gets
written and tested, and it is the only phase with no user-visible risk.
Phase B — turns through the serve, behind a switch. POST /prompt for the turn, events from Phase A,
POST /interrupt for stop. Keep opencode run reachable by config so a bad day is one restart from the
known-good path. The switch is the deliverable, not a detail.
Phase C — the capabilities that motivated it. delivery: "steer" wired to the existing "send now"
button, delivery: "queue" to the queue, streaming deltas to the composer. These are the visible wins
and they are cheap once B holds.
Phase D — retire the subprocess, only after B has run for a while. Deleting it early converts every future problem into an emergency.
What changes for the user
Better: text appears as it is generated instead of in blocks; the queue and "send now" work on OpenCode exactly as they do on Claude; stop interrupts without destroying the session.
Worse, potentially: the serve becomes load-bearing. Today a serve crash costs session listing and nothing else, because turns are subprocesses. After this it costs every turn in flight. That trade is the whole decision.
The risk I would not take blind
Warm sessions bring a lifetime problem OpenCode does not currently have. A subprocess ends when the turn ends; there is nothing to garbage-collect, adopt after a restart, or leak. A serve session persists, so this migration imports the entire class of problems the Claude path spent months getting right — idle GC, orphan adoption, releasing versus killing, the supersede race I fixed this morning.
That is not an argument against doing it. It is an argument for Phase B keeping the old path one config flip away, and for not doing Phase D on the same day as Phase B.
Where to start
Phase A, runner.ts's sibling, with the session.next.* fixtures captured from a real turn rather than
hand-written — docs/opencode-fork-decision.md records how to drive one with plain curl, and
runner.test.ts is the pattern for pinning a mapping without spawning anything.