# The serve-based turn path: what it was, and whether to rebuild it

Written 2026-08-10, before deleting `event-mapper.ts` and the unused half of
`api/chat/opencode/client.ts`. `docs/opencode-parity.md` item 7 asks for exactly this: read the dead
design into a note first, because Phase 2 may rebuild it.

It also answers Phase 2's blocking question, which turned out to be cheap to settle.

---

## The question Phase 2 was waiting on

`docs/opencode-phase0-review.md` narrowed the fork to one thing:

> Moving turns onto the serve would reintroduce exactly the single-directory coupling that caused the
> original pain, **unless the serve's API can take a per-request directory**. That question — not "why
> was it replaced" — is now the one to answer first.

**It can. Tested against the running serve (opencode 1.17.9 on this machine):**

```
POST /session?directory=/tmp/oc-phase2-probe   →   { directory: "/tmp/oc-phase2-probe" }   ✅ matches
POST /session   body { directory: … }          →   { directory: "<serve cwd>" }            ❌ ignored
```

`directory` is a **query parameter on every `/session*` route** — create, message, abort, fork,
summarize, prompt_async, shell, all of them — alongside a `workspace` param. It is not a body field,
which is why a first probe that sent it in the body appeared to disprove the whole idea.

**So the coupling is gone on both paths.** `opencode run --dir` anchors a subprocess turn (verified in
the phase-0 review), and `?directory=` anchors a serve turn. The single-server constraint that shelved
this work does not exist in either architecture any more.

That removes the reason not to move. It does not by itself decide the move — see the trade below.

---

## What the dead code actually was

Two files, ~200 of ~390 platform-side lines, all reachable from nothing:

**`event-mapper.ts` (146 lines)** — maps OpenCode's SSE event stream to officer's `ChatEvent`s. Handles
`message.part.updated` (text deltas, tool state transitions), `message.updated`, `session.idle` and
`session.error`. Its shape assumes a _streaming_ source: partial text arriving as deltas, tool calls
transitioning pending → running → completed as separate events.

**The SSE half of `client.ts`** — `subscribe(sessionId, listener)`, one shared `GET /event` stream per
base URL demultiplexed to per-session listeners, with reconnect. Plus `createSession`, `postMessage`,
`abort`, `isServerHealthy`.

The live half of `client.ts` stays: `listSessions`, `getSession`, `getMessages`, `deleteSession`,
`renameSession`, all used by `opencode-sessions.ts` for the chat list and transcript reads.

### Why this matters for a rebuild

The dead mapper is **not** a sketch to be dusted off — it is a finished, working shape for a design that
was measured against a real event stream. Two things in it are worth keeping if the serve path returns:

1. **The delta model.** `runner.ts` emits whole `text` blocks because `opencode run --format json` emits
   whole blocks; the mapper emits deltas because SSE emits deltas. Token streaming (parity Phase 3, item 11) is not new work on the serve path — it is this file.
2. **Tool-state transitions.** The mapper tracks a tool call across pending/running/completed. The
   subprocess path only ever sees the finished call.

Both are recoverable from git after deletion (`5d077a4` is the last commit where the serve path was
live), which is the argument for deleting rather than keeping it compiled-but-unreachable: an unused
file rots silently against a moving API, and this one is already pinned to a version two minor releases
behind what some machines run.

---

## The trade, now that the blocker is gone

**Keep the subprocess (`opencode run --dir`)**

- No rewrite; it works today.
- Permanently forfeits: token streaming, mid-turn injection, background tasks, live-session enumeration,
  reattach-by-id, interrupt-without-teardown. Every one of those is a `stdin: 'ignore'` consequence.
- One process per turn, no warm state to leak or garbage-collect.

**Move turns onto the serve (`POST /session/{id}/message?directory=…`)**

- Unblocks the whole of parity Phase 3 at once — those six capabilities are all downstream of a
  persistent, addressable session.
- Re-adopts an SSE stream officer must keep alive, demultiplex and reconnect. That machinery already
  exists in the deleted code, so the cost is smaller than it looks.
- Introduces warm sessions and therefore a lifetime question OpenCode currently does not have: idle GC,
  orphan adoption after a restart, the same problems the Claude path spent months getting right.
- The serve becomes load-bearing rather than a convenience. Today a serve crash costs session listing;
  then it would cost every turn in flight.

**A third option, not in the parity doc:** move only what needs the serve. Keep `opencode run` for turns
and add `?directory=`-scoped serve calls for enumeration and reattach. That buys the Live panel and
reattach-by-id without warm sessions or an SSE loop. It does not buy streaming or mid-turn injection,
which are the two most visible gaps.

## Recommendation

**Do not start the migration on this pass.** The blocker is cleared and that is the deliverable here;
choosing between the three is a product call about how much OpenCode should behave like Claude, and it
should be made deliberately rather than as a side effect of a cleanup branch.

If it is taken: the third option first. It is incremental, it is the only one with no lifetime
questions, and it makes the Live panel — which today silently omits every running OpenCode turn — tell
the truth.

---

## Verified facts this note rests on

| Claim                                                                              | How                                                       |
| ---------------------------------------------------------------------------------- | --------------------------------------------------------- |
| `?directory=` on `POST /session` is honoured                                       | Created a session, read `directory` back: matched         |
| A body `directory` is ignored                                                      | Same call with the field in the body: got the serve's cwd |
| `directory` is on every `/session*` route                                          | Read the serve's own `/doc` (OpenAPI)                     |
| `event-mapper.ts` has no importers outside `client.ts`                             | grep                                                      |
| `createSession`/`postMessage`/`abort`/`isServerHealthy` have no callers            | grep for call sites                                       |
| `listSessions`/`getSession`/`getMessages`/`deleteSession`/`renameSession` are live | all from `opencode-sessions.ts`                           |

Machine note: this server runs opencode **1.17.9**; the phase-0 review's tests ran against **1.18.11**
elsewhere. The `?directory=` result above is from 1.17.9, so it holds on the older of the two.
