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
|
> The conclusion is reversed below. The mistake is written up rather than deleted, because the shape of
|
||||||
> it is the useful part.
|
> 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
|
changer. Having them as primitives, plus a resumable per-session cursor that mirrors officer's durable
|
||||||
replay, is a strong argument for migrating.
|
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
|
**The new pipeline has its own credential store, separate from `auth.json`.** `opencode run`, the CLI and
|
||||||
harness where picking the wrong model produces silence rather than an error is worse than one without
|
the legacy `/session` surface all read `~/.local/share/opencode/auth.json`, which holds the Zen key — so
|
||||||
streaming. Things to try, cheapest first:
|
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.
|
The fix was one call, and it persists across a serve restart (verified — a paid model still ran after
|
||||||
2. Compare `/config/providers` against what `run` resolves, to see whether the ids differ in form.
|
`pm2 restart officer-opencode`):
|
||||||
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.
|
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
|
## 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
|
visible ones (token streaming, mid-turn injection, background tasks, interrupt-without-teardown) are
|
||||||
downstream of `stdin: 'ignore'` and therefore of the Phase 2 fork.
|
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
|
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
|
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
|
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
|
Until the model question is answered, turns stay on `opencode run --dir`, which is verified working on
|
||||||
1.18.16.
|
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
|
## 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 |
|
| MCP tools | `--mcp-config` | **Nothing** — no MCP anywhere in the OpenCode path | No |
|
||||||
| `messageCount` on the list | from the transcript | hardcoded `0` | 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** |
|
| 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 |
|
| Identity | validates `X-Officer-User` | **None** — flagged in `TODO.md:42-47` | No |
|
||||||
| Tests | 4 test files on the pure pieces | **Zero** | 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.
|
||||||
@@ -304,6 +304,18 @@ const server = serve({
|
|||||||
'/.well-known/carddav': honoServer.fetch,
|
'/.well-known/carddav': honoServer.fetch,
|
||||||
'/dav': honoServer.fetch,
|
'/dav': honoServer.fetch,
|
||||||
'/dav/*': honoServer.fetch,
|
'/dav/*': honoServer.fetch,
|
||||||
|
// Vaultwarden for third-party Bitwarden clients, unauthenticated at Officer's layer. Needs its own
|
||||||
|
// entry for the same reason /dav does: only the paths listed here reach hono, and anything else
|
||||||
|
// falls through to the SPA — which answers 200 with the React shell, so a missing line here looks
|
||||||
|
// like a working endpoint returning nonsense rather than a 404.
|
||||||
|
'/vaultwarden/*': honoServer.fetch,
|
||||||
|
// The same proxy at the root, so the extension needs only the bare Officer URL. These four prefixes
|
||||||
|
// are Vaultwarden's alone — nothing in Officer answers on them — so routing them here costs nothing.
|
||||||
|
// `/api/*` already reaches hono below, where a Bitwarden client header diverts it.
|
||||||
|
'/identity/*': honoServer.fetch,
|
||||||
|
'/notifications/*': honoServer.fetch,
|
||||||
|
'/icons/*': honoServer.fetch,
|
||||||
|
'/events/*': honoServer.fetch,
|
||||||
'/': officerWeb,
|
'/': officerWeb,
|
||||||
'/*': officerWeb,
|
'/*': officerWeb,
|
||||||
'/api': honoServer.fetch,
|
'/api': honoServer.fetch,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
renameOpenCodeSession,
|
renameOpenCodeSession,
|
||||||
isOpenCodeSessionId,
|
isOpenCodeSessionId,
|
||||||
} from './opencode-sessions';
|
} from './opencode-sessions';
|
||||||
import { getOpenCodeSession } from './opencode/state';
|
import { getOpenCodePrompt, getOpenCodeSession } from './opencode/state';
|
||||||
import { listChatModels } from './list-models';
|
import { listChatModels } from './list-models';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
import { readSttConfig } from '../server-settings/stt';
|
import { readSttConfig } from '../server-settings/stt';
|
||||||
@@ -55,7 +55,8 @@ chatRouter.use(async (ctx, next) => {
|
|||||||
|
|
||||||
// The working directory a request operates on: an explicit ?cwd= (a chosen pwd), else the default
|
// The working directory a request operates on: an explicit ?cwd= (a chosen pwd), else the default
|
||||||
// general_chat_sessions dir. Claude groups sessions by cwd, so this selects which project group we read.
|
// general_chat_sessions dir. Claude groups sessions by cwd, so this selects which project group we read.
|
||||||
// (OpenCode sessions all live in the one fixed server and ignore cwd.)
|
// OpenCode runs on one fixed serve, but each session records the directory its turn ran in, so cwd
|
||||||
|
// selects there too.
|
||||||
const cwdOf = (ctx: Context, email: string): string => ctx.req.query('cwd')?.trim() || getGeneralChatSessionsCwd(email);
|
const cwdOf = (ctx: Context, email: string): string => ctx.req.query('cwd')?.trim() || getGeneralChatSessionsCwd(email);
|
||||||
|
|
||||||
// GET /chat/pwds — the default /chat dir plus every directory that already has Claude sessions.
|
// GET /chat/pwds — the default /chat dir plus every directory that already has Claude sessions.
|
||||||
@@ -173,9 +174,12 @@ chatRouter.get('/live', async (ctx) => {
|
|||||||
isGenerating: true,
|
isGenerating: true,
|
||||||
pendingTasks: 0,
|
pendingTasks: 0,
|
||||||
harness: 'opencode' as const,
|
harness: 'opencode' as const,
|
||||||
// Still null for the first seconds of a turn: the id is only known once the subprocess prints it.
|
// OpenCode's own title wins as soon as it exists — it is derived from the conversation and is
|
||||||
// That window is real and short, and showing nothing beats showing a key the user has never seen.
|
// better than anything we would compose. Until then (and it titles asynchronously, so "until
|
||||||
title: meta?.title ?? null,
|
// then" covers the whole time a turn is RUNNING, which is exactly what this panel shows) fall
|
||||||
|
// back to the prompt that started the session. Same shape as the Claude side, which has never
|
||||||
|
// shown a live row without a name.
|
||||||
|
title: meta?.title && meta.title !== '(untitled)' ? meta.title : (getOpenCodePrompt(sessionKey) ?? null),
|
||||||
cwd: meta?.cwd || null,
|
cwd: meta?.cwd || null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,8 +39,12 @@ export function invalidateModelCache(): void {
|
|||||||
openCodeCache = null;
|
openCodeCache = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OpenCodeModel = {
|
||||||
|
capabilities?: { input?: { image?: boolean }; reasoning?: boolean };
|
||||||
|
};
|
||||||
|
|
||||||
type ProvidersResponse = {
|
type ProvidersResponse = {
|
||||||
providers?: Array<{ id?: string; models?: Record<string, unknown> }>;
|
providers?: Array<{ id?: string; models?: Record<string, OpenCodeModel> }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Enumerate OpenCode models from the fixed server's GET /config/providers (reliable — no subprocess).
|
// Enumerate OpenCode models from the fixed server's GET /config/providers (reliable — no subprocess).
|
||||||
@@ -58,19 +62,20 @@ async function listOpenCodeModels(): Promise<ModelInfo[]> {
|
|||||||
const models: ModelInfo[] = [];
|
const models: ModelInfo[] = [];
|
||||||
for (const provider of data.providers ?? []) {
|
for (const provider of data.providers ?? []) {
|
||||||
const providerId = provider.id ?? '';
|
const providerId = provider.id ?? '';
|
||||||
for (const modelId of Object.keys(provider.models ?? {})) {
|
for (const [modelId, model] of Object.entries(provider.models ?? {})) {
|
||||||
models.push({
|
models.push({
|
||||||
id: `${providerId}/${modelId}`,
|
id: `${providerId}/${modelId}`,
|
||||||
name: modelId,
|
name: modelId,
|
||||||
provider: providerId,
|
provider: providerId,
|
||||||
contextWindow: 200000,
|
contextWindow: 200000,
|
||||||
maxTokens: 8192,
|
maxTokens: 8192,
|
||||||
reasoning: false,
|
reasoning: model?.capabilities?.reasoning ?? false,
|
||||||
// False because nothing carries them: `handleOpenCodeChat`'s message type has no `images`
|
// Was hardcoded `false`, correctly, while nothing carried images — the composer gates on this
|
||||||
// field, so an attached image is rendered in the bubble, never sent, and silently dropped.
|
// flag, so advertising `true` offered a capability that did not exist. Images are now plumbed
|
||||||
// The composer gates on this flag, so advertising `true` offered a capability that did not
|
// through `OpenCodeRunParams` to `opencode run --file`, so the honest answer is the model's
|
||||||
// exist. Flip it back when images are plumbed through OpenCodeRunParams (parity doc, Phase 4).
|
// own: OpenCode publishes it per model and we had never read it. Defaults to false, so a model
|
||||||
images: false,
|
// that does not declare the capability keeps the affordance hidden rather than offering it.
|
||||||
|
images: model?.capabilities?.input?.image ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import { isPlaceholderOpenCodeTitle } from './opencode-sessions';
|
||||||
|
|
||||||
|
// OpenCode names a session `New session - <ISO>` until it has titled it from the conversation, which it
|
||||||
|
// does asynchronously. The strings below are real ones observed on 1.18.16 — both the placeholder and
|
||||||
|
// the good title it was replaced by.
|
||||||
|
|
||||||
|
describe('isPlaceholderOpenCodeTitle', () => {
|
||||||
|
it('recognises the placeholder OpenCode uses before it has titled a session', () => {
|
||||||
|
expect(isPlaceholderOpenCodeTitle('New session - 2026-08-10T15:44:17.178Z')).toBe(true);
|
||||||
|
expect(isPlaceholderOpenCodeTitle('New session - 2026-08-10T12:41:02.757Z')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats an absent or empty title as unnamed too', () => {
|
||||||
|
expect(isPlaceholderOpenCodeTitle(undefined)).toBe(true);
|
||||||
|
expect(isPlaceholderOpenCodeTitle('')).toBe(true);
|
||||||
|
expect(isPlaceholderOpenCodeTitle(' ')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a real title, including one that merely mentions a session', () => {
|
||||||
|
// The title OpenCode actually gave a turn of ours, and the reason this is a pattern match rather
|
||||||
|
// than a `startsWith('New session')`: a genuine title is allowed to begin with those words.
|
||||||
|
expect(isPlaceholderOpenCodeTitle('Single color in oc-red2.png')).toBe(false);
|
||||||
|
expect(isPlaceholderOpenCodeTitle('New session handling in the runner')).toBe(false);
|
||||||
|
expect(isPlaceholderOpenCodeTitle('New session - what should it be called?')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,23 +7,39 @@ import { logger } from './logger';
|
|||||||
// fixed pm2-managed server's HTTP API (never the DB directly). Returns the same shapes as the Claude
|
// fixed pm2-managed server's HTTP API (never the DB directly). Returns the same shapes as the Claude
|
||||||
// reader, tagged harness:'opencode', so chat.ts can merge both harnesses transparently.
|
// reader, tagged harness:'opencode', so chat.ts can merge both harnesses transparently.
|
||||||
//
|
//
|
||||||
// Sessions do NOT all live in one project. That claim was true when turns went through the serve and
|
// Sessions do NOT all live in one project. Each records the directory its turn ran in — the serve takes
|
||||||
// inherited its directory; turns are now `opencode run --dir <cwd>` subprocesses, so each session
|
// it per request as `x-opencode-directory` — and one serve holds sessions for many. That is why the cwd
|
||||||
// records the directory it ran in and one serve happily lists sessions across many. Verified: a single
|
// filter has to read `directory` rather than assume, and why the list has to come from a read that spans
|
||||||
// serve returned 7 sessions spread over several directories, which is also why the cwd filter has to
|
// projects (`client.listSessions`, which is where the project-scoping trap is written up).
|
||||||
// read `directory` rather than assume.
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List OpenCode sessions for a working directory, so each context (/chat pwd, email account, project)
|
* List OpenCode sessions for a working directory, so each context (/chat pwd, email account, project)
|
||||||
* sees only its own. With no cwd, returns all. Never throws — returns [] if the server is unavailable.
|
* sees only its own. With no cwd, returns all. Never throws — returns [] if the server is unavailable.
|
||||||
*
|
*
|
||||||
* Filters on the session's own `directory`, which is what OpenCode records when the runner starts it
|
* Filters on the session's own `directory`, which is what OpenCode records from the directory the turn
|
||||||
* with `--dir`. It used to filter on `metadata.officer.cwd`, a tag whose only writer
|
* ran in. It used to filter on `metadata.officer.cwd`, a tag whose only writer
|
||||||
* (`client.createSession`) has no callers — so the comparison was against `undefined` for every session
|
* (`client.createSession`) has no callers — so the comparison was against `undefined` for every session
|
||||||
* and the list was ALWAYS empty. `cwdOf` in chat.ts substitutes a default when no `?cwd=` is given, so
|
* and the list was ALWAYS empty. `cwdOf` in chat.ts substitutes a default when no `?cwd=` is given, so
|
||||||
* the `!cwd` escape never fired either and there was no configuration in which an OpenCode session
|
* the `!cwd` escape never fired either and there was no configuration in which an OpenCode session
|
||||||
* appeared in /chat. Verified against the live server: 7 sessions present, 0 returned.
|
* appeared in /chat. Verified against the live server: 7 sessions present, 0 returned.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* OpenCode's own placeholder name for a session it has not titled yet.
|
||||||
|
*
|
||||||
|
* It titles a session from the conversation, but ASYNCHRONOUSLY and after there is something to read —
|
||||||
|
* a finished turn ends up called "Single color in oc-red2.png", which is better than anything we would
|
||||||
|
* generate. Until then the session is literally named `New session - 2026-08-10T15:44:17.178Z`.
|
||||||
|
*
|
||||||
|
* That window is exactly when a session is most visible: `/chat/live` shows turns that are RUNNING, so
|
||||||
|
* the placeholder is what the panel catches. Treating it as "not yet titled" lets the good name arrive
|
||||||
|
* on its own. Forcing `--title` on the run instead would fix the transient case by permanently
|
||||||
|
* replacing opencode's title with a truncated prompt — worse where it lasts longest.
|
||||||
|
*/
|
||||||
|
export const isPlaceholderOpenCodeTitle = (title: string | undefined): boolean => {
|
||||||
|
const trimmed = title?.trim();
|
||||||
|
return !trimmed || /^New session - \d{4}-\d{2}-\d{2}T[\d:.]+Z?$/.test(trimmed);
|
||||||
|
};
|
||||||
|
|
||||||
export async function listOpenCodeSessions(cwd?: string): Promise<ClaudeSessionSummary[]> {
|
export async function listOpenCodeSessions(cwd?: string): Promise<ClaudeSessionSummary[]> {
|
||||||
try {
|
try {
|
||||||
const { baseUrl } = await ensureServer();
|
const { baseUrl } = await ensureServer();
|
||||||
@@ -35,7 +51,7 @@ export async function listOpenCodeSessions(cwd?: string): Promise<ClaudeSessionS
|
|||||||
const updated = s.time?.updated ?? created;
|
const updated = s.time?.updated ?? created;
|
||||||
return {
|
return {
|
||||||
id: s.id,
|
id: s.id,
|
||||||
title: s.title || '(untitled)',
|
title: isPlaceholderOpenCodeTitle(s.title) ? '(untitled)' : (s.title ?? '(untitled)'),
|
||||||
cwd: s.directory ?? '',
|
cwd: s.directory ?? '',
|
||||||
createdAt: new Date(created).toISOString(),
|
createdAt: new Date(created).toISOString(),
|
||||||
updatedAt: new Date(updated).toISOString(),
|
updatedAt: new Date(updated).toISOString(),
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
// One connection per `opencode serve` base URL, for the REST reads the chat list and transcript need.
|
// One connection per `opencode serve` base URL, for the REST reads the chat list and transcript need.
|
||||||
// The SDK-style `/session/*` route family is used (feature-complete, incl. DELETE).
|
// The SDK-style `/session/*` route family is used for the per-id reads and writes (feature-complete,
|
||||||
|
// incl. DELETE) — those answer for any session regardless of project, verified 200 with and without a
|
||||||
|
// directory header. The LIST is the exception and goes through `/api/session`; see `listSessions`.
|
||||||
//
|
//
|
||||||
// This used to also hold a shared SSE subscription (`GET /event`) demultiplexed per session, plus
|
// This used to also hold a shared SSE subscription (`GET /event`) demultiplexed per session, plus
|
||||||
// `createSession`, `postMessage`, `abort` and `isServerHealthy` — the client half of a serve-based turn
|
// `createSession`, `postMessage`, `abort` and `isServerHealthy` — the client half of a serve-based turn
|
||||||
@@ -12,10 +14,30 @@ class ServerConnection {
|
|||||||
|
|
||||||
// ── Session history (REST; OpenCode's SQLite store is the source of truth) ──
|
// ── Session history (REST; OpenCode's SQLite store is the source of truth) ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every session the serve knows, across every project.
|
||||||
|
*
|
||||||
|
* `GET /session` — the endpoint this used — answers for ONE project: the one the request's directory
|
||||||
|
* resolves to, which with no `x-opencode-directory` header is the serve's own cwd
|
||||||
|
* (`DATA_PATH/opencode_server`). That directory is not a git checkout, so it resolves to the catch-all
|
||||||
|
* project `global`, and so does every other non-git directory — which is why the general chat dir
|
||||||
|
* listed fine and nothing looked wrong. A cwd that IS a git checkout gets its own project and its
|
||||||
|
* sessions were simply never returned. Measured on the live serve: `/session` 8, `/api/session` 13,
|
||||||
|
* and a session created in a git directory came back 0 times from `/session` and 1 from `/api/session`.
|
||||||
|
*
|
||||||
|
* Chat pwds are project directories, so that is the ordinary case, not the edge one.
|
||||||
|
*
|
||||||
|
* The two surfaces disagree about the shape, and the difference is silent: `/session` carries the
|
||||||
|
* working directory as top-level `directory`, `/api/session` as `location.directory` with no top-level
|
||||||
|
* field at all, wrapped in `{data: …}`. Normalised here so callers keep reading `directory` — reading
|
||||||
|
* the wrong one yields `undefined` for every session, which filters the list down to nothing.
|
||||||
|
*/
|
||||||
async listSessions(): Promise<OpenCodeSessionInfo[]> {
|
async listSessions(): Promise<OpenCodeSessionInfo[]> {
|
||||||
const res = await fetch(`${this.baseUrl}/session`);
|
const res = await fetch(`${this.baseUrl}/api/session`);
|
||||||
if (!res.ok) throw new Error(`opencode GET /session → ${res.status}`);
|
if (!res.ok) throw new Error(`opencode GET /api/session → ${res.status}`);
|
||||||
return (await res.json()) as OpenCodeSessionInfo[];
|
const body = (await res.json()) as { data?: ApiSessionInfo[] } | ApiSessionInfo[];
|
||||||
|
const sessions = Array.isArray(body) ? body : (body.data ?? []);
|
||||||
|
return sessions.map((s) => ({ ...s, directory: s.directory ?? s.location?.directory ?? null }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One session's own record. The only place its working directory can be read on resume. */
|
/** One session's own record. The only place its working directory can be read on resume. */
|
||||||
@@ -72,6 +94,9 @@ export type OpenCodeSessionInfo = {
|
|||||||
directory?: string | null;
|
directory?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** `/api/session`'s shape: the same record, with the directory one level down. Normalised by `listSessions`. */
|
||||||
|
type ApiSessionInfo = OpenCodeSessionInfo & { location?: { directory?: string | null } };
|
||||||
|
|
||||||
export type OpenCodeStoredPart = {
|
export type OpenCodeStoredPart = {
|
||||||
type?: string;
|
type?: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
|
|||||||
@@ -12,4 +12,25 @@ export const setOpenCodeSession = (sessionKey: string, opencodeSessionId: string
|
|||||||
|
|
||||||
export const clearOpenCodeSession = (sessionKey: string): void => {
|
export const clearOpenCodeSession = (sessionKey: string): void => {
|
||||||
sessionKeyToOpenCode.delete(sessionKey);
|
sessionKeyToOpenCode.delete(sessionKey);
|
||||||
|
sessionKeyToPrompt.delete(sessionKey);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// What the user asked, kept only to name a session OpenCode has not named yet.
|
||||||
|
//
|
||||||
|
// OpenCode titles a session from the conversation, and does it well — but asynchronously, so a RUNNING
|
||||||
|
// turn is called `New session - <ISO>`, which is precisely when `/chat/live` displays it. This is the
|
||||||
|
// stand-in for that window: shown while the real title is still the placeholder, and dropped the moment
|
||||||
|
// OpenCode publishes its own, which is always the better one.
|
||||||
|
//
|
||||||
|
// Set on the FIRST turn of a session only, so it stays the name of the conversation rather than
|
||||||
|
// following whatever was asked most recently.
|
||||||
|
const sessionKeyToPrompt = new Map<string, string>();
|
||||||
|
|
||||||
|
export const rememberOpenCodePrompt = (sessionKey: string, prompt: string): void => {
|
||||||
|
if (sessionKeyToPrompt.has(sessionKey)) return;
|
||||||
|
const oneLine = prompt.replace(/\s+/g, ' ').trim();
|
||||||
|
if (!oneLine) return;
|
||||||
|
sessionKeyToPrompt.set(sessionKey, oneLine.length > 60 ? `${oneLine.slice(0, 59)}…` : oneLine);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getOpenCodePrompt = (sessionKey: string): string | undefined => sessionKeyToPrompt.get(sessionKey);
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class SessionManager {
|
|||||||
cwd,
|
cwd,
|
||||||
model,
|
model,
|
||||||
piProcess: null,
|
piProcess: null,
|
||||||
ws: null,
|
sockets: new Set(),
|
||||||
lastActivity: Date.now(),
|
lastActivity: Date.now(),
|
||||||
idleTimer: null,
|
idleTimer: null,
|
||||||
streamBuffer: '',
|
streamBuffer: '',
|
||||||
@@ -145,7 +145,7 @@ class SessionManager {
|
|||||||
attachWs(sessionId: string, ws: any): void {
|
attachWs(sessionId: string, ws: any): void {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (session) {
|
if (session) {
|
||||||
session.ws = ws;
|
session.sockets.add(ws);
|
||||||
session.lastActivity = Date.now();
|
session.lastActivity = Date.now();
|
||||||
|
|
||||||
if (session.idleTimer) {
|
if (session.idleTimer) {
|
||||||
@@ -155,14 +155,24 @@ class SessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
detachWs(sessionId: string): void {
|
/**
|
||||||
|
* Removes one socket. The caller must say WHICH — a bare `detachWs(sessionId)` used to null the
|
||||||
|
* session's only socket field, so a stale client's close event silenced whichever client had attached
|
||||||
|
* after it. A close is only the end of the conversation when nothing else is still watching.
|
||||||
|
*/
|
||||||
|
detachWs(sessionId: string, ws: any): void {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (session) {
|
if (session) {
|
||||||
session.ws = null;
|
session.sockets.delete(ws);
|
||||||
session.lastActivity = Date.now();
|
session.lastActivity = Date.now();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether anything is still watching — the idle GC must not start while another client is attached. */
|
||||||
|
hasSockets(sessionId: string): boolean {
|
||||||
|
return (this.sessions.get(sessionId)?.sockets.size ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
setIdleTimeout(sessionId: string, timeoutMs: number): void {
|
setIdleTimeout(sessionId: string, timeoutMs: number): void {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
|
|||||||
@@ -299,7 +299,13 @@ export type UserSession = {
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
model: string;
|
model: string;
|
||||||
piProcess: any | null;
|
piProcess: any | null;
|
||||||
ws: any | null;
|
/**
|
||||||
|
* Every socket watching this conversation, not the most recent one. Two panes in one window, or a
|
||||||
|
* laptop and an iPad on the same chat, are both ordinary now that a tab holds several panes — and a
|
||||||
|
* single `ws` field meant the newest attach silently stole the turn from everyone else, while any one
|
||||||
|
* of them closing set it to null and killed delivery for the rest.
|
||||||
|
*/
|
||||||
|
sockets: Set<any>;
|
||||||
lastActivity: number;
|
lastActivity: number;
|
||||||
idleTimer: Timer | null;
|
idleTimer: Timer | null;
|
||||||
streamBuffer: string;
|
streamBuffer: string;
|
||||||
|
|||||||
@@ -28,17 +28,24 @@ describe('decideResume', () => {
|
|||||||
expect(decideResume(undefined, 'claude-code')).toEqual({ kind: 'adopt', model: 'claude-code' });
|
expect(decideResume(undefined, 'claude-code')).toEqual({ kind: 'adopt', model: 'claude-code' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stays unknown rather than defaulting to claude, which is the whole bug', () => {
|
it('marks a total unknown as an assumption instead of passing it off as knowledge', () => {
|
||||||
// No record and no claim. Previously this became `claude-code` and was indistinguishable from a real
|
// No record and no claim. This still adopts — see below — but the caller can tell it apart, which is
|
||||||
// Claude session; now it declines to adopt and declines to run the cut-off check.
|
// what keeps the durable cut-off row off a session nobody has identified.
|
||||||
expect(decideResume(undefined, undefined)).toEqual({ kind: 'replay-only' });
|
expect(decideResume(undefined, undefined)).toEqual({ kind: 'assume', model: 'claude-code' });
|
||||||
expect(decideResume(undefined, '')).toEqual({ kind: 'replay-only' });
|
expect(decideResume(undefined, '')).toEqual({ kind: 'assume', model: 'claude-code' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('never reports replay-only once anything is known, so a live turn is always re-bound', () => {
|
it('always yields a model to adopt with, because an unbound socket misses the whole turn', () => {
|
||||||
// The failure mode in the other direction: declining to adopt a session we could have identified
|
// Regression guard. Refusing to adopt when the harness was unknown looked principled and broke the
|
||||||
// would silently drop the reconnect that makes turn output resume.
|
// app inside an hour: `useChat.ts` only sends `model` `if (modelRef.current)`, so a reconnect
|
||||||
expect(decideResume('claude-code', undefined).kind).toBe('known');
|
// without one is routine. The socket never re-bound, the live turn's output went nowhere, and the
|
||||||
expect(decideResume('', 'claude-code').kind).toBe('adopt');
|
// transcript collapsed to "turn completed without output" until a refresh rebuilt it from the log.
|
||||||
|
for (const d of [
|
||||||
|
decideResume('claude-code', undefined),
|
||||||
|
decideResume(undefined, 'opencode/x'),
|
||||||
|
decideResume(undefined, undefined),
|
||||||
|
]) {
|
||||||
|
expect(d.model).toBeTruthy();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
UserSession,
|
UserSession,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { sessionManager } from './session-manager';
|
import { sessionManager } from './session-manager';
|
||||||
|
import { rememberOpenCodePrompt } from './opencode/state';
|
||||||
import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code';
|
import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code';
|
||||||
import { sendOpenCodeStreaming } from '@@/channels/send-opencode';
|
import { sendOpenCodeStreaming } from '@@/channels/send-opencode';
|
||||||
import { ensureGeneralChatSessionsCwd } from './claude-sessions';
|
import { ensureGeneralChatSessionsCwd } from './claude-sessions';
|
||||||
@@ -157,8 +158,10 @@ export function close(ws: ServerWebSocket<WSData>): void {
|
|||||||
|
|
||||||
const sessionId = wsToSessionMap.get(ws);
|
const sessionId = wsToSessionMap.get(ws);
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
sessionManager.detachWs(sessionId);
|
sessionManager.detachWs(sessionId, ws);
|
||||||
sessionManager.setIdleTimeout(sessionId, IDLE_TIMEOUT_MS);
|
// Only once nothing is watching. Another pane or another device still attached means the
|
||||||
|
// conversation is live, and arming the idle GC here would collect it out from under them.
|
||||||
|
if (!sessionManager.hasSockets(sessionId)) sessionManager.setIdleTimeout(sessionId, IDLE_TIMEOUT_MS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,7 +246,7 @@ function createMessageHandler(sessionId: string, model: string) {
|
|||||||
const session = sessionManager.getSession(sessionId);
|
const session = sessionManager.getSession(sessionId);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
foldIntoSession(session, msg, model);
|
foldIntoSession(session, msg, model);
|
||||||
sendToClient(session.ws as ServerWebSocket<WSData> | null, msg, seq);
|
for (const socket of session.sockets) sendToClient(socket as ServerWebSocket<WSData>, msg, seq);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,6 +399,10 @@ async function handleOpenCodeChat(
|
|||||||
cwd?: string;
|
cwd?: string;
|
||||||
cwdRoot?: string;
|
cwdRoot?: string;
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
|
// The whole of B4 lived in this omission. The browser sent images, the bubble rendered them, and
|
||||||
|
// they stopped at this signature — so they were never passed on and never reached the model, with
|
||||||
|
// nothing anywhere reporting a loss.
|
||||||
|
images?: PromptImage[];
|
||||||
},
|
},
|
||||||
effectivePrompt: string,
|
effectivePrompt: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -403,6 +410,9 @@ async function handleOpenCodeChat(
|
|||||||
|
|
||||||
const cwd = await resolveChatCwd(msg, email, userId);
|
const cwd = await resolveChatCwd(msg, email, userId);
|
||||||
|
|
||||||
|
// Names this session in the Live panel until OpenCode gets round to titling it. First turn only.
|
||||||
|
rememberOpenCodePrompt(sessionId, msg.displayText || msg.prompt);
|
||||||
|
|
||||||
const groupSlug = msg.groupSlug || null;
|
const groupSlug = msg.groupSlug || null;
|
||||||
|
|
||||||
const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug, msg.context, msg.contextId);
|
const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug, msg.context, msg.contextId);
|
||||||
@@ -457,6 +467,7 @@ async function handleOpenCodeChat(
|
|||||||
cwd,
|
cwd,
|
||||||
model,
|
model,
|
||||||
resumeSessionId: msg.resumeSessionId,
|
resumeSessionId: msg.resumeSessionId,
|
||||||
|
images: msg.images,
|
||||||
onMessage,
|
onMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -617,19 +628,17 @@ async function handleResumeCursor(
|
|||||||
|
|
||||||
const known = sessionManager.getSession(sessionId);
|
const known = sessionManager.getSession(sessionId);
|
||||||
const decision = decideResume(known?.model, msg.model);
|
const decision = decideResume(known?.model, msg.model);
|
||||||
const model = decision.kind === 'replay-only' ? null : decision.model;
|
|
||||||
|
|
||||||
if (decision.kind === 'adopt') {
|
if (decision.kind !== 'known') {
|
||||||
|
// Adopt on an assumption too: an unbound socket misses the turn entirely, which is worse than a
|
||||||
|
// harness guess that only ever costs us the cut-off check below.
|
||||||
adoptOrphanedSession(ws, sessionId, decision.model, msg.cwd ?? '');
|
adoptOrphanedSession(ws, sessionId, decision.model, msg.cwd ?? '');
|
||||||
} else if (decision.kind === 'replay-only') {
|
if (decision.kind === 'assume') {
|
||||||
// Replay the durable log and stop there. Not adopting costs a live re-subscription; adopting on a
|
logger.warn('resume-cursor for an unknown session with no model; adopting on the default', { sessionId });
|
||||||
// guess cost correctness — see decideResume.
|
}
|
||||||
logger.warn('resume-cursor names a session this process does not know, and no model; replaying only', {
|
|
||||||
sessionId,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionManager.attachWs(sessionId, ws); // a no-op when adoption was skipped
|
sessionManager.attachWs(sessionId, ws);
|
||||||
wsToSessionMap.set(ws as any, sessionId);
|
wsToSessionMap.set(ws as any, sessionId);
|
||||||
try {
|
try {
|
||||||
const events = await getChatEventsSince(sessionId, cursor ?? 0);
|
const events = await getChatEventsSince(sessionId, cursor ?? 0);
|
||||||
@@ -639,15 +648,19 @@ async function handleResumeCursor(
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to replay chat events on resume-cursor', { sessionId, error: String(err) });
|
logger.error('Failed to replay chat events on resume-cursor', { sessionId, error: String(err) });
|
||||||
}
|
}
|
||||||
// Only ask when the harness is actually known. The check writes a DURABLE row, so a wrong answer here
|
// Only ask when the harness is actually known — never on an assumption. This check writes a DURABLE
|
||||||
// is permanent — silence is the safe failure.
|
// row, so a wrong answer is permanent: that was B7, where a defaulted `claude-code` made officer ask
|
||||||
if (msg.generating && model) await endTurnIfAgentIsGone(ws, sessionId, model);
|
// the Claude sidecar about an OpenCode session, hear "not generating", and write "the agent went
|
||||||
|
// away" into a turn that was running perfectly well.
|
||||||
|
if (msg.generating && decision.kind !== 'assume') {
|
||||||
|
await endTurnIfAgentIsGone([ws], sessionId, decision.model);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ResumeDecision =
|
export type ResumeDecision =
|
||||||
| { kind: 'known'; model: string }
|
| { kind: 'known'; model: string }
|
||||||
| { kind: 'adopt'; model: string }
|
| { kind: 'adopt'; model: string }
|
||||||
| { kind: 'replay-only' };
|
| { kind: 'assume'; model: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which harness a resuming session belongs to, and what that permits.
|
* Which harness a resuming session belongs to, and what that permits.
|
||||||
@@ -668,13 +681,21 @@ export type ResumeDecision =
|
|||||||
*
|
*
|
||||||
* Two rules. **The server's own record beats the client's claim** — a session in memory already knows its
|
* Two rules. **The server's own record beats the client's claim** — a session in memory already knows its
|
||||||
* harness, and letting a socket re-declare it is how the wrong sidecar gets a session in the first place.
|
* harness, and letting a socket re-declare it is how the wrong sidecar gets a session in the first place.
|
||||||
* **An unknown harness stays unknown**: no adoption, no cut-off check, just the replay. Defaulting is
|
* **A guess is still a guess**: when neither knows, the session is adopted on the default so delivery
|
||||||
* what made a guess indistinguishable from knowledge.
|
* keeps working, but it is marked `assume` and nothing durable may be written from it.
|
||||||
|
*
|
||||||
|
* The middle position — refusing to adopt at all when the harness is unknown — was tried and was WRONG,
|
||||||
|
* visibly so within the hour. `useChat.ts` sends `model` only `if (modelRef.current)`, so a reconnect
|
||||||
|
* without one is ordinary, not exotic; declining to adopt left the socket unbound to a live turn, and
|
||||||
|
* the running turn's output went nowhere. On screen: the transcript collapsed to "turn completed
|
||||||
|
* without output" and only a refresh — which rebuilds from the durable log — brought it back.
|
||||||
|
*
|
||||||
|
* So adoption is about DELIVERY and must be generous. Only the durable write needs certainty.
|
||||||
*/
|
*/
|
||||||
export function decideResume(knownModel: string | undefined, claimedModel: string | undefined): ResumeDecision {
|
export function decideResume(knownModel: string | undefined, claimedModel: string | undefined): ResumeDecision {
|
||||||
if (knownModel) return { kind: 'known', model: knownModel };
|
if (knownModel) return { kind: 'known', model: knownModel };
|
||||||
if (claimedModel) return { kind: 'adopt', model: claimedModel };
|
if (claimedModel) return { kind: 'adopt', model: claimedModel };
|
||||||
return { kind: 'replay-only' };
|
return { kind: 'assume', model: DEFAULT_MODEL };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -802,7 +823,7 @@ async function handleAttach(ws: ServerWebSocket<WSData>, msg: { claudeSessionId:
|
|||||||
* so its sessions are left alone rather than guessed at.
|
* so its sessions are left alone rather than guessed at.
|
||||||
*/
|
*/
|
||||||
async function endTurnIfAgentIsGone(
|
async function endTurnIfAgentIsGone(
|
||||||
ws: ServerWebSocket<WSData> | null,
|
targets: Iterable<ServerWebSocket<WSData> | null>,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
model: string,
|
model: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -815,11 +836,11 @@ async function endTurnIfAgentIsGone(
|
|||||||
const event: ServerMessage = { type: 'cut-off' };
|
const event: ServerMessage = { type: 'cut-off' };
|
||||||
try {
|
try {
|
||||||
const seq = await appendChatEvent(sessionId, event);
|
const seq = await appendChatEvent(sessionId, event);
|
||||||
sendToClient(ws, event, seq);
|
for (const target of targets) sendToClient(target, event, seq);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Still tell this client — an un-replayable explanation beats a spinner that never stops.
|
// Still tell every client — an un-replayable explanation beats a spinner that never stops.
|
||||||
logger.error('Failed to persist cut-off notice', { sessionId, error: String(err) });
|
logger.error('Failed to persist cut-off notice', { sessionId, error: String(err) });
|
||||||
sendToClient(ws, event);
|
for (const target of targets) sendToClient(target, event);
|
||||||
}
|
}
|
||||||
logger.info('Ended a turn whose agent had gone', { sessionId });
|
logger.info('Ended a turn whose agent had gone', { sessionId });
|
||||||
}
|
}
|
||||||
@@ -831,7 +852,11 @@ async function endTurnIfAgentIsGone(
|
|||||||
sidecar.onClaudeSidecarStarted(() => {
|
sidecar.onClaudeSidecarStarted(() => {
|
||||||
for (const session of sessionManager.getAllSessions()) {
|
for (const session of sessionManager.getAllSessions()) {
|
||||||
if (!session.isGenerating) continue;
|
if (!session.isGenerating) continue;
|
||||||
void endTurnIfAgentIsGone(session.ws as ServerWebSocket<WSData> | null, session.sessionId, session.model);
|
void endTurnIfAgentIsGone(
|
||||||
|
session.sockets as Set<ServerWebSocket<WSData>>,
|
||||||
|
session.sessionId,
|
||||||
|
session.model,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { createRouter } from '../../create-router';
|
||||||
|
import { getVaultServerUrl } from './sidecar-server';
|
||||||
|
import { stripHopByHop, redactPath } from './proxy-util';
|
||||||
|
|
||||||
|
// Vaultwarden, reachable at Officer's own hostname with NO Officer authentication.
|
||||||
|
//
|
||||||
|
// ── Why this exists ──
|
||||||
|
//
|
||||||
|
// The Bitwarden browser extension speaks Vaultwarden's protocol and nothing else: it obtains its own
|
||||||
|
// bearer token from `/identity/connect/token` and has nowhere to put a platform JWT. `/api/vault` cannot
|
||||||
|
// serve it — that router requires an Officer session and REPLACES the caller's Authorization header with
|
||||||
|
// a server-held Vaultwarden token, which is the right design for our own clients (the device then holds
|
||||||
|
// no vault credential at all) and an impossible one for a third-party client.
|
||||||
|
//
|
||||||
|
// So this mount exists to retire a second public hostname. Andre pointed the extension straight at
|
||||||
|
// Vaultwarden; that URL can now go away, and the extension can use the platform's.
|
||||||
|
//
|
||||||
|
// ── Why it is acceptable to leave unauthenticated ──
|
||||||
|
//
|
||||||
|
// It is not a new exposure, it is the same exposure through one door instead of two: everything here was
|
||||||
|
// already reachable at the Vaultwarden URL this replaces, guarded by the same thing that guards it now —
|
||||||
|
// the vault's own master password and Vaultwarden's own auth. Officer adds no security to a Bitwarden
|
||||||
|
// client today because it cannot: it has no credential to check.
|
||||||
|
//
|
||||||
|
// It is also going behind Tailscale, so the surface is a private network rather than the internet.
|
||||||
|
//
|
||||||
|
// ── TEMPORARY ──
|
||||||
|
//
|
||||||
|
// The intended end state is a browser extension of our own, reusing `@officer/vault` (already proven to
|
||||||
|
// run as a plain JS bundle outside React Native — the iOS autofill extension hosts it in JavaScriptCore)
|
||||||
|
// against the `/api/vault/session/login` broker. Then the client authenticates to OFFICER, Vaultwarden is
|
||||||
|
// never addressed directly by anything, and this mount is deleted rather than adjusted.
|
||||||
|
//
|
||||||
|
// ── The rule that keeps it safe ──
|
||||||
|
//
|
||||||
|
// DUMB PASS-THROUGH, and more so than `/api/vault`: that one rewrites the Authorization header, this one
|
||||||
|
// touches nothing. Method, path, query, headers, status and both body streams go through verbatim. The
|
||||||
|
// vault is end-to-end encrypted and no part of this may parse, decrypt, buffer, cache or log a body —
|
||||||
|
// paths are redacted in logs for the same reason. Every line of "cleverness" added here is reachable
|
||||||
|
// without an Officer session, which is the whole reason it must stay boring.
|
||||||
|
|
||||||
|
export const publicVaultRouter = createRouter();
|
||||||
|
|
||||||
|
const PREFIX = '/vaultwarden';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paths that belong to Vaultwarden and to nothing else in this platform.
|
||||||
|
*
|
||||||
|
* These are served at the ROOT so the extension can be configured with the bare Officer URL — no
|
||||||
|
* `/vaultwarden` suffix — which is the point: one hostname, one URL, nothing to explain. None of them
|
||||||
|
* collide with an Officer route, so no sniffing is involved; the path alone is the answer.
|
||||||
|
*/
|
||||||
|
export const VAULT_ONLY_PREFIXES = ['/identity', '/notifications', '/icons', '/events'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is this a Bitwarden client talking to us?
|
||||||
|
*
|
||||||
|
* Needed only for `/api/*`, which is the one namespace both servers claim — Vaultwarden has
|
||||||
|
* `/api/sync`, `/api/ciphers`, `/api/settings/domains`; Officer has `/api/chat`, `/api/settings` and
|
||||||
|
* everything else. The path cannot decide it, so the client says who it is: every Bitwarden client
|
||||||
|
* stamps `Bitwarden-Client-Name` (`browser`, `desktop`, `cli`, `web`), and older ones `Device-Type`.
|
||||||
|
*
|
||||||
|
* This is ROUTING, not authentication, which is why trusting a client-supplied header is fine here: the
|
||||||
|
* worst a forged header achieves is reaching Vaultwarden, which then demands its own credential exactly
|
||||||
|
* as it would have. Nothing is authorised by this function.
|
||||||
|
*/
|
||||||
|
export const isBitwardenClient = (headers: Headers): boolean =>
|
||||||
|
headers.has('bitwarden-client-name') || headers.has('bitwarden-client-version') || headers.has('device-type');
|
||||||
|
|
||||||
|
/** Everything Vaultwarden serves, whichever door it arrived through. */
|
||||||
|
export const isVaultwardenPath = (path: string, headers: Headers): boolean => {
|
||||||
|
if (VAULT_ONLY_PREFIXES.some((p) => path === p || path.startsWith(`${p}/`))) return true;
|
||||||
|
return path.startsWith('/api/') && isBitwardenClient(headers);
|
||||||
|
};
|
||||||
|
|
||||||
|
publicVaultRouter.all('/*', async (ctx) => {
|
||||||
|
const base = getVaultServerUrl();
|
||||||
|
if (!base) return ctx.text('Vault sidecar not available', 503);
|
||||||
|
|
||||||
|
const url = new URL(ctx.req.url);
|
||||||
|
// Mounted at `/vaultwarden` AND at the root prefixes above. Strip the prefix only when it is there,
|
||||||
|
// so `/vaultwarden/identity/...` and a bare `/identity/...` both reach Vaultwarden's own path.
|
||||||
|
const subpath = (url.pathname.startsWith(PREFIX) ? url.pathname.slice(PREFIX.length) : url.pathname) || '/';
|
||||||
|
const target = `${base}${subpath}${url.search}`;
|
||||||
|
const method = ctx.req.method;
|
||||||
|
const hasBody = method !== 'GET' && method !== 'HEAD';
|
||||||
|
|
||||||
|
// Authorization is FORWARDED, not replaced — the opposite of `/api/vault`. The client's Vaultwarden
|
||||||
|
// token is the only credential in play, and Vaultwarden is the only thing entitled to judge it.
|
||||||
|
const headers = stripHopByHop(ctx.req.raw.headers);
|
||||||
|
|
||||||
|
const init: RequestInit & { duplex?: 'half' } = { method, headers, redirect: 'manual' };
|
||||||
|
if (hasBody) {
|
||||||
|
init.body = ctx.req.raw.body;
|
||||||
|
init.duplex = 'half';
|
||||||
|
}
|
||||||
|
|
||||||
|
let upstream: Response;
|
||||||
|
try {
|
||||||
|
upstream = await fetch(target, init);
|
||||||
|
} catch {
|
||||||
|
console.error(`[vault:public] ${method} ${redactPath(subpath + url.search)} -> sidecar unreachable`);
|
||||||
|
return ctx.text('Vault sidecar unreachable', 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No 401-refresh-and-retry here, deliberately. That belongs to `/api/vault`, which owns the token it
|
||||||
|
// injected; this router holds no token and a 401 is Vaultwarden's answer to the CLIENT, which the
|
||||||
|
// client knows how to handle — it re-authenticates with the master password.
|
||||||
|
return new Response(upstream.body, { status: upstream.status, headers: stripHopByHop(upstream.headers) });
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { TurnMessage } from '@@/api/chat/types';
|
import type { PromptImage, TurnMessage } from '@@/api/chat/types';
|
||||||
import { logger } from '@@/api/chat/logger';
|
import { logger } from '@@/api/chat/logger';
|
||||||
import * as sidecar from '@@/sidecar-registry';
|
import * as sidecar from '@@/sidecar-registry';
|
||||||
import { getOpenCodeSession } from '@@/api/chat/opencode/state';
|
import { getOpenCodeSession } from '@@/api/chat/opencode/state';
|
||||||
@@ -20,6 +20,7 @@ type OpenCodeStreamingParams = {
|
|||||||
role?: string;
|
role?: string;
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
durable?: boolean;
|
durable?: boolean;
|
||||||
|
images?: PromptImage[];
|
||||||
// Finished turn messages, already committed by the sidecar; `seq` is the cursor id to deliver them under.
|
// Finished turn messages, already committed by the sidecar; `seq` is the cursor id to deliver them under.
|
||||||
onMessage: (msg: TurnMessage, seq?: number) => void;
|
onMessage: (msg: TurnMessage, seq?: number) => void;
|
||||||
};
|
};
|
||||||
@@ -59,6 +60,7 @@ export async function sendOpenCodeStreaming(params: OpenCodeStreamingParams): Pr
|
|||||||
model: params.model,
|
model: params.model,
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
durable: params.durable,
|
durable: params.durable,
|
||||||
|
images: params.images,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
unsub();
|
unsub();
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { taskLogsRouter } from './api/task-logs/task-logs';
|
|||||||
import { router as fileBrowserRouter } from './api/file-browser/router';
|
import { router as fileBrowserRouter } from './api/file-browser/router';
|
||||||
import { musicRouter } from './api/music/router';
|
import { musicRouter } from './api/music/router';
|
||||||
import { vaultRouter } from './api/vault/router';
|
import { vaultRouter } from './api/vault/router';
|
||||||
|
import { publicVaultRouter, VAULT_ONLY_PREFIXES, isBitwardenClient } from './api/vault/public-router';
|
||||||
import { agentHandoffRouter } from './api/agent-handoff/router';
|
import { agentHandoffRouter } from './api/agent-handoff/router';
|
||||||
import { slskdRouter } from './api/slskd/router';
|
import { slskdRouter } from './api/slskd/router';
|
||||||
import { headscaleRouter } from './api/headscale/router';
|
import { headscaleRouter } from './api/headscale/router';
|
||||||
@@ -103,6 +104,27 @@ honoServer.route('/api/waitlist', waitlistRouter);
|
|||||||
// gating still applies via originScopeMiddleware above (OFFICER_VAULT_ORIGIN → /api/vault). The
|
// gating still applies via originScopeMiddleware above (OFFICER_VAULT_ORIGIN → /api/vault). The
|
||||||
// notifications WebSocket is upgraded at the serve level (server.tsx).
|
// notifications WebSocket is upgraded at the serve level (server.tsx).
|
||||||
honoServer.route('/api/vault', vaultRouter);
|
honoServer.route('/api/vault', vaultRouter);
|
||||||
|
|
||||||
|
// The same Vaultwarden, with NO Officer authentication, so the Bitwarden browser extension can point at
|
||||||
|
// this host instead of at a second public hostname for Vaultwarden. Deliberately its own mount rather
|
||||||
|
// than a mode of the router above: that one requires an Officer session and swaps the caller's
|
||||||
|
// Authorization header for a server-held token, and blending the two would put an unauthenticated branch
|
||||||
|
// inside the authenticated path. Temporary — see public-router.ts for what replaces it and why leaving it
|
||||||
|
// open is not a new exposure.
|
||||||
|
honoServer.route('/vaultwarden', publicVaultRouter);
|
||||||
|
|
||||||
|
// …and at the ROOT, so the extension can be pointed at the bare Officer URL with no path at all.
|
||||||
|
//
|
||||||
|
// Registered BEFORE `/api` is mounted, because hono matches in registration order and this has to win
|
||||||
|
// for a Bitwarden client. It is deliberately narrow: the four prefixes below belong to Vaultwarden and
|
||||||
|
// to nothing else here, and `/api/*` is diverted ONLY when the request carries a Bitwarden client
|
||||||
|
// header. An ordinary Officer request never matches, so nothing that worked before changes.
|
||||||
|
for (const prefix of VAULT_ONLY_PREFIXES) honoServer.route(prefix, publicVaultRouter);
|
||||||
|
|
||||||
|
honoServer.use('/api/*', async (ctx, next) => {
|
||||||
|
if (!isBitwardenClient(ctx.req.raw.headers)) return next();
|
||||||
|
return publicVaultRouter.fetch(ctx.req.raw, ctx.env);
|
||||||
|
});
|
||||||
honoServer.get('/api/integrations/google/callback', googleCallbackHandler);
|
honoServer.get('/api/integrations/google/callback', googleCallbackHandler);
|
||||||
|
|
||||||
// Agent-to-agent handoff — mounted TOP-LEVEL for the same reason the vault is: the caller is a Claude
|
// Agent-to-agent handoff — mounted TOP-LEVEL for the same reason the vault is: the caller is a Claude
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
// Hand the serve's NEW api surface the provider key it cannot find on its own.
|
||||||
|
//
|
||||||
|
// ── The problem this exists to prevent ──
|
||||||
|
//
|
||||||
|
// opencode keeps credentials in two unrelated places. The CLI, `opencode run` and the legacy
|
||||||
|
// `/session/*` surface read `~/.local/share/opencode/auth.json`. The newer `/api/session/*` surface —
|
||||||
|
// the one with `delivery: "steer" | "queue"`, `/interrupt` and a resumable per-session event stream —
|
||||||
|
// reads its own integration store instead (`/api/integration`, `/api/credential`), and knows nothing
|
||||||
|
// about that file.
|
||||||
|
//
|
||||||
|
// With no credential the new pipeline does not fail. It falls back to whatever needs none, which is the
|
||||||
|
// free tier, and a request for a paid model is simply never executed: prompt accepted, `prompt.admitted`
|
||||||
|
// and `prompted` emitted, no step, no error, no assistant message, forever. That silence cost most of an
|
||||||
|
// afternoon to diagnose (docs/opencode-fork-decision.md) and would cost it again on every new machine.
|
||||||
|
//
|
||||||
|
// So the sidecar connects it at start-up rather than relying on somebody having run a curl by hand.
|
||||||
|
//
|
||||||
|
// ── Deliberately best-effort ──
|
||||||
|
//
|
||||||
|
// Never throws and never blocks start-up. Turns run through `opencode run`, which uses `auth.json` and
|
||||||
|
// is unaffected by any of this; failing here costs the new pipeline only, and the sidecar is far more
|
||||||
|
// useful up than down. The connection persists in opencode's own store, so this is a no-op on every
|
||||||
|
// start after the first.
|
||||||
|
|
||||||
|
const AUTH_PATH = join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'opencode', 'auth.json');
|
||||||
|
|
||||||
|
const ATTEMPTS = 6;
|
||||||
|
const RETRY_DELAY_MS = 1_500;
|
||||||
|
|
||||||
|
/** The provider key opencode already holds for itself, or null. Never logged, never returned to callers. */
|
||||||
|
function readProviderKey(providerId: string): string | null {
|
||||||
|
try {
|
||||||
|
const auth = JSON.parse(readFileSync(AUTH_PATH, 'utf8')) as Record<string, { type?: string; key?: string }>;
|
||||||
|
const entry = auth[providerId];
|
||||||
|
return entry?.type === 'api' && typeof entry.key === 'string' && entry.key ? entry.key : null;
|
||||||
|
} catch {
|
||||||
|
return null; // no auth file, unreadable, or not JSON — nothing to connect
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect `auth.json`'s key for one provider to the serve's integration store.
|
||||||
|
*
|
||||||
|
* `providerId` doubles as the integration id: opencode names them the same, so the Zen key stored under
|
||||||
|
* `opencode` connects to integration `opencode`.
|
||||||
|
*/
|
||||||
|
export async function connectProviderCredential(baseUrl: string, providerId = 'opencode'): Promise<void> {
|
||||||
|
const key = readProviderKey(providerId);
|
||||||
|
if (!key) {
|
||||||
|
console.log(`[opencode] no ${providerId} key in auth.json; the new API surface will only reach free models`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retried, because `/api/health` answers before the integration store is ready: connecting immediately
|
||||||
|
// after the health check returns 500, and the identical request succeeds seconds later. Measured, not
|
||||||
|
// assumed — the first version of this shipped without the retry and failed on its first real boot.
|
||||||
|
//
|
||||||
|
// Only 5xx is retried. A 4xx means the request itself is wrong (bad key, unknown integration) and
|
||||||
|
// repeating it just prints the same complaint five times.
|
||||||
|
for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${baseUrl}/api/integration/${providerId}/connect/key`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key, label: 'officer-opencode sidecar' }),
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
console.log(`[opencode] connected the ${providerId} credential to the api surface`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The body is deliberately not logged: a credential endpoint's error may quote what it was given.
|
||||||
|
if (res.status < 500) {
|
||||||
|
console.error(`[opencode] could not connect the ${providerId} credential: HTTP ${res.status}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (attempt === ATTEMPTS) {
|
||||||
|
console.error(
|
||||||
|
`[opencode] could not connect the ${providerId} credential after ${ATTEMPTS} attempts: HTTP ${res.status}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (attempt === ATTEMPTS) {
|
||||||
|
console.error(
|
||||||
|
`[opencode] could not connect the ${providerId} credential:`,
|
||||||
|
err instanceof Error ? err.message : err,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Bun.sleep(RETRY_DELAY_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,17 +4,19 @@ import { join } from 'node:path';
|
|||||||
import { DATA_PATH } from '../../data-path';
|
import { DATA_PATH } from '../../data-path';
|
||||||
import { createSidecarConnector } from '../connect';
|
import { createSidecarConnector } from '../connect';
|
||||||
import { sweepRecordedServe } from './serve-sweep';
|
import { sweepRecordedServe } from './serve-sweep';
|
||||||
|
import { connectProviderCredential } from './connect-credential';
|
||||||
import { createSessionLogStore } from '../claude/session-log';
|
import { createSessionLogStore } from '../claude/session-log';
|
||||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||||
import { runOpenCodeTurn, killOpenCodeTurn, listRunningOpenCodeTurns, stopAllOpenCodeTurns } from './runner';
|
import type { RunnerMessage } from './serve-runner';
|
||||||
|
import { runOpenCodeTurnOnServe, killServeTurn, listRunningServeTurns, stopAllServeTurns } from './serve-runner';
|
||||||
|
|
||||||
// The OpenCode sidecar (officer-opencode). Same philosophy as officer-claude: a singleton process that
|
// The OpenCode sidecar (officer-opencode). Same philosophy as officer-claude: a singleton process that
|
||||||
// OWNS its runtime — here, an `opencode serve` — registers with the API server, and answers commands. It
|
// OWNS its runtime — here, an `opencode serve` — registers with the API server, and answers commands. It
|
||||||
// listens on a random port, reported to the API on connect so it can route there.
|
// listens on a random port, reported to the API on connect so it can route there.
|
||||||
//
|
//
|
||||||
// The serve's working directory is DATA_PATH/opencode_server, and that is now ALL it is: turns do not go
|
// The serve runs EVERYTHING: turns (serve-runner.ts), session CRUD and model enumeration. It used to be
|
||||||
// through the serve, they are `opencode run --dir <cwd>` subprocesses (runner.ts). The serve is used for
|
// CRUD only, with turns spawned as `opencode run --dir <cwd>` subprocesses — that path was deleted on
|
||||||
// session CRUD and model enumeration only.
|
// 2026-08-10 once the serve had streaming, mid-turn injection and interrupt working end to end.
|
||||||
|
|
||||||
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
|
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
|
||||||
const OPENCODE_BIN = process.env.OPENCODE_BIN || join(homedir(), '.opencode', 'bin', 'opencode');
|
const OPENCODE_BIN = process.env.OPENCODE_BIN || join(homedir(), '.opencode', 'bin', 'opencode');
|
||||||
@@ -132,11 +134,16 @@ if (!(await waitHealthy(baseUrl, HEALTH_TIMEOUT_MS))) {
|
|||||||
}
|
}
|
||||||
console.log(`[opencode] serve healthy on port ${port}`);
|
console.log(`[opencode] serve healthy on port ${port}`);
|
||||||
|
|
||||||
|
// The new /api surface keeps credentials separately from auth.json and would otherwise reach free models
|
||||||
|
// only — silently. Best-effort and not awaited for correctness: turns go through `opencode run`, which
|
||||||
|
// reads auth.json directly and does not depend on this.
|
||||||
|
void connectProviderCredential(baseUrl);
|
||||||
|
|
||||||
// ── Command handlers ──
|
// ── Command handlers ──
|
||||||
|
|
||||||
type ReplyFn = (msg: SidecarEvent) => void;
|
type ReplyFn = (msg: SidecarEvent) => void;
|
||||||
|
|
||||||
const RUNNER_CONFIG = { bin: OPENCODE_BIN, fallbackCwd: SERVE_CWD };
|
const SERVE_RUNNER_CONFIG = { baseUrl, fallbackCwd: SERVE_CWD };
|
||||||
|
|
||||||
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
||||||
switch (cmd.type) {
|
switch (cmd.type) {
|
||||||
@@ -148,23 +155,29 @@ function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
|||||||
// Turn output goes through the session log: translated to TurnMessages and committed to
|
// Turn output goes through the session log: translated to TurnMessages and committed to
|
||||||
// chat_session_events here, in the process that produced it. Officer being down during a turn
|
// chat_session_events here, in the process that produced it. Officer being down during a turn
|
||||||
// no longer costs the transcript — the browser replays it from its cursor.
|
// no longer costs the transcript — the browser replays it from its cursor.
|
||||||
runOpenCodeTurn(cmd.params, RUNNER_CONFIG, (msg) => {
|
// Same emit contract either way, which is what makes the switch a switch: the durable commit and
|
||||||
|
// the routing fact behave identically whether a subprocess or the serve produced the event.
|
||||||
|
const onMessage = (msg: RunnerMessage) => {
|
||||||
if (msg.type === 'opencode:event') {
|
if (msg.type === 'opencode:event') {
|
||||||
sessionLog.push(sessionKey, msg.event, durable);
|
sessionLog.push(sessionKey, msg.event, durable);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// opencode:session is a routing fact (which `ses_…` to resume), not transcript — send it live.
|
// opencode:session is a routing fact (which `ses_…` to resume), not transcript — send it live.
|
||||||
connection.send(msg);
|
connection.send(msg);
|
||||||
});
|
};
|
||||||
|
|
||||||
|
void runOpenCodeTurnOnServe(cmd.params, SERVE_RUNNER_CONFIG, onMessage);
|
||||||
reply({ type: 'opencode:spawned', id: cmd.id, sessionKey });
|
reply({ type: 'opencode:spawned', id: cmd.id, sessionKey });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'opencode:list':
|
case 'opencode:list':
|
||||||
reply({ type: 'opencode:sessions', id: cmd.id, sessions: listRunningOpenCodeTurns() });
|
reply({ type: 'opencode:sessions', id: cmd.id, sessions: listRunningServeTurns() });
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'opencode:kill':
|
case 'opencode:kill':
|
||||||
killOpenCodeTurn(cmd.sessionKey);
|
// An INTERRUPT, not a kill: the turn stops and the session survives, so the conversation can be
|
||||||
|
// continued rather than only re-opened.
|
||||||
|
void killServeTurn(cmd.sessionKey, SERVE_RUNNER_CONFIG);
|
||||||
sessionLog.drop(cmd.sessionKey);
|
sessionLog.drop(cmd.sessionKey);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
@@ -215,7 +228,8 @@ async function shutdown(signal: string) {
|
|||||||
// Before the connection goes: killing a turn produces an event, and that event's durable write travels
|
// Before the connection goes: killing a turn produces an event, and that event's durable write travels
|
||||||
// over this socket. Tearing it down first would stop every turn silently — the exact outcome this is
|
// over this socket. Tearing it down first would stop every turn silently — the exact outcome this is
|
||||||
// here to prevent.
|
// here to prevent.
|
||||||
const stopped = stopAllOpenCodeTurns(`The OpenCode sidecar restarted (${signal}), so this turn stopped.`);
|
const message = `The OpenCode sidecar restarted (${signal}), so this turn stopped.`;
|
||||||
|
const stopped = stopAllServeTurns(message);
|
||||||
if (stopped > 0) {
|
if (stopped > 0) {
|
||||||
console.log(`[opencode] ${signal} received, stopping ${stopped} in-flight turn(s)...`);
|
console.log(`[opencode] ${signal} received, stopping ${stopped} in-flight turn(s)...`);
|
||||||
await Promise.race([sessionLog.flush(), Bun.sleep(SHUTDOWN_FLUSH_MS)]);
|
await Promise.race([sessionLog.flush(), Bun.sleep(SHUTDOWN_FLUSH_MS)]);
|
||||||
|
|||||||
@@ -1,235 +0,0 @@
|
|||||||
import { afterAll, describe, expect, it } from 'bun:test';
|
|
||||||
import { mkdtempSync, writeFileSync, chmodSync, rmSync } from 'node:fs';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import type { RunnerMessage } from './runner';
|
|
||||||
import {
|
|
||||||
killOpenCodeTurn,
|
|
||||||
listRunningOpenCodeTurns,
|
|
||||||
mapRunLine,
|
|
||||||
runOpenCodeTurn,
|
|
||||||
stopAllOpenCodeTurns,
|
|
||||||
} from './runner';
|
|
||||||
|
|
||||||
// The first tests on the OpenCode path, which had none.
|
|
||||||
//
|
|
||||||
// This covers the NDJSON → ChatEvent mapping from `opencode run --format json`, which is the piece most
|
|
||||||
// likely to break against a new release: the event shape is not a documented contract, and this project
|
|
||||||
// already runs two different opencode versions across two machines (1.18.11 on the Mac, 1.17.9 on alpha
|
|
||||||
// — measured 2026-08-10; this file previously had them the wrong way round).
|
|
||||||
// Before this, a shape change would have surfaced as a silently empty or malformed turn.
|
|
||||||
//
|
|
||||||
// The fixtures below are the shapes the live 1.17.9 binary emits. If one of these tests fails after an
|
|
||||||
// upgrade, re-read the real NDJSON from the installed binary before editing the expectation — the test
|
|
||||||
// failing is the feature.
|
|
||||||
|
|
||||||
describe('mapRunLine — what it forwards', () => {
|
|
||||||
it('forwards a text part as final text, because run emits whole blocks not deltas', () => {
|
|
||||||
const out = mapRunLine(JSON.stringify({ type: 'text', sessionID: 'ses_1', part: { type: 'text', text: 'hello' } }));
|
|
||||||
|
|
||||||
expect(out?.events).toEqual([{ type: 'text', text: 'hello' }]);
|
|
||||||
expect(out?.sessionId).toBe('ses_1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('drops an empty text part rather than emitting a blank message', () => {
|
|
||||||
expect(mapRunLine(JSON.stringify({ type: 'text', part: { type: 'text', text: '' } }))?.events).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('splits one resolved tool part into start then result', () => {
|
|
||||||
// `run` hands over the tool already finished, unlike the SSE path which saw it transition. Both
|
|
||||||
// events are still emitted so the UI renders a call and its output rather than output alone.
|
|
||||||
const out = mapRunLine(
|
|
||||||
JSON.stringify({
|
|
||||||
type: 'tool_use',
|
|
||||||
sessionID: 'ses_1',
|
|
||||||
part: {
|
|
||||||
type: 'tool',
|
|
||||||
tool: 'bash',
|
|
||||||
callID: 'call_1',
|
|
||||||
state: { status: 'completed', input: { cmd: 'ls' }, output: 'a\nb' },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(out?.events).toEqual([
|
|
||||||
{ type: 'tool:start', toolCallId: 'call_1', toolName: 'bash', toolInput: { cmd: 'ls' } },
|
|
||||||
{ type: 'tool:result', toolCallId: 'call_1', output: 'a\nb', isError: false },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports a failed tool with its error as the output', () => {
|
|
||||||
const out = mapRunLine(
|
|
||||||
JSON.stringify({
|
|
||||||
type: 'tool_use',
|
|
||||||
part: {
|
|
||||||
type: 'tool',
|
|
||||||
tool: 'bash',
|
|
||||||
callID: 'call_2',
|
|
||||||
state: { status: 'error', error: 'boom', output: 'ignored' },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// The error replaces the output rather than sitting beside it: a failed call has nothing useful in
|
|
||||||
// `output`, and showing both would put a stale value under an error.
|
|
||||||
expect(out?.events[1]).toEqual({ type: 'tool:result', toolCallId: 'call_2', output: 'boom', isError: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores a tool part with no callID, which cannot be correlated to a result', () => {
|
|
||||||
const out = mapRunLine(JSON.stringify({ type: 'tool_use', part: { type: 'tool', tool: 'bash' } }));
|
|
||||||
expect(out?.events).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('says nothing about step_start and other unknown types', () => {
|
|
||||||
expect(mapRunLine(JSON.stringify({ type: 'step_start', sessionID: 'ses_1' }))?.events).toEqual([]);
|
|
||||||
expect(mapRunLine(JSON.stringify({ type: 'something_new_in_1_19' }))?.events).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null for a non-JSON line, because opencode interleaves plain logs with the stream', () => {
|
|
||||||
expect(mapRunLine('Shell cwd was reset to /somewhere')).toBeNull();
|
|
||||||
expect(mapRunLine('')).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('mapRunLine — cost', () => {
|
|
||||||
it('reports per-step tokens and cost as a delta for the caller to accumulate', () => {
|
|
||||||
const out = mapRunLine(
|
|
||||||
JSON.stringify({ type: 'step_finish', part: { tokens: { input: 10, output: 4 }, cost: 0.002 } }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(out?.costDelta).toEqual({ inputTokens: 10, outputTokens: 4, totalUSD: 0.002 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is zero for every line that is not a step_finish, so accumulation is unconditional', () => {
|
|
||||||
const text = mapRunLine(JSON.stringify({ type: 'text', part: { type: 'text', text: 'x' } }));
|
|
||||||
expect(text?.costDelta).toEqual({ inputTokens: 0, outputTokens: 0, totalUSD: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('treats missing tokens and a missing cost as zero rather than NaN', () => {
|
|
||||||
// A NaN here would propagate into the turn total and render as an empty or broken cost in the UI.
|
|
||||||
const out = mapRunLine(JSON.stringify({ type: 'step_finish', part: {} }));
|
|
||||||
expect(out?.costDelta).toEqual({ inputTokens: 0, outputTokens: 0, totalUSD: 0 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('mapRunLine — session id', () => {
|
|
||||||
it('reports the session id from any line that carries one', () => {
|
|
||||||
// The caller emits it only the first time; this function has no memory, which is what makes it
|
|
||||||
// testable line by line.
|
|
||||||
expect(mapRunLine(JSON.stringify({ type: 'step_start', sessionID: 'ses_abc' }))?.sessionId).toBe('ses_abc');
|
|
||||||
expect(mapRunLine(JSON.stringify({ type: 'text', part: { text: 'hi' } }))?.sessionId).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── The turn lifecycle, which `mapRunLine`'s extraction deliberately left with the caller ──
|
|
||||||
//
|
|
||||||
// Emitting the session id once and accumulating cost were kept out of the pure mapper because they span
|
|
||||||
// lines. That was right, and it left them as the only untested logic on this path — which is exactly
|
|
||||||
// where the supersede defect lived: a replaced turn's `proc.exited` fired late and ran `finish()` against
|
|
||||||
// the turn that had replaced it.
|
|
||||||
//
|
|
||||||
// No real `opencode` needed. `RunnerConfig.bin` is the only injection point, so a shell script that
|
|
||||||
// outlives the test stands in for a turn that is still generating.
|
|
||||||
|
|
||||||
const stubDir = mkdtempSync(join(tmpdir(), 'oc-runner-test-'));
|
|
||||||
|
|
||||||
/** Stands in for a turn that is still generating. */
|
|
||||||
const STUB_BIN = join(stubDir, 'fake-opencode');
|
|
||||||
writeFileSync(STUB_BIN, '#!/bin/sh\nsleep 30\n');
|
|
||||||
chmodSync(STUB_BIN, 0o755);
|
|
||||||
|
|
||||||
/** Stands in for a turn that fails on its own. A script, not `/bin/false` — that is `/usr/bin/false` on macOS. */
|
|
||||||
const FAILING_BIN = join(stubDir, 'failing-opencode');
|
|
||||||
writeFileSync(FAILING_BIN, '#!/bin/sh\necho "boom" >&2\nexit 1\n');
|
|
||||||
chmodSync(FAILING_BIN, 0o755);
|
|
||||||
|
|
||||||
const CONFIG = { bin: STUB_BIN, fallbackCwd: stubDir };
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
rmSync(stubDir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wait for something to BECOME true, rather than sleeping a guessed interval and hoping.
|
|
||||||
*
|
|
||||||
* These tests spawn real processes, so every "has it happened yet" is at the mercy of machine load —
|
|
||||||
* and a fixed `sleep(750)` duly failed once on a box that was busy running opencode probes. Polling
|
|
||||||
* makes a slow machine slow instead of red. Absence assertions still need a fixed wait, since there is
|
|
||||||
* no event to wait for; those are marked where they appear.
|
|
||||||
*/
|
|
||||||
async function waitFor(what: () => boolean, timeoutMs = 8000): Promise<void> {
|
|
||||||
const deadline = Date.now() + timeoutMs;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
if (what()) return;
|
|
||||||
await Bun.sleep(25);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('runOpenCodeTurn — a second turn on a live session', () => {
|
|
||||||
it('lets the replacement keep the session: no error, still listed, still killable', async () => {
|
|
||||||
const sessionKey = 'sess-supersede';
|
|
||||||
const messages: RunnerMessage[] = [];
|
|
||||||
const emit = (msg: RunnerMessage) => messages.push(msg);
|
|
||||||
|
|
||||||
runOpenCodeTurn({ sessionKey, prompt: 'first', cwd: stubDir }, CONFIG, emit);
|
|
||||||
runOpenCodeTurn({ sessionKey, prompt: 'second', cwd: stubDir }, CONFIG, emit);
|
|
||||||
|
|
||||||
// Wait for the superseded child to actually die — its exit handler is what used to reach across.
|
|
||||||
// Absence assertion, so there is no event to wait for: give it a generous fixed window instead, and
|
|
||||||
// wait on something observable (the kill landing) rather than purely on the clock.
|
|
||||||
await waitFor(() => listRunningOpenCodeTurns().length === 1);
|
|
||||||
await Bun.sleep(1500);
|
|
||||||
|
|
||||||
// 1. Nothing is emitted for a turn the system replaced on purpose. This one mattered most: the emit
|
|
||||||
// is committed to chat_session_events by the sidecar, so a false "OpenCode exited" became history.
|
|
||||||
expect(messages.filter((m) => m.type === 'opencode:event' && m.event.type === 'error')).toEqual([]);
|
|
||||||
|
|
||||||
// 2. The replacement is still enumerable — the Live panel's whole purpose.
|
|
||||||
expect(listRunningOpenCodeTurns()).toContainEqual({ sessionKey });
|
|
||||||
|
|
||||||
// 3. And still reachable by the stop button, rather than orphaned with no handle.
|
|
||||||
killOpenCodeTurn(sessionKey);
|
|
||||||
await waitFor(() => messages.some((m) => m.type === 'opencode:event' && m.event.type === 'stopped'));
|
|
||||||
expect(listRunningOpenCodeTurns()).not.toContainEqual({ sessionKey });
|
|
||||||
expect(messages.some((m) => m.type === 'opencode:event' && m.event.type === 'stopped')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stops every in-flight turn on shutdown, and says why in the transcript', async () => {
|
|
||||||
// `opencode run` is spawned, not supervised, so a sidecar restart used to leave these alive:
|
|
||||||
// reparented, still spending tokens and still writing files, with nothing reading their output.
|
|
||||||
const messages: RunnerMessage[] = [];
|
|
||||||
const emit = (m: RunnerMessage) => messages.push(m);
|
|
||||||
|
|
||||||
runOpenCodeTurn({ sessionKey: 'sess-x', prompt: 'a', cwd: stubDir }, CONFIG, emit);
|
|
||||||
runOpenCodeTurn({ sessionKey: 'sess-y', prompt: 'b', cwd: stubDir }, CONFIG, emit);
|
|
||||||
expect(listRunningOpenCodeTurns()).toHaveLength(2);
|
|
||||||
|
|
||||||
const stopped = stopAllOpenCodeTurns('sidecar restarted');
|
|
||||||
|
|
||||||
// Synchronous on purpose: the caller is about to call process.exit, so nothing that waits for
|
|
||||||
// `proc.exited` would ever run. A turn killed that way just trails off mid-tool-call.
|
|
||||||
expect(stopped).toBe(2);
|
|
||||||
expect(listRunningOpenCodeTurns()).toEqual([]);
|
|
||||||
const errors = messages.filter((m) => m.type === 'opencode:event' && m.event.type === 'error');
|
|
||||||
expect(errors).toHaveLength(2);
|
|
||||||
|
|
||||||
// And the late exits must not add a second, worse ending on top of the one just written.
|
|
||||||
await Bun.sleep(500);
|
|
||||||
expect(messages.filter((m) => m.type === 'opencode:event' && m.event.type === 'error')).toHaveLength(2);
|
|
||||||
expect(messages.some((m) => m.type === 'opencode:event' && m.event.type === 'stopped')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('still reports a turn that dies on its own, rather than swallowing every exit', async () => {
|
|
||||||
// The guard must not overreach: an ordinary failure is still an error the user needs to see.
|
|
||||||
const sessionKey = 'sess-solo';
|
|
||||||
const messages: RunnerMessage[] = [];
|
|
||||||
|
|
||||||
runOpenCodeTurn({ sessionKey, prompt: 'only', cwd: stubDir }, { ...CONFIG, bin: FAILING_BIN }, (m) =>
|
|
||||||
messages.push(m),
|
|
||||||
);
|
|
||||||
await waitFor(() => messages.some((m) => m.type === 'opencode:event' && m.event.type === 'error'));
|
|
||||||
|
|
||||||
expect(messages.some((m) => m.type === 'opencode:event' && m.event.type === 'error')).toBe(true);
|
|
||||||
expect(listRunningOpenCodeTurns()).not.toContainEqual({ sessionKey });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,388 +0,0 @@
|
|||||||
import { existsSync } from 'node:fs';
|
|
||||||
import type { Subprocess } from 'bun';
|
|
||||||
import type { ChatEvent, MessageCost } from '../../api/chat/types';
|
|
||||||
import type { OpenCodeRunParams } from '../protocol';
|
|
||||||
|
|
||||||
// Drives one chat turn by spawning `opencode run … --format json` and mapping its newline-delimited
|
|
||||||
// JSON events to the shared ChatEvent contract. This is the reliable path: `--dir <cwd>` hard-anchors
|
|
||||||
// every tool to the chat's working directory, and `run` (unlike serve + POST /message) reports tool
|
|
||||||
// completion faithfully and exits when the turn is done — no wedged "running" tools. `--dangerously-
|
|
||||||
// skip-permissions` auto-approves so tools never block on an approval we can't answer over this channel.
|
|
||||||
//
|
|
||||||
// A watchdog guards against a genuinely hung child (an interactive prompt, a `sleep`, a network stall):
|
|
||||||
// an inactivity timer (reset on every stdout chunk) and an absolute per-turn ceiling both kill the
|
|
||||||
// process and emit a clean `error`, so the UI never sits at "Working…" forever.
|
|
||||||
|
|
||||||
const INACTIVITY_MS = 120_000; // no stdout for this long → assume wedged, kill
|
|
||||||
const HARD_CAP_MS = 10 * 60_000; // absolute per-turn ceiling
|
|
||||||
|
|
||||||
export type RunnerConfig = {
|
|
||||||
bin: string; // absolute path to the opencode binary
|
|
||||||
fallbackCwd: string; // used when params.cwd is missing/nonexistent
|
|
||||||
};
|
|
||||||
|
|
||||||
// What a turn reports to the sidecar it runs in. `opencode:event` is deliberately not a wire event any
|
|
||||||
// more: the sidecar translates each one into a TurnMessage and commits it before officer sees anything,
|
|
||||||
// so the durable record does not depend on officer being up (see index.ts).
|
|
||||||
export type RunnerMessage =
|
|
||||||
| { type: 'opencode:event'; sessionKey: string; event: ChatEvent }
|
|
||||||
| { type: 'opencode:session'; sessionKey: string; sessionId: string };
|
|
||||||
|
|
||||||
type Emit = (msg: RunnerMessage) => void;
|
|
||||||
|
|
||||||
type RunHandle = {
|
|
||||||
proc: Subprocess;
|
|
||||||
killedByUser: boolean;
|
|
||||||
/**
|
|
||||||
* Set when a newer turn has taken this sessionKey over.
|
|
||||||
*
|
|
||||||
* A killed process dies asynchronously, so a replaced turn's `proc.exited` fires LONG after its
|
|
||||||
* replacement is already running and registered under the same key. Without this flag that late
|
|
||||||
* handler ran the full completion path against the wrong turn: it emitted `OpenCode exited with code
|
|
||||||
* 143` — which the sidecar commits to `chat_session_events`, so a false failure became permanent
|
|
||||||
* history — and then deleted its replacement from `running`, which blinded the Live panel, made the
|
|
||||||
* stop button a no-op, and orphaned a process nothing could reach.
|
|
||||||
*/
|
|
||||||
superseded: boolean;
|
|
||||||
/**
|
|
||||||
* End this turn from outside the closure that owns it, with a reason.
|
|
||||||
*
|
|
||||||
* `killOpenCodeTurn` can kill a process and let `proc.exited` do the rest, because it has time.
|
|
||||||
* Shutdown does not: the sidecar is about to call `process.exit`, so nothing asynchronous will ever
|
|
||||||
* run again and a turn killed that way would simply stop mid-sentence, leaving a transcript that
|
|
||||||
* trails off. Settling synchronously is what puts the explanation in the log before we go.
|
|
||||||
*/
|
|
||||||
finish: (event: ChatEvent) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
// One turn per sessionKey; a new turn supersedes any stale process for that key.
|
|
||||||
const running = new Map<string, RunHandle>();
|
|
||||||
|
|
||||||
// Shape of `opencode run --format json` events.
|
|
||||||
//
|
|
||||||
// Verified live against opencode 1.18.11 (this Mac) and 1.17.9 (alpha) — MEASURED on 2026-08-10, having
|
|
||||||
// previously been recorded the other way round here: the "this server" in the original note meant alpha,
|
|
||||||
// and the comment was copied to a machine where it was false. Nothing enforces a version anyway; the
|
|
||||||
// binary is whatever is installed, and the two machines in this project already differ.
|
|
||||||
//
|
|
||||||
// `runner.test.ts` pins the mapping so a shape change fails a test rather than a turn; if it starts
|
|
||||||
// failing, re-read the NDJSON from the installed binary before editing the test.
|
|
||||||
type RunPart = {
|
|
||||||
type?: string;
|
|
||||||
text?: string;
|
|
||||||
tool?: string;
|
|
||||||
callID?: string;
|
|
||||||
state?: { status?: string; input?: unknown; output?: unknown; error?: unknown };
|
|
||||||
tokens?: { input?: number; output?: number };
|
|
||||||
cost?: number;
|
|
||||||
};
|
|
||||||
type RunEvent = { type?: string; sessionID?: string; part?: RunPart };
|
|
||||||
|
|
||||||
export function runOpenCodeTurn(params: OpenCodeRunParams, config: RunnerConfig, emit: Emit): void {
|
|
||||||
const { sessionKey } = params;
|
|
||||||
|
|
||||||
// Supersede any lingering turn for this session. Mark it BEFORE killing: the flag is what tells its
|
|
||||||
// own exit handler that this death was intentional and belongs to nobody.
|
|
||||||
const stale = running.get(sessionKey);
|
|
||||||
if (stale) {
|
|
||||||
stale.superseded = true;
|
|
||||||
try {
|
|
||||||
stale.proc.kill();
|
|
||||||
} catch {
|
|
||||||
/* already gone */
|
|
||||||
}
|
|
||||||
running.delete(sessionKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
const args = ['run', '--format', 'json', '--dangerously-skip-permissions'];
|
|
||||||
if (params.cwd) args.push('--dir', params.cwd);
|
|
||||||
if (params.model) args.push('--model', params.model);
|
|
||||||
if (params.resumeSessionId) args.push('--session', params.resumeSessionId);
|
|
||||||
args.push(params.prompt);
|
|
||||||
|
|
||||||
const cwd = params.cwd && existsSync(params.cwd) ? params.cwd : config.fallbackCwd;
|
|
||||||
|
|
||||||
const proc = Bun.spawn([config.bin, ...args], {
|
|
||||||
cwd,
|
|
||||||
stdin: 'ignore', // == /dev/null: `run` hangs waiting on stdin otherwise
|
|
||||||
stdout: 'pipe',
|
|
||||||
stderr: 'pipe',
|
|
||||||
});
|
|
||||||
|
|
||||||
// `finish` is a placeholder for the few synchronous lines until the real one below exists — it closes
|
|
||||||
// over `handle`, so the two cannot both be defined first. Nothing can call it in between.
|
|
||||||
const handle: RunHandle = { proc, killedByUser: false, superseded: false, finish: () => {} };
|
|
||||||
running.set(sessionKey, handle);
|
|
||||||
|
|
||||||
let done = false;
|
|
||||||
let reportedSession = false;
|
|
||||||
let cost: MessageCost = { inputTokens: 0, outputTokens: 0, totalUSD: 0 };
|
|
||||||
let stderrTail = '';
|
|
||||||
|
|
||||||
const emitEvent = (event: ChatEvent) => emit({ type: 'opencode:event', sessionKey, event });
|
|
||||||
|
|
||||||
let inactivityTimer: ReturnType<typeof setTimeout> | undefined;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retire this turn: stop its watchdogs, release its slot, and optionally say why it ended.
|
|
||||||
*
|
|
||||||
* The delete is identity-checked because `sessionKey` is not this turn's to own once it has been
|
|
||||||
* superseded — the map may already hold a live replacement under that key, and deleting by name alone
|
|
||||||
* removed it. `null` retires silently, which is what a superseded turn needs: it must still clear its
|
|
||||||
* timers (an armed 10-minute `hardTimer` would otherwise fire an error at whichever turn holds the key
|
|
||||||
* by then, reproducing the same cross-talk on a delay) while emitting nothing at all.
|
|
||||||
*/
|
|
||||||
const settle = (event: ChatEvent | null) => {
|
|
||||||
if (done) return;
|
|
||||||
done = true;
|
|
||||||
clearTimeout(hardTimer);
|
|
||||||
if (inactivityTimer) clearTimeout(inactivityTimer);
|
|
||||||
if (running.get(sessionKey) === handle) running.delete(sessionKey);
|
|
||||||
if (event) emitEvent(event);
|
|
||||||
};
|
|
||||||
|
|
||||||
const finish = (event: ChatEvent) => settle(event);
|
|
||||||
handle.finish = finish;
|
|
||||||
|
|
||||||
// ── Watchdogs ──
|
|
||||||
const hardTimer = setTimeout(() => {
|
|
||||||
try {
|
|
||||||
proc.kill();
|
|
||||||
} catch {
|
|
||||||
/* already gone */
|
|
||||||
}
|
|
||||||
finish({ type: 'error', message: `OpenCode turn exceeded ${HARD_CAP_MS / 1000}s and was stopped` });
|
|
||||||
}, HARD_CAP_MS);
|
|
||||||
|
|
||||||
const bumpInactivity = () => {
|
|
||||||
if (done) return;
|
|
||||||
if (inactivityTimer) clearTimeout(inactivityTimer);
|
|
||||||
inactivityTimer = setTimeout(() => {
|
|
||||||
try {
|
|
||||||
proc.kill();
|
|
||||||
} catch {
|
|
||||||
/* already gone */
|
|
||||||
}
|
|
||||||
finish({
|
|
||||||
type: 'error',
|
|
||||||
message: `OpenCode turn stalled (no output for ${INACTIVITY_MS / 1000}s) and was stopped`,
|
|
||||||
});
|
|
||||||
}, INACTIVITY_MS);
|
|
||||||
};
|
|
||||||
bumpInactivity();
|
|
||||||
|
|
||||||
// ── Capture a tail of stderr for error reporting ──
|
|
||||||
void (async () => {
|
|
||||||
const dec = new TextDecoder();
|
|
||||||
try {
|
|
||||||
for await (const chunk of proc.stderr as unknown as AsyncIterable<Uint8Array>) {
|
|
||||||
stderrTail = (stderrTail + dec.decode(chunk, { stream: true })).slice(-2000);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* stream closed */
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
// ── Parse stdout: newline-delimited JSON events ──
|
|
||||||
void (async () => {
|
|
||||||
const dec = new TextDecoder();
|
|
||||||
let buf = '';
|
|
||||||
try {
|
|
||||||
for await (const chunk of proc.stdout as unknown as AsyncIterable<Uint8Array>) {
|
|
||||||
bumpInactivity();
|
|
||||||
buf += dec.decode(chunk, { stream: true });
|
|
||||||
let nl: number;
|
|
||||||
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
||||||
const line = buf.slice(0, nl).trim();
|
|
||||||
buf = buf.slice(nl + 1);
|
|
||||||
if (line) handleLine(line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const last = buf.trim();
|
|
||||||
if (last) handleLine(last);
|
|
||||||
} catch {
|
|
||||||
/* stream closed / process killed */
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
function handleLine(line: string): void {
|
|
||||||
// A retired turn says nothing more. Stdout is drained asynchronously, so a killed process can still
|
|
||||||
// have buffered lines in flight — and for a superseded turn those would be emitted under a
|
|
||||||
// sessionKey that now belongs to its replacement, interleaving one turn's output into another's.
|
|
||||||
if (done) return;
|
|
||||||
|
|
||||||
const mapped = mapRunLine(line);
|
|
||||||
if (!mapped) return;
|
|
||||||
|
|
||||||
// Report the OpenCode session id once, so the API can resume it (`--session`) next turn.
|
|
||||||
if (!reportedSession && mapped.sessionId) {
|
|
||||||
reportedSession = true;
|
|
||||||
emit({ type: 'opencode:session', sessionKey, sessionId: mapped.sessionId });
|
|
||||||
}
|
|
||||||
|
|
||||||
cost = {
|
|
||||||
inputTokens: cost.inputTokens + mapped.costDelta.inputTokens,
|
|
||||||
outputTokens: cost.outputTokens + mapped.costDelta.outputTokens,
|
|
||||||
totalUSD: cost.totalUSD + mapped.costDelta.totalUSD,
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const event of mapped.events) emitEvent(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Completion: process exit is the authoritative turn-done signal ──
|
|
||||||
void proc.exited.then((code) => {
|
|
||||||
if (done) return;
|
|
||||||
// Replaced on purpose: not a result, not an error, and not this turn's session any more.
|
|
||||||
if (handle.superseded) {
|
|
||||||
settle(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (handle.killedByUser) {
|
|
||||||
finish({ type: 'stopped' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (code === 0) {
|
|
||||||
finish({ type: 'result', cost });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const tail = stderrTail.trim();
|
|
||||||
finish({
|
|
||||||
type: 'error',
|
|
||||||
message: tail ? `OpenCode exited (${code}): ${tail.slice(-500)}` : `OpenCode exited with code ${code}`,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The turns this process is running right now.
|
|
||||||
*
|
|
||||||
* The OpenCode analog of `claude-manager.listSessions`, and deliberately thinner. Claude holds a warm
|
|
||||||
* session that outlives a turn, so it can report one that is merely open; OpenCode spawns a subprocess
|
|
||||||
* per turn and has nothing between them. So a session appears here only while it is generating — which
|
|
||||||
* is exactly the state the Live panel exists to show, and the state that was invisible for OpenCode.
|
|
||||||
*
|
|
||||||
* No `pendingTasks`: `opencode run` has no background-task concept, so reporting 0 would suggest a
|
|
||||||
* capability that does not exist rather than an empty one.
|
|
||||||
*/
|
|
||||||
export function listRunningOpenCodeTurns(): { sessionKey: string }[] {
|
|
||||||
return Array.from(running.keys()).map((sessionKey) => ({ sessionKey }));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Kill every turn this process is running, because the process itself is going away.
|
|
||||||
*
|
|
||||||
* A turn is a child of this sidecar only in the bookkeeping sense: `opencode run` is spawned, not
|
|
||||||
* supervised, so `pm2 restart officer-opencode` used to leave every in-flight turn ALIVE — reparented,
|
|
||||||
* still spending tokens, and still writing files as the agent, while the only reader of its stdout had
|
|
||||||
* exited. The turn's output went nowhere and the transcript simply stopped mid-tool-call, which is
|
|
||||||
* indistinguishable from the agent hanging.
|
|
||||||
*
|
|
||||||
* Both halves matter. Killing the children stops the invisible work; settling them synchronously writes
|
|
||||||
* a reason into the transcript, so a reload after a restart explains itself instead of trailing off.
|
|
||||||
* Returns how many were stopped, so the caller can skip the flush wait when there were none.
|
|
||||||
*/
|
|
||||||
export function stopAllOpenCodeTurns(message: string): number {
|
|
||||||
const handles = [...running.values()];
|
|
||||||
for (const handle of handles) {
|
|
||||||
// Suppress the exit handler's own error: this death is accounted for, and `finish` below is the
|
|
||||||
// account. Without it a late `proc.exited` would be a second, less accurate ending.
|
|
||||||
handle.killedByUser = true;
|
|
||||||
try {
|
|
||||||
handle.proc.kill();
|
|
||||||
} catch {
|
|
||||||
/* already gone */
|
|
||||||
}
|
|
||||||
handle.finish({ type: 'error', message });
|
|
||||||
}
|
|
||||||
return handles.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function killOpenCodeTurn(sessionKey: string): void {
|
|
||||||
const handle = running.get(sessionKey);
|
|
||||||
if (!handle) return;
|
|
||||||
handle.killedByUser = true;
|
|
||||||
try {
|
|
||||||
handle.proc.kill();
|
|
||||||
} catch {
|
|
||||||
/* already gone */
|
|
||||||
}
|
|
||||||
// proc.exited fires → finish({ type: 'stopped' }).
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── The NDJSON → ChatEvent mapping, as a pure function ──
|
|
||||||
//
|
|
||||||
// Extracted from `handleLine` so it can be tested without spawning a binary. This is the piece most
|
|
||||||
// likely to break against a new OpenCode release — the event shape is not a stable contract and this
|
|
||||||
// project already runs two different versions across two machines — and it was the only untested part
|
|
||||||
// of the path. `runner.test.ts` pins it.
|
|
||||||
//
|
|
||||||
// Pure by construction: it takes a line and returns what should happen, holding no state. The caller
|
|
||||||
// owns the two stateful concerns, because they span lines rather than belonging to one: emitting the
|
|
||||||
// session id exactly once, and accumulating cost across steps.
|
|
||||||
|
|
||||||
export type MappedRunLine = {
|
|
||||||
/** Present on any event that names a session; the caller emits it only the first time. */
|
|
||||||
sessionId?: string;
|
|
||||||
/** Events to forward, in order. A tool part yields `tool:start` then `tool:result`. */
|
|
||||||
events: ChatEvent[];
|
|
||||||
/** Per-step tokens/cost to add to the turn total. Zeroes for every non-`step_finish` line. */
|
|
||||||
costDelta: { inputTokens: number; outputTokens: number; totalUSD: number };
|
|
||||||
};
|
|
||||||
|
|
||||||
const NO_COST = { inputTokens: 0, outputTokens: 0, totalUSD: 0 };
|
|
||||||
|
|
||||||
/** `null` for a line that is not JSON at all — `opencode` interleaves plain log lines with the stream. */
|
|
||||||
export function mapRunLine(line: string): MappedRunLine | null {
|
|
||||||
let evt: RunEvent;
|
|
||||||
try {
|
|
||||||
evt = JSON.parse(line) as RunEvent;
|
|
||||||
} catch {
|
|
||||||
return null; // non-JSON log line
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionId = evt.sessionID;
|
|
||||||
const events: ChatEvent[] = [];
|
|
||||||
|
|
||||||
switch (evt.type) {
|
|
||||||
case 'text': {
|
|
||||||
// `run` emits complete text parts (not token deltas) — forward each as final text.
|
|
||||||
const text = evt.part?.text;
|
|
||||||
if (typeof text === 'string' && text.length > 0) events.push({ type: 'text', text });
|
|
||||||
return { sessionId, events, costDelta: NO_COST };
|
|
||||||
}
|
|
||||||
case 'tool_use': {
|
|
||||||
// In `run`, the tool part arrives already resolved (status + output). Emit start then result.
|
|
||||||
const part = evt.part;
|
|
||||||
if (!part || part.type !== 'tool' || !part.callID) return { sessionId, events, costDelta: NO_COST };
|
|
||||||
const st = part.state ?? {};
|
|
||||||
events.push({
|
|
||||||
type: 'tool:start',
|
|
||||||
toolCallId: part.callID,
|
|
||||||
toolName: part.tool ?? 'tool',
|
|
||||||
toolInput: (st.input as Record<string, unknown>) ?? {},
|
|
||||||
});
|
|
||||||
const isError = st.status === 'error';
|
|
||||||
events.push({
|
|
||||||
type: 'tool:result',
|
|
||||||
toolCallId: part.callID,
|
|
||||||
output: String((isError ? st.error : st.output) ?? ''),
|
|
||||||
isError,
|
|
||||||
});
|
|
||||||
return { sessionId, events, costDelta: NO_COST };
|
|
||||||
}
|
|
||||||
case 'step_finish': {
|
|
||||||
const t = evt.part?.tokens;
|
|
||||||
return {
|
|
||||||
sessionId,
|
|
||||||
events,
|
|
||||||
costDelta: {
|
|
||||||
inputTokens: t?.input ?? 0,
|
|
||||||
outputTokens: t?.output ?? 0,
|
|
||||||
totalUSD: typeof evt.part?.cost === 'number' ? evt.part.cost : 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return { sessionId, events, costDelta: NO_COST }; // step_start etc. — nothing to forward
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import { isKnownServeEvent, mapServeEvent } from './serve-events';
|
||||||
|
|
||||||
|
// Fixtures are VERBATIM captures from opencode 1.18.16 — one real turn that ran `echo hello-from-tool`
|
||||||
|
// through the bash tool and then answered in prose. Ids and timestamps are as they arrived.
|
||||||
|
//
|
||||||
|
// They are real for the same reason `runner.test.ts`'s are: this mapping's only job is to match a shape
|
||||||
|
// nobody documents, so a hand-written fixture would test my imagination rather than the binary. If one
|
||||||
|
// of these fails after an upgrade, re-capture before editing the expectation.
|
||||||
|
|
||||||
|
const SESSION = 'ses_01346bde5ffeB4hWdcQiq1BQUG';
|
||||||
|
const ASSISTANT = 'msg_fecb96b3e0010o8smEyjxT2noD';
|
||||||
|
const CALL = 'toolu_01TyLVFSSXus2eoyANdjieWW';
|
||||||
|
|
||||||
|
describe('mapServeEvent — text', () => {
|
||||||
|
it('turns a text delta into a delta, because that is what makes output appear as it is typed', () => {
|
||||||
|
const out = mapServeEvent({
|
||||||
|
type: 'session.next.text.delta',
|
||||||
|
data: { timestamp: 1, sessionID: SESSION, assistantMessageID: ASSISTANT, textID: 'text-0', delta: 'It' },
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(out?.events).toEqual([{ type: 'delta', text: 'It' }]);
|
||||||
|
expect(out?.sessionId).toBe(SESSION);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns the ended text into the committed block', () => {
|
||||||
|
const out = mapServeEvent({
|
||||||
|
type: 'session.next.text.ended',
|
||||||
|
data: { sessionID: SESSION, textID: 'text-0', text: 'It printed **hello-from-tool**.' },
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(out?.events).toEqual([{ type: 'text', text: 'It printed **hello-from-tool**.' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says nothing for text.started, which carries no text at all', () => {
|
||||||
|
// Verified against the capture: `text.started` has `{textID}` and nothing else. Emitting an empty
|
||||||
|
// text here would put a blank assistant bubble on screen before a single token arrived.
|
||||||
|
const out = mapServeEvent({
|
||||||
|
type: 'session.next.text.started',
|
||||||
|
data: { sessionID: SESSION, textID: 'text-0' },
|
||||||
|
} as never);
|
||||||
|
expect(out?.events).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops an empty delta rather than emitting nothing-shaped events', () => {
|
||||||
|
expect(mapServeEvent({ type: 'session.next.text.delta', data: { delta: '' } } as never)?.events).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mapServeEvent — tools', () => {
|
||||||
|
it('starts the tool row on tool.called, where the input is finally a real object', () => {
|
||||||
|
const out = mapServeEvent({
|
||||||
|
type: 'session.next.tool.called',
|
||||||
|
data: {
|
||||||
|
sessionID: SESSION,
|
||||||
|
assistantMessageID: ASSISTANT,
|
||||||
|
callID: CALL,
|
||||||
|
tool: 'bash',
|
||||||
|
input: { command: 'echo hello-from-tool' },
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(out?.events).toEqual([
|
||||||
|
{ type: 'tool:start', toolCallId: CALL, toolName: 'bash', toolInput: { command: 'echo hello-from-tool' } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays silent through the input stream, which is half-parsed JSON', () => {
|
||||||
|
// This is the reason `tool:start` waits for `tool.called`. The real delta below is a fragment of a
|
||||||
|
// JSON object; rendering a tool row with `{"comman` as its arguments would be worse than waiting.
|
||||||
|
const started = mapServeEvent({
|
||||||
|
type: 'session.next.tool.input.started',
|
||||||
|
data: { sessionID: SESSION, callID: CALL, name: 'bash' },
|
||||||
|
} as never);
|
||||||
|
const delta = mapServeEvent({
|
||||||
|
type: 'session.next.tool.input.delta',
|
||||||
|
data: { sessionID: SESSION, callID: CALL, delta: '{"comman' },
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(started?.events).toEqual([]);
|
||||||
|
expect(delta?.events).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flattens the success content blocks into one output string', () => {
|
||||||
|
const out = mapServeEvent({
|
||||||
|
type: 'session.next.tool.success',
|
||||||
|
data: {
|
||||||
|
sessionID: SESSION,
|
||||||
|
callID: CALL,
|
||||||
|
structured: { exit: 0, truncated: false },
|
||||||
|
content: [{ type: 'text', text: 'hello-from-tool' }],
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(out?.events).toEqual([{ type: 'tool:result', toolCallId: CALL, output: 'hello-from-tool', isError: false }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a failed tool with its error as the output', () => {
|
||||||
|
const out = mapServeEvent({
|
||||||
|
type: 'session.next.tool.failed',
|
||||||
|
data: { sessionID: SESSION, callID: CALL, error: { message: 'command not found' } },
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(out?.events).toEqual([
|
||||||
|
{ type: 'tool:result', toolCallId: CALL, output: 'command not found', isError: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a tool event with no callID, which cannot be correlated', () => {
|
||||||
|
expect(mapServeEvent({ type: 'session.next.tool.called', data: { tool: 'bash' } } as never)?.events).toEqual([]);
|
||||||
|
expect(mapServeEvent({ type: 'session.next.tool.success', data: {} } as never)?.events).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mapServeEvent — accounting', () => {
|
||||||
|
it('reports per-step tokens and cost for the caller to accumulate', () => {
|
||||||
|
const out = mapServeEvent({
|
||||||
|
type: 'session.next.step.ended',
|
||||||
|
data: {
|
||||||
|
sessionID: SESSION,
|
||||||
|
finish: 'tool-calls',
|
||||||
|
cost: 0.0042,
|
||||||
|
tokens: { input: 3, output: 57, reasoning: 0, cache: { read: 3850, write: 0 } },
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(out?.costDelta).toEqual({ inputTokens: 3, outputTokens: 57, totalUSD: 0.0042 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits nothing terminal for a step that merely ended in tool calls', () => {
|
||||||
|
// `finish: "tool-calls"` is a step boundary MID-turn — the model stopped to run a tool and will
|
||||||
|
// continue. Treating it as the end of the turn would cut every tool-using conversation in half.
|
||||||
|
const out = mapServeEvent({
|
||||||
|
type: 'session.next.step.ended',
|
||||||
|
data: { sessionID: SESSION, finish: 'tool-calls', cost: 0, tokens: { input: 3, output: 57 } },
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(out?.events).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats missing tokens and cost as zero rather than NaN', () => {
|
||||||
|
const out = mapServeEvent({ type: 'session.next.step.ended', data: { sessionID: SESSION } } as never);
|
||||||
|
expect(out?.costDelta).toEqual({ inputTokens: 0, outputTokens: 0, totalUSD: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a failed step as an error carrying the provider message', () => {
|
||||||
|
// The real one, from a probe where the provider was down.
|
||||||
|
const out = mapServeEvent({
|
||||||
|
type: 'session.next.step.failed',
|
||||||
|
data: { sessionID: SESSION, error: { type: 'unknown', message: 'Provider request failed with HTTP 503' } },
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(out?.events).toEqual([{ type: 'error', message: 'Provider request failed with HTTP 503' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mapServeEvent — envelope', () => {
|
||||||
|
it('carries the durable cursor when reading the durable stream, and not otherwise', () => {
|
||||||
|
// The per-session stream stamps `durable.seq`; the global live stream does not. The caller needs
|
||||||
|
// that difference to know what it may commit and replay.
|
||||||
|
const durable = mapServeEvent({
|
||||||
|
type: 'session.next.text.ended',
|
||||||
|
durable: { seq: 12 },
|
||||||
|
data: { sessionID: SESSION, text: 'hi' },
|
||||||
|
} as never);
|
||||||
|
const live = mapServeEvent({ type: 'session.next.text.ended', data: { sessionID: SESSION, text: 'hi' } } as never);
|
||||||
|
|
||||||
|
expect(durable?.seq).toBe(12);
|
||||||
|
expect(live?.seq).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('always reports the session id, because the live stream is global', () => {
|
||||||
|
// Not a detail: `/api/event` carries EVERY session's events, so a consumer that forgets to filter
|
||||||
|
// splices one conversation into another.
|
||||||
|
for (const type of ['session.next.text.delta', 'session.next.tool.called', 'session.next.step.ended']) {
|
||||||
|
expect(mapServeEvent({ type, data: { sessionID: SESSION, delta: 'x', callID: CALL } } as never)?.sessionId).toBe(
|
||||||
|
SESSION,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for something that is not an event at all', () => {
|
||||||
|
expect(mapServeEvent(null)).toBeNull();
|
||||||
|
expect(mapServeEvent(undefined)).toBeNull();
|
||||||
|
expect(mapServeEvent({} as never)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is quiet about an unknown future type, but does not claim to know it', () => {
|
||||||
|
// Forward compatibility with a silent tell: an unrecognised type produces no events, and
|
||||||
|
// `isKnownServeEvent` is how a caller notices a release added something worth mapping.
|
||||||
|
expect(mapServeEvent({ type: 'session.next.something_new_in_1_19', data: {} } as never)?.events).toEqual([]);
|
||||||
|
expect(isKnownServeEvent('session.next.something_new_in_1_19')).toBe(false);
|
||||||
|
expect(isKnownServeEvent('session.next.text.delta')).toBe(true);
|
||||||
|
expect(isKnownServeEvent(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import type { ChatEvent } from '../../api/chat/types';
|
||||||
|
|
||||||
|
// `session.next.*` → ChatEvent. Phase A of the serve migration (docs/opencode-serve-migration-plan.md).
|
||||||
|
//
|
||||||
|
// NOTHING ROUTES THROUGH THIS YET. Turns still run as `opencode run` subprocesses via runner.ts; this is
|
||||||
|
// the mapping half of the serve path, written and pinned first so the switch-over is not also the moment
|
||||||
|
// the parsing is discovered to be wrong.
|
||||||
|
//
|
||||||
|
// ── The two streams, which is the part worth understanding ──
|
||||||
|
//
|
||||||
|
// The serve publishes the same turn twice, and the split maps exactly onto what officer already does for
|
||||||
|
// Claude:
|
||||||
|
//
|
||||||
|
// • `GET /api/session/{id}/event?after=<seq>` — DURABLE, per session, replayable. Every event carries
|
||||||
|
// `durable.seq`. Carries whole values (`text.ended` with the full text) and NO deltas. This is the
|
||||||
|
// transcript: what belongs in `chat_session_events`, and what a reconnecting browser replays.
|
||||||
|
//
|
||||||
|
// • `GET /api/event` — LIVE, GLOBAL, ephemeral. Carries the deltas
|
||||||
|
// (`text.delta`, `tool.input.delta`) and no cursor. This is what makes text appear as it is typed.
|
||||||
|
//
|
||||||
|
// Measured, not inferred: the same turn produced 13 events on the durable stream and 21 on the live one,
|
||||||
|
// the difference being 3 `text.delta` and 5 `tool.input.delta`. Reading only the durable stream — which
|
||||||
|
// is what I did first — makes it look like the serve cannot stream at all, and would have quietly killed
|
||||||
|
// the main reason for migrating.
|
||||||
|
//
|
||||||
|
// The live stream being GLOBAL is the real cost of this design: it carries every session's events, so a
|
||||||
|
// consumer must filter on `sessionID` and cannot assume it owns the socket.
|
||||||
|
//
|
||||||
|
// ── Fixture provenance ──
|
||||||
|
//
|
||||||
|
// Every shape here was captured from opencode 1.18.16 running a real turn that used the bash tool and
|
||||||
|
// then answered in prose. See `serve-events.test.ts`, whose fixtures are verbatim captures.
|
||||||
|
|
||||||
|
/** The envelope every `session.next.*` event arrives in. `durable` is present only on the durable stream. */
|
||||||
|
export type ServeEvent = {
|
||||||
|
type?: string;
|
||||||
|
durable?: { seq?: number };
|
||||||
|
data?: {
|
||||||
|
sessionID?: string;
|
||||||
|
messageID?: string;
|
||||||
|
assistantMessageID?: string;
|
||||||
|
callID?: string;
|
||||||
|
textID?: string;
|
||||||
|
name?: string;
|
||||||
|
tool?: string;
|
||||||
|
input?: Record<string, unknown>;
|
||||||
|
delta?: string;
|
||||||
|
text?: string;
|
||||||
|
content?: { type?: string; text?: string }[];
|
||||||
|
structured?: Record<string, unknown>;
|
||||||
|
error?: { message?: string; type?: string };
|
||||||
|
finish?: string;
|
||||||
|
cost?: number;
|
||||||
|
tokens?: { input?: number; output?: number };
|
||||||
|
model?: { id?: string; providerID?: string };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MappedServeEvent = {
|
||||||
|
/** The session this belongs to. The live stream is global, so a consumer MUST filter on it. */
|
||||||
|
sessionId?: string;
|
||||||
|
/** The durable cursor, when this came from the durable stream. Absent on live events. */
|
||||||
|
seq?: number;
|
||||||
|
events: ChatEvent[];
|
||||||
|
costDelta: { inputTokens: number; outputTokens: number; totalUSD: number };
|
||||||
|
/**
|
||||||
|
* Present only on `step.ended`, and the answer to "is the turn over".
|
||||||
|
*
|
||||||
|
* A subprocess turn ends by exiting; a serve turn has no such moment, so this is the only signal.
|
||||||
|
* `"tool-calls"` means the model paused to run a tool and WILL continue — treating it as terminal cuts
|
||||||
|
* every tool-using conversation in half. `"stop"` is the real end. Anything else (e.g. `"length"`) is
|
||||||
|
* also an ending, so the caller should test for "not tool-calls" rather than for "stop".
|
||||||
|
*/
|
||||||
|
stepFinish?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NO_COST = { inputTokens: 0, outputTokens: 0, totalUSD: 0 };
|
||||||
|
|
||||||
|
/** `tool.success` returns content blocks; officer's `tool:result` wants one string. */
|
||||||
|
function textOfContent(content: { type?: string; text?: string }[] | undefined): string {
|
||||||
|
if (!Array.isArray(content)) return '';
|
||||||
|
return content
|
||||||
|
.map((c) => (typeof c?.text === 'string' ? c.text : ''))
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One serve event in, whatever officer should do about it out.
|
||||||
|
*
|
||||||
|
* Pure by construction, exactly like `mapRunLine`: no accumulation, no memory, no I/O. The caller owns
|
||||||
|
* anything that spans events — summing cost across steps, and deciding whether it is reading the live or
|
||||||
|
* the durable stream.
|
||||||
|
*
|
||||||
|
* Returns `null` for an event that is not ours to interpret (another session's, or an envelope with no
|
||||||
|
* type). Everything recognised but deliberately ignored returns an empty `events` array instead, so
|
||||||
|
* "we saw it and had nothing to say" stays distinguishable from "we did not understand it".
|
||||||
|
*/
|
||||||
|
export function mapServeEvent(evt: ServeEvent | null | undefined): MappedServeEvent | null {
|
||||||
|
if (!evt || typeof evt.type !== 'string') return null;
|
||||||
|
|
||||||
|
const d = evt.data ?? {};
|
||||||
|
const base = { sessionId: d.sessionID, seq: evt.durable?.seq };
|
||||||
|
const nothing = (): MappedServeEvent => ({ ...base, events: [], costDelta: NO_COST });
|
||||||
|
|
||||||
|
switch (evt.type) {
|
||||||
|
// ── Text ──
|
||||||
|
case 'session.next.text.delta': {
|
||||||
|
// Live stream only. `delta` is the increment, never the whole value.
|
||||||
|
const text = d.delta;
|
||||||
|
if (typeof text !== 'string' || text.length === 0) return nothing();
|
||||||
|
return { ...base, events: [{ type: 'delta', text }], costDelta: NO_COST };
|
||||||
|
}
|
||||||
|
case 'session.next.text.ended': {
|
||||||
|
// The committed block. Appears on BOTH streams, which is intended: officer's client treats `text`
|
||||||
|
// as the value that supersedes whatever the deltas built up, the same as the Claude path.
|
||||||
|
const text = d.text;
|
||||||
|
if (typeof text !== 'string' || text.length === 0) return nothing();
|
||||||
|
return { ...base, events: [{ type: 'text', text }], costDelta: NO_COST };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tools ──
|
||||||
|
//
|
||||||
|
// `tool:start` is emitted on `tool.called`, NOT on `tool.input.started`, because only `tool.called`
|
||||||
|
// carries the resolved `input` object. `tool.input.started` knows the name but the arguments are
|
||||||
|
// still being streamed a few characters at a time (`tool.input.delta`: `{"comman`), and a tool row
|
||||||
|
// rendered with half-parsed JSON as its arguments is worse than one that appears a moment later.
|
||||||
|
case 'session.next.tool.called': {
|
||||||
|
const toolCallId = d.callID;
|
||||||
|
if (!toolCallId) return nothing();
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
type: 'tool:start',
|
||||||
|
toolCallId,
|
||||||
|
toolName: d.tool ?? d.name ?? 'tool',
|
||||||
|
toolInput: (d.input as Record<string, unknown>) ?? {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
costDelta: NO_COST,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'session.next.tool.success': {
|
||||||
|
const toolCallId = d.callID;
|
||||||
|
if (!toolCallId) return nothing();
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
events: [{ type: 'tool:result', toolCallId, output: textOfContent(d.content), isError: false }],
|
||||||
|
costDelta: NO_COST,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'session.next.tool.failed': {
|
||||||
|
const toolCallId = d.callID;
|
||||||
|
if (!toolCallId) return nothing();
|
||||||
|
// The error replaces the output rather than sitting beside it — same call the run path makes.
|
||||||
|
const message = d.error?.message ?? textOfContent(d.content) ?? '';
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
events: [{ type: 'tool:result', toolCallId, output: String(message), isError: true }],
|
||||||
|
costDelta: NO_COST,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step accounting ──
|
||||||
|
case 'session.next.step.ended': {
|
||||||
|
// Per-step tokens and cost, to be summed by the caller. `finish: "tool-calls"` is a step boundary
|
||||||
|
// mid-turn, not the end of the turn — the turn has ended when the prompt is fully answered, which
|
||||||
|
// is a caller-level question, so nothing terminal is emitted here.
|
||||||
|
const t = d.tokens;
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
events: [],
|
||||||
|
stepFinish: typeof d.finish === 'string' ? d.finish : undefined,
|
||||||
|
costDelta: {
|
||||||
|
inputTokens: t?.input ?? 0,
|
||||||
|
outputTokens: t?.output ?? 0,
|
||||||
|
totalUSD: typeof d.cost === 'number' ? d.cost : 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'session.next.step.failed': {
|
||||||
|
const message = d.error?.message ?? 'OpenCode step failed';
|
||||||
|
return { ...base, events: [{ type: 'error', message }], costDelta: NO_COST };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Seen and deliberately silent ──
|
||||||
|
//
|
||||||
|
// Named rather than swept into `default` so that a genuinely NEW event type still lands in the
|
||||||
|
// unknown bucket, where it can be noticed.
|
||||||
|
case 'session.next.prompt.admitted':
|
||||||
|
case 'session.next.prompted':
|
||||||
|
case 'session.next.step.started':
|
||||||
|
case 'session.next.text.started':
|
||||||
|
case 'session.next.tool.input.started':
|
||||||
|
case 'session.next.tool.input.delta':
|
||||||
|
case 'session.next.tool.input.ended':
|
||||||
|
case 'session.next.tool.progress':
|
||||||
|
case 'session.next.model.switched':
|
||||||
|
case 'session.next.agent.switched':
|
||||||
|
case 'server.connected':
|
||||||
|
return nothing();
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nothing();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a type is one this mapper knows about — for logging what a new opencode release added. */
|
||||||
|
export const isKnownServeEvent = (type: string | undefined): boolean =>
|
||||||
|
typeof type === 'string' && (type === 'server.connected' || KNOWN.has(type));
|
||||||
|
|
||||||
|
const KNOWN = new Set([
|
||||||
|
'session.next.text.delta',
|
||||||
|
'session.next.text.ended',
|
||||||
|
'session.next.text.started',
|
||||||
|
'session.next.tool.called',
|
||||||
|
'session.next.tool.success',
|
||||||
|
'session.next.tool.failed',
|
||||||
|
'session.next.tool.input.started',
|
||||||
|
'session.next.tool.input.delta',
|
||||||
|
'session.next.tool.input.ended',
|
||||||
|
'session.next.tool.progress',
|
||||||
|
'session.next.step.started',
|
||||||
|
'session.next.step.ended',
|
||||||
|
'session.next.step.failed',
|
||||||
|
'session.next.prompt.admitted',
|
||||||
|
'session.next.prompted',
|
||||||
|
'session.next.model.switched',
|
||||||
|
'session.next.agent.switched',
|
||||||
|
]);
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
import type { ChatEvent, MessageCost } from '../../api/chat/types';
|
||||||
|
import type { OpenCodeRunParams } from '../protocol';
|
||||||
|
import { mapServeEvent } from './serve-events';
|
||||||
|
|
||||||
|
// How an OpenCode turn runs. The only way, since 2026-08-10.
|
||||||
|
//
|
||||||
|
// It used to be `opencode run --format json`, a subprocess per turn with `stdin: 'ignore'`. Everything
|
||||||
|
// that path could not do followed from that one closed pipe: no token streaming, no mid-turn injection,
|
||||||
|
// no queue, and a stop that could only kill the session rather than interrupt it. The serve offers all
|
||||||
|
// four as primitives, each verified end to end before the subprocess was deleted
|
||||||
|
// (docs/opencode-fork-decision.md, docs/opencode-serve-migration-plan.md).
|
||||||
|
//
|
||||||
|
// There is no fallback engine any more. If this path breaks, the recovery is git, not a config flag —
|
||||||
|
// a deliberate choice made while nothing depended on OpenCode.
|
||||||
|
//
|
||||||
|
// ── One global stream, demultiplexed ──
|
||||||
|
//
|
||||||
|
// The serve publishes each turn on two streams (docs/opencode-serve-migration-plan.md). This reads the
|
||||||
|
// LIVE one, `GET /api/event`, because it is a strict superset of the durable stream's content — same
|
||||||
|
// `tool.called`, `tool.success`, `step.ended`, `text.ended`, PLUS the deltas — and deltas are the point.
|
||||||
|
//
|
||||||
|
// It is GLOBAL: one socket carries every session on the box, so everything here filters on `sessionID`.
|
||||||
|
// Forgetting that would splice one conversation into another. There is exactly one subscription for the
|
||||||
|
// process, opened on the first turn and shared, because opening one per turn would multiply the same
|
||||||
|
// firehose by the number of turns.
|
||||||
|
//
|
||||||
|
// What this loses versus the durable stream is `durable.seq`, i.e. replay-after-the-fact. That matters
|
||||||
|
// for surviving an officer restart mid-turn and is deliberately NOT in Phase B: the sidecar commits
|
||||||
|
// every event to `chat_session_events` as it arrives (unchanged from the subprocess path), which is the
|
||||||
|
// same durability guarantee the subprocess had. Reading the durable stream to recover a turn this
|
||||||
|
// process never saw is its own change.
|
||||||
|
|
||||||
|
type ServeConfig = {
|
||||||
|
/** The serve's base URL, e.g. http://127.0.0.1:53100 */
|
||||||
|
baseUrl: string;
|
||||||
|
/** Used when a turn names no cwd — same fallback the subprocess runner applies. */
|
||||||
|
fallbackCwd: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a turn reports to the sidecar it runs in.
|
||||||
|
*
|
||||||
|
* Lived in `runner.ts` until the subprocess path was deleted (Phase D). `opencode:event` is not a wire
|
||||||
|
* event: the sidecar translates each one into a TurnMessage and commits it to `chat_session_events`
|
||||||
|
* before officer sees anything, so the durable record does not depend on officer being up.
|
||||||
|
* `opencode:session` is the routing fact — which `ses_…` to resume — and goes over the wire live.
|
||||||
|
*/
|
||||||
|
export type RunnerMessage =
|
||||||
|
| { type: 'opencode:event'; sessionKey: string; event: ChatEvent }
|
||||||
|
| { type: 'opencode:session'; sessionKey: string; sessionId: string };
|
||||||
|
|
||||||
|
type Emit = (msg: RunnerMessage) => void;
|
||||||
|
|
||||||
|
type ServeTurn = {
|
||||||
|
sessionKey: string;
|
||||||
|
openCodeSessionId: string;
|
||||||
|
cost: MessageCost;
|
||||||
|
done: boolean;
|
||||||
|
emit: Emit;
|
||||||
|
finish: (event: ChatEvent) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Live turns, by OpenCode session id — the id the event stream speaks. */
|
||||||
|
const byOpenCodeId = new Map<string, ServeTurn>();
|
||||||
|
/** The same turns by officer's key, which is what `kill` and the Live panel use. */
|
||||||
|
const bySessionKey = new Map<string, ServeTurn>();
|
||||||
|
|
||||||
|
// ── The one shared subscription ──
|
||||||
|
|
||||||
|
let streamStarted = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read `/api/event` forever, routing each event to the turn that owns it.
|
||||||
|
*
|
||||||
|
* Reconnects on drop with a fixed delay. A serve restart, a network blip or the stream simply ending
|
||||||
|
* must not permanently deafen the sidecar — every subsequent turn would hang with no output, which is
|
||||||
|
* the worst failure this path has, because it looks exactly like a slow model.
|
||||||
|
*/
|
||||||
|
function ensureEventStream(config: ServeConfig): void {
|
||||||
|
if (streamStarted) return;
|
||||||
|
streamStarted = true;
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${config.baseUrl}/api/event`, { headers: { accept: 'text/event-stream' } });
|
||||||
|
if (!res.ok || !res.body) throw new Error(`event stream → ${res.status}`);
|
||||||
|
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() ?? '';
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.startsWith('data:')) continue;
|
||||||
|
try {
|
||||||
|
handleServeEvent(JSON.parse(line.slice(5).trim()));
|
||||||
|
} catch {
|
||||||
|
/* a frame we could not parse is not worth killing the stream over */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[opencode] event stream dropped, reconnecting:', err instanceof Error ? err.message : err);
|
||||||
|
}
|
||||||
|
await Bun.sleep(1_000);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleServeEvent(raw: unknown): void {
|
||||||
|
const mapped = mapServeEvent(raw as never);
|
||||||
|
if (!mapped?.sessionId) return;
|
||||||
|
|
||||||
|
const turn = byOpenCodeId.get(mapped.sessionId);
|
||||||
|
if (!turn || turn.done) return; // another session's, or one we have already finished
|
||||||
|
|
||||||
|
turn.cost = {
|
||||||
|
inputTokens: turn.cost.inputTokens + mapped.costDelta.inputTokens,
|
||||||
|
outputTokens: turn.cost.outputTokens + mapped.costDelta.outputTokens,
|
||||||
|
totalUSD: turn.cost.totalUSD + mapped.costDelta.totalUSD,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const event of mapped.events) {
|
||||||
|
// An error from the harness ends the turn: nothing follows a failed step, and leaving the turn open
|
||||||
|
// would strand the UI on a spinner.
|
||||||
|
if (event.type === 'error') {
|
||||||
|
turn.finish(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
turn.emit({ type: 'opencode:event', sessionKey: turn.sessionKey, event });
|
||||||
|
}
|
||||||
|
|
||||||
|
// `tool-calls` means the model paused to run a tool and will continue. Anything else is the end.
|
||||||
|
if (mapped.stepFinish && mapped.stepFinish !== 'tool-calls') {
|
||||||
|
turn.finish({ type: 'result', cost: turn.cost });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HTTP helpers ──
|
||||||
|
|
||||||
|
async function serveJson<T>(config: ServeConfig, path: string, init: RequestInit & { cwd: string }): Promise<T | null> {
|
||||||
|
const { cwd, ...rest } = init;
|
||||||
|
const res = await fetch(`${config.baseUrl}${path}`, {
|
||||||
|
...rest,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
// The location is per REQUEST on this surface, not a property of the session. A call without it
|
||||||
|
// runs against the serve's own directory, which is not where the user's files are.
|
||||||
|
'x-opencode-directory': cwd,
|
||||||
|
...(rest.headers ?? {}),
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`${path} → ${res.status}`);
|
||||||
|
const text = await res.text();
|
||||||
|
if (!text) return null;
|
||||||
|
// This surface wraps everything in `{data: …}`; the legacy one does not. Reading `.id` off the
|
||||||
|
// envelope silently yields undefined, which is how an entire afternoon disappeared once.
|
||||||
|
const body = JSON.parse(text) as { data?: T } | T;
|
||||||
|
return (body as { data?: T }).data ?? (body as T);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The turn ──
|
||||||
|
|
||||||
|
export async function runOpenCodeTurnOnServe(
|
||||||
|
params: OpenCodeRunParams,
|
||||||
|
config: ServeConfig,
|
||||||
|
emit: Emit,
|
||||||
|
): Promise<void> {
|
||||||
|
const { sessionKey } = params;
|
||||||
|
const cwd = params.cwd || config.fallbackCwd;
|
||||||
|
|
||||||
|
ensureEventStream(config);
|
||||||
|
|
||||||
|
// ── A message arriving while a turn is running is an INJECTION, not a new turn ──
|
||||||
|
//
|
||||||
|
// This is where the subprocess and the serve genuinely part company. `opencode run` had no input
|
||||||
|
// channel, so a second message could only supersede: kill the process, start again, lose the turn.
|
||||||
|
// The serve takes another prompt into the RUNNING turn, so the right move is to hand it over and keep
|
||||||
|
// the existing turn exactly as it is.
|
||||||
|
//
|
||||||
|
// Keeping the same turn object is the load-bearing part. Retiring it and registering a replacement —
|
||||||
|
// which is what this did at first — stops officer routing the events the serve is still producing,
|
||||||
|
// while the serve carries on regardless. The output goes nowhere and the turn looks hung.
|
||||||
|
//
|
||||||
|
// `steer` because the user typed it during the turn and means it now; officer's composer already
|
||||||
|
// treats a send-while-generating as "add this to what you are doing". A prompt sent when nothing is
|
||||||
|
// running takes `queue`, which is a no-op with an empty queue but never accidentally merges two
|
||||||
|
// messages into one turn.
|
||||||
|
const live = bySessionKey.get(sessionKey);
|
||||||
|
if (live && !live.done) {
|
||||||
|
try {
|
||||||
|
await serveJson(config, `/api/session/${live.openCodeSessionId}/prompt`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt: { text: params.prompt, files: promptFiles(params.images) },
|
||||||
|
delivery: 'steer',
|
||||||
|
}),
|
||||||
|
cwd,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// The turn itself is unharmed — only the injection failed — so say so and leave it running.
|
||||||
|
emit({
|
||||||
|
type: 'opencode:event',
|
||||||
|
sessionKey,
|
||||||
|
event: { type: 'error', message: `OpenCode would not take that mid-turn: ${errText(err)}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let openCodeSessionId = params.resumeSessionId ?? '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!openCodeSessionId) {
|
||||||
|
const created = await serveJson<{ id: string }>(config, '/api/session', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ location: { directory: cwd } }),
|
||||||
|
cwd,
|
||||||
|
});
|
||||||
|
if (!created?.id) throw new Error('session create returned no id');
|
||||||
|
openCodeSessionId = created.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.model) {
|
||||||
|
// `providerID/modelID`, the same string the subprocess passes to `--model`.
|
||||||
|
const slash = params.model.indexOf('/');
|
||||||
|
const providerID = slash > 0 ? params.model.slice(0, slash) : 'opencode';
|
||||||
|
const id = slash > 0 ? params.model.slice(slash + 1) : params.model;
|
||||||
|
await serveJson(config, `/api/session/${openCodeSessionId}/model`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ model: { providerID, id } }),
|
||||||
|
cwd,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
emit({
|
||||||
|
type: 'opencode:event',
|
||||||
|
sessionKey,
|
||||||
|
event: { type: 'error', message: `Could not start an OpenCode session: ${errText(err)}` },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const turn: ServeTurn = {
|
||||||
|
sessionKey,
|
||||||
|
openCodeSessionId,
|
||||||
|
cost: { inputTokens: 0, outputTokens: 0, totalUSD: 0 },
|
||||||
|
done: false,
|
||||||
|
emit,
|
||||||
|
finish: (event) => retire(turn, event),
|
||||||
|
};
|
||||||
|
byOpenCodeId.set(openCodeSessionId, turn);
|
||||||
|
bySessionKey.set(sessionKey, turn);
|
||||||
|
|
||||||
|
// Officer learns which `ses_…` to resume next time — a routing fact, not transcript.
|
||||||
|
emit({ type: 'opencode:session', sessionKey, sessionId: openCodeSessionId });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await serveJson(config, `/api/session/${openCodeSessionId}/prompt`, {
|
||||||
|
method: 'POST',
|
||||||
|
// `delivery` is stated explicitly because it DEFAULTS to `"steer"`, which injects into a running
|
||||||
|
// turn. For an ordinary send that is the wrong default — two quick messages would merge into one
|
||||||
|
// turn instead of running in order. `steer` is Phase C's job, wired to the button that means it.
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt: { text: params.prompt, files: promptFiles(params.images) },
|
||||||
|
delivery: 'queue',
|
||||||
|
}),
|
||||||
|
cwd,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
retire(turn, { type: 'error', message: `OpenCode refused the prompt: ${errText(err)}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function retire(turn: ServeTurn, event: ChatEvent | null): void {
|
||||||
|
if (turn.done) return;
|
||||||
|
turn.done = true;
|
||||||
|
if (byOpenCodeId.get(turn.openCodeSessionId) === turn) byOpenCodeId.delete(turn.openCodeSessionId);
|
||||||
|
if (bySessionKey.get(turn.sessionKey) === turn) bySessionKey.delete(turn.sessionKey);
|
||||||
|
if (event) turn.emit({ type: 'opencode:event', sessionKey: turn.sessionKey, event });
|
||||||
|
}
|
||||||
|
|
||||||
|
const errText = (err: unknown): string => (err instanceof Error ? err.message : String(err));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Images as `prompt.files`, which this surface takes by URI.
|
||||||
|
*
|
||||||
|
* **`data:` URIs, not `file://`.** Measured, because the choice is not obvious and the wrong one fails
|
||||||
|
* at the provider rather than at the API: a `file://` attachment is accepted with a 200 and then dies
|
||||||
|
* inside the turn with `Anthropic Messages media must contain valid base64`. A `data:` URI round-trips
|
||||||
|
* and the model describes the image.
|
||||||
|
*
|
||||||
|
* This is strictly better than the subprocess path, which has to spill each image to a temp file for
|
||||||
|
* `--file` and delete it afterwards. Here the bytes go in the request and there is nothing to clean up.
|
||||||
|
*
|
||||||
|
* Silently dropping these is exactly defect B4 — the user sees their image in their own bubble and the
|
||||||
|
* model never receives it — so this exists before the serve path is switched on for anyone, not after.
|
||||||
|
*/
|
||||||
|
function promptFiles(images: OpenCodeRunParams['images']): { uri: string; name: string }[] | undefined {
|
||||||
|
if (!images?.length) return undefined;
|
||||||
|
return images.map((image, index) => ({
|
||||||
|
uri: `data:${image.mediaType || 'image/png'};base64,${image.data}`,
|
||||||
|
name: `attachment-${index + 1}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The turns running right now, for `opencode:list` and the Live panel. */
|
||||||
|
export function listRunningServeTurns(): { sessionKey: string }[] {
|
||||||
|
return [...bySessionKey.keys()].map((sessionKey) => ({ sessionKey }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop a turn without destroying its session — the thing the subprocess path cannot do.
|
||||||
|
*
|
||||||
|
* `POST /interrupt` leaves the conversation intact and resumable, where killing a subprocess ended it.
|
||||||
|
*/
|
||||||
|
export async function killServeTurn(sessionKey: string, config: ServeConfig): Promise<void> {
|
||||||
|
const turn = bySessionKey.get(sessionKey);
|
||||||
|
if (!turn) return;
|
||||||
|
try {
|
||||||
|
await fetch(`${config.baseUrl}/api/session/${turn.openCodeSessionId}/interrupt`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'x-opencode-directory': config.fallbackCwd },
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* interrupt is best-effort; the turn is retired either way so the UI is never stuck */
|
||||||
|
}
|
||||||
|
retire(turn, { type: 'stopped' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retire every live turn, for shutdown.
|
||||||
|
*
|
||||||
|
* Note what this does NOT do, and why it is right: the turns keep running inside the serve, which is a
|
||||||
|
* separate process and survives us. Officer stops routing them and says so in the transcript. When the
|
||||||
|
* subprocess ran turns, shutdown had to kill children or they were orphaned; here the work is somebody
|
||||||
|
* else's and killing it would be the wrong call.
|
||||||
|
*/
|
||||||
|
export function stopAllServeTurns(message: string): number {
|
||||||
|
const turns = [...bySessionKey.values()];
|
||||||
|
for (const turn of turns) retire(turn, { type: 'error', message });
|
||||||
|
return turns.length;
|
||||||
|
}
|
||||||
@@ -26,8 +26,8 @@ export type LiveClaudeSession = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An OpenCode turn in flight. Only ever the generating ones — see `listRunningOpenCodeTurns` for why
|
* An OpenCode turn in flight. Only ever the generating ones, which is why it carries neither
|
||||||
* this carries neither `isGenerating` (it is always true) nor `pendingTasks` (no such concept).
|
* `isGenerating` (always true) nor `pendingTasks` (no such concept on this harness).
|
||||||
*
|
*
|
||||||
* It deliberately carries no id for OpenCode's own `ses_…` session, and does not need to: the sidecar
|
* It deliberately carries no id for OpenCode's own `ses_…` session, and does not need to: the sidecar
|
||||||
* only ever knows its own `sessionKey`, while the `ses_…` is reported separately over `opencode:session`
|
* only ever knows its own `sessionKey`, while the `ses_…` is reported separately over `opencode:session`
|
||||||
@@ -63,12 +63,13 @@ export type SidecarCommand =
|
|||||||
// records are in memory and die with `pm2 restart officer`, while the agent keeps running. Without it
|
// records are in memory and die with `pm2 restart officer`, while the agent keeps running. Without it
|
||||||
// a live session is invisible until a browser happens to reconnect to it by id.
|
// a live session is invisible until a browser happens to reconnect to it by id.
|
||||||
| { type: 'claude:list'; id: string }
|
| { type: 'claude:list'; id: string }
|
||||||
// OpenCode — drive a turn via `opencode run … --format json` (tools re-anchored to cwd via --dir)
|
// OpenCode — drive a turn through the serve (POST /api/session/{id}/prompt), anchored to the chat cwd
|
||||||
|
// by a per-request location header. Was an `opencode run` subprocess until 2026-08-10.
|
||||||
| { type: 'opencode:run-streaming'; id: string; params: OpenCodeRunParams }
|
| { type: 'opencode:run-streaming'; id: string; params: OpenCodeRunParams }
|
||||||
| { type: 'opencode:kill'; id: string; sessionKey: string }
|
| { type: 'opencode:kill'; id: string; sessionKey: string }
|
||||||
// Which OpenCode turns are running right now. The counterpart of `claude:list`, and thinner for a
|
// Which OpenCode turns are running right now. The counterpart of `claude:list`, and thinner for a
|
||||||
// reason: OpenCode has no warm session between turns, so there is nothing to report but the running
|
// reason: OpenCode has no warm session between turns, so there is nothing to report but the running
|
||||||
// ones. See `listRunningOpenCodeTurns`.
|
// ones — see `listRunningServeTurns`.
|
||||||
| { type: 'opencode:list'; id: string }
|
| { type: 'opencode:list'; id: string }
|
||||||
// VNC
|
// VNC
|
||||||
| { type: 'vnc:start'; id: string; params: VncStartParams }
|
| { type: 'vnc:start'; id: string; params: VncStartParams }
|
||||||
@@ -197,6 +198,14 @@ export type OpenCodeRunParams = {
|
|||||||
cwd?: string; // passed to `opencode run --dir` — hard-re-anchors tools to this directory
|
cwd?: string; // passed to `opencode run --dir` — hard-re-anchors tools to this directory
|
||||||
model?: string; // `providerID/modelID` (e.g. opencode/claude-haiku-4-5); passed to --model verbatim
|
model?: string; // `providerID/modelID` (e.g. opencode/claude-haiku-4-5); passed to --model verbatim
|
||||||
resumeSessionId?: string; // OpenCode `ses_…` id to continue (`--session`)
|
resumeSessionId?: string; // OpenCode `ses_…` id to continue (`--session`)
|
||||||
|
/**
|
||||||
|
* Images for this turn, base64 as the browser sent them.
|
||||||
|
*
|
||||||
|
* `opencode run` takes attachments as PATHS (`-f`), not inline data, so the sidecar writes each one to
|
||||||
|
* a temp file and deletes it when the turn ends. They travel as data because that is what crosses the
|
||||||
|
* websocket from the browser, and because officer and the sidecar are not guaranteed to share a disk.
|
||||||
|
*/
|
||||||
|
images?: PromptImage[];
|
||||||
durable?: boolean; // commit turn output to chat_session_events (default true) — see ClaudeSpawnStreamingParams
|
durable?: boolean; // commit turn output to chat_session_events (default true) — see ClaudeSpawnStreamingParams
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -15,14 +15,39 @@ export const useChatWebSocket = ({ url, onMessage, onOpen }: UseChatWebSocketPar
|
|||||||
const retryRef = useRef(0);
|
const retryRef = useRef(0);
|
||||||
const retryTimeoutRef = useRef<number | null>(null);
|
const retryTimeoutRef = useRef<number | null>(null);
|
||||||
const isCleaningUpRef = useRef(false);
|
const isCleaningUpRef = useRef(false);
|
||||||
|
/** A deferred teardown, cancelled when the effect re-runs — see the cleanup below. */
|
||||||
|
const closeTimerRef = useRef<number | null>(null);
|
||||||
const onMessageRef = useRef(onMessage);
|
const onMessageRef = useRef(onMessage);
|
||||||
onMessageRef.current = onMessage;
|
onMessageRef.current = onMessage;
|
||||||
const onOpenRef = useRef(onOpen);
|
const onOpenRef = useRef(onOpen);
|
||||||
onOpenRef.current = onOpen;
|
onOpenRef.current = onOpen;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Messages typed before the socket was ready.
|
||||||
|
*
|
||||||
|
* `send` used to drop them: `readyState !== OPEN` returned, silently, with no error and no retry — so
|
||||||
|
* pressing enter did nothing and the turn never happened. That window is not rare. React's dev
|
||||||
|
* StrictMode double-invokes effects, so every socket is created, closed and recreated on mount, and a
|
||||||
|
* reconnect after a drop reopens it again; with several chat panes on screen there are several sockets
|
||||||
|
* doing this at once. One of them is always briefly not OPEN.
|
||||||
|
*
|
||||||
|
* Queued and flushed on open, in order. The mobile chat app does exactly this and for exactly this
|
||||||
|
* reason — the composer is allowed to fire before the transport is ready.
|
||||||
|
*/
|
||||||
|
const pendingRef = useRef<string[]>([]);
|
||||||
|
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
if (isCleaningUpRef.current) return;
|
if (isCleaningUpRef.current) return;
|
||||||
if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) return;
|
// CONNECTING counts as ours, not just OPEN. The deferred teardown below keeps a remount's socket
|
||||||
|
// alive mid-handshake, and this is what reclaims it: checking only OPEN meant an effect re-run
|
||||||
|
// built a SECOND socket and orphaned the first, which then stayed open forever with its own `open`
|
||||||
|
// handler bailing on the socketRef mismatch.
|
||||||
|
//
|
||||||
|
// This is per-instance and does NOT explain the two sockets a /chat/new load opens — measured with
|
||||||
|
// a WebSocket-constructor counter, those come from two separate `useChat` instances mounting, each
|
||||||
|
// with its own refs. Unresolved, and tracked separately; both connect, so it reads as healthy.
|
||||||
|
const existing = socketRef.current;
|
||||||
|
if (existing && (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING)) return;
|
||||||
|
|
||||||
const socket = new WebSocket(url);
|
const socket = new WebSocket(url);
|
||||||
socketRef.current = socket;
|
socketRef.current = socket;
|
||||||
@@ -31,7 +56,12 @@ export const useChatWebSocket = ({ url, onMessage, onOpen }: UseChatWebSocketPar
|
|||||||
if (socketRef.current !== socket) return;
|
if (socketRef.current !== socket) return;
|
||||||
setIsConnected(true);
|
setIsConnected(true);
|
||||||
retryRef.current = 0;
|
retryRef.current = 0;
|
||||||
|
// BEFORE onOpen, deliberately: onOpen sends the resume/attach handshake, and anything the user
|
||||||
|
// typed while connecting belongs after that, not in front of it.
|
||||||
|
const queued = pendingRef.current;
|
||||||
|
pendingRef.current = [];
|
||||||
onOpenRef.current?.();
|
onOpenRef.current?.();
|
||||||
|
for (const message of queued) socket.send(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.addEventListener('message', (ev) => {
|
socket.addEventListener('message', (ev) => {
|
||||||
@@ -59,25 +89,56 @@ export const useChatWebSocket = ({ url, onMessage, onOpen }: UseChatWebSocketPar
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// A pending teardown from a remount that is about to be undone — see below.
|
||||||
|
if (closeTimerRef.current !== null) {
|
||||||
|
clearTimeout(closeTimerRef.current);
|
||||||
|
closeTimerRef.current = null;
|
||||||
|
}
|
||||||
isCleaningUpRef.current = false;
|
isCleaningUpRef.current = false;
|
||||||
connect();
|
connect();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
/**
|
||||||
|
* Close LATER, not now.
|
||||||
|
*
|
||||||
|
* Closing here directly is correct for a real unmount and disastrous for a remount, and this hook
|
||||||
|
* cannot tell them apart at the moment it runs. React's dev StrictMode double-invokes every effect
|
||||||
|
* (mount → unmount → mount), and a subtree that is re-created — a resolved transcript, a parent key
|
||||||
|
* change — does the same. Each time, the socket was closed while still CONNECTING, the browser
|
||||||
|
* logged "closed before the connection is established", and the replacement was closed in turn, so
|
||||||
|
* the view could churn forever and never hold a connection while sitting on Disconnected.
|
||||||
|
*
|
||||||
|
* Deferring by a tick makes the two distinguishable. A remount re-runs the effect immediately and
|
||||||
|
* cancels this timer, so the live socket is kept and the handshake completes. A real unmount has
|
||||||
|
* nobody to cancel it and the socket closes a frame later, which costs nothing.
|
||||||
|
*/
|
||||||
isCleaningUpRef.current = true;
|
isCleaningUpRef.current = true;
|
||||||
if (retryTimeoutRef.current !== null) {
|
if (retryTimeoutRef.current !== null) {
|
||||||
clearTimeout(retryTimeoutRef.current);
|
clearTimeout(retryTimeoutRef.current);
|
||||||
retryTimeoutRef.current = null;
|
retryTimeoutRef.current = null;
|
||||||
}
|
}
|
||||||
if (socketRef.current) {
|
const socket = socketRef.current;
|
||||||
socketRef.current.close();
|
closeTimerRef.current = window.setTimeout(() => {
|
||||||
socketRef.current = null;
|
closeTimerRef.current = null;
|
||||||
}
|
if (!isCleaningUpRef.current) return; // remounted: the effect above already reclaimed it
|
||||||
|
if (socket) socket.close();
|
||||||
|
if (socketRef.current === socket) socketRef.current = null;
|
||||||
|
}, 0);
|
||||||
};
|
};
|
||||||
}, [url]);
|
}, [url]);
|
||||||
|
|
||||||
const send = (data: Record<string, unknown>) => {
|
const send = (data: Record<string, unknown>) => {
|
||||||
const socket = socketRef.current;
|
const socket = socketRef.current;
|
||||||
if (!socket || socket.readyState !== WebSocket.OPEN) return;
|
const message = JSON.stringify(data);
|
||||||
socket.send(JSON.stringify(data));
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||||
|
socket.send(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Not open yet, or reconnecting. Hold it rather than dropping it — see `pendingRef`. Bounded so a
|
||||||
|
// socket that never comes back cannot grow this without limit; the oldest go first, because the
|
||||||
|
// newest message is the one the user is still waiting on.
|
||||||
|
pendingRef.current.push(message);
|
||||||
|
if (pendingRef.current.length > 50) pendingRef.current.shift();
|
||||||
};
|
};
|
||||||
|
|
||||||
return { isConnected, send };
|
return { isConnected, send };
|
||||||
|
|||||||
@@ -10,6 +10,24 @@ import type { SelectedSession } from './ChatDetailPanel';
|
|||||||
import { chatListPath, chatNewPath, chatSessionPath, cwdFromSplat } from './chat-routes';
|
import { chatListPath, chatNewPath, chatSessionPath, cwdFromSplat } from './chat-routes';
|
||||||
import { PwdSelector } from './PwdSelector';
|
import { PwdSelector } from './PwdSelector';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which agent owns a conversation, said on EVERY row.
|
||||||
|
*
|
||||||
|
* Only OpenCode rows carried a badge before, which marks one harness and leaves the other inferred from
|
||||||
|
* its absence — fine when you already know the list mixes two, useless when you don't. The list is
|
||||||
|
* merged from two stores (Claude transcripts, OpenCode's session db) and the harness decides where a
|
||||||
|
* reply is dispatched, so it is a property of the row, not a footnote on the odd one out.
|
||||||
|
*
|
||||||
|
* `harness` is absent on older Claude rows, so anything that is not OpenCode reads as Claude — the same
|
||||||
|
* default the server applies.
|
||||||
|
*/
|
||||||
|
const HarnessBadge = ({ harness }: { harness?: 'claude' | 'opencode' }) =>
|
||||||
|
harness === 'opencode' ? (
|
||||||
|
<span className="rounded bg-info/10 px-1.5 py-0.5 font-medium text-info">OpenCode</span>
|
||||||
|
) : (
|
||||||
|
<span className="rounded bg-primary/10 px-1.5 py-0.5 font-medium text-primary">Claude</span>
|
||||||
|
);
|
||||||
|
|
||||||
// Reads the /chat conversation list from Claude's own transcript store (source of truth).
|
// Reads the /chat conversation list from Claude's own transcript store (source of truth).
|
||||||
// Clicking a session loads its transcript and continues the real Claude session via --resume.
|
// Clicking a session loads its transcript and continues the real Claude session via --resume.
|
||||||
export const SessionList = () => {
|
export const SessionList = () => {
|
||||||
@@ -194,13 +212,14 @@ export const SessionList = () => {
|
|||||||
className="min-w-0 flex-1"
|
className="min-w-0 flex-1"
|
||||||
meta={[
|
meta={[
|
||||||
<RelativeTime key="t" value={session.updatedAt} />,
|
<RelativeTime key="t" value={session.updatedAt} />,
|
||||||
session.harness === 'opencode' ? (
|
<HarnessBadge key="h" harness={session.harness} />,
|
||||||
<span key="h" className="rounded bg-info/10 px-1.5 py-0.5 font-medium text-info">
|
// Claude's transcripts give a real turn count. OpenCode's session list has no
|
||||||
OpenCode
|
// count field, so the number on those rows is a hardcoded 0 and costs an HTTP
|
||||||
</span>
|
// call per row to make real — the badge stands in its place rather than beside a
|
||||||
) : (
|
// zero that means "not asked".
|
||||||
`${session.messageCount} msg${session.messageCount === 1 ? '' : 's'}`
|
session.harness === 'opencode'
|
||||||
),
|
? null
|
||||||
|
: `${session.messageCount} msg${session.messageCount === 1 ? '' : 's'}`,
|
||||||
// One row, several transcripts: this conversation was `/clear`ed and carried on,
|
// One row, several transcripts: this conversation was `/clear`ed and carried on,
|
||||||
// and the server has spliced the parts back together. Worth saying out loud —
|
// and the server has spliced the parts back together. Worth saying out loud —
|
||||||
// the message count above covers all of them, and opening the row shows all of
|
// the message count above covers all of them, and opening the row shows all of
|
||||||
|
|||||||
Reference in New Issue
Block a user