step 4/4: the docs say permissions too, and capability means one thing again
44 files of prose — CLAUDE.md, AGENTS.md, TODO.md, 20 docs, both plugin design
documents, and the comment surface the earlier steps could not reach.
Applied against an explicit keep-list, not swept, because the word turned out to
have SIX meanings in this repository rather than the three the offscale doc
recorded:
permissions renamed (steps 1–2)
$OFFICER_ROOT/capabilities/ KEPT — the item store, and now the only thing
the word means that is ours
sidecar routing keys renamed to `handles` (step 3)
Lightning wallet KEPT — a domain term, and on the wire to the mobile apps
terminfo queries KEPT — XTGETTCAP, in the pty sidecar
InvoiceShelf KEPT — per-resource { write, bulkDelete } flags
The sweep still falsified two things, both caught by checking rather than by
review, and both in prose that discusses more than one meaning at once:
CLAUDE.md began claiming the item store lives at `$OFFICER_ROOT/permissions`.
It does not; that directory is on disk and full of skills and tools.
And the offscale doc's own note about the collision became
"Named `permissions`, NOT `permissions`" — a sentence that had eaten the thing
it existed to warn about.
Both restored, and the note rewritten to say what is now true: capability means
one thing of ours, and three that belong to somebody else's vocabulary.
Verified live after restart: self and admin permission endpoints 200, gated
route 200, agent-status 200, 9 grants intact with 6 permissions offered.
tsgo clean, 797 tests, 787 pass, same 7.
The rename is done. Four steps, no data lost, no client break that survived
the step it was introduced in.
This commit is contained in:
@@ -25,11 +25,11 @@ engine. But `loadOpenCodeSession` reads the transcript through the legacy route
|
||||
**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 |
|
||||
| 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
|
||||
@@ -42,7 +42,7 @@ 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`
|
||||
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`).
|
||||
|
||||
@@ -61,21 +61,21 @@ Pushing the filter down fixes the normal case and brings `search=`, `order=`, `p
|
||||
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 |
|
||||
| | **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*.
|
||||
operation ids are literally `v2.*`. It is not something to adopt; it is something to _finish_.
|
||||
|
||||
Two qualifications, both from the source at tag `v1.18.16`:
|
||||
|
||||
- **Upstream calls it experimental.** `packages/protocol/src/api.ts` titles it `"opencode HttpApi"`,
|
||||
version `"0.0.1"`, described as *"Experimental HttpApi surface for selected instance routes"*, with
|
||||
version `"0.0.1"`, described as _"Experimental HttpApi surface for selected instance routes"_, with
|
||||
every group annotated the same way. Meanwhile `/session/*` is the surface the public docs actually
|
||||
document, and it is not deprecated. The internal direction is unambiguous; the external commitment is
|
||||
nil.
|
||||
@@ -88,15 +88,15 @@ Two qualifications, both from the source at tag `v1.18.16`:
|
||||
`session.next.*` today, but put the names behind one mapping table, because they are scheduled to
|
||||
change wholesale.
|
||||
|
||||
Same for the `v2` suffix itself. `packages/schema/AGENTS.md`: *"V1 coexistence is temporary… delete the
|
||||
V1 subtree when the legacy runtime is retired"* and *"Do not preserve `V2` as the permanent name for the
|
||||
replacement architecture."* Both halves of today's naming are transitional.
|
||||
Same for the `v2` suffix itself. `packages/schema/AGENTS.md`: _"V1 coexistence is temporary… delete the
|
||||
V1 subtree when the legacy runtime is retired"_ and _"Do not preserve `V2` as the permanent name for the
|
||||
replacement architecture."_ Both halves of today's naming are transitional.
|
||||
|
||||
**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.
|
||||
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:
|
||||
|
||||
@@ -130,7 +130,7 @@ plus `GET /config/providers` for the model list (`list-models.ts:58`). The one e
|
||||
|
||||
### 4a. Adding context to a turn that is already running
|
||||
|
||||
The capability the subprocess path could never have, and the reason the migration happened.
|
||||
The permission the subprocess path could never have, and the reason the migration happened.
|
||||
|
||||
```
|
||||
POST /api/session/{id}/prompt
|
||||
@@ -139,12 +139,12 @@ POST /api/session/{id}/prompt
|
||||
"delivery": "steer" | "queue", "resume": true|false }
|
||||
```
|
||||
|
||||
Spec description: *"Durably admit one session input and schedule agent-loop execution unless resume is
|
||||
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
|
||||
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`).
|
||||
@@ -188,13 +188,13 @@ that matter for building on it:
|
||||
- `after` is an **exclusive** lower bound on the durable seq, and the aggregate is the session.
|
||||
Omitting it replays the session from 0.
|
||||
- **Replay-then-live is gap-free by construction**: it reads `WHERE seq > after ORDER BY seq ASC`,
|
||||
advances its cursor to the last row, and on every wake re-reads *the database* rather than draining a
|
||||
advances its cursor to the last row, and on every wake re-reads _the database_ rather than draining a
|
||||
pubsub buffer. Sequences are strictly monotonic and contiguous per session, enforced with explicit
|
||||
`Sequence mismatch` / `Replay diverged` errors.
|
||||
- **The first cursor is free.** `POST …/prompt` returns `{admittedSeq, id, sessionID, prompt, delivery,
|
||||
timeCreated, promotedSeq?}` — measured at 22 ms — and `admittedSeq` feeds straight back as `after`.
|
||||
timeCreated, promotedSeq?}` — measured at 22 ms — and `admittedSeq` feeds straight back as `after`.
|
||||
|
||||
Note the two cursor kinds are unrelated: the session *list* uses an opaque base64url cursor
|
||||
Note the two cursor kinds are unrelated: the session _list_ uses an opaque base64url cursor
|
||||
(`cursor.previous` / `cursor.next`), this one is a plain integer.
|
||||
|
||||
**But the two streams are not interchangeable, and the schema says why.** `SessionDurableEvent` is a
|
||||
@@ -233,14 +233,14 @@ The two "v2"s are not the same kind of change, which matters if we implement one
|
||||
`{action, resource, effect}`; a request from `{permission, patterns[], metadata, always[], tool?}` to
|
||||
`{action, resources[], save?[], metadata?, source?}`, with the tool linkage becoming a tagged union
|
||||
`source: {type:"tool", messageID, callID}`; and the reply loses its free-text `message`. The public
|
||||
V2 docs say the same in config terms: *"Do not use `permission`, `bash`, or `task` in V2
|
||||
configuration."*
|
||||
V2 docs say the same in config terms: _"Do not use `permission`, `bash`, or `task` in V2
|
||||
configuration."_
|
||||
- **Questions v2 is a re-homing.** Field shapes are byte-identical to v1 — `questions[]` of
|
||||
`{question, header, options[], multiple?, custom?}`, answers as `string[][]`. Only the namespace and
|
||||
event names changed.
|
||||
|
||||
Which family a 1.18.16 agent actually emits is worth measuring before building UI: the manifest the
|
||||
`/api` protocol is *built* from excludes the v1 families, but the server wires the **full** manifest
|
||||
`/api` protocol is _built_ from excludes the v1 families, but the server wires the **full** manifest
|
||||
(`makeApi({definitions: EventManifest.Latest.values()})`), which is why both appear in the `/api/event`
|
||||
union on our own `/doc`.
|
||||
|
||||
@@ -276,17 +276,17 @@ Our mapper recognises 18 names and maps 7. The server emits **130 event type str
|
||||
`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) |
|
||||
| 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
|
||||
@@ -318,7 +318,7 @@ with no model, against a serve with no configured default, hangs silently.** Wor
|
||||
- **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.
|
||||
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=<last seq>`, not restart.
|
||||
@@ -352,7 +352,9 @@ import { createOpencodeClient } from '@opencode-ai/sdk/v2';
|
||||
const client = createOpencodeClient({ baseUrl });
|
||||
const admitted = await client.v2.session.prompt({ sessionID, prompt: { text }, delivery: 'steer' });
|
||||
const events = await client.v2.session.events({ sessionID, after: admitted.data.admittedSeq });
|
||||
for await (const ev of events.stream) { /* ev.type, ev.durable.seq */ }
|
||||
for await (const ev of events.stream) {
|
||||
/* ev.type, ev.durable.seq */
|
||||
}
|
||||
```
|
||||
|
||||
`client.v2.session.*` covers list/create/active/get/switchAgent/switchModel/prompt/compact/wait/
|
||||
@@ -402,7 +404,7 @@ Then the two that are real features needing UI: **permissions/questions** (§4d)
|
||||
- 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.
|
||||
`experimental/` prefix. So item 6 is worth doing _and_ worth writing behind one function.
|
||||
|
||||
---
|
||||
|
||||
@@ -449,7 +451,7 @@ was published hours before this file was written.
|
||||
vs `Definitions` — the delta exclusion), `packages/schema/src/{permission,question}.ts` and their
|
||||
`v1/` counterparts, `packages/schema/src/session-input.ts` (`admittedSeq`), `packages/schema/AGENTS.md`
|
||||
(the V1/V2 naming intent), `packages/core/src/event.ts` (replay-then-live), `packages/sdk/js/script/
|
||||
build.ts` and `src/v2/client.ts`. PRs #27415 (the engine landing in 1.15.0), #33993, #35217, #35229
|
||||
build.ts` and `src/v2/client.ts`. PRs #27415 (the engine landing in 1.15.0), #33993, #35217, #35229
|
||||
(the renames).
|
||||
- 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`,
|
||||
|
||||
Reference in New Issue
Block a user