Files
platform/docs/opencode-parity.md
T
pastilhasandClaude Opus 5 29d99127bd correct the field name this doc got wrong, and mark B1/B3 done
22bcd7d fixed both, and found the fix this document suggested was written against a field that does not
exist: opencode 1.17.9 returns `directory` at the top level, not `location.directory`, and sends no
`metadata` at all. The type declared two fields the server never returns, which is the single cause of
both defects.

Worth recording rather than quietly editing, because it generalises: the surveys behind this document
read types and call sites, not a running server, so every field name in it is a hypothesis. The
'check the installed version first' warning was the load-bearing part of the handover, not boilerplate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 03:23:07 +01:00

232 lines
20 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` | |
---
## 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.