Revert "chat: root the OpenCode server at the chat cwd (email chat now runs in the account dir)"

This reverts commit d60c73b3a3.
This commit is contained in:
2026-07-25 10:08:48 +00:00
parent 9b9938aaa9
commit bab0d1b7f8
3 changed files with 18 additions and 124 deletions
+7 -4
View File
@@ -105,10 +105,13 @@ class ServerConnection {
return (await res.json()) as T;
}
async createSession(title?: string): Promise<string> {
// OpenCode has no per-session `directory` — the working dir is the server's cwd (server-manager
// roots the server at the desired dir). So we only optionally set a title here.
const session = await this.postJson<{ id?: string }>('/session', title ? { title } : {});
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;
}
+8 -113
View File
@@ -1,124 +1,19 @@
import type { Subprocess } from 'bun';
import { homedir } from 'os';
import { join } from 'path';
import { mkdirSync } from 'fs';
import { logger } from '../logger';
// OpenCode binds a session to the SERVER's working directory (POST /session has no per-session
// `directory`). So a chat that must run in a specific dir needs a server rooted there:
// - general /chat (no specific cwd) → the fixed, pm2-managed server on OPENCODE_SERVER_URL.
// - context-scoped chats (email account dir, project dir, …) → a pooled server spawned in that cwd.
// The OpenCode server is a fixed, pm2-managed process (see officer-opencode in ecosystem.config.cjs)
// listening on a known port — not spawned per-cwd by us. All OpenCode chat + session traffic goes to
// this one server; sessions live in its project store. Override the URL with OPENCODE_SERVER_URL.
export const OPENCODE_SERVER_URL = process.env.OPENCODE_SERVER_URL || 'http://127.0.0.1:4096';
const OPENCODE_BIN = process.env.OPENCODE_BIN || join(homedir(), '.opencode', 'bin', 'opencode');
const HEALTH_TIMEOUT_MS = 20_000;
const HEALTH_POLL_MS = 200;
const START_ATTEMPTS = 3;
type OpenCodeServer = { baseUrl: string; proc: Subprocess };
// Pooled per-cwd servers (one per working directory). The fixed server is not in here.
const servers = new Map<string, OpenCodeServer>();
const starting = new Map<string, Promise<OpenCodeServer>>();
async function isHealthy(baseUrl: string): Promise<boolean> {
export async function isServerHealthy(): Promise<boolean> {
try {
const res = await fetch(`${baseUrl}/api/health`, { signal: AbortSignal.timeout(3000) });
const res = await fetch(`${OPENCODE_SERVER_URL}/api/health`, { signal: AbortSignal.timeout(3000) });
return res.ok;
} catch {
return false;
}
}
export async function isServerHealthy(): Promise<boolean> {
return isHealthy(OPENCODE_SERVER_URL);
}
async function waitHealthy(baseUrl: string, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await isHealthy(baseUrl)) return true;
await new Promise((r) => setTimeout(r, HEALTH_POLL_MS));
}
return false;
}
/** Grab an ephemeral free port by briefly binding one and releasing it. */
function getFreePort(): number {
const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
const port = probe.port;
probe.stop(true);
if (port == null) throw new Error('failed to acquire a free port');
return port;
}
async function startServer(cwd: string, home: string): Promise<OpenCodeServer> {
mkdirSync(cwd, { recursive: true });
for (let attempt = 1; attempt <= START_ATTEMPTS; attempt += 1) {
const port = getFreePort();
const baseUrl = `http://127.0.0.1:${port}`;
// Loopback-only + no password → open on 127.0.0.1. HOME is the user's home so `opencode` reads
// that user's ~/.local/share/opencode auth. cwd roots the session working directory.
const proc = Bun.spawn([OPENCODE_BIN, 'serve', '--port', String(port), '--hostname', '127.0.0.1'], {
cwd,
env: { ...process.env, HOME: home },
stdout: 'ignore',
stderr: 'ignore',
});
if (await waitHealthy(baseUrl, HEALTH_TIMEOUT_MS)) {
logger.info('opencode serve started', { cwd, baseUrl });
proc.exited.then((code) => {
if (servers.get(cwd)?.proc === proc) servers.delete(cwd);
logger.warn('opencode serve exited', { cwd, code });
});
return { baseUrl, proc };
}
logger.warn('opencode serve failed health check, retrying', { cwd, baseUrl, attempt });
try {
proc.kill();
} catch {
/* already gone */
}
}
throw new Error(`opencode serve failed to start for cwd ${cwd}`);
}
async function ensureCwdServer(cwd: string, home: string): Promise<{ baseUrl: string }> {
const existing = servers.get(cwd);
if (existing && (await isHealthy(existing.baseUrl))) return { baseUrl: existing.baseUrl };
if (existing) {
try {
existing.proc.kill();
} catch {
/* already gone */
}
servers.delete(cwd);
}
const inflight = starting.get(cwd);
if (inflight) return { baseUrl: (await inflight).baseUrl };
const promise = startServer(cwd, home);
starting.set(cwd, promise);
try {
const server = await promise;
servers.set(cwd, server);
return { baseUrl: server.baseUrl };
} finally {
starting.delete(cwd);
}
}
/**
* The base URL of the OpenCode server a chat should use. With no cwd → the fixed pm2 server; with a
* cwd → a pooled server rooted at that directory (so the session's agent operates there).
*/
export async function ensureServer(cwd?: string, home?: string): Promise<{ baseUrl: string }> {
if (!cwd) return { baseUrl: OPENCODE_SERVER_URL };
return ensureCwdServer(cwd, home ?? homedir());
/** The base URL of the fixed OpenCode server. */
export async function ensureServer(): Promise<{ baseUrl: string }> {
return { baseUrl: OPENCODE_SERVER_URL };
}
+3 -7
View File
@@ -4,7 +4,6 @@ import { ensureServer } from '@@/api/chat/opencode/server-manager';
import { getConnection } from '@@/api/chat/opencode/client';
import { createEventMapper } from '@@/api/chat/opencode/event-mapper';
import { getOpenCodeSession, setOpenCodeSession } from '@@/api/chat/opencode/state';
import { getHomeDirForRole } from '../data-path';
// The OpenCode analog of send-claude-code.ts's streaming path. Drives a turn against a warm
// `opencode serve` over HTTP + SSE, mapping events to the shared ChatEvent contract.
@@ -34,11 +33,9 @@ function splitModel(model: string): { providerID: string; modelID: string } {
}
export async function sendOpenCodeStreaming(params: OpenCodeStreamingParams): Promise<OpenCodeStreamingHandle> {
logger.info('OpenCode streaming exec', { sessionKey: params.sessionKey, model: params.model, cwd: params.cwd });
logger.info('OpenCode streaming exec', { sessionKey: params.sessionKey, model: params.model });
// params.cwd set (email/project) → a server rooted there; unset (/chat) → the fixed pm2 server.
const home = getHomeDirForRole(params.email, params.role ?? '');
const { baseUrl } = await ensureServer(params.cwd, home);
const { baseUrl } = await ensureServer();
const conn = getConnection(baseUrl);
// Resolve the OpenCode session: a known mapping, or — when resuming from history — the sessionKey is
@@ -48,8 +45,7 @@ export async function sendOpenCodeStreaming(params: OpenCodeStreamingParams): Pr
(params.sessionKey.startsWith('ses_') ? params.sessionKey : undefined) ??
params.resumeSessionId;
if (!opencodeSessionId) {
// The working dir comes from the server (rooted at cwd above), not a per-session param.
opencodeSessionId = await conn.createSession();
opencodeSessionId = await conn.createSession(params.cwd);
}
setOpenCodeSession(params.sessionKey, opencodeSessionId);
const sessionId = opencodeSessionId;