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>
116 lines
7.2 KiB
Markdown
116 lines
7.2 KiB
Markdown
# 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 — not started, deliberately.**
|
|
|
|
Not yet lived with. Nothing here has run a real conversation with a person at the other end.
|
|
|
|
## 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.
|