delete the dead serve-turn client, and answer phase 2's blocking question first

Phase 1 item 7, done in the order the parity doc asks: read the dead design into a note, then delete it.
`docs/opencode-serve-path.md` records what `event-mapper.ts` and the SSE half of `client.ts` did, and
what a rebuild would want back from them — the delta model and tool-state transitions, which are exactly
parity Phase 3's token streaming rather than new work.

Deleted: `event-mapper.ts` entirely, and `subscribe`/the shared `GET /event` SSE loop, `createSession`,
`postMessage`, `abort` from the client, plus `isServerHealthy` from server-manager. All had no callers.
`client.ts` goes 200-odd lines to 99. What stays is the REST reads the chat list and transcript use:
listSessions, getSession, getMessages, deleteSession, renameSession.

While in there, Phase 2's blocking question turned out to be cheap to settle, so it is answered rather
than left open. The review asked whether the serve can take a per-request directory, since without one a
serve-based turn path would reintroduce the single-directory coupling that shelved this work:

  POST /session?directory=/tmp/oc-phase2-probe  ->  directory: "/tmp/oc-phase2-probe"   honoured
  POST /session  with directory in the BODY     ->  directory: "<serve cwd>"            ignored

It is a query parameter on every /session* route. So the coupling is gone on both architectures and the
blocker is cleared. The note does NOT start the migration: which of the three options to take is a
product call, and it lays them out rather than presuming one.

The first probe put `directory` in the body and appeared to prove the opposite. Recorded in the note,
because it is the obvious way to test this and it gives a confident wrong answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 02:55:18 +00:00
co-authored by Claude Opus 5
parent cfbf58cdba
commit d7b223127a
4 changed files with 132 additions and 292 deletions
+120
View File
@@ -0,0 +1,120 @@
# The serve-based turn path: what it was, and whether to rebuild it
Written 2026-08-10, before deleting `event-mapper.ts` and the unused half of
`api/chat/opencode/client.ts`. `docs/opencode-parity.md` item 7 asks for exactly this: read the dead
design into a note first, because Phase 2 may rebuild it.
It also answers Phase 2's blocking question, which turned out to be cheap to settle.
---
## The question Phase 2 was waiting on
`docs/opencode-phase0-review.md` narrowed the fork to one thing:
> Moving turns onto the serve would reintroduce exactly the single-directory coupling that caused the
> original pain, **unless the serve's API can take a per-request directory**. That question — not "why
> was it replaced" — is now the one to answer first.
**It can. Tested against the running serve (opencode 1.17.9 on this machine):**
```
POST /session?directory=/tmp/oc-phase2-probe → { directory: "/tmp/oc-phase2-probe" } ✅ matches
POST /session body { directory: … } → { directory: "<serve cwd>" } ❌ ignored
```
`directory` is a **query parameter on every `/session*` route** — create, message, abort, fork,
summarize, prompt_async, shell, all of them — alongside a `workspace` param. It is not a body field,
which is why a first probe that sent it in the body appeared to disprove the whole idea.
**So the coupling is gone on both paths.** `opencode run --dir` anchors a subprocess turn (verified in
the phase-0 review), and `?directory=` anchors a serve turn. The single-server constraint that shelved
this work does not exist in either architecture any more.
That removes the reason not to move. It does not by itself decide the move — see the trade below.
---
## What the dead code actually was
Two files, ~200 of ~390 platform-side lines, all reachable from nothing:
**`event-mapper.ts` (146 lines)** — maps OpenCode's SSE event stream to officer's `ChatEvent`s. Handles
`message.part.updated` (text deltas, tool state transitions), `message.updated`, `session.idle` and
`session.error`. Its shape assumes a _streaming_ source: partial text arriving as deltas, tool calls
transitioning pending → running → completed as separate events.
**The SSE half of `client.ts`**`subscribe(sessionId, listener)`, one shared `GET /event` stream per
base URL demultiplexed to per-session listeners, with reconnect. Plus `createSession`, `postMessage`,
`abort`, `isServerHealthy`.
The live half of `client.ts` stays: `listSessions`, `getSession`, `getMessages`, `deleteSession`,
`renameSession`, all used by `opencode-sessions.ts` for the chat list and transcript reads.
### Why this matters for a rebuild
The dead mapper is **not** a sketch to be dusted off — it is a finished, working shape for a design that
was measured against a real event stream. Two things in it are worth keeping if the serve path returns:
1. **The delta model.** `runner.ts` emits whole `text` blocks because `opencode run --format json` emits
whole blocks; the mapper emits deltas because SSE emits deltas. Token streaming (parity Phase 3, item 11) is not new work on the serve path — it is this file.
2. **Tool-state transitions.** The mapper tracks a tool call across pending/running/completed. The
subprocess path only ever sees the finished call.
Both are recoverable from git after deletion (`5d077a4` is the last commit where the serve path was
live), which is the argument for deleting rather than keeping it compiled-but-unreachable: an unused
file rots silently against a moving API, and this one is already pinned to a version two minor releases
behind what some machines run.
---
## The trade, now that the blocker is gone
**Keep the subprocess (`opencode run --dir`)**
- No rewrite; it works today.
- Permanently forfeits: token streaming, mid-turn injection, background tasks, live-session enumeration,
reattach-by-id, interrupt-without-teardown. Every one of those is a `stdin: 'ignore'` consequence.
- One process per turn, no warm state to leak or garbage-collect.
**Move turns onto the serve (`POST /session/{id}/message?directory=…`)**
- Unblocks the whole of parity Phase 3 at once — those six capabilities are all downstream of a
persistent, addressable session.
- Re-adopts an SSE stream officer must keep alive, demultiplex and reconnect. That machinery already
exists in the deleted code, so the cost is smaller than it looks.
- Introduces warm sessions and therefore a lifetime question OpenCode currently does not have: idle GC,
orphan adoption after a restart, the same problems the Claude path spent months getting right.
- The serve becomes load-bearing rather than a convenience. Today a serve crash costs session listing;
then it would cost every turn in flight.
**A third option, not in the parity doc:** move only what needs the serve. Keep `opencode run` for turns
and add `?directory=`-scoped serve calls for enumeration and reattach. That buys the Live panel and
reattach-by-id without warm sessions or an SSE loop. It does not buy streaming or mid-turn injection,
which are the two most visible gaps.
## Recommendation
**Do not start the migration on this pass.** The blocker is cleared and that is the deliverable here;
choosing between the three is a product call about how much OpenCode should behave like Claude, and it
should be made deliberately rather than as a side effect of a cleanup branch.
If it is taken: the third option first. It is incremental, it is the only one with no lifetime
questions, and it makes the Live panel — which today silently omits every running OpenCode turn — tell
the truth.
---
## Verified facts this note rests on
| Claim | How |
| ---------------------------------------------------------------------------------- | --------------------------------------------------------- |
| `?directory=` on `POST /session` is honoured | Created a session, read `directory` back: matched |
| A body `directory` is ignored | Same call with the field in the body: got the serve's cwd |
| `directory` is on every `/session*` route | Read the serve's own `/doc` (OpenAPI) |
| `event-mapper.ts` has no importers outside `client.ts` | grep |
| `createSession`/`postMessage`/`abort`/`isServerHealthy` have no callers | grep for call sites |
| `listSessions`/`getSession`/`getMessages`/`deleteSession`/`renameSession` are live | all from `opencode-sessions.ts` |
Machine note: this server runs opencode **1.17.9**; the phase-0 review's tests ran against **1.18.11**
elsewhere. The `?directory=` result above is from 1.17.9, so it holds on the older of the two.
+8 -136
View File
@@ -1,143 +1,15 @@
import type { OpenCodeEvent } from './event-mapper';
import { logger } from '../logger';
// One connection per `opencode serve` base URL: a single shared SSE subscription on `GET /event`
// demultiplexed to per-session listeners, plus the REST calls a turn needs. The SDK-style
// `/session/*` route family is used (feature-complete, incl. DELETE).
type Listener = (event: OpenCodeEvent) => void;
// 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).
//
// 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
// path that was replaced by `opencode run --dir` subprocesses and had no callers left. Removed
// 2026-08-10; `docs/opencode-serve-path.md` records what it did and what a rebuild would want back,
// because Phase 2 may want exactly this shape again.
class ServerConnection {
private listeners = new Map<string, Set<Listener>>();
private sseRunning = false;
constructor(private readonly baseUrl: string) {}
subscribe(sessionId: string, listener: Listener): () => void {
let set = this.listeners.get(sessionId);
if (!set) {
set = new Set();
this.listeners.set(sessionId, set);
}
set.add(listener);
void this.ensureSse();
return () => {
const current = this.listeners.get(sessionId);
if (!current) return;
current.delete(listener);
if (current.size === 0) this.listeners.delete(sessionId);
};
}
private dispatch(event: OpenCodeEvent): void {
const sessionId = event.properties?.sessionID;
if (typeof sessionId !== 'string') return;
const set = this.listeners.get(sessionId);
if (!set) return;
for (const listener of set) {
try {
listener(event);
} catch (err) {
logger.error('opencode SSE listener threw', { error: String(err) });
}
}
}
/** Keep a single `/event` SSE stream open while any session has listeners; reconnect on drop. */
private async ensureSse(): Promise<void> {
if (this.sseRunning) return;
this.sseRunning = true;
void (async () => {
while (this.listeners.size > 0) {
try {
const res = await fetch(`${this.baseUrl}/event`, { headers: { accept: 'text/event-stream' } });
if (!res.body) throw new Error('no SSE body');
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// SSE frames are separated by a blank line.
let sep = buffer.indexOf('\n\n');
while (sep !== -1) {
const frame = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
this.handleFrame(frame);
sep = buffer.indexOf('\n\n');
}
}
} catch (err) {
logger.warn('opencode SSE stream error; will reconnect', { baseUrl: this.baseUrl, error: String(err) });
}
if (this.listeners.size > 0) await new Promise((r) => setTimeout(r, 500));
}
this.sseRunning = false;
})();
}
private handleFrame(frame: string): void {
const data = frame
.split('\n')
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).trim())
.join('');
if (!data) return;
try {
this.dispatch(JSON.parse(data) as OpenCodeEvent);
} catch {
/* non-JSON keep-alive or partial frame */
}
}
private async postJson<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${this.baseUrl}${path}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`opencode POST ${path}${res.status} ${await res.text().catch(() => '')}`);
return (await res.json()) as T;
}
async createSession(metadata?: Record<string, unknown>, title?: string): Promise<string> {
// OpenCode has no per-session `directory` (a session inherits the server's cwd). We instead tag
// the session with our own free-form `metadata` (e.g. { officer: { cwd } }) — round-trips on the
// list + detail endpoints and is never touched by opencode core — to know where it belongs.
const body: Record<string, unknown> = {};
if (metadata) body.metadata = metadata;
if (title) body.title = title;
const session = await this.postJson<{ id?: string }>('/session', body);
if (!session.id) throw new Error('opencode POST /session returned no id');
return session.id;
}
async postMessage(
sessionId: string,
providerID: string,
modelID: string,
text: string,
system?: string,
): Promise<void> {
// `system` is appended to OpenCode's built-in system prompt (additive, not an override) and is
// sent to the LLM as a system message — it never appears as a visible chat part.
const body: Record<string, unknown> = {
model: { providerID, modelID },
parts: [{ type: 'text', text }],
};
if (system) body.system = system;
await this.postJson(`/session/${sessionId}/message`, body);
}
async abort(sessionId: string): Promise<void> {
await this.postJson(`/session/${sessionId}/abort`, {}).catch(() => {});
}
// ── Session history (REST; OpenCode's SQLite store is the source of truth) ──
async listSessions(): Promise<OpenCodeSessionInfo[]> {
@@ -1,146 +0,0 @@
import type { ChatEvent, MessageCost } from '../types';
// Translates OpenCode's SSE events into our internal ChatEvent union — the OpenCode analog of
// sidecar/claude/stream-parser.ts. Shapes verified live against opencode 1.17.9's `GET /event`:
//
// message.part.updated { part:{ id, type } } → learn partID→type
// message.part.delta { partID, delta } → delta (only if partID is a text part)
// message.part.updated { part:{ type:'tool', callID, tool, state } } → tool:start / tool:result
// message.updated { info:{ role:'assistant', cost, tokens } } → stash final cost
// session.idle → result (turn complete)
// session.error → error
//
// IMPORTANT: deltas always carry `field:'text'` even for the model's *reasoning* — reasoning and answer
// are only distinguishable by the delta's part TYPE (a message.part.updated declaring the part as
// `reasoning` vs `text` always precedes that part's deltas). So we gate deltas on partID being a `text`
// part; reasoning-part deltas are dropped (parity with the Claude harness, which hides thinking).
// The turn translator (`sidecar/claude/turn-stream.ts`, shared by both harnesses) flushes the assistant
// text buffer on tool:start and result, so no explicit `text` event is needed — the deltas are enough.
export type OpenCodeEvent = {
id?: string;
type: string;
properties?: Record<string, unknown>;
};
type ToolState = {
status?: string;
input?: Record<string, unknown>;
output?: string;
error?: string;
};
type OpenCodePart = {
id?: string;
type?: string;
callID?: string;
tool?: string;
field?: string;
state?: ToolState;
};
type AssistantInfo = {
role?: string;
cost?: number;
tokens?: { input?: number; output?: number };
};
/**
* Returns a stateful handler that maps raw OpenCode events to ChatEvents, invoking `onEvent` for each.
* State tracks per-callID tool progress (to emit start/result exactly once) and the latest assistant
* cost/tokens (emitted with the terminal `result`).
*/
export function createEventMapper(onEvent: (event: ChatEvent) => void) {
const toolStarted = new Set<string>();
const toolFinished = new Set<string>();
const partTypes = new Map<string, string>(); // partID → type (declared before that part's deltas)
let cost: MessageCost = { inputTokens: 0, outputTokens: 0, totalUSD: 0 };
let done = false;
const finish = (event: ChatEvent) => {
if (done) return;
done = true;
onEvent(event);
};
return function handle(evt: OpenCodeEvent): void {
const p = evt.properties ?? {};
switch (evt.type) {
case 'message.part.delta': {
// Only stream deltas belonging to a `text` part — reasoning parts also emit field:'text' deltas.
const partID = p.partID as string | undefined;
if (partID && partTypes.get(partID) === 'text' && typeof p.delta === 'string' && p.delta.length > 0) {
onEvent({ type: 'delta', text: p.delta });
}
return;
}
case 'message.part.updated': {
const part = p.part as OpenCodePart | undefined;
if (!part) return;
if (part.id && part.type) partTypes.set(part.id, part.type);
if (part.type !== 'tool' || !part.callID) return;
const callID = part.callID;
const status = part.state?.status;
if ((status === 'running' || status === 'completed' || status === 'error') && !toolStarted.has(callID)) {
toolStarted.add(callID);
onEvent({
type: 'tool:start',
toolCallId: callID,
toolName: part.tool ?? 'tool',
toolInput: part.state?.input ?? {},
});
}
if (status === 'completed' && !toolFinished.has(callID)) {
toolFinished.add(callID);
onEvent({
type: 'tool:result',
toolCallId: callID,
output: String(part.state?.output ?? ''),
isError: false,
});
} else if (status === 'error' && !toolFinished.has(callID)) {
toolFinished.add(callID);
onEvent({
type: 'tool:result',
toolCallId: callID,
output: String(part.state?.error ?? 'Tool failed'),
isError: true,
});
}
return;
}
case 'message.updated': {
const info = (p.info as AssistantInfo | undefined) ?? (p as AssistantInfo) ?? {};
if (info.role === 'assistant' && info.tokens) {
cost = {
inputTokens: info.tokens.input ?? 0,
outputTokens: info.tokens.output ?? 0,
totalUSD: info.cost ?? 0,
};
}
return;
}
case 'session.idle': {
finish({ type: 'result', cost });
return;
}
case 'session.error': {
const error = p.error;
const message =
typeof error === 'string' ? error : ((error as { message?: string })?.message ?? 'OpenCode error');
finish({ type: 'error', message });
return;
}
default:
return;
}
};
}
@@ -4,16 +4,10 @@ import { getOpenCodeServerUrl } from './sidecar-server';
// random port (cwd = DATA_PATH/opencode-sidecar) and reports it to the API (getOpenCodeServerUrl).
// All OpenCode HTTP traffic routes to whatever port the sidecar last reported.
export async function isServerHealthy(): Promise<boolean> {
const baseUrl = getOpenCodeServerUrl();
if (!baseUrl) return false;
try {
const res = await fetch(`${baseUrl}/api/health`, { signal: AbortSignal.timeout(3000) });
return res.ok;
} catch {
return false;
}
}
// `isServerHealthy` lived here and had no callers — removed 2026-08-10 with the rest of the dead
// serve-turn client. It polled `/api/health`, which is a real route and answers `{"healthy":true}`, so
// it worked; it simply had nothing asking. `/global/health` is the richer one (it also reports the
// version) if a health check is ever wanted back.
/** The base URL of the OpenCode server the sidecar is running. Throws if it hasn't reported in yet. */
export async function ensureServer(): Promise<{ baseUrl: string }> {