retire two phantom gaps, and plan the migration
Crash-recovery state is not a gap: state:sync goes to the proxy capability and carries proxySecret, and syncState/getCachedState have no callers at all. 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. Identity is deferred, not forgotten: TODO.md already records it, and chat is kind execution, which the grants API refuses to share at any level, so no member can reach it. Also adds the serve migration plan, written while the facts are fresh and nothing is on fire. It leads with the five things that will bite whoever implements it — per-request location, the data wrapper, delivery defaulting to steer, silent failure on an unconnected credential, and the session.next event names — because none of them are in the API docs and each cost time to find today. Phased so the old path stays one config flip away, and so warm-session lifetime (idle GC, orphan adoption, the supersede race) is imported deliberately rather than discovered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+13
-1
@@ -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 |
|
||||
|
||||
|
||||
@@ -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 <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:
|
||||
|
||||
| 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=<seq>` | 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: <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.
|
||||
|
||||
## 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.
|
||||
Reference in New Issue
Block a user