diff --git a/docs/opencode-api-2-assessment.md b/docs/opencode-api-2-assessment.md new file mode 100644 index 00000000..b2d60c48 --- /dev/null +++ b/docs/opencode-api-2-assessment.md @@ -0,0 +1,353 @@ +# OpenCode's newer API — what it is, what it would cost, what it buys + +Written 2026-08-11 against **opencode 1.18.16**, from three sources: the running server's own OpenAPI +document (`GET /doc` on `opencode serve`), live probes against a real serve, and upstream docs/npm. +Every claim below is marked by where it came from. Measurements were taken on the local serve +(port 49698, the `officer-opencode` sidecar's own) and cleaned up afterwards — the session store is +back to the 50 rows it started with. + +Read this before starting any opencode work. Two live defects fell out of writing it (§1), and the +naming is actively misleading (§2). + +--- + +## 1. Two live defects, found while measuring + +Neither is a migration concern. Both are broken right now, in production, and both are consequences of +being half-migrated. + +### 1a. Every OpenCode conversation created since 2026-08-10 opens EMPTY + +Since Phase D, turns run through `POST /api/session/{id}/prompt`, so the session belongs to the newer +engine. But `loadOpenCodeSession` reads the transcript through the legacy route +(`client.ts:51` → `GET /session/{id}/message`). + +**The two surfaces are mutually blind.** Measured, both directions, on a session created via `/api` and +run to completion with a real model reply: + +| read | api-created session | legacy-created session | +|---|---|---| +| `GET /session/{id}/message` (what we call) | **`[]` — 0 messages** | 200, full transcript | +| `GET /api/session/{id}/message` | 200, 3 messages | **500** | +| `GET /session/{id}` (the record) | 200, title + directory | 200 | + +So the row appears in the list with its title and directory, and opens with nothing in it. And the +inverse is equally true: switching the reader to `/api` without keeping the old one would empty every +conversation from before 2026-08-10. + +The fix is not "swap the endpoint" — it is "route by which engine owns the session", and there is no +field that says so. The one usable discriminator found tonight is that the legacy read returns `[]` +rather than erroring. + +### 1b. The session list silently truncates at 50 + +`GET /api/session` defaults to **50 rows** and returns a `cursor.next`. Measured: with 50 sessions in +the store the list returns 50 *and still offers a next cursor*; adding a 51st and asking `?limit=200` +returns 51 (and `limit` is capped at 100 — 200 is accepted for the list but `/history` rejects >100 +with `Expected a value less than or equal to 100`). + +`client.ts:36` sends neither `limit` nor `cursor`, so **once the store passes 50 sessions the oldest +stop appearing in `/chat`**. The local store is at exactly 50 today. This is in code shipped this +morning (`adaaba6`). + +The same endpoint takes `directory=` — verified filtering correctly (`?directory=/tmp/oc-cap` → 11 +rows, all in that directory). We fetch everything and filter client-side in `opencode-sessions.ts:49`. +Pushing the filter down fixes the normal case and brings `search=`, `order=`, `project=` with it. + +--- + +## 2. The naming, because "API 2.0" means two different things + +There is no version string "2.0" in the running server. `GET /doc` self-reports +`{"openapi":"3.1.0","info":{"title":"opencode","version":"1.0.0"}}`. What actually exists: + +| | **legacy** | **the `/api/*` surface** | **OpenCode 2.0 beta** | +|---|---|---|---| +| where | in 1.18.16 | in 1.18.16 | separate product, binary `opencode2`, npm `@next` | +| routes | 111 paths | 51 paths | ~100 paths, still moving | +| operationIds | `session.list` | **`v2.session.list`** | — | +| we use it | reads: transcript, delete, rename | writes: every turn since 2026-08-10 | not at all | +| docs | opencode.ai/docs/server (stale — never mentions `/api/*`) | undocumented publicly | opencode.ai/v2/docs | + +So "API 2.0" most likely means **the `/api/*` surface — which we already run on for turns**. Its +operation ids are literally `v2.*`. It is not something to adopt; it is something to *finish*. + +**OpenCode 2.0 the product is a different question**, and the answer tonight is not yet: the beta docs +carry the banner *"we may wipe your data, things may break, and APIs, configuration, and plugin APIs +may change"*, releases ship ~6/day, and the migration guide states three intentional breaking changes +(plugin API, server API contracts, TUI config), with *"Integrations that call the V1 server API must +migrate to the V2 API"*. No deprecation date for the legacy surface is published anywhere. + +Two facts worth knowing regardless: + +- **The repo moved.** `github.com/sst/opencode` 301s to **`github.com/anomalyco/opencode`**. Every npm + package now points there. No announcement was found explaining it. +- **There is already a typed client for the surface we run.** `@opencode-ai/sdk@1.18.16` ships two + generated clients: the default export covers legacy only, and **`@opencode-ai/sdk/v2` covers all 51 + `/api/*` routes**. We have no opencode dependency at all today — every call is hand-rolled `fetch`. + +--- + +## 3. What we call today + +Two of our processes talk to one serve, with no shared client. + +**Sidecar (`src/servers/sidecar/opencode/`) — already on `/api/*`:** `POST /api/session` +(`serve-runner.ts:222`), `POST …/model` (`:236`), `POST …/prompt` (`:266`, `:199`), `POST …/interrupt` +(`:328`), `GET /api/event` (`:86`), `GET /api/health` (`index.ts:79`), +`POST /api/integration/{provider}/connect/key` (`connect-credential.ts:66`). + +**API server (`src/servers/api/chat/opencode/client.ts`) — still legacy:** `GET /session/{id}` (`:45`), +`GET /session/{id}/message` (`:51`), `DELETE /session/{id}` (`:57`), `PATCH /session/{id}` (`:62`), +plus `GET /config/providers` for the model list (`list-models.ts:58`). The one exception is +`GET /api/session` for the list (`:36`), moved this morning. + +51 routes exist. We call 7. + +--- + +## 4. What the newer surface has that we don't use + +### 4a. Adding context to a turn that is already running + +The capability the subprocess path could never have, and the reason the migration happened. + +``` +POST /api/session/{id}/prompt +{ "id": "msg_…", "prompt": { "text", "files": [{uri,name,description,source}], + "agents": [{name,source}] }, + "delivery": "steer" | "queue", "resume": true|false } +``` + +Spec description: *"Durably admit one session input and schedule agent-loop execution unless resume is +false."* + +- **`delivery: "steer"` injects into the RUNNING turn** — the model takes the new text as part of the + work in flight. No kill, no restart, no lost context. We already send it (`serve-runner.ts:199`) but + only on the accidental path: a message that happens to arrive mid-turn. Nothing in the UI *asks* for + it, and nothing distinguishes "add this to what you're doing" from "here's my next message". +- **`delivery: "queue"`** runs after the current turn. It must be stated explicitly — **the field + defaults to `steer`** — or two quick messages merge into one turn (`serve-runner.ts:268`). +- **`prompt.files[]`** attaches content to that same input; measured last night, it must be a `data:` + URI (a `file://` one is accepted with 200 and dies inside the provider). Each attachment also takes + a `description`, which we don't send. +- **`prompt.agents[]`** attaches an agent to the input. Unused, unexplored. +- **`id`** lets the caller mint the `msg_…` id, which is how a send survives a retry without + double-posting. We let the server mint it and therefore can't. + +Measured: the POST returns in **22 ms** with `{"admittedSeq":1,"id":"msg_…","delivery":"queue"}`. It is +an admission receipt, not a turn — and `admittedSeq` is the durable cursor for everything that follows. + +### 4b. Surviving a restart mid-turn — verified working + +``` +GET /api/session/{id}/event?after= "Replay durable events after an aggregate sequence, + then continue with new durable events." +GET /api/session/{id}/history?limit=&after= "Read one finite page of public durable Session events + after an exclusive aggregate sequence." +``` + +Driven end to end tonight on a real turn (free model, "Reply with exactly: hi"): + +``` +seq 1 session.next.prompt.admitted seq 6 session.next.context.updated +seq 2 session.next.prompted seq 7 session.next.step.started +seq 3 session.next.model.switched seq 8 session.next.text.started +seq 4 session.next.prompt.admitted seq 9 session.next.text.ended +seq 5 session.next.prompted seq 10 session.next.step.ended +``` + +`?after=5` returned exactly 6–10. `GET …/event?after=7` replayed 8, 9, 10 and then held the socket open +for more. Every durable event carries `{aggregateID, seq: integer, version}`, so `after=` is that +integer. This is the documented, working answer to the gap Phase B left open and +`docs/opencode-testing-checklist.md` calls the most likely thing to be broken. + +**But the two streams are not interchangeable, and the schema says why.** `SessionDurableEvent` is a +`oneOf` of exactly 28 members, and the five it omits are `text.delta`, `tool.input.delta`, +`reasoning.delta`, `compaction.delta` and the retry error. **Deltas are live-only by design; the +durable log stores whole values.** So a client that wants both token streaming and restart recovery +must read both streams: the global live one for deltas, the per-session durable one for the replayable +spine. Last night's 13-vs-21 event count was this same fact, found by counting instead of by reading. + +### 4c. Knowing what is running, without having started it + +``` +GET /api/session/active "Retrieve foreground Session drains currently owned by this OpenCode + process. Sessions absent from the result are inactive." +POST /api/session/{id}/wait "Wait for a session agent loop to become idle." +``` + +Today "what is running" is an in-memory map in our sidecar (`serve-runner.ts:66`). Restart the sidecar +and the truth is gone — which is why `/chat/live` can be wrong after a restart. `session/active` is the +server's own answer and survives us. + +### 4d. Permissions and questions — nothing in officer models this + +``` +GET|POST /api/session/{id}/permission POST …/permission/{requestID}/reply +GET /api/permission/saved DELETE /api/permission/saved/{id} +GET /api/session/{id}/question POST …/question/{requestID}/reply | /reject +``` + +Plus `permission.v2.asked` / `question.v2.asked` events (the v1 families still exist alongside; the +only `deprecated: true` operation in the entire document is `POST /session/{id}/permissions/{id}`). + +An opencode agent that wants consent, or that asks a question mid-turn, gets no answer from officer. We +don't subscribe to those events and have no route to reply on. Claude's harness runs +`--dangerously-skip-permissions`, so this has never been modelled for either harness. Largest single +behavioural gap. + +### 4e. Undo, compaction, context + +``` +POST /api/session/{id}/revert/stage {messageID, files?} …/revert/commit …/revert/clear +POST /api/session/{id}/compact GET /api/session/{id}/context +``` + +Stage a revert to a message, then commit or discard. Explicit compaction with +`compaction.started/delta/ended` events, and a readable context state. Officer has none of this. + +### 4f. The rest + +`GET /api/agent`, `/api/skill`, `/api/command`, `/api/model`, `/api/provider`, `/api/fs/{list,find,read}`, +`GET|POST /api/pty` (+`connect`, `connect-token`), `POST /api/session/{id}/agent` (switch agent +mid-session), `/api/reference`, `/api/location`, `/api/integration`, `/api/credential/{id}`. + +`GET /api/model` and `/api/provider` are the `/api` equivalents of the `/config/providers` call our +model list is built on (87 models locally). `/api/pty` overlaps our own pty sidecar. + +--- + +## 5. What the event stream carries that we drop + +Our mapper recognises 18 names and maps 7. The server emits **130 event type strings**, 32 in the +`session.next.*` family plus eight plain `session.*` (`idle`, `status`, `error`, `compacted`, +`created`, `deleted`, `updated`, `diff`). + +| dropped | what it would give | +|---|---| +| `reasoning.started/delta/ended` | thinking, streamed — we show none for opencode | +| `tool.input.delta` / `.started` / `.ended` | a tool call rendering as its arguments arrive | +| `tool.progress` | long tools reporting instead of appearing hung | +| `shell.started/ended` | shell commands as a first-class thing | +| `compaction.*` | telling the user the context was compacted | +| `revert.*` | §4e | +| `retried` | a retry that currently looks like a stall | +| `prompt.admitted` / `prompted` | acknowledgement — the exact window where silence has twice cost an afternoon | +| `session.idle` | the real turn-end signal (see below) | + +We end a turn on `step.ended` with `finish !== 'tool-calls'` (`serve-runner.ts:139`), because there is +no turn-ended event in what we read. `session.idle` looks like what that rule approximates, and it is +not in our `KNOWN` set. + +--- + +## 6. Two silent-failure modes, both reproduced tonight + +Both produce the identical signature — `prompt.admitted`, `prompted`, then **nothing, forever**: + +1. **No credential connected** for the `/api` surface. Already known and fixed at boot + (`connect-credential.ts`), but the failure has no error. +2. **No model on the session and no server default.** New tonight: my first probe sat at + `admitted → prompted` and stopped. `GET /config` reports `model: None`, and the session had no model + because I hadn't set one. `POST …/model` then re-prompting produced the full 10-event turn above. + +Our runner only sends `POST …/model` when `params.model` is set (`serve-runner.ts:231`). **A turn sent +with no model, against a serve with no configured default, hangs silently.** Worth an explicit check. + +--- + +## 7. What finishing the migration would cost + +- **Both readers stay.** §1a: `/api` reads 500 on legacy-owned sessions, legacy reads `[]` on + api-owned ones. Routing by ownership is required, and no field declares ownership. +- **Delete and rename cannot move.** `/api/session/{sessionID}` is **GET only**; `DELETE` and `PATCH` + exist only on the legacy route (spec-verified, and a live `DELETE` returned 200). +- **The transcript shape differs.** Legacy items are `{info:{role,…}, parts:[…]}` — what + `opencode-sessions.ts:81` parses. `/api` items are + `{id, time, type:'assistant', agent, model:{id,providerID,variant}, content:[{type:'text',id,text}], + finish, cost, tokens}`. A second mapper, or a shared normaliser. +- **The SSE parser needs to grow up.** `serve-runner.ts:89` is `data:`-only: no `event:`, no `id:`, no + comments, no `retry:`, no multi-line frames, fixed 1 s reconnect with no backoff. A cursored stream + must resume at `?after=`, not restart. +- **Two envelope unwrappers and three hand-written type sets** (`serve-runner.ts:161`, `client.ts:38`; + types pinned by comment to two different opencode versions, 1.17.9 and 1.18.16). `@opencode-ai/sdk/v2` + would replace most of this — a dependency decision, and note our install is frozen, so it is a + deliberate lockfile change. +- **Stale comments in at least nine files** still describe the deleted `opencode run` subprocess path + (`protocol.ts:198`, `serve-events.ts:5`, `connect-credential.ts:24`, `index.ts:137`, + `websocket.ts:454`, `chat.ts:127`, `list-models.ts:75`, `sidecar-server.ts:8`, `send-opencode.ts:7`). + Two of them actively lie: they say turns read `auth.json` and don't depend on the credential connect. + They now do. + +Unrelated but found while inventorying: the settings UI writes provider keys to `~/.pi/agent/auth.json` +(`chat-providers.ts:10`) while the credential connect reads `~/.local/share/opencode/auth.json` +(`connect-credential.ts:29`). Two different files. + +--- + +## 8. What we'd gain immediately + +Ordered by value over effort. 1–3 are bug fixes, not features. + +1. **Transcripts that aren't empty** — route the read by session ownership. This is broken in + production now. (§1a) +2. **A list that doesn't stop at 50**, filtered server-side by `?directory=`. One call site. (§1b) +3. **A turn that can't hang silently** — set a model explicitly, or check `/config` for a default, and + say so out loud when neither exists. (§6) +4. **"Add to what you're doing" as a real control** — `delivery: "steer"` on a deliberate trigger + rather than only when a message happens to land mid-turn. The plumbing already exists. (§4a) +5. **Idempotent sends** — mint our own `msg_…`. One field. (§4a) +6. **Restart recovery** — subscribe `?after=` alongside the live stream. Verified working. (§4b) +7. **A truthful live panel** — `GET /api/session/active`. (§4c) +8. **Richer streaming for free** — reasoning deltas, tool-input deltas, tool progress, retries. Already + arriving on the socket we already read, and dropped in a `default:` case. (§5) +9. **Explicit compaction and context** instead of a long conversation quietly getting more expensive. + +Then the two that are real features needing UI: **permissions/questions** (§4d) and **revert** (§4e). + +--- + +## 9. What this does NOT get us + +- It does not retire the legacy surface: delete, rename and every pre-2026-08-10 transcript stay there, + with no deprecation date published. +- It does not touch the Claude harness — a different sidecar, a different protocol. Every gain above + lands on one harness only, while the chat UI assumes the two behave alike. +- It does not put us on OpenCode 2.0. Note the direction of travel there: the beta **removes** + `/api/session/{id}/history` and `/api/session/{id}/event` — the two durable routes item 6 depends on + — replacing them with `GET /api/experimental/session/{id}/log?after=&follow=`. Same idea, new path, + `experimental/` prefix. So item 6 is worth doing *and* worth writing behind one function. + +--- + +## 10. Open questions + +1. Is there a field that says which engine owns a session? Tonight's only discriminator is behavioural + (legacy returns `[]`). If not, we need our own record — we already store `sessionKey → ses_…` in + `opencode/state.ts` and could record the surface with it. +2. Do the `v2` permission/question events replace the v1 ones or run alongside? Both are emitted; + which one a 1.18.16 agent actually uses is unconfirmed. +3. What is `/api/*`'s auth story? The spec declares no `securitySchemes` yet every route declares a + `401`. The v1 docs describe HTTP Basic via `OPENCODE_SERVER_PASSWORD`; we run with none, on + loopback. In the 2.0 beta this is formalised as basic auth read from + `~/.local/state/opencode/service.json`. +4. Does `@opencode-ai/sdk/v2` work against 1.18.16 exactly? Generated from the same surface, but + unverified by running it. +5. When did `/api/*` first appear in the 1.x line, and when does the legacy surface actually go? Both + UNKNOWN — the published changelog names no `/api/` additions at all. + +--- + +## 11. Sources + +- Live: `GET /doc` on `opencode serve` 1.18.16 (162 paths, 51 under `/api/`), plus the probes recorded + above against the local sidecar's serve on port 49698. +- Code: `src/servers/sidecar/opencode/*`, `src/servers/api/chat/opencode/*`, `opencode-sessions.ts`, + `list-models.ts`, `send-opencode.ts`. +- Upstream: opencode.ai/v2/docs/migrate-v1, opencode.ai/v2/docs, opencode.ai/docs/server, + github.com/anomalyco/opencode (formerly sst/opencode), npm `@opencode-ai/sdk` 1.18.16, + `@opencode-ai/client@next`. +- Prior art in this repo: `docs/opencode-parity.md`, `-fork-decision.md`, `-serve-migration-plan.md`, + `-serve-path.md`, `-testing-checklist.md`, `-phase0-review.md`, `-phase1-report.md`, + `-phase1-review.md`.