Merge remote-tracking branch 'gitea/master' into sidecar-app-store
This commit is contained in:
@@ -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?
|
||||
@@ -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 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.
|
||||
|
||||
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. 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=<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`.
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user