Merge remote-tracking branch 'gitea/master' into sidecar-app-store

This commit is contained in:
2026-08-11 19:10:52 +00:00
30 changed files with 2313 additions and 729 deletions
+186
View File
@@ -0,0 +1,186 @@
# Per-agent git identity
**Status: idea, not implemented. Nothing in this document has been built.** Written 2026-08-10 from a
read of the live spawn path; the file:line references were verified against `dc6b623`.
## The goal
A team of agents works on this project, sometimes several of them in the same repository at once. Each
one should commit under its own identity, so `git log` answers "which agent wrote this" without anybody
having to remember to say so.
Today it cannot. Every agent commits as the owner, because every agent *is* the owner as far as the OS
is concerned.
## How git identity can be overridden at all
Identity is unverified metadata, not authentication — a default, never a constraint. Anyone who can
commit can claim any name and email, by any of:
```bash
git -c user.name=X -c user.email=x@y.z commit # per-invocation config
git commit --author="X <x@y.z>" # author only; committer stays whoever ran it
GIT_AUTHOR_NAME=X GIT_AUTHOR_EMAIL=x@y.z \
GIT_COMMITTER_NAME=X GIT_COMMITTER_EMAIL=x@y.z git commit # env; both identities
```
Precedence: `--author` > `GIT_AUTHOR_*` env > `-c user.email` > local config > global config.
Two consequences that shape the design below:
- **Author and committer are different fields.** `--author` alone leaves the committer as the owner, and
`git log` shows only the author by default. Set both, or the attribution is half-fiction. To read
both: `git log --format='%an <%ae> | %cn <%ce>'`.
- **Environment beats instruction.** Telling an agent "commit as X" in its prompt is a rule it can
forget. `GIT_AUTHOR_*` in the process environment applies to every git invocation in that process
whether or not anyone remembered. The whole point is to make the identity unforgettable rather than
well-intentioned, so this belongs in the environment.
## Why the obvious approach does not work here
The first instinct is a PM2 `env` block per agent, or setting `process.env.GIT_AUTHOR_NAME` in the
sidecar at boot. **Both are wrong on this platform**, and for the same reason:
`officer-agent` is **one process running many concurrent sessions.** `sessions` is a
`Map<string, PersistentSession>` (`src/servers/sidecar/claude/claude-manager.ts:213`); each session owns
its own long-lived `query()` and its own warm `claude` child, reused across turns and collected after 30
minutes idle. A singleton lockfile enforces one sidecar per owner (`user-instance.ts:95-98`).
So a process-level variable — whether from PM2, from `.env`, or assigned in `user-instance.ts` — is
shared by every agent and every session on the box. It can say "an agent did this". It cannot say which.
The `ecosystem.config.cjs` entry is bare anyway, which is worth recording since it looks like a place
where env might already be happening:
```js
{ name: 'officer-agent', script: 'bun', args: 'run src/servers/sidecar/claude/user-instance.ts', watch: false },
```
No `env`, no `cwd`, no `interpreter` — for any app in the file.
## What the spawn path actually looks like
The live path is the Agent SDK, not `Bun.spawn`. `claude-manager.ts:320-360`:
```ts
const q = query({
prompt: input.gen as AsyncIterable<SdkUserMessage>,
options: {
cwd: params.cwd ?? HOST_HOME,
permissionMode: 'bypassPermissions',
pathToClaudeCodeExecutable: CLAUDE_BIN,
env: cleanEnv as Record<string, string>,
...
},
});
```
`cleanEnv` is built once, at `claude-manager.ts:315`, by destructuring three keys back out of the
sidecar's own environment:
```ts
const { CLAUDECODE: _c, CLAUDE_CODE_ENTRYPOINT: _e, CLAUDE_CODE_SSE_PORT: _s, ...cleanEnv } = process.env;
```
That is the whole story: the child gets the sidecar's full `process.env` minus the three nested-session
guards, and nothing is added per turn.
**This is the good news.** `env` is *already* a per-`query()` option. It is built once today, but there
is no structural reason it has to be — which makes `claude-manager.ts:315` the single injection point
for everything below.
### What identity exists today
Almost none, and none of it at the OS level.
- `sessionKey` — officer's uuid, the key in the `sessions` map. Reaches the child only as a transport
field on the pushed message.
- **Agent name and persona are prompt-only.** `buildAgentPrompt`
(`src/servers/api/agents/agent-runner.ts:71-79`) inlines the agent's `AGENT.md` into the *first user
message*. There is no `systemPrompt`, no `--agents`, no per-agent settings file.
- The one durable per-agent handle is the working directory: `getAgentRunsDir(agent.dirName)`
(`agent-runner.ts:144`), deliberately shared across all runs of that agent so the CLI groups their
transcripts.
`grep -rn "GIT_AUTHOR\|GIT_COMMITTER" src/` returns nothing. Verified. Any commit an agent makes today
is attributed to whatever `~/.gitconfig` says — the human owner, identically for every agent.
## The proposed change
Three edits, all on the claude path:
1. **`src/servers/sidecar/protocol.ts`** — add an optional `gitIdentity: { name: string; email: string }`
to `ClaudeSpawnStreamingParams` (the type begins at line 170). Optional so every existing caller is
untouched.
2. **`claude-manager.ts:315`** — build `cleanEnv` per session rather than once, merging the identity in
as all four variables when present:
```ts
...(params.gitIdentity && {
GIT_AUTHOR_NAME: params.gitIdentity.name,
GIT_AUTHOR_EMAIL: params.gitIdentity.email,
GIT_COMMITTER_NAME: params.gitIdentity.name,
GIT_COMMITTER_EMAIL: params.gitIdentity.email,
}),
```
3. **`agent-runner.ts:144`** — populate it from `agent.dirName`, next to where the cwd pin is already
derived from the same field.
Chat sessions (`api/chat/websocket.ts`) would pass nothing and keep committing as the owner, which is
almost certainly right: a chat turn is the human driving directly.
Use a domain that is actually controlled — `<dirName>@officer.dev` — so Gitea can be made to map or
deliberately not-map these authors later.
## Known limitations of the proposal
### opencode cannot do this at all
Since the `serve` migration (`a3dbda7`, phase D) there is **no process spawned per turn**. One shared
`opencode serve` starts at sidecar boot (`src/servers/sidecar/opencode/index.ts:112-116`) with no `env`
key — full inheritance — and turns are driven over HTTP against it, with per-turn cwd carried as a
request header (`serve-runner.ts:155`). Every session shares that one process environment.
Per-agent git identity on the opencode path therefore requires either a serve per agent, or an upstream
API field. Neither is a small change, and this document does not propose one.
### Attribution is not isolation, and isolation is the real problem
There is **no filesystem isolation** between agents. They share one real `HOME`
(`HOME_DIR=/home/pastilhas`), one `~/.claude`, one credential store; `user-instance.ts:75-78` says this
outright, and it is the stated reason `chat` is an `execution` capability that can never be granted.
`grep -ril worktree src/` returns nothing — worktrees are used nowhere.
cwd is the only per-session variation and it is not a boundary, since absolute paths escape it freely.
So two agents told to work on the same repository will share one working tree: fighting over
`index.lock`, staging each other's half-finished edits, interleaving commits. Per-agent identity makes
that **legible after the fact**. It does nothing to prevent it.
If agents are genuinely to work the same repo concurrently, the isolation question is the larger and
more urgent one — a worktree or a clone per agent — and per-agent identity composes naturally with it
(a worktree per agent is also the cleanest place to put a per-worktree git identity).
### If worktrees do arrive, note this trap
`git worktree` shares one `.git/config`, so `git config --local user.email` in one worktree changes it
for **all** of them. Per-worktree config needs `extensions.worktreeConfig true` and then
`git config --worktree user.email …`. Environment variables sidestep the whole issue, which is another
argument for the env approach above.
## Deliberately not proposed
- **Signing.** Per-agent SSH signing keys would make attribution unforgeable rather than conventional.
For a trusted local fleet where every agent already runs as the owner with
`permissionMode: 'bypassPermissions'`, an agent that wanted to forge another's identity has far easier
routes. Revisit only if agents stop being equally trusted.
- **`Co-Authored-By:` trailers.** Useful when a human and an agent genuinely share a commit, and both
Gitea and GitHub attribute them. Orthogonal to this, and a prompt-level convention rather than an
environment one.
## Open questions
- Should pipeline steps (`pipeline-executor.ts`) get an identity too, or only named agents?
- Is `dirName` the right identity, or should an agent's `AGENT.md` declare its own name and email — so
the identity is authored where the persona is, rather than derived from a directory?
- Does anything downstream — Gitea webhooks, activity feeds, the dashboards — assume commits belong to
the owner and break when they do not?
+457
View File
@@ -0,0 +1,457 @@
# 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*.
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
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.
- **`session.next` is the event family of that rewritten engine, and the name is already dead
upstream.** It arrived in **1.15.0** (PR #27415, "Add Effect-native core event system", merged
2026-05-15) as an interim prefix. On the `v2` branch all 36 session events have dropped `.next.`
`session.step.started`, `session.text.delta` — along with renames: `agent.switched`
`agent.selected`, `model.switched``model.selected`, `prompted``prompt.promoted`. Those renames
are **v2-branch only**; the 1.x line we run still emits `session.next.*`. Code against
`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.
**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=<seq> "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 610. `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.
The upstream implementation (`packages/core/src/event.ts`, `durable()`) makes three things explicit
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
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`.
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
`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}`).
The two "v2"s are not the same kind of change, which matters if we implement one of them:
- **Permissions v2 is a real contract change.** A rule goes from `{permission, pattern, action}` to
`{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."*
- **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
(`makeApi({definitions: EventManifest.Latest.values()})`), which is why both appear in the `/api/event`
union on our own `/doc`.
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=<last seq>`, 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). This is the part a
dependency would delete outright — see below.
- **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.
---
## 7b. The SDK is generated from the document we have been reading by hand
`@opencode-ai/sdk@1.18.16` (published 2026-08-10, versioned in lockstep with the CLI) is built by
`packages/sdk/js/script/build.ts`, which runs opencode's own `generate` to produce the OpenAPI document
and feeds it to `@hey-api/openapi-ts`. **It is generated from the same `/doc` we probed**, which is
about as good a guarantee of shape-agreement as exists.
It ships two clients. The default export is the legacy surface. `@opencode-ai/sdk/v2` is ours:
```ts
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 */ }
```
`client.v2.session.*` covers list/create/active/get/switchAgent/switchModel/prompt/compact/wait/
context/history/events/interrupt/message(s); there is also `client.v2.event.subscribe`,
`client.v2.permission.*`, `client.v2.question.*`, `fs`, `model`, `provider`, `agent`, `skill`, `pty`.
`createOpencodeClient` takes `directory` and injects it as both the `x-opencode-*` headers and the
`location[directory]` query param — the thing we hand-roll in two places.
That would delete: our hand-rolled SSE reader, both envelope unwrappers, three hand-written type sets,
and the model-id string splitting. It is a dependency change, and installs here are frozen, so it is a
deliberate `bun install --no-frozen-lockfile` plus a read of the lockfile diff. Worth noting the
package's only dependency is `cross-spawn`.
Not to be confused with two siblings the v2 docs mention: `@opencode-ai/sdk-next` is marked private and
is not on npm, and `@opencode-ai/client` is a private generation target for the beta line.
---
## 8. What we'd gain immediately
Ordered by value over effort. 13 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=<seq>` 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. Which permission/question family does a 1.18.16 agent actually emit? Both are declared and both
appear in our `/doc`, because the server wires the full manifest. Measure before building UI.
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? It is generated from this exact server's
OpenAPI output and versioned in lockstep, so it should — but nobody here has run it.
5. When did `/api/*` first appear in the 1.x line? UNKNOWN; the changelog names no `/api/` additions.
The event family underneath it landed in 1.15.0. And no dated removal plan exists for `/session/*`,
`permission.asked/replied` or `question.*` — only the undated internal intent quoted in §2.
6. Will the `v2`-branch event renames reach the 1.x line, or only ship with OpenCode 2.0? No merge
found, no statement either way. This decides whether the mapping table in §2 is a one-off or a
permanent seam.
7. Is a durable `seq` stable across a server restart or a session move? It is a database column, so it
should be, but no durability guarantee is documented and we have not tested it. Item 6 in §8 depends
on the answer.
Version state at the time of writing: **1.18.16 is the newest release** (2026-08-10) and contains
nothing API-facing. The active stream is the 2.0 beta, cutting releases continuously — the most recent
was published hours before this file was written.
---
## 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, docs: opencode.ai/v2/docs/migrate-v1, opencode.ai/v2/docs, opencode.ai/v2/docs/permissions,
opencode.ai/docs/server, opencode.ai/changelog.
- Upstream, source at tag `v1.18.16` in **github.com/anomalyco/opencode** (formerly `sst/opencode`,
which 301s): `packages/protocol/src/api.ts` and `groups/session.ts` (the surface's own "experimental"
self-description, the `after` parameter), `packages/schema/src/session-event.ts` (`DurableDefinitions`
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
(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`,
`-serve-path.md`, `-testing-checklist.md`, `-phase0-review.md`, `-phase1-report.md`,
`-phase1-review.md`.
+74 -11
View File
@@ -5,7 +5,8 @@
> The conclusion is reversed below. The mistake is written up rather than deleted, because the shape of
> it is the useful part.
**Decision: the fork is worth taking. Not started, and gated on one open question.**
**Decision: the fork is worth taking, and it is no longer blocked.** Not started; the blocker turned out
to be a missing credential and is fixed — see RESOLVED below.
---
@@ -41,19 +42,81 @@ Steer and queue are exactly the two features we hand-built for Claude and that A
changer. Having them as primitives, plus a resumable per-session cursor that mirrors officer's durable
replay, is a strong argument for migrating.
## The open question, which is now the only blocker
## RESOLVED — it was a missing credential, not a bug
**Why does `claude-sonnet-4-6` run under `opencode run --model` and silently die under the new API?**
Andre said he had a paid Zen key working in his terminal and suggested it simply was not set up here. He
was right, and this is the second wrong conclusion I reached on this page.
Until that is answered, the migration cannot be adopted: model choice is a user-facing control, and a
harness where picking the wrong model produces silence rather than an error is worse than one without
streaming. Things to try, cheapest first:
**The new pipeline has its own credential store, separate from `auth.json`.** `opencode run`, the CLI and
the legacy `/session` surface all read `~/.local/share/opencode/auth.json`, which holds the Zen key — so
they reach paid models. The `/api/*` surface reads integrations instead (`/api/integration`,
`/api/credential`), and ours had **none connected**. With no credential it silently fell back to what
needs none, which is exactly the free tier.
1. `variant` on `ModelRef` — it is the one field of the three we never set.
2. Compare `/config/providers` against what `run` resolves, to see whether the ids differ in form.
3. Whether it is auth-scoped: alpha's default model failed `401 missing_api_key` on a *different*
provider (`nano-gpt`), which proves the new pipeline surfaces provider auth errors properly when it
gets that far — so sonnet's silence is not a generic auth failure.
The fix was one call, and it persists across a serve restart (verified — a paid model still ran after
`pm2 restart officer-opencode`):
```
POST /api/integration/opencode/connect/key { "key": "<zen key>", "label": "…" } → 204
```
Afterwards `claude-sonnet-4-6` and `claude-haiku-4-5` both run on the new pipeline. **The fork is
unblocked**, and everything the table above promises — steer, queue, interrupt, resumable per-session
SSE — is available with real models.
Two consequences worth carrying:
- **alpha needs the same one-time connect** before it can use the new pipeline.
- The sidecar should do this itself at boot rather than relying on someone having run it by hand, since
a missing credential degrades to "only free models work" with no error anywhere.
### What the evidence looked like while I was getting it wrong
Recorded because the shape repeats: the failure was **silent and total** for paid models, and the cost
table drew a perfect line — every cost-0 model ran, every cost>0 model did not. I read that as a billing
boundary inside a broken pipeline. It was a billing boundary caused by an absent credential, which is a
far more ordinary explanation and one Andre reached from knowing his own setup rather than from the API.
The tell I had and did not use: the configured default is `opencode/big-pickle`, and a session created
with no model ran on `ling-3.0-tiny-free` **instead of the default**. A pipeline ignoring its configured
default is a pipeline that cannot use it — that is a credential symptom, and it was sitting in the
`/config/providers` output the whole time.
## The original diagnosis, kept for the record: only free models run
It is not sonnet, and it is not `variant`. Swept four models through `POST /api/session/{id}/model`
followed by a prompt:
| Model | New pipeline |
| -------------------- | ------------ |
| `longcat-2.0-free` | **ran** |
| `ling-3.0-tiny-free` | **ran** |
| `claude-haiku-4-5` | never ran |
| `claude-sonnet-4-6` | never ran |
| `gpt-5.1-codex-mini` | never ran |
**Every `-free` model runs; every paid model silently does not.** Ruled out along the way:
- **Not `variant`.** `claude-sonnet-4-6` advertises `["low","medium","high","max"]` and session create
echoes back `variant: "default"`, which is not among them — a promising theory that turned out to be
wrong: setting `variant: "high"` explicitly also never ran. Tested rather than assumed, which is the
whole lesson of this file.
- **Not missing credentials.** `opencode auth list` shows an OpenCode Zen API key in
`~/.local/share/opencode/auth.json` plus `ANTHROPIC_API_KEY` in the environment.
- **Not the sidecar's environment.** The *same* sidecar process runs `claude-sonnet-4-6` correctly
through `opencode run --model`, verified end to end. Same user, same home, same auth file.
So the new pipeline does not resolve paid-model credentials, and fails **silently** rather than
reporting it — while the legacy path and `run` both authenticate fine. Note it *can* surface provider
auth errors when it reaches that far: alpha's default (`nano-gpt`) returned a clean
`401 missing_api_key`. The silence is specific to opencode-zen paid models.
This reads as an upstream bug in an in-progress pipeline, not something configurable on our side.
**Consequence for the fork:** blocked, but precisely. Officer's users pick real models; a harness that
works only on free tiers is not adoptable. Re-run the sweep above after each `opencode upgrade` — the
day a paid model runs there, the migration is unblocked and worth doing immediately, because steer and
queue are already proven.
## Revised recommendation
+26 -2
View File
@@ -91,7 +91,7 @@ is gone. What remains is bucket 1 — capabilities Claude has and OpenCode does
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
**The fork is REOPENED, unblocked, 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
@@ -102,6 +102,30 @@ passed that one model.
Until the model question is answered, turns stay on `opencode run --dir`, which is verified working on
1.18.16.
**Crash-recovery state is not a gap either.** `state:sync` is sent to the `proxy` capability and carries
`proxySecret` — it is the Anthropic proxy s state, not a chat recovery record — and `syncState` /
`getCachedState` have **no callers at all** outside `sidecar-registry.ts`. The row compared OpenCode
against a mechanism officer never consults. The real recovery story now exists and is better: a sidecar
restart stops in-flight turns and writes the reason to `chat_session_events`, and `/chat/live`
enumerates what is running.
**Identity is correctly deferred, not forgotten.** `TODO.md:40-47` already records that `pty`, `vault`
and `opencode` receive no identity and are covered today only because those capabilities are owner-only —
"a correct outcome resting on the wrong layer". `chat` is `kind: execution`, which the grants API refuses
to share at any level, so this cannot be reached by a member. It is latent by construction.
**`messageCount` is a non-issue, not a gap.** `SessionList.tsx:197-203` renders an `OpenCode` badge in
place of the count for OpenCode rows, so the hardcoded `0` is never displayed. Computing a real count
would cost one HTTP call per listed session — the session record carries no count field — to populate
something nothing renders. Left alone deliberately.
**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
@@ -123,7 +147,7 @@ Ordered roughly by user-visible value.
| 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 |
| Crash-recovery state on disk | `claude-state.json` | **not a gap — see below** | No |
| Identity | validates `X-Officer-User` | **None** — flagged in `TODO.md:42-47` | No |
| Tests | 4 test files on the pure pieces | **Zero** | No |
+119
View File
@@ -0,0 +1,119 @@
# Moving OpenCode turns onto the serve — the plan
Written 2026-08-10, after the fork was unblocked (`docs/opencode-fork-decision.md`). **Nothing here is
implemented.** It exists so the work can start from verified facts rather than from the API docs, which
have been wrong or misleading three times on this path.
Andre should read "What changes for the user" and "The risk I would not take blind" before this starts.
---
## What we are moving from and to
Today every turn is `opencode run --dir <cwd> --format json`, a subprocess with `stdin: 'ignore'`. It
works, it is verified end to end, and its limits are all consequences of that one closed pipe.
The serve's `/api/session/*` surface offers, and I have run each of these against 1.18.16:
| Capability | How | Verified |
| ------------------------ | --------------------------------------------------- | --------------------------------------------------- |
| Mid-turn injection | `POST /prompt` `{delivery: "steer"}` | yes — steered a running turn |
| Queue behind a turn | `POST /prompt` `{delivery: "queue"}` | yes — "ONE" then "TWO", no errors |
| Token streaming | `GET /api/event` (GLOBAL, live) — `text.delta` | yes — deltas reassemble to the committed text |
| Reconnect + replay | `GET /api/session/{id}/event?after=<seq>` (durable) | yes — replayed a finished session |
| Interrupt, session lives | `POST /interrupt` → 204 | endpoint only, not exercised |
| Model selection | `POST /model` → 204 | yes — runs on the chosen model |
| Images | `prompt.files` | not exercised (we have images via `--file` already) |
## There are TWO streams, and this is the thing to get right
Corrected after Phase A; the table above originally implied one. The serve publishes each turn twice:
- **`GET /api/session/{id}/event?after=<seq>`** — durable, per session, replayable, every event carrying
`durable.seq`. Whole values only (`text.ended` with the full text). **No deltas.**
- **`GET /api/event`** — live, **global**, ephemeral. Carries `text.delta` and `tool.input.delta`. No cursor.
Measured on one real turn: 13 events durable, 21 live, the difference being 3 `text.delta` and 5
`tool.input.delta`. **Reading only the per-session stream — which is what I did first — makes it look
like the serve cannot stream at all**, and would have quietly removed the main reason to migrate.
The split maps exactly onto what officer already does for Claude: durable → `chat_session_events`, live →
UI deltas. The cost is that the live stream is GLOBAL, so a consumer must filter on `sessionID` and
cannot assume it owns the socket.
## Facts that will bite whoever implements this
Each of these cost time to find. None is in the API docs.
1. **The location is per REQUEST, not per session.** `x-opencode-directory: <cwd>` header, or
`?location[directory]=` as a deepObject query. A session created with `location` in the body and then
prompted without the header does not behave.
2. **Responses wrap in `{"data": …}`** on this surface; the legacy `/session/*` returns bare objects.
Reading `body.id` instead of `body.data.id` yields `undefined` silently.
3. **`delivery` defaults to `"steer"`.** Omitting it injects into a running turn, which is NOT the safe
default for an ordinary "send" — it must be set explicitly per intent.
4. **A model with no connected credential fails silently.** Prompt admitted, `prompt.admitted` and
`prompted` emitted, then nothing, forever. The sidecar now connects the credential at boot
(`connect-credential.ts`), and this failure mode is why that exists.
5. **The event names are `session.next.*`**`step.started`, `text.started`, `text.ended`,
`tool.called`, `tool.success`, `step.ended`, `step.failed`. Not the shapes `mapRunLine` handles.
## Status, 2026-08-10
- **Phase A — done.** `serve-events.ts` + tests, fixtures captured from real turns.
- **Phase B — done, behind `OPENCODE_TURNS=serve` (default: subprocess).** `serve-runner.ts`. Verified
end to end through the chat socket: tool call, tool result, **3 streaming deltas**, text, cost. Stop is
an interrupt and the session survives it.
- **Phase C — server half done.** A message sent while a turn runs is injected with `delivery: "steer"`
into the RUNNING turn, verified end to end. No client change was needed: officer's composer already
sends mid-turn, and the subprocess path was superseding where the serve steers.
- **Phase D — done.** The subprocess is deleted: no `runner.ts`, no `OPENCODE_TURNS` switch, no fallback
engine. Andre called it — nothing depends on OpenCode, so the cost of removing the escape hatch is
near zero and the recovery is git. 818 lines went with it, all of them workarounds for `stdin` being
`/dev/null`.
Not yet lived with. **Nothing here has run a real conversation with a person at the other end** — see
`docs/opencode-testing-checklist.md` for what to try and what is most likely to be broken.
## Shape of the work
**Phase A — read the stream without depending on it.** Add a serve-based reader alongside the existing
runner: subscribe to `/api/session/{id}/event`, map `session.next.*``ChatEvent`, and prove the mapping
against real turns. Do not route any user traffic through it. This is where `mapRunLine`'s successor gets
written and tested, and it is the only phase with no user-visible risk.
**Phase B — turns through the serve, behind a switch.** `POST /prompt` for the turn, events from Phase A,
`POST /interrupt` for stop. Keep `opencode run` reachable by config so a bad day is one restart from the
known-good path. The switch is the deliverable, not a detail.
**Phase C — the capabilities that motivated it.** `delivery: "steer"` wired to the existing "send now"
button, `delivery: "queue"` to the queue, streaming deltas to the composer. These are the visible wins
and they are cheap once B holds.
**Phase D — retire the subprocess**, only after B has run for a while. Deleting it early converts every
future problem into an emergency.
## What changes for the user
Better: text appears as it is generated instead of in blocks; the queue and "send now" work on OpenCode
exactly as they do on Claude; stop interrupts without destroying the session.
Worse, potentially: the serve becomes load-bearing. Today a serve crash costs session listing and nothing
else, because turns are subprocesses. After this it costs every turn in flight. That trade is the whole
decision.
## The risk I would not take blind
**Warm sessions bring a lifetime problem OpenCode does not currently have.** A subprocess ends when the
turn ends; there is nothing to garbage-collect, adopt after a restart, or leak. A serve session persists,
so this migration imports the entire class of problems the Claude path spent months getting right — idle
GC, orphan adoption, releasing versus killing, the supersede race I fixed this morning.
That is not an argument against doing it. It is an argument for Phase B keeping the old path one config
flip away, and for not doing Phase D on the same day as Phase B.
## Where to start
Phase A, `runner.ts`'s sibling, with the `session.next.*` fixtures captured from a real turn rather than
hand-written — `docs/opencode-fork-decision.md` records how to drive one with plain `curl`, and
`runner.test.ts` is the pattern for pinning a mapping without spawning anything.
+86
View File
@@ -0,0 +1,86 @@
# OpenCode: what to test tonight
Every OpenCode turn now runs through the serve (`serve-runner.ts`). The `opencode run` subprocess is
deleted, so this is not a comparison against a fallback — it is the only path.
I have driven each item in **Should already work** end to end through the real chat socket, with a
script rather than a browser. Nothing below has been used by a person in a real conversation, and the
things in **Unproven** are unproven because probes cannot answer them.
---
## Before you start
```bash
cd ~/projects/officer-suite/platform
pm2 logs officer-opencode --lines 40 # the sidecar's own account of what happened
```
On boot you should see three lines: `serve healthy on port …`, `connected the opencode credential to the
api surface`, and the sidecar registering. **If the credential line is missing or says it could not
connect, stop** — paid models will silently do nothing, which is the failure that cost most of an
afternoon (`docs/opencode-fork-decision.md`).
Rolling back is `git revert` of `a3dbda7` (Phase D) and a restart. There is no config flag any more.
Alpha is unaffected until you pull.
---
## Should already work — confirm, do not investigate
Each verified by me end to end. If one fails, that is new information and worth stopping on.
- [ ] **A plain turn.** Ask for something short. Text arrives.
- [ ] **Streaming.** Ask for something long — "count slowly to 50". Text should appear **progressively**,
not in one block at the end. This is the headline change; the old path could not do it.
- [ ] **A tool call.** "Run `echo hi` with bash." A tool row appears with the command as its arguments,
then its output.
- [ ] **Cost.** The turn ends with a token count attached.
- [ ] **An image.** Drop a screenshot in and ask what it shows. It must actually be described — the
failure mode is a confident answer about nothing, which is what B4 was.
- [ ] **Stop.** Press stop mid-turn. The turn ends **and the conversation stays usable** — send another
message straight after and it should answer. Previously stop destroyed the session.
- [ ] **Mid-turn injection.** While a turn runs, send another message. It should join the RUNNING turn
rather than starting a new one or superseding it.
## Unproven — this is the actual testing
- [ ] **Resume from history.** Open an older OpenCode conversation from the list and continue it. This
exercises `resumeSessionId`, which I never tested against the serve. **Most likely thing to be
broken.**
- [ ] **An idle session, an hour later.** Send a message to a conversation you have not touched for a
while. Warm sessions are new here — the subprocess had nothing to go stale.
- [ ] **A sidecar restart mid-turn.** `pm2 restart officer-opencode` while a turn is generating. Expect
the transcript to say the turn stopped. **The turn itself keeps running inside the serve** — that
is intended, not a bug, but nobody has watched what it looks like from the browser.
- [ ] **An officer restart mid-turn.** Different from the above: officer is the relay, the sidecar keeps
committing to `chat_session_events`. On reload the transcript should be intact. This is the one I
would least like to be wrong about.
- [ ] **Two conversations at once.** The live event stream is GLOBAL — one socket carries every session
and `serve-runner` filters on `sessionID`. If that filter is wrong, output from one conversation
appears in another. Two panels side by side is the test.
- [ ] **The Live panel.** A running OpenCode turn should appear, named, and disappear when it ends.
- [ ] **A long turn.** Ten-plus minutes. The old path had watchdogs (inactivity, hard cap) that are gone
with it; the serve has its own ideas about timeouts and I have not found their edges.
- [ ] **A failing tool.** Ask it to run a command that does not exist. The error should land in the
transcript as a failed tool, not as a dead turn.
## Known gaps, so you do not report them as bugs
- **No durable replay.** `serve-runner` reads the live stream only. Events are still committed to
`chat_session_events` as they arrive, so the transcript survives — but recovering a turn *this sidecar
process never saw* would need the `?after=` cursor, and that is not built.
- **Notifications/thinking/background tasks.** Not implemented on this harness. Thinking is deliberately
out of scope for both harnesses.
- **`messageCount` shows nothing** for OpenCode rows. The UI renders an `OpenCode` badge instead; not a
gap (`docs/opencode-parity.md`).
## What is useful to tell me
For anything that misbehaves: what you did, what appeared, and the last twenty lines of
`pm2 logs officer-opencode`. The sidecar logs its own errors, and a silent failure with clean logs is a
different diagnosis from a loud one.
If a turn produces **nothing at all** — no text, no error, no spinner ending — check the credential line
from boot first. That specific silence has one cause and I have chased it twice.