Three read-only surveys — the Claude sidecar as the reference, the OpenCode sidecar as it stands, and every officer/frontend branch on harness. No code changed. The headline is not a missing feature. OpenCode sessions never appear in the chat list at all: the list filters on `metadata.officer.cwd`, and the only writer of that tag has zero callers, because sessions are created by `opencode run --dir` rather than the API that would tag them. Resuming one is worse — the model is dropped between the resolver and the chat hook, so a `ses_…` id reaches `claude --resume`. That is wrong-harness dispatch, not degradation. Eight such defects are catalogued before any parity work. Everything else hangs off one decision: OpenCode turns are a one-shot subprocess with `stdin: 'ignore'`, while Claude turns live inside a persistent streaming session. Token streaming, mid-turn injection, background tasks, live-session enumeration and reattach-by-id are all downstream of that, and the serve that could support them is already running and used only for CRUD. The doc refuses to plan past that fork until someone establishes why the serve-based turn path was replaced. Four buckets rather than one list: broken now, Claude-has-it, neither-has-it, and what OpenCode has that Claude does not — the last because it is what disappears in a project framed as catching up. Thinking is out of scope for both harnesses by decision, and the selector is hidden rather than implemented: it renders today and does nothing on either path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
17 KiB
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.tsare 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 atwebsocket.ts:263and read by neither handler — file content rides in the prompt prefix instead. Dead for both; worth deleting or wiring./clearand/modelas client-handled slash commands. ThedeliverBatchcomment claims they are; only/helpis implemented. Stale on both.
Bucket 3 — structural, unlikely to be worth forcing
/clearchain 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, soturn-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
.jsonlfiles off disk; OpenCode's is aGET /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)
- 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
ThinkingLevelis 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, thethinkingLevelthread throughuseChat/useEmbeddableChat) can go in the same change or a follow-up. - Fix the session-list filter (B1). Decide the tag-or-don't-filter question: either write
metadata.officerat session creation from the runner, or drop the cwd filter and derive the directory fromlocation.directory. The second is smaller and matches where sessions actually come from. Until this lands, nothing else in the OpenCode UI is reachable. - Pass the model through on resume (B2). Add
modeltoNewChatPropsand threadselected.model→useChat. One prop, and it stopsses_…ids reachingclaude --resume. - Return a real cwd on OpenCode session detail (B3).
location.directory, the same source the list uses. - Stop advertising images on OpenCode models (B4) — flip
list-models.ts:44tofalse— or plumb images throughOpenCodeRunParams. Flipping the flag is the honest one-liner; plumbing is Phase 3. - Guard the OpenCode subscription like the Claude one (B5), and call
clearOpenCodeSessionon disconnect (B6).
Phase 1 — delete what is dead
- Remove
event-mapper.tsentirely, 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. - Correct the stale comments — the "all sessions live in one project" claim, the
opencode-sidecarvsopencode_serverpath names, the AGENTS.md instruction to read the cwd from a system prompt that is never sent. - 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
- 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)
- Token streaming (
deltaevents). - Emit the
ses_…id as a transcript-level session message → permalink, refresh survival, reattach. opencode:find-session+opencode:listverbs → reattach-by-id and the Live panel.- Persistent session → interrupt-without-teardown, idle GC, and mid-turn injection.
- Background tasks, if OpenCode has an equivalent concept at all.
Phase 4 — the rest
- Images (if not done as a Phase 0 flag-flip).
- MCP tools.
- Identity on the sidecar connection (
TODO.md:42-47). - Real
messageCountand model metadata rather than hardcoded defaults. - Crash-recovery state on disk.
Two things to verify before trusting any of this
- The surveys initially disagreed about which file is the live event path.
runner.ts:174-214is authoritative;event-mapper.tsis dead. Any claim sourced from the mapper — particularly that OpenCode emitsdelta— is wrong. The live path emits wholetextblocks. docs/sidecar-audit-2026-07.mdpredates this and overlaps it. Where they disagree, this document was written against the current tree and the audit was not.