Files
platform/docs/opencode-serve-migration-plan.md
pastilhasandClaude Opus 5 45e3e86d72 write down what to test, and what is most likely broken
The serve is the only path now, so this is not a comparison against a fallback.

Split by what I have actually driven end to end versus what probes cannot answer. The second
list is the real testing: resume from history (never exercised against the serve, and my
pick for most likely broken), an idle session, a sidecar restart mid-turn, an officer restart
mid-turn, and two conversations at once — that last one because the live event stream is
global and a wrong sessionID filter would splice one conversation into another.

Known gaps are listed so they do not get reported as bugs, and the one silent failure mode
with a single cause — a turn producing nothing at all — points at the credential line from
boot, which I have chased twice already.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 19:34:48 +01:00

7.6 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 carrying durable.seq. Whole values only (text.ended with the full text). No deltas.
  • GET /api/event — live, global, ephemeral. Carries text.delta and tool.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.

  1. The location is per REQUEST, not per session. x-opencode-directory: <cwd> header, or ?location[directory]= as a deepObject query. A session created with location in the body and then prompted without the header does not behave.
  2. Responses wrap in {"data": …} on this surface; the legacy /session/* returns bare objects. Reading body.id instead of body.data.id yields undefined silently.
  3. delivery defaults 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.
  4. A model with no connected credential fails silently. Prompt admitted, prompt.admitted and prompted emitted, then nothing, forever. The sidecar now connects the credential at boot (connect-credential.ts), and this failure mode is why that exists.
  5. The event names are session.next.*step.started, text.started, text.ended, tool.called, tool.success, step.ended, step.failed. Not the shapes mapRunLine handles.

Status, 2026-08-10

  • Phase A — done. serve-events.ts + tests, fixtures captured from real turns.
  • Phase B — done, behind OPENCODE_TURNS=serve (default: subprocess). serve-runner.ts. Verified end to end through the chat socket: tool call, tool result, 3 streaming deltas, text, cost. Stop is an interrupt and the session survives it.
  • Phase C — server half done. A message sent while a turn runs is injected with delivery: "steer" into the RUNNING turn, verified end to end. No client change was needed: officer's composer already sends mid-turn, and the subprocess path was superseding where the serve steers.
  • Phase D — done. The subprocess is deleted: no runner.ts, no OPENCODE_TURNS switch, no fallback engine. Andre called it — nothing depends on OpenCode, so the cost of removing the escape hatch is near zero and the recovery is git. 818 lines went with it, all of them workarounds for stdin being /dev/null.

Not yet lived with. Nothing here has run a real conversation with a person at the other end — see docs/opencode-testing-checklist.md for what to try and what is most likely to be broken.

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.