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:
@@ -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[]> {
|
||||
|
||||
Reference in New Issue
Block a user