import { spawn } from "node:child_process"; import { EventEmitter } from "node:events"; import { createInterface } from "node:readline"; import { codexMcpArgs } from "./tools.mjs"; import { VERSION } from "./version.mjs"; export class AppServer extends EventEmitter { constructor({ cwd, resumeThreadId = "", command = "codex", args = ["app-server", "--listen", "stdio://"], environment = {}, developerInstructions = "", // Empty means "whatever ~/.codex/config.toml says", which is how this // bridge has always chosen a model. A name here overrides it per thread. model = "", effort = "", }) { super(); this.cwd = cwd; this.command = command; this.args = args.includes("app-server") ? [...args, ...codexMcpArgs(cwd,environment)] : args; this.environment = environment; this.resumeThreadId = resumeThreadId; this.developerInstructions = developerInstructions; this.model = model; this.effort = effort; this.child = null; this.nextId = 1; this.pending = new Map(); this.threadId = null; this.turnId = null; this.closed = false; } async connect() { this.child = spawn(this.command, this.args, { cwd: this.cwd, env: { ...process.env, ...this.environment, aesel: "1", EASEL_VERSION: VERSION, }, stdio: ["pipe", "pipe", "pipe"], }); this.child.once("error", (error) => this.#failAll(error)); this.child.once("exit", (code, signal) => { const suffix = signal ? ` (${signal})` : code === null ? "" : ` (${code})`; this.#failAll(new Error(`engine bridge closed${suffix}`)); this.emit("exit", { code, signal }); }); createInterface({ input: this.child.stdout }).on("line", (line) => { const trimmed = line.trim(); if (!trimmed) return; try { this.#receive(JSON.parse(trimmed)); } catch (error) { // See claude-server.mjs: a line that was meant to be JSON is a protocol // fault; anything else is the CLI addressing a person and belongs in the // log rather than in someone's transcript as an error. if (trimmed.startsWith("{") || trimmed.startsWith("[")) { this.emit("protocolError", new Error(`invalid engine message: ${error.message}`)); } else { this.emit("log", trimmed); } } }); createInterface({ input: this.child.stderr }).on("line", (line) => { if (line.trim()) this.emit("log", line.trim()); }); await this.request("initialize", { clientInfo: { name: "easel", title: "aesel", version: VERSION, }, capabilities: { experimentalApi: true }, }); this.notify("initialized", {}); return this.resumeThreadId ? this.resumeThread(this.resumeThreadId) : this.newThread(); } async newThread() { const result = await this.request("thread/start", { cwd: this.cwd, approvalPolicy: "on-request", approvalsReviewer: "user", sandbox: "workspace-write", ephemeral: false, sessionStartSource: this.threadId ? "clear" : "startup", ...(this.model ? { model: this.model } : {}), ...(this.developerInstructions ? { developerInstructions: this.developerInstructions } : {}), }); this.threadId = result.thread.id; this.turnId = null; return result; } async resumeThread(threadId) { const result = await this.request("thread/resume", { threadId, cwd: this.cwd, approvalPolicy: "on-request", approvalsReviewer: "user", sandbox: "workspace-write", ...(this.model ? { model: this.model } : {}), ...(this.developerInstructions ? { developerInstructions: this.developerInstructions } : {}), }); this.threadId = result.thread.id; this.turnId = null; return result; } async startTurn(text, {images=[]}={}) { if (!this.threadId) throw new Error("thread is not ready"); const result = await this.request("turn/start", { threadId: this.threadId, input: [{ type: "text", text },...images.map(image=>({type:"image",url:`data:${image.mimeType};base64,${image.data}`}))], ...(this.effort ? { effort: this.effort } : {}), }); this.turnId = result.turn.id; return result; } async interrupt() { if (!this.threadId || !this.turnId) return; await this.request("turn/interrupt", { threadId: this.threadId, turnId: this.turnId, }); } request(method, params) { const id = this.nextId++; this.#send({ method, id, params }); return new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject }); }); } notify(method, params) { this.#send({ method, params }); } respond(id, result) { this.#send({ id, result }); } reject(id, code, message) { this.#send({ id, error: { code, message } }); } close() { if (this.closed) return; this.closed = true; this.child?.kill("SIGTERM"); } #send(message) { if (!this.child?.stdin.writable) throw new Error("engine bridge is not writable"); this.child.stdin.write(`${JSON.stringify(message)}\n`); } #receive(message) { if (Object.hasOwn(message, "id") && !message.method) { const waiter = this.pending.get(message.id); if (!waiter) return; this.pending.delete(message.id); if (message.error) { waiter.reject(new Error(message.error.message || "engine request failed")); } else { waiter.resolve(message.result); } return; } if (Object.hasOwn(message, "id") && message.method) { this.emit("request", message); return; } if (message.method) this.emit("notification", message); } #failAll(error) { if (this.closed && this.pending.size === 0) return; for (const { reject } of this.pending.values()) reject(error); this.pending.clear(); this.emit("fatal", error); } }