The email chat's working directory now resolves to DATA_PATH/<owner>/email_accounts/<accountEmail> (created if missing), so the agent operates in the selected account's dir (emails.db, attachment_cache, …). - websocket.ts: new resolveChatCwd — context 'email' → the account dir (via a new resolveEmailCwd), 'chat' → the pwd/claude_sessions dir, else the given cwd. Both the Claude and OpenCode handlers use it. The account defaults to the owner's first enabled account for now; the account selector will pass it as contextId later. - OpenCode honors the cwd again: send-opencode passes it as the session `directory` (client.createSession(directory?)) for context-scoped chats; the general /chat still omits it and uses the fixed server's default project. Verified against the live server that directory-bound sessions create + list. No frontend change — the /email panel already sends context:'email'. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
192 lines
6.4 KiB
TypeScript
192 lines
6.4 KiB
TypeScript
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;
|
|
|
|
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(directory?: string, title?: string): Promise<string> {
|
|
// `directory` binds the session's working dir (e.g. an email account dir). Omit it for the general
|
|
// /chat, so the session lives in the fixed server's own project (its cwd).
|
|
const body: Record<string, unknown> = {};
|
|
if (directory) body.directory = directory;
|
|
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): Promise<void> {
|
|
await this.postJson(`/session/${sessionId}/message`, {
|
|
model: { providerID, modelID },
|
|
parts: [{ type: 'text', text }],
|
|
});
|
|
}
|
|
|
|
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[]> {
|
|
const res = await fetch(`${this.baseUrl}/session`);
|
|
if (!res.ok) throw new Error(`opencode GET /session → ${res.status}`);
|
|
return (await res.json()) as OpenCodeSessionInfo[];
|
|
}
|
|
|
|
async getMessages(sessionId: string): Promise<OpenCodeStoredMessage[]> {
|
|
const res = await fetch(`${this.baseUrl}/session/${sessionId}/message`);
|
|
if (!res.ok) throw new Error(`opencode GET /session/${sessionId}/message → ${res.status}`);
|
|
return (await res.json()) as OpenCodeStoredMessage[];
|
|
}
|
|
|
|
async deleteSession(sessionId: string): Promise<boolean> {
|
|
const res = await fetch(`${this.baseUrl}/session/${sessionId}`, { method: 'DELETE' });
|
|
return res.ok;
|
|
}
|
|
|
|
async renameSession(sessionId: string, title: string): Promise<boolean> {
|
|
const res = await fetch(`${this.baseUrl}/session/${sessionId}`, {
|
|
method: 'PATCH',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ title }),
|
|
});
|
|
return res.ok;
|
|
}
|
|
}
|
|
|
|
// Shapes returned by the `/session/*` read endpoints (verified against opencode 1.17.9).
|
|
export type OpenCodeSessionInfo = {
|
|
id: string;
|
|
title?: string;
|
|
time?: { created?: number; updated?: number };
|
|
location?: { directory?: string | null };
|
|
};
|
|
|
|
export type OpenCodeStoredPart = {
|
|
type?: string;
|
|
text?: string;
|
|
tool?: string;
|
|
callID?: string;
|
|
state?: { input?: Record<string, unknown>; output?: string; error?: string };
|
|
};
|
|
|
|
export type OpenCodeStoredMessage = {
|
|
info?: { role?: string; modelID?: string; model?: { modelID?: string; providerID?: string } };
|
|
parts?: OpenCodeStoredPart[];
|
|
};
|
|
|
|
const connections = new Map<string, ServerConnection>();
|
|
|
|
export function getConnection(baseUrl: string): ServerConnection {
|
|
let conn = connections.get(baseUrl);
|
|
if (!conn) {
|
|
conn = new ServerConnection(baseUrl);
|
|
connections.set(baseUrl, conn);
|
|
}
|
|
return conn;
|
|
}
|
|
|
|
export type { ServerConnection };
|