# 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, which is still Andre's call and still unstarted. `docs/opencode-serve-path.md` frames it. --- ## 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).**~~ **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.