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:
2026-08-15 16:31:11 +00:00
parent f9fd002ff4
commit 027b10bd6e
45 changed files with 721 additions and 694 deletions
+49 -47
View File
@@ -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`,