# OpenCode parity — where it stands, and what to do about it 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` | | --- ## 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` | **None** — `state:sync` returns an error | 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).** Decide the tag-or-don't-filter question: either write `metadata.officer` at session creation from the runner, or drop the cwd filter and derive the directory from `location.directory`. The second is smaller and matches where sessions actually come from. **Until this lands, nothing else in the OpenCode UI is reachable.** 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).** `location.directory`, the same source the list uses. 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.