Bucket 1 lists them as No, and phase 4 put them behind the migration. opencode run takes --file, so the path we already use carries them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
274 lines
23 KiB
Markdown
274 lines
23 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` | |
|
||
|
||
**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 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.
|
||
|
||
**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` | **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.
|