The serve has a second, newer API surface nobody here had looked at, and it publishes exactly what the parity doc calls impossible under stdin ignore: delivery steer and queue on POST /prompt, an interrupt that does not tear down, and a per-session event stream with an after cursor — the durable-replay machinery officer hand-built for claude, as a primitive. That would have made migrating obvious. It does not execute. A prompt is accepted with an admittedSeq, stored, emits prompt.admitted and prompted, and then never steps. Ruled out separately: the model, the permissions (build is *:allow, no pending requests), the per-request location (the surface is location-scoped via header or a deepObject query, supplied everywhere, no change), and a config gate. The legacy POST /session/id/message?directory= generates fine in 17s, so the serve itself works — only the new pipeline is inert. session.next.* is the tell. And not a version problem, which is the part everything here had backwards: this Mac runs 1.18.11 and alpha runs 1.17.9, measured. The dead pipeline was tested on the NEWER binary. The original "this server runs 1.17.9" meant alpha and was copied to a machine where it was false; corrected in runner.ts and the test. So building against it now would produce code that looks finished and does nothing, which is the failure mode this project keeps rediscovering. One request reopens the question after any upgrade, and the doc names it. Also de-flakes the lifecycle tests: they spawn real processes, and a fixed sleep(750) went red once on a machine busy running these probes. Presence assertions poll now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6.3 KiB
The Phase 2 fork: decided, and the probe behind it
Decision: turns stay on opencode run --dir. Do not migrate to the serve.
Taken 2026-08-10 by the agent now owning this work, after probing the serve rather than reasoning about
it. docs/opencode-serve-path.md framed the three options and deferred the choice as a product call.
It is no longer a product call — the option that would have justified migrating does not currently run.
What changed the analysis
The serve exposes two API surfaces, and every prior document was written against the older one:
/session/*— what the deleted client used.?directory=per request./api/session/*— a newer surface nobody here had looked at.
The new one publishes, in /doc, exactly the capabilities the parity doc lists as impossible for
OpenCode because of stdin: 'ignore':
| Capability | New API | Parity doc said |
|---|---|---|
| Mid-turn injection | POST /prompt with delivery: "steer" |
No — needs fork |
| Queue behind a turn | same, delivery: "queue" |
No — needs fork |
| Interrupt without teardown | POST /interrupt → 204 |
No — needs fork |
| Token streaming | GET /event → text/event-stream |
No — needs fork |
| Reconnect + replay | same, ?after=<seq> — a resumable per-session cursor |
not considered |
| Compaction seams | POST /compact |
"no signal exists" |
| Images | prompt.files |
Phase 4 |
| Interactive approvals | /permission, /question + reply/reject |
not considered |
A per-session stream with a cursor is the striking one: it is the durable-replay machinery officer had to hand-build for Claude, offered as a primitive. That would have made the migration look obvious.
Why the answer is still no
It does not execute. On the newest binary we run, a prompt to /api/session/{id}/prompt is accepted
(200, with an admittedSeq), stored as a user message, emits session.next.prompt.admitted and
session.next.prompted — and then nothing. No step.started, no assistant reply, indefinitely.
Ruled out, each by a separate probe:
- Not the model. Reproduced with no model, and with an explicit
{providerID: 'opencode', id: 'claude-sonnet-4-6'}accepted and echoed back by session create. - Not permissions.
GET /permissionandGET /questionboth return{"data":[]}, and thebuildagent's own config is*: allow. - Not the missing location. The new surface is location-scoped per request —
x-opencode-directoryheader, or?location[directory]=as a deepObject query (/docconfirmslocationisdeepObject). Supplying it on every call, including the event stream, changes nothing. - Not a config gate.
/confighas noexperimentalkey;experimentalis null. - Not the version. See below.
The serve itself is fine. The legacy path generates normally: POST /session/{id}/message?directory=
returned a complete assistant reply in 17s with cost and tokens, and honoured the directory
(cwd: /private/tmp/oc-serve-probe).
So the split is precise: the serve can run turns, but only through the old endpoint, which has neither
steer nor queue. The session.next.* event naming is the tell — a next-generation pipeline that
accepts input and is not yet wired to a runner.
The version, which everything in this repo had backwards
runner.ts and the phase-0/phase-1 documents state 1.17.9 "here" and 1.18.11 "elsewhere". Measured:
- This Mac: 1.18.11
- alpha: 1.17.9
The other agent's "this server" meant alpha, and the comment was copied without re-measuring. It matters for exactly one conclusion: the dead pipeline was tested on the newer binary, so this is not "we are behind, upgrade and it works". We are ahead, and it still does not run.
What this means for each option
- Keep the subprocess — chosen. It works; verified end to end today (
session:init→assistant:text→resultwith cost). Forfeits streaming, injection, background tasks, reattach. - Migrate to the serve's legacy path — would buy token streaming and reattach, at the cost of the SSE demux/reconnect machinery and warm-session lifetime questions (idle GC, orphan adoption — the problems the Claude path spent months getting right). It would not buy steer or queue, which are the two most visible gaps. Poor trade.
- Migrate to the serve's new path — buys everything, and cannot be built against today.
Building against a preview that accepts input and never runs it is the worst of the three: the code would look finished and do nothing, which is the failure mode this project keeps rediscovering.
The trigger to reopen this
One command decides it, and it needs no design work:
POST /api/session/{id}/prompt → does `session.next.step.started` ever arrive?
When that produces a real turn on the installed binary, the migration becomes clearly worth doing and should target the new surface directly — not the legacy one — because steer, queue, interrupt and a resumable per-session cursor map one-to-one onto what officer already does for Claude by hand.
Re-run POST /api/session + POST /prompt after any opencode upgrade. Until then this is settled, and
docs/opencode-parity.md's bucket 1 should be read as "deferred by evidence", not "pending a decision".
Findings worth keeping regardless
deliverydefaults to"steer"when omitted — the admitted event says so.- New-surface responses are wrapped in
{"data": …}; the legacy surface returns bare objects. Readingbody.idinstead ofbody.data.idsilently yieldsundefined. ?after=on the event stream really does replay history — verified by replaying a finished session's events after the fact.- The serve is unauthenticated locally (
OPENCODE_SERVER_PASSWORDunset), which is how all of the above was probed with plaincurl.