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>
291 lines
24 KiB
Markdown
291 lines
24 KiB
Markdown
# OpenCode parity — where it stands, and what to do about it
|
||
|
||
> **If you were handed this to implement: do Phase 0 and Phase 1 only.**
|
||
>
|
||
> Phase 2 is a decision, not a task — if you reach it, investigate why the serve-based turn path was
|
||
> replaced and write the answer down. Do not start building it.
|
||
>
|
||
> **Before fixing anything, open `/chat` and look for an OpenCode session in the list.** If none appears,
|
||
> defect B1 is confirmed and this document was written against the current tree. If one does appear, stop
|
||
> and re-check the whole B-list — it came from read-only surveys and only the event-path claim was
|
||
> re-verified at source. See "If you are picking this up cold" near the end.
|
||
|
||
Written 2026-08-10, from three read-only surveys of the Claude sidecar, the OpenCode sidecar, and every
|
||
officer/frontend branch on harness. Nothing here has been implemented.
|
||
|
||
**The goal is not 100% parity.** OpenCode is a different harness with a different API contract, and some
|
||
of what Claude Code does has no equivalent. The goal is _as much parity as is worth having_, plus an
|
||
honest account of what is impossible so nobody re-litigates it in six months. Anything OpenCode can do
|
||
that Claude cannot is recorded too — that bucket is the one that quietly disappears in a project framed
|
||
as "catch up".
|
||
|
||
**Thinking/effort is deliberately out of scope, for BOTH harnesses** (Andre, 2026-08-10). He has never
|
||
turned it on, considers the reasoning output noise to the reader, and is happy with results without it.
|
||
It is currently dead on the Claude path as well — `ThinkingLevel` is accepted on the wire and never
|
||
forwarded — so the honest move is to hide the control rather than implement it. It is the first task
|
||
below.
|
||
|
||
---
|
||
|
||
## The one architectural decision everything else depends on
|
||
|
||
**OpenCode turns run as a one-shot subprocess; Claude turns run inside a persistent session.**
|
||
|
||
`runner.ts:72` spawns `opencode run --format json …` per turn with `stdin: 'ignore'` — literally
|
||
`/dev/null`. The process exits with the turn, and there is no input channel to a running one. Claude, by
|
||
contrast, holds one long-lived `query()` per session driven by a streaming-input queue
|
||
(`claude-manager.ts:140-148`), which is why a message pushed mid-turn reaches the running turn.
|
||
|
||
Meanwhile the OpenCode **serve** is running the whole time (`index.ts:102-130`) and is used only for
|
||
session CRUD and model enumeration. Turns do not go through it.
|
||
|
||
So there is a fork, and most of the todo hangs off it:
|
||
|
||
- **Keep the subprocess.** Cheap, no rewrite. Permanently forfeits token streaming, mid-turn injection,
|
||
background tasks, live-session enumeration, and reattach-by-transcript-id.
|
||
- **Move turns onto the serve's HTTP/SSE API.** Larger, and the thing that makes the rest possible. The
|
||
dead `client.ts` + `event-mapper.ts` are the skeleton of exactly this design — it existed once and was
|
||
replaced (`5d077a4` → `71e39b7`). Worth understanding _why_ it was replaced before rebuilding it.
|
||
|
||
**Nothing in Phase 2 or beyond is worth starting until that question is answered.** Phases 0 and 1 are
|
||
worth doing either way.
|
||
|
||
---
|
||
|
||
## Bucket 0 — not parity gaps, just broken
|
||
|
||
These are live defects, not missing features. Each makes OpenCode less usable than the code implies.
|
||
|
||
| # | Defect | Where | Effect |
|
||
| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
|
||
| B1 | **No OpenCode session ever appears in the chat list.** The list filters on `metadata.officer.cwd`, and the only writer of that tag (`client.createSession`) has zero callers — the sidecar creates sessions via `opencode run --dir` instead. `cwdOf` always passes a truthy cwd, so the filter rejects everything. | `opencode-sessions.ts:21`, `client.ts:108-118`, `chat.ts:35,49` | The `OpenCode` badge at `SessionList.tsx:197` is unreachable code. |
|
||
| B2 | **Resuming an OpenCode session dispatches it to the Claude CLI.** `selected.model` is never passed into `NewChat`, so no model reaches the socket, so `DEFAULT_MODEL` (`claude-code`) wins and `isClaudeModel` is true. A `ses_…` id is then handed to `claude --resume`. | `ChatDetailPanel.tsx:236-245` (no `model` prop), `useChat.ts:589`, `websocket.ts:278,283,359` | Wrong-harness dispatch, not degradation. |
|
||
| B3 | **Resumed OpenCode sessions lose their working directory.** Detail returns `cwd: ''` unconditionally; falsy all the way down to `resolveChatCwd`, which falls back to the default chat dir. | `opencode-sessions.ts:84`, `ChatDetailPanel.tsx:167`, `websocket.ts:88` | Directly contradicts the design note that OpenCode needs the cwd every turn. |
|
||
| B4 | **Images are offered and silently discarded.** Every OpenCode model is advertised `images: true`; the composer accepts drops and paste; the bubble renders the image — and `handleOpenCodeChat`'s param type omits `images`, so it never leaves officer. | `list-models.ts:44`, `websocket.ts:390-399`, `send-opencode.ts:12-25` | The user sees their image and the model never receives it. |
|
||
| B5 | **Duplicate subscriptions leak on OpenCode.** Claude guards on `_claudeKill` to avoid opening a second session-scoped subscription; the OpenCode handler has no guard and overwrites the previous handle every turn. | `websocket.ts:441,455-456`, cf. the warning at `:345` | Doubled delivery after any termination that isn't `result`/`error`/`stopped`. |
|
||
| B6 | **`clearOpenCodeSession` is never called**, so the sessionKey→`ses_…` map grows for the process lifetime and a reused key resumes a stale session. | `opencode/state.ts:13` | Also in-memory only — an officer restart loses every mapping. |
|
||
| B7 | **Latent spurious `cut-off`.** `resume-cursor` defaults `model` to `claude-code`; `endTurnIfAgentIsGone` then asks the Claude sidecar about a key it never had, gets `false`, and appends a durable "agent went away" row to a live turn. Currently masked only because the client always happens to send `model` alongside `sessionId`. | `websocket.ts:607,621,749-757` | A permanent, reload-surviving false error row. |
|
||
| B8 | **In-flight `opencode run` children survive sidecar shutdown** and are not tracked, so their output is lost. Separately, `sweepStaleServes` is `/proc`-based and therefore a no-op on macOS — orphaned serves accumulate on this machine. | `index.ts:197-209`, `:41-75` | |
|
||
|
||
**Status, 2026-08-10: bucket 0 is CLOSED — B1–B8 are all fixed.**
|
||
|
||
B1–B6 in phases 0 and 1 (`22bcd7d`, `492509a`, `013e629`, `7774a25`) — see `docs/opencode-phase1-report.md`.
|
||
B7 fixed separately, after the review: `decideResume` in `websocket.ts` replaces `msg.model || DEFAULT_MODEL`.
|
||
|
||
B7's write-up above understates it. The spurious `cut-off` was the visible half; the same default also
|
||
sent the session to `adoptOrphanedSession` as a Claude one, which subscribes it to the wrong sidecar's
|
||
bus (so an OpenCode turn's output never arrives) and pins `session.model`, so stopping it calls
|
||
`killClaude` on a key that sidecar never held — a stop button that silently does nothing. All three had
|
||
the one cause, and all three were masked by the client always sending `model`.
|
||
|
||
B8 fixed the same day, both halves. `stopAllOpenCodeTurns` on shutdown, settling each turn synchronously
|
||
so the transcript says why it ended; and a pidfile sweep beside the `/proc` one, which was a no-op on
|
||
macOS and let orphaned serves accumulate there.
|
||
|
||
Also fixed after the review, and not in this table because it was found by reviewing the fix for B5/B6:
|
||
a superseded OpenCode turn ran its whole completion path against the turn that replaced it. See
|
||
`docs/opencode-phase1-review.md`.
|
||
|
||
**What bucket 0 being closed does and does not mean.** Every defect that made OpenCode behave *wrongly*
|
||
is gone. What remains is bucket 1 — capabilities Claude has and OpenCode does not — and most of the
|
||
visible ones (token streaming, mid-turn injection, background tasks, interrupt-without-teardown) are
|
||
downstream of `stdin: 'ignore'` and therefore of the Phase 2 fork.
|
||
|
||
**The fork is REOPENED, unblocked, and worth taking.** The serve publishes a newer `/api/session/*` surface offering
|
||
those capabilities natively, and on 1.18.16 **`delivery: "steer"` and `delivery: "queue"` are both
|
||
verified working** — mid-turn injection and queueing, as primitives, plus `/interrupt` and a resumable
|
||
per-session event stream. One blocker remains: `claude-sonnet-4-6` silently does not run on that surface
|
||
(it runs fine under `opencode run`). `docs/opencode-fork-decision.md` has the evidence, the open
|
||
question, and a correction — an earlier version of that file concluded the opposite because every probe
|
||
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
|
||
something nothing renders. Left alone deliberately.
|
||
|
||
**Images are done, and they never needed the fork** (bucket 1 lists them as "No — see B4", and Phase 4
|
||
put them behind the migration). `opencode run` takes attachments with `--file`, so the subprocess path
|
||
carries them today: the sidecar spills each image to a temp file for the turn and removes it in
|
||
`settle`. Verified end to end — a red PNG over the chat socket to `opencode/claude-sonnet-4-6` came back
|
||
"Red". `list-models` now reports each model's own `capabilities.input.image` instead of a hardcoded
|
||
`false`, so the composer gate became load-bearing in the right direction.
|
||
|
||
---
|
||
|
||
## Bucket 1 — Claude has it, OpenCode does not
|
||
|
||
Ordered roughly by user-visible value.
|
||
|
||
| Capability | Claude | OpenCode | Depends on the fork? |
|
||
| --------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------- | -------------------- |
|
||
| Token streaming | `delta` events from `stream_event` | **No** — `run` emits complete text parts (`runner.ts:176-177`) | **Yes** |
|
||
| Mid-turn injection / queue-into-turn | streaming input queue | **No** — `stdin: 'ignore'` | **Yes** |
|
||
| Transcript id reaching the browser live | `session:claude` → permalink, reattach | Emitted as a routing fact only (`index.ts:153-154`), never a transcript message | Partly |
|
||
| Reattach by transcript id | `claude:find-session` | **No verb** — `handleAttach` is Claude-only by construction | Partly |
|
||
| Live-session enumeration (`/chat/live`) | `claude:list` | **No verb** — a running OpenCode turn is invisible in the Live panel | Partly |
|
||
| Background tasks | `pendingTasks`, `task:started`/`task:notification` | **No** — nothing can arrive after `result` | **Yes** |
|
||
| Compaction seams | PreCompact hook + `compact_boundary` | **No signal exists** | Unknown |
|
||
| Interrupt without teardown | `claude:interrupt` keeps the session warm | **No** — stop is a full kill | **Yes** |
|
||
| Cut-off detection | `endTurnIfAgentIsGone` | Explicitly skipped (`websocket.ts:749`) | No |
|
||
| Images | content blocks | **No** — see B4 | No |
|
||
| 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` | **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 |
|
||
|
||
---
|
||
|
||
## Bucket 2 — neither has it
|
||
|
||
- **Thinking/effort.** Accepted on the wire, never forwarded, on both paths. Out of scope by decision;
|
||
the control should be removed.
|
||
- **`attachmentIds`.** Declared at `websocket.ts:263` and read by neither handler — file content rides in
|
||
the prompt prefix instead. Dead for both; worth deleting or wiring.
|
||
- **`/clear` and `/model` as client-handled slash commands.** The `deliverBatch` comment claims they are;
|
||
only `/help` is implemented. Stale on both.
|
||
|
||
---
|
||
|
||
## Bucket 3 — structural, unlikely to be worth forcing
|
||
|
||
- **`/clear` chain merging, dividers, `partCount`.** Claude's chains are an artefact of how the CLI
|
||
handles `/clear`; OpenCode has no equivalent concept. The UI already degrades correctly here.
|
||
- **Per-subagent text attribution.** Claude stamps `parentToolUseId`; nothing in the OpenCode path sets
|
||
it, so `turn-stream`'s per-speaker buffering collapses to one buffer. Only matters if OpenCode gains
|
||
subagents.
|
||
- **The Anthropic OAuth proxy.** Claude-specific by nature — OpenCode has its own auth story.
|
||
|
||
---
|
||
|
||
## Bucket 4 — OpenCode has it, Claude does not
|
||
|
||
Deliberately kept, though nothing here is scheduled. To be filled in as we learn the harness; the survey
|
||
was scoped to parity and did not go looking. Known so far:
|
||
|
||
- **A real HTTP + SSE server, always running**, with session CRUD as a first-class API rather than
|
||
transcript-file archaeology. Claude's session list is built by scanning and parsing `.jsonl` files off
|
||
disk; OpenCode's is a `GET /session`. If the turn path moves onto the serve, a lot of the Claude-side
|
||
file-scanning machinery has no OpenCode equivalent _because it does not need one_.
|
||
- **Multi-provider models** (`GET /config/providers`) — not tied to one vendor's credentials.
|
||
|
||
---
|
||
|
||
## The todo, in order
|
||
|
||
Each phase is independently shippable. Nothing here is a big-bang rewrite.
|
||
|
||
### Phase 0 — make what exists honest (no architecture decisions needed)
|
||
|
||
1. **Hide the thinking selector.** It is the cheapest item here and the most clearly right: the control
|
||
renders today and does nothing on _either_ harness, because `ThinkingLevel` is accepted on the wire
|
||
and never forwarded. A control that lies is worse than an absent one. Hide the selector first; the
|
||
dead plumbing under it (`types.ts:46`, `types.ts:69`, `websocket.ts:262`, the `thinkingLevel` thread
|
||
through `useChat`/`useEmbeddableChat`) can go in the same change or a follow-up.
|
||
2. ~~**Fix the session-list filter (B1).**~~ **DONE — `22bcd7d`.**
|
||
3. **Pass the model through on resume (B2).** Add `model` to `NewChatProps` and thread
|
||
`selected.model` → `useChat`. One prop, and it stops `ses_…` ids reaching `claude --resume`.
|
||
4. ~~**Return a real cwd on OpenCode session detail (B3).**~~ **DONE — `22bcd7d`.**
|
||
|
||
> **Correction, and read this before trusting any field name below.** This document told you to derive
|
||
> the directory from `location.directory`. **That field does not exist.** opencode 1.17.9's `GET /session`
|
||
> returns `directory` at the top level, with no `location` object and no `metadata` at all — so the type
|
||
> declared two fields the server never sends, which is the single cause of both B1 and B3. `22bcd7d`
|
||
> found this by reading the live server rather than the type, and deleted `officerMeta` and the metadata
|
||
> tag outright rather than fixing them: the only writer of that tag has no callers, and tagging would
|
||
> have been a second source of truth for something `directory` already answers.
|
||
>
|
||
> Two lessons for whoever picks up the rest. The surveys behind this document read types and call sites,
|
||
> not a running server, so **every field name here is a hypothesis** — the "check the installed version"
|
||
> warning was not boilerplate. And the confirmation that matters is the empirical one: 7 sessions present,
|
||
> 0 returned, badge unreachable; now 1 listed under the default dir and 6 filtered to their own. 5. **Stop advertising images on OpenCode models (B4)** — flip `list-models.ts:44` to `false` — _or_ plumb
|
||
> images through `OpenCodeRunParams`. Flipping the flag is the honest one-liner; plumbing is Phase 3. 6. **Guard the OpenCode subscription like the Claude one (B5)**, and call `clearOpenCodeSession` on
|
||
> disconnect (B6).
|
||
|
||
### Phase 1 — delete what is dead
|
||
|
||
7. **Remove `event-mapper.ts` entirely**, plus the unused SSE machinery, `createSession`, `postMessage`,
|
||
`abort`, `isServerHealthy`. Roughly 200 of ~390 platform-side lines. A prior audit
|
||
(`docs/sidecar-audit-2026-07.md`) already flagged this.
|
||
**Read it before deleting** — it is the skeleton of the serve-based design Phase 2 may rebuild, so it
|
||
may be worth reading into a design note first and deleting after.
|
||
8. **Correct the stale comments** — the "all sessions live in one project" claim, the `opencode-sidecar`
|
||
vs `opencode_server` path names, the AGENTS.md instruction to read the cwd from a system prompt that
|
||
is never sent.
|
||
9. **Add the first tests.** `runner.ts`'s NDJSON→ChatEvent mapping is pure and currently untested, and it
|
||
is the piece most likely to break against a new OpenCode release. Both files pin behaviour to
|
||
"verified against opencode 1.17.9" with nothing enforcing it.
|
||
|
||
### Phase 2 — the fork
|
||
|
||
10. **Decide: subprocess or serve.** Investigate why the serve-based turn path was replaced
|
||
(`5d077a4` → `71e39b7`) before rebuilding it. Write the answer down either way — this decision
|
||
determines whether items 11-15 are possible at all.
|
||
|
||
### Phase 3 — parity that follows from the fork (serve path only)
|
||
|
||
11. Token streaming (`delta` events).
|
||
12. Emit the `ses_…` id as a transcript-level session message → permalink, refresh survival, reattach.
|
||
13. `opencode:find-session` + `opencode:list` verbs → reattach-by-id and the Live panel.
|
||
14. Persistent session → interrupt-without-teardown, idle GC, and **mid-turn injection**.
|
||
15. Background tasks, if OpenCode has an equivalent concept at all.
|
||
|
||
### Phase 4 — the rest
|
||
|
||
16. Images (if not done as a Phase 0 flag-flip).
|
||
17. MCP tools.
|
||
18. Identity on the sidecar connection (`TODO.md:42-47`).
|
||
19. Real `messageCount` and model metadata rather than hardcoded defaults.
|
||
20. Crash-recovery state on disk.
|
||
|
||
---
|
||
|
||
## If you are picking this up cold
|
||
|
||
Phases 0 and 1 are ready to implement as written. Phase 2 is a **decision**, not a task — do not start it
|
||
as work.
|
||
|
||
**Reproduce each defect before fixing it.** The B-list came from read-only surveys, and one of those
|
||
surveys reasoned from a dead file for part of its report (see below). Only the event-path claim was
|
||
re-verified at source. Every B item names its files and lines; open them and confirm the defect is real
|
||
and still present before changing anything. A "fix" to something that was never broken is worse than the
|
||
defect, because the next reader will trust it.
|
||
|
||
Two that are worth extra care:
|
||
|
||
- **B2** is a six-hop chain from `ChatDetailPanel` down to `isClaudeModel`. Confirm the whole chain
|
||
rather than the endpoints — it is the kind of claim that is right in outline and wrong about which hop
|
||
drops the value.
|
||
- **B1** implies OpenCode sessions are invisible in the UI today. That is trivially checkable by opening
|
||
`/chat` and looking. Do that first: it either confirms the whole B-list's provenance in one glance, or
|
||
tells you the surveys were working from a stale tree.
|
||
|
||
This machine and the home-lab server run the same repo but not necessarily the same OpenCode binary.
|
||
Both `runner.ts:38` and `client.ts:170` pin behaviour to "verified against opencode 1.17.9" with nothing
|
||
enforcing it, so check the installed version before trusting any NDJSON shape in here.
|
||
|
||
## Two things to verify before trusting any of this
|
||
|
||
- The surveys initially disagreed about which file is the live event path. **`runner.ts:174-214` is
|
||
authoritative**; `event-mapper.ts` is dead. Any claim sourced from the mapper — particularly that
|
||
OpenCode emits `delta` — is wrong. The live path emits whole `text` blocks.
|
||
- `docs/sidecar-audit-2026-07.md` predates this and overlaps it. Where they disagree, this document was
|
||
written against the current tree and the audit was not.
|