# 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.
