@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
337 lines (336 loc) • 14.5 kB
JavaScript
import { spawn } from "node:child_process";
import { mkdir, open, readFile, rm, stat } from "node:fs/promises";
import path from "node:path";
import { WebSocket } from "ws";
import { codexExecutable } from "./codex-binary.js";
import { object } from "./protocol.js";
/** A Phren-owned `codex app-server` and its WebSocket-on-UDS client. The
* visible pane is a second client (`codex resume <threadId> --remote
* unix://<sock>`); turns and approvals from either side reach both. Protocol
* and transport follow `.scratch/ref/codex-daemon-spike.md`. */
const STARTUP_TIMEOUT_MS = 15_000;
const STARTUP_POLL_MS = 100;
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
const KILL_GRACE_MS = 2_000;
/** A JSON-RPC error reply, carrying the server's `code` beside the message. */
export class AppServerRpcError extends Error {
code;
method;
constructor(code, message, method) {
super(message);
this.code = code;
this.method = method;
this.name = "AppServerRpcError";
}
}
/** This codex build cannot listen on a unix socket (0.113 accepts only stdio://
* and ws://). Typing into the pane still works, so callers treat it as "no
* app-server here", not as a failure to report on every launch. */
export class AppServerUnsupportedError extends Error {
}
const unixListenUnsupported = new Map();
/** Start one `codex app-server --listen unix://<socketPath>` and wait until its
* socket accepts WebSocket connections. The server is the worker's; `stop()`
* is the only owner. Extra `env` is layered over the current process's. */
export async function spawnAppServer(options) {
const env = { ...(options.replaceEnv ? {} : process.env), ...(options.env ?? {}), ...(options.codexHome ? { CODEX_HOME: options.codexHome } : {}) };
// The merged environment's PATH, so a caller's partial `env` still finds codex.
const codexBin = options.codexBin ?? codexExecutable(env);
const known = unixListenUnsupported.get(codexBin);
if (known)
throw new AppServerUnsupportedError(known);
await mkdir(path.dirname(options.socketPath), { recursive: true, mode: 0o700 });
// A socket file left by a dead server would pass the stat below and block the bind.
await rm(options.socketPath, { force: true });
const log = options.logFile ? await open(options.logFile, "a", 0o600) : undefined;
let child;
const command = [codexBin, "app-server", ...(options.config ?? []).flatMap(value => ["-c", value]), "--listen", `unix://${options.socketPath}`];
const [file, ...args] = options.scope ? ["systemd-run", "--user", "--scope", "--quiet", "--collect", `--unit=${options.scope}`, "--", ...command] : command;
try {
child = spawn(file, args, {
cwd: options.cwd,
env,
detached: options.detached === true,
// No stdin: the app-server is reached over its socket, never over stdio.
// stderr is kept (bounded) so a failed start can say why.
stdio: ["ignore", "ignore", log ? log.fd : "pipe"],
});
}
finally {
await log?.close();
}
// A missing binary is an `error` event, not an exit: without a listener
// it would take the Hook down.
let failure;
child.once("error", error => { failure = error; });
let stderr = "";
child.stderr?.on("data", (chunk) => { stderr = (stderr + chunk.toString("utf8")).slice(-2_000); });
const why = async () => {
const text = options.logFile ? (await readFile(options.logFile, "utf8").catch(() => "")).slice(-2_000) : stderr;
return text.trim() ? `: ${text.trim().split("\n").slice(-3).join(" | ")}` : "";
};
const deadline = Date.now() + STARTUP_TIMEOUT_MS;
while (true) {
if (failure)
throw new Error(`codex app-server could not start: ${failure.message}`);
if (child.exitCode !== null || child.signalCode !== null) {
const detail = await why();
if (/unsupported --listen URL/.test(detail)) {
const message = `this codex build cannot listen on a unix socket (needs a newer codex)${detail}`;
unixListenUnsupported.set(codexBin, message);
throw new AppServerUnsupportedError(message);
}
throw new Error(`codex app-server exited before ${options.socketPath} was ready (${child.exitCode ?? child.signalCode})${await why()}`);
}
if (await socketAccepts(options.socketPath))
break;
if (Date.now() > deadline) {
child.kill("SIGKILL");
throw new Error(`Timed out waiting for codex app-server at ${options.socketPath}${await why()}`);
}
await delay(STARTUP_POLL_MS);
}
// Once ready, stop reading stderr so a detached server doesn't hold the Hook open.
child.stderr?.destroy();
if (options.detached)
child.unref();
return { child, socketPath: options.socketPath, stop: () => stopChild(child) };
}
function stopChild(child) {
return new Promise(resolve => {
if (child.exitCode !== null || child.signalCode !== null) {
resolve();
return;
}
// A SIGKILL that leaves no exit event (the process died in the window
// above) still resolves, so stop() cannot hang on a dead server.
const kill = setTimeout(() => { if (child.exitCode === null && child.signalCode === null)
child.kill("SIGKILL"); resolve(); }, KILL_GRACE_MS);
kill.unref();
child.once("exit", () => { clearTimeout(kill); resolve(); });
child.kill("SIGTERM");
});
}
async function socketAccepts(socketPath) {
try {
await stat(socketPath);
}
catch {
return false;
}
return new Promise(resolve => {
const ws = new WebSocket(socketUrl(socketPath), { perMessageDeflate: false });
let settled = false;
const finish = (ok) => { if (settled)
return; settled = true; ws.close(); resolve(ok); };
ws.once("open", () => finish(true));
ws.once("error", () => finish(false));
});
}
/** `perMessageDeflate: false` is required: offering compression makes the
* server reject the handshake with "socket hang up" (spike, Transport). */
function socketUrl(socketPath) { return `ws+unix://${socketPath}:/`; }
/** Connect and complete `initialize` + `initialized` (spike client.mjs,
* transcript-turn.jsonl ids 1 and the notification). */
export async function connectAppServer(socketPath, options) {
const timeoutMs = options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
const ws = new WebSocket(socketUrl(socketPath), { perMessageDeflate: false });
await opened(ws, timeoutMs);
const client = new AppServerConnection(ws);
try {
await client.request("initialize", {
clientInfo: { name: options.clientName, title: options.clientTitle ?? options.clientName, version: options.clientVersion ?? "0.0.1" },
capabilities: { experimentalApi: true },
}, timeoutMs);
}
catch (error) {
client.close();
throw error;
}
client.notify("initialized");
return client;
}
function opened(ws, timeoutMs) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => { cleanup(); reject(new Error("Timed out connecting to codex app-server")); }, timeoutMs);
const onOpen = () => { cleanup(); resolve(); };
const onError = (error) => { cleanup(); reject(error); };
const cleanup = () => { clearTimeout(timer); ws.off("open", onOpen); ws.off("error", onError); };
ws.once("open", onOpen);
ws.once("error", onError);
});
}
function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
class AppServerConnection {
ws;
pending = new Map();
nextId = 1;
waiters = new Map();
listeners = new Set();
closeListeners = new Set();
constructor(ws) {
this.ws = ws;
ws.on("message", data => this.handle(data));
ws.on("close", () => {
this.failAll(new Error("codex app-server connection closed"));
for (const listener of [...this.closeListeners])
listener();
this.closeListeners.clear();
});
ws.on("error", () => this.failAll(new Error("codex app-server connection error")));
}
request(method, params, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {
const id = this.nextId++;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.waiters.delete(id);
reject(new Error(`Timed out waiting for codex app-server ${method}`));
}, timeoutMs);
this.waiters.set(id, { method, resolve, reject, timer });
this.write({ id, method, ...(params === undefined ? {} : { params }) });
});
}
notify(method, params) {
this.write({ method, ...(params === undefined ? {} : { params }) });
}
async threadStart(params) { return object(await this.request("thread/start", params)); }
async threadResume(params) {
return object(await this.request("thread/resume", { excludeTurns: true, ...params }));
}
async threadLoadedList() {
const result = object(await this.request("thread/loaded/list", {}));
return Array.isArray(result.data) ? result.data.filter((id) => typeof id === "string") : [];
}
async turnStart(params) {
const result = object(await this.request("turn/start", params));
const turnId = object(result.turn).id;
if (typeof turnId !== "string")
throw new Error("codex app-server turn/start returned no turn id");
return { turnId };
}
async turnSteer(params) {
const turnId = object(await this.request("turn/steer", params)).turnId;
if (typeof turnId !== "string")
throw new Error("codex app-server turn/steer returned no turn id");
return { turnId };
}
turnInterrupt(params) {
return this.request("turn/interrupt", { threadId: params.threadId, turnId: params.turnId });
}
async interruptTurn(threadId, turnId) {
for (const request of [...this.pending.values()]) {
// Elicitations may carry no threadId; they still belong to this client
// and would keep blocking the turn, so settle them too.
if (request.threadId !== undefined && request.threadId !== threadId)
continue;
this.respond(request.requestId, declineResult(request.method));
}
await this.turnInterrupt({ threadId, turnId });
}
respond(requestId, result) {
if (!this.pending.delete(requestId))
return;
this.write({ id: requestId, result });
}
respondError(requestId, code, message) {
if (!this.pending.delete(requestId))
return;
this.write({ id: requestId, error: { code, message } });
}
on(listener) {
this.listeners.add(listener);
return () => { this.listeners.delete(listener); };
}
onClose(listener) {
this.closeListeners.add(listener);
return () => { this.closeListeners.delete(listener); };
}
close() {
this.failAll(new Error("codex app-server client closed"));
this.listeners.clear();
try {
this.ws.close();
}
catch { /* already closed */ }
}
write(message) {
if (this.ws.readyState !== WebSocket.OPEN)
throw new Error("codex app-server connection is not open");
this.ws.send(JSON.stringify(message));
}
handle(raw) {
let message;
try {
message = object(JSON.parse(String(raw)));
}
catch {
return;
}
const id = message.id;
const method = message.method;
if (typeof method === "string" && id !== undefined) {
const params = object(message.params);
const request = { requestId: id, method, params,
...(typeof params.threadId === "string" ? { threadId: params.threadId } : {}) };
this.pending.set(request.requestId, request);
this.emit({ kind: "request", ...request });
return;
}
if (typeof method === "string") {
const params = object(message.params);
if (method === "serverRequest/resolved") {
const requestId = params.requestId;
this.pending.delete(requestId);
this.emit({ kind: "resolved", requestId, ...(typeof params.threadId === "string" ? { threadId: params.threadId } : {}) });
return;
}
this.emit({ kind: "notification", method, params });
return;
}
if (id === undefined)
return;
const waiter = this.waiters.get(id);
if (!waiter)
return;
this.waiters.delete(id);
clearTimeout(waiter.timer);
const error = object(message.error);
if (message.error !== undefined) {
waiter.reject(new AppServerRpcError(Number(error.code), String(error.message ?? `codex app-server ${waiter.method} failed`), waiter.method));
}
else {
waiter.resolve(message.result);
}
}
failAll(error) {
for (const waiter of this.waiters.values()) {
clearTimeout(waiter.timer);
waiter.reject(error);
}
this.waiters.clear();
this.pending.clear();
}
emit(event) {
for (const listener of this.listeners)
listener(event);
}
}
/** The shape that declines one server request so the turn can be torn down.
* T3 `settlePendingApprovals("cancel")` / `settlePendingUserInputs({})` and the
* per-method responses in its `handleServerRequest` blocks. */
export function declineResult(method) {
switch (method) {
case "item/commandExecution/requestApproval":
case "item/fileChange/requestApproval":
return { decision: "cancel" };
case "item/permissions/requestApproval":
// `scope` is required (PermissionsRequestApprovalResponse, 0.155.1).
return { permissions: {}, scope: "turn" };
case "item/tool/requestUserInput":
return { answers: {} };
case "mcpServer/elicitation/request":
return { action: "cancel" };
default:
return {};
}
}