Files
platform/docs/opencode-serve-migration-plan.md
T
pastilhas 027b10bd6e step 4/4: the docs say permissions too, and capability means one thing again
44 files of prose — CLAUDE.md, AGENTS.md, TODO.md, 20 docs, both plugin design
documents, and the comment surface the earlier steps could not reach.

Applied against an explicit keep-list, not swept, because the word turned out to
have SIX meanings in this repository rather than the three the offscale doc
recorded:

  permissions          renamed (steps 1–2)
  $OFFICER_ROOT/capabilities/  KEPT — the item store, and now the only thing
                               the word means that is ours
  sidecar routing keys renamed to `handles` (step 3)
  Lightning wallet     KEPT — a domain term, and on the wire to the mobile apps
  terminfo queries     KEPT — XTGETTCAP, in the pty sidecar
  InvoiceShelf         KEPT — per-resource { write, bulkDelete } flags

The sweep still falsified two things, both caught by checking rather than by
review, and both in prose that discusses more than one meaning at once:

CLAUDE.md began claiming the item store lives at `$OFFICER_ROOT/permissions`.
It does not; that directory is on disk and full of skills and tools.

And the offscale doc's own note about the collision became
"Named `permissions`, NOT `permissions`" — a sentence that had eaten the thing
it existed to warn about.

Both restored, and the note rewritten to say what is now true: capability means
one thing of ours, and three that belong to somebody else's vocabulary.

Verified live after restart: self and admin permission endpoints 200, gated
route 200, agent-status 200, 9 grants intact with 6 permissions offered.
tsgo clean, 797 tests, 787 pass, same 7.

The rename is done. Four steps, no data lost, no client break that survived
the step it was introduced in.
2026-08-15 16:31:11 +00:00

120 lines
7.6 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:
| Permission | 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 permissions 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.