# Phases 0 and 1: implementation report, and Phase 2's question answered **To:** whoever wrote `opencode-parity.md` and `opencode-phase0-review.md`. **From:** the implementation pass, 2026-08-10 (overnight). **Scope:** everything in Phase 0 and Phase 1, plus the one Phase 2 question that turned out to be cheap. Nothing of the Phase 2 migration was started. Both documents were good to work from. The handover instruction — _reproduce each defect before fixing it_ — earned its place three separate times, detailed below. --- ## What landed | Commit | Work | | --------- | --------------------------------------------------------------- | | `22bcd7d` | B1 + B3 — session listing, and a resumed session's directory | | `492509a` | B2 — route a resumed OpenCode session to OpenCode | | `013e629` | B4 (first attempt), B5, B6, thinking selector | | `7774a25` | B4 properly — gate the composer on the capability | | `cfbf58c` | Delete the `AGENTS.md` injection + the one-project comment | | `d7b2231` | Delete the dead serve-turn client; add `opencode-serve-path.md` | | `8b409e8` | Phase 1 finish — stale comments, version pin, first tests | | `e8bd946` | `opencode:list` — running OpenCode turns in the Live panel | Suite 562 → 573 tests, all passing. `tsgo` clean throughout. --- ## Phase 2: the blocking question is answered `opencode-phase0-review.md` narrowed the fork to one thing — whether the serve can take a per-request directory, since without it a serve-based turn path reintroduces the coupling that shelved this work. **It can.** Against the running serve (1.17.9 on the home-lab box): ``` POST /session?directory=/tmp/oc-phase2-probe → directory: "/tmp/oc-phase2-probe" honoured POST /session with directory in the BODY → directory: "" ignored ``` `directory` is a **query parameter on every `/session*` route** — create, message, abort, fork, summarize, prompt_async, shell — alongside `workspace`. Read off the serve's own `/doc`. **Worth flagging as a trap:** the body form is silently ignored and yields a session in the serve's own cwd, which reads exactly like "the serve cannot do per-request directories". My first probe did this and gave a confident wrong answer. Anyone re-checking this should use the query string. So the coupling is gone in both architectures: `--dir` anchors a subprocess turn (your test), and `?directory=` anchors a serve turn (this one). `docs/opencode-serve-path.md` has the three options — keep the subprocess, move turns to the serve, or a third the parity doc did not list: move only enumeration and reattach to the serve and leave turns on `opencode run`. It recommends the third and **does not start any of them**, because which one to take is a product decision. The first slice of that third option is already in (`e8bd946`, below). --- ## Where your documents were wrong, and how Three corrections. All were caught by the reproduce-first rule, and none would have been caught by reading. **1. `location.directory` does not exist** (parity doc B1/B3, and its suggested fix). opencode 1.17.9+ returns `directory` at the top level, with no `location` wrapper and no `metadata`. The suggested fix — "derive the directory from `location.directory`" — would have compiled and silently produced `''` forever. You have since corrected this in `29d9912`. **2. B2 is right in outline, and the interesting part is not in the outline.** The doc says `selected.model` is never passed. In fact it _is_ produced correctly — `/chat/:id` resolves `detail.model` to `opencode/big-pickle` and puts it on `selected` — and then dropped at the `` boundary, while `initialModel` reads `locationState?.model`, a field **nothing in the tree ever writes**. So the value existed and was read from the wrong place. Your instruction to confirm all six hops rather than the endpoints is exactly what surfaced that. **3. `runner.ts`'s mapping was untested but not pure** (Phase 1 item 9). It lived inside `handleLine` as a closure over `emit`, the accumulated cost, and a reported-session flag, so it could not be called without spawning a binary. Extracted as `mapRunLine` — line in, `{sessionId, events, costDelta}` out — with the two line-spanning concerns left to the caller, because they are not properties of a line. Behaviour unchanged; 11 tests now pin it. --- ## Where I deliberately did not follow the spec **B5 — the parity doc says to guard "like the Claude one". Copying that guard would have broken OpenCode.** Claude keeps one warm session and skips re-subscribing on later turns; OpenCode spawns a fresh `opencode run` per turn, so a new subscription each time is _correct_. The actual defect was overwriting the previous handle without detaching it. Fixed that instead. (Your review reached the same conclusion independently, which was reassuring to read afterwards.) **B4 — closed the way your review asked, not the way the parity doc did.** The doc offered the flag flip as "the honest one-liner"; you correctly pointed out that flipping it changed nothing observable because no code read the capability. The composer now gates on it — drop zone, paste path, attach menu — so the flag is load-bearing. Unknown model still allows images: a missing capability should not remove a working control. **Thinking selector — removed, not hidden.** The doc said hide; hiding a control that does nothing still leaves it in the tree to be re-found. The inert plumbing beneath it is untouched, because deleting that touches the socket contract and belongs in its own change. --- ## `opencode:list`, and the one thing to check first `e8bd946` adds `opencode:list` / `opencode:sessions` and merges both harnesses in `/chat/live`, asked in parallel, each failing toward empty. The OpenCode row is deliberately thinner than the Claude one rather than faked into parity: `isGenerating` is always true (a subprocess exists only while it generates, so there is no "merely open"), `pendingTasks` is always 0 (no background-task concept — a number would imply one), and title/cwd are null until the `ses_…` id is reported. **It has never returned a non-empty list.** No OpenCode turn was running to enumerate. Both failure modes are invisible — a sidecar that does not reply, and a reply whose shape does not match, both degrade to `[]`, which looks exactly like "nothing is running". **One real OpenCode turn while watching `/chat/live` settles it, and that is the highest-value five minutes available tomorrow.** --- ## Other things left unverified Testing was explicitly de-prioritised for this pass, so these are recorded rather than resolved: - **The image gate in a browser.** The API reports `images: false` for all 61 OpenCode models and `true` for the three Claude ones, and the gate reads that field — but no UI was opened. - **`mapRunLine` fixtures are hand-written**, not captured from a live turn. If one fails after an upgrade, re-read the binary's real output before editing the expectation. - **No OpenCode turn was run after deleting `AGENTS.md`.** The premise was verified (the run path sends no system prompt, so the instruction pointed at nothing) but the outcome was not observed. `COMMS/BLOCKERS.md` has the same list plus the mistakes I made getting here. --- ## Suggested next, if you are writing the following spec 1. **Exercise `opencode:list`** — it is the only new capability whose happy path is unproven. 2. **Decide the fork.** The blocker is gone; `opencode-serve-path.md` frames it. If the answer is "not yet", say so in the parity doc so it stops reading as pending work. 3. **The remaining Phase 1 residue**: `sweepStaleServes` is `/proc`-based and a no-op on macOS (B8), and in-flight `opencode run` children still survive sidecar shutdown untracked. 4. **B7** — the latent spurious `cut-off`, still unaddressed and still masked only by the client always happening to send `model` alongside `sessionId`. My B2 fix makes the client send `model` _more_ reliably, which deepens the mask rather than removing it. Item 4 is worth doing before something changes the client's habits and un-masks it.