diff --git a/docs/opencode-parity.md b/docs/opencode-parity.md index 6d9079ee..39405bd0 100644 --- a/docs/opencode-parity.md +++ b/docs/opencode-parity.md @@ -102,6 +102,18 @@ passed that one model. Until the model question is answered, turns stay on `opencode run --dir`, which is verified working on 1.18.16. +**Crash-recovery state is not a gap either.** `state:sync` is sent to the `proxy` capability and carries +`proxySecret` — it is the Anthropic proxy s state, not a chat recovery record — and `syncState` / +`getCachedState` have **no callers at all** outside `sidecar-registry.ts`. The row compared OpenCode +against a mechanism officer never consults. The real recovery story now exists and is better: a sidecar +restart stops in-flight turns and writes the reason to `chat_session_events`, and `/chat/live` +enumerates what is running. + +**Identity is correctly deferred, not forgotten.** `TODO.md:40-47` already records that `pty`, `vault` +and `opencode` receive no identity and are covered today only because those capabilities are owner-only — +"a correct outcome resting on the wrong layer". `chat` is `kind: execution`, which the grants API refuses +to share at any level, so this cannot be reached by a member. It is latent by construction. + **`messageCount` is a non-issue, not a gap.** `SessionList.tsx:197-203` renders an `OpenCode` badge in place of the count for OpenCode rows, so the hardcoded `0` is never displayed. Computing a real count would cost one HTTP call per listed session — the session record carries no count field — to populate @@ -135,7 +147,7 @@ Ordered roughly by user-visible value. | MCP tools | `--mcp-config` | **Nothing** — no MCP anywhere in the OpenCode path | No | | `messageCount` on the list | from the transcript | hardcoded `0` | No | | Idle GC / warm-session lifetime | 30-min heartbeat, task-aware | N/A — nothing warm to collect | **Yes** | -| Crash-recovery state on disk | `claude-state.json` | **None** — `state:sync` returns an error | No | +| Crash-recovery state on disk | `claude-state.json` | **not a gap — see below** | No | | Identity | validates `X-Officer-User` | **None** — flagged in `TODO.md:42-47` | No | | Tests | 4 test files on the pure pieces | **Zero** | No | diff --git a/docs/opencode-serve-migration-plan.md b/docs/opencode-serve-migration-plan.md new file mode 100644 index 00000000..8bd94267 --- /dev/null +++ b/docs/opencode-serve-migration-plan.md @@ -0,0 +1,86 @@ +# 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 --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 /event` → `text/event-stream` | yes — `text.started`/`text.ended` observed | +| Reconnect + replay | same, `?after=` | 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) | + +## 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: ` 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. + +## 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.