# 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=` — 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 /permission` and `GET /question` both return `{"data":[]}`, and the `build` agent's own config is `*: allow`. - **Not the missing location.** The new surface is location-scoped per request — `x-opencode-directory` header, or `?location[directory]=` as a deepObject query (`/doc` confirms `location` is `deepObject`). Supplying it on every call, including the event stream, changes nothing. - **Not a config gate.** `/config` has no `experimental` key; `experimental` is 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` → `result` with 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 - `delivery` defaults to `"steer"` when omitted — the admitted event says so. - New-surface responses are wrapped in `{"data": …}`; the legacy surface returns bare objects. Reading `body.id` instead of `body.data.id` silently yields `undefined`. - `?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_PASSWORD` unset), which is how all of the above was probed with plain `curl`.