agent-orchestrator/packages/plugins/agent-codex/src/app-server-client.ts

505 lines
16 KiB
TypeScript

/**
* Codex App-Server JSON-RPC Client
*
* Manages a `codex app-server` subprocess and communicates via
* newline-delimited JSON over stdin/stdout. Provides typed methods
* for thread management, turn execution, and conversation resume.
*
* Protocol reference: Codex app-server developer guide
* Implementation reference: codex-autorunner integrations/app_server/client.py
*/
import { spawn, type ChildProcess } from "node:child_process";
import { randomUUID } from "node:crypto";
import { createInterface, type Interface as ReadlineInterface } from "node:readline";
import { EventEmitter } from "node:events";
// =============================================================================
// Types
// =============================================================================
/** JSON-RPC request sent from client to server */
export interface JsonRpcRequest {
jsonrpc: "2.0";
id: string;
method: string;
params: Record<string, unknown>;
}
/** JSON-RPC response from server */
export interface JsonRpcResponse {
id: string;
result?: Record<string, unknown>;
error?: JsonRpcError;
}
/** JSON-RPC error object */
export interface JsonRpcError {
code: number;
message: string;
data?: unknown;
}
/** JSON-RPC notification from server (no id) */
export interface JsonRpcNotification {
method: string;
params: Record<string, unknown>;
}
/** Approval request from server (has id + method + params) */
export interface JsonRpcApprovalRequest {
id: string | number;
method: string;
params: Record<string, unknown>;
}
/** Parsed message from the server — could be response, notification, or approval request */
type ServerMessage = JsonRpcResponse | JsonRpcNotification | JsonRpcApprovalRequest;
/** Callback for server notifications */
export type NotificationHandler = (method: string, params: Record<string, unknown>) => void;
/** Callback for approval requests */
export type ApprovalHandler = (
id: string | number,
method: string,
params: Record<string, unknown>,
) => Promise<ApprovalDecision>;
/** Approval decision values */
export type ApprovalDecision =
| "accept"
| "acceptForSession"
| "decline"
| "cancel";
/** Options for creating a CodexAppServerClient */
export interface AppServerClientOptions {
/** Path to codex binary (default: "codex") */
binaryPath?: string;
/** Working directory for the app-server process */
cwd?: string;
/** Environment variables for the process */
env?: Record<string, string>;
/** Timeout for requests in ms (default: 60000) */
requestTimeout?: number;
/** Handler for server notifications */
onNotification?: NotificationHandler;
/** Handler for approval requests (auto-accepts if not provided) */
onApproval?: ApprovalHandler;
}
/** Thread start parameters */
export interface ThreadStartParams {
model?: string;
modelProvider?: string;
cwd?: string;
/** Codex approval policy: untrusted (ask for all), on-request, or never */
approvalPolicy?: "untrusted" | "on-request" | "never";
/** Codex sandbox mode */
sandbox?: "read-only" | "workspace-write" | "danger-full-access";
/** Personality/instruction preset */
personality?: string;
}
/** Turn start parameters */
export interface TurnStartParams {
threadId: string;
input: string;
cwd?: string;
model?: string;
}
/** Pending request waiting for a response */
interface PendingRequest {
resolve: (result: Record<string, unknown>) => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout>;
}
// =============================================================================
// Client Implementation
// =============================================================================
/**
* JSON-RPC client for Codex's app-server mode.
*
* Usage:
* ```ts
* const client = new CodexAppServerClient({ cwd: "/my/project" });
* await client.connect();
*
* const thread = await client.threadStart({ model: "o3-mini" });
* const turn = await client.turnStart({ threadId: thread.id, input: "Fix the bug" });
*
* await client.close();
* ```
*/
export class CodexAppServerClient extends EventEmitter {
private process: ChildProcess | null = null;
private readline: ReadlineInterface | null = null;
private pending = new Map<string, PendingRequest>();
private initialized = false;
private closed = false;
private connecting = false;
private readonly binaryPath: string;
private readonly cwd: string | undefined;
private readonly env: Record<string, string> | undefined;
private readonly requestTimeout: number;
private readonly onNotification: NotificationHandler | undefined;
private readonly onApproval: ApprovalHandler | undefined;
constructor(options: AppServerClientOptions = {}) {
super();
this.binaryPath = options.binaryPath ?? "codex";
this.cwd = options.cwd;
this.env = options.env;
this.requestTimeout = options.requestTimeout ?? 60_000;
this.onNotification = options.onNotification;
this.onApproval = options.onApproval;
}
/** Whether the client is connected and initialized */
get isConnected(): boolean {
return this.initialized && !this.closed && this.process !== null;
}
/**
* Spawn the app-server process and perform the initialization handshake.
* Must be called before any other method.
*/
async connect(): Promise<void> {
if (this.closed) throw new Error("Client is closed");
if (this.initialized) throw new Error("Client is already connected");
if (this.connecting) throw new Error("Client is already connecting");
this.connecting = true;
try {
this.process = spawn(this.binaryPath, ["app-server"], {
cwd: this.cwd,
env: this.env ? { ...process.env, ...this.env } : undefined,
stdio: ["pipe", "pipe", "pipe"],
});
if (!this.process.stdout || !this.process.stdin) {
throw new Error("Failed to open stdio pipes for codex app-server");
}
// Drain stderr to prevent the child process from blocking when
// the pipe buffer fills up.
this.process.stderr?.resume();
// Set up line-based reading from stdout
this.readline = createInterface({ input: this.process.stdout });
this.readline.on("line", (line) => this.handleLine(line));
// Handle process exit
this.process.once("exit", (code, signal) => {
this.handleProcessExit(code, signal);
});
this.process.once("error", (err) => {
this.handleProcessError(err);
});
await this.initialize();
} catch (err) {
this.connecting = false;
await this.close();
// Reset closed flag so the client can retry connect() after a
// transient handshake failure. The guard on line 172 ensures
// this.closed is always false when we reach this point.
this.closed = false;
throw err;
}
this.connecting = false;
}
/**
* Gracefully close the app-server process.
* Sends SIGTERM first, then SIGKILL after timeout.
*/
async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
this.initialized = false;
// Reject all pending requests
for (const [id, pending] of this.pending) {
clearTimeout(pending.timer);
pending.reject(new Error("Client closed"));
this.pending.delete(id);
}
if (this.readline) {
this.readline.close();
this.readline = null;
}
if (this.process && this.process.exitCode === null) {
const proc = this.process;
await new Promise<void>((resolve) => {
const killTimer = setTimeout(() => {
try {
proc.kill("SIGKILL");
} catch {
// Already dead
}
resolve();
}, 5_000);
proc.once("exit", () => {
clearTimeout(killTimer);
resolve();
});
try {
proc.kill("SIGTERM");
} catch {
clearTimeout(killTimer);
resolve();
}
});
}
this.process = null;
}
// ---------------------------------------------------------------------------
// Thread Management
// ---------------------------------------------------------------------------
/** Create a new conversation thread */
async threadStart(params: ThreadStartParams = {}): Promise<Record<string, unknown>> {
return this.sendRequest("thread/start", { ...params });
}
/** Resume an existing conversation thread by ID */
async threadResume(threadId: string): Promise<Record<string, unknown>> {
return this.sendRequest("thread/resume", { threadId });
}
/** List threads (with optional cursor-based pagination) */
async threadList(cursor?: string, limit?: number): Promise<Record<string, unknown>> {
const params: Record<string, unknown> = {};
if (cursor) params["cursor"] = cursor;
if (limit !== undefined) params["limit"] = limit;
return this.sendRequest("thread/list", params);
}
/** Archive a thread */
async threadArchive(threadId: string): Promise<Record<string, unknown>> {
return this.sendRequest("thread/archive", { threadId });
}
// ---------------------------------------------------------------------------
// Turn Management
// ---------------------------------------------------------------------------
/** Start a new turn (send a message to the agent) */
async turnStart(params: TurnStartParams): Promise<Record<string, unknown>> {
return this.sendRequest("turn/start", {
threadId: params.threadId,
input: [{ type: "text", text: params.input }],
...(params.cwd ? { cwd: params.cwd } : {}),
...(params.model ? { model: params.model } : {}),
});
}
/** Interrupt a running turn */
async turnInterrupt(threadId: string, turnId: string): Promise<Record<string, unknown>> {
return this.sendRequest("turn/interrupt", { threadId, turnId });
}
// ---------------------------------------------------------------------------
// Model Discovery
// ---------------------------------------------------------------------------
/** List available models */
async modelList(cursor?: string, limit?: number): Promise<Record<string, unknown>> {
const params: Record<string, unknown> = {};
if (cursor) params["cursor"] = cursor;
if (limit !== undefined) params["limit"] = limit;
return this.sendRequest("model/list", params);
}
// ---------------------------------------------------------------------------
// Low-level Protocol
// ---------------------------------------------------------------------------
/** Send a JSON-RPC request and wait for the response */
async sendRequest(method: string, params: Record<string, unknown> = {}): Promise<Record<string, unknown>> {
if (!this.initialized && method !== "initialize") {
throw new Error("Client not initialized — call connect() first");
}
if (this.closed) throw new Error("Client is closed");
if (!this.process?.stdin?.writable) {
throw new Error("stdin not writable — process may have exited");
}
const id = randomUUID();
const request: JsonRpcRequest = { jsonrpc: "2.0", id, method, params };
return new Promise<Record<string, unknown>>((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`Request ${method} timed out after ${this.requestTimeout}ms`));
}, this.requestTimeout);
this.pending.set(id, { resolve, reject, timer });
this.writeLine(JSON.stringify(request));
});
}
/** Send a JSON-RPC notification (no response expected) */
sendNotification(method: string, params: Record<string, unknown> = {}): void {
if (this.closed) return;
if (!this.process?.stdin?.writable) return;
this.writeLine(JSON.stringify({ jsonrpc: "2.0", method, params }));
}
/** Respond to an approval request from the server */
sendApprovalResponse(id: string | number, decision: ApprovalDecision): void {
if (this.closed) return;
if (!this.process?.stdin?.writable) return;
this.writeLine(JSON.stringify({ jsonrpc: "2.0", id, result: { decision } }));
}
// ---------------------------------------------------------------------------
// Internal
// ---------------------------------------------------------------------------
private async initialize(): Promise<void> {
const result = await this.sendRequest("initialize", {
clientInfo: {
name: "ao-agent-codex",
title: "Agent Orchestrator — Codex Plugin",
version: "0.1.1",
},
});
// Send the initialized notification to complete the handshake
this.sendNotification("initialized", {});
this.initialized = true;
this.emit("connected", result);
}
private writeLine(line: string): void {
if (!this.process?.stdin?.writable) return;
this.process.stdin.write(line + "\n");
}
private handleLine(line: string): void {
const trimmed = line.trim();
if (!trimmed) return;
let msg: ServerMessage;
try {
const parsed: unknown = JSON.parse(trimmed);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return;
msg = parsed as ServerMessage;
} catch {
// Skip malformed lines
return;
}
// Classify the message
if ("id" in msg && msg.id !== undefined) {
const id = String(msg.id);
// Check if this is a response to a pending request
const pending = this.pending.get(id);
if (pending) {
this.pending.delete(id);
clearTimeout(pending.timer);
if ("error" in msg && msg.error) {
pending.reject(
new Error(`JSON-RPC error ${msg.error.code}: ${msg.error.message}`),
);
} else {
pending.resolve((msg as JsonRpcResponse).result ?? {});
}
return;
}
// If not a pending response, it's a server-initiated request (approval)
if ("method" in msg && typeof msg.method === "string") {
this.handleApprovalRequest(msg as JsonRpcApprovalRequest);
return;
}
}
// Notification (no id, has method)
if ("method" in msg && typeof msg.method === "string" && !("id" in msg)) {
const notification = msg as JsonRpcNotification;
this.emit("notification", notification.method, notification.params);
if (this.onNotification) {
this.onNotification(notification.method, notification.params);
}
}
}
private async handleApprovalRequest(request: JsonRpcApprovalRequest): Promise<void> {
try {
this.emit("approval", request.id, request.method, request.params);
if (this.onApproval) {
const decision = await this.onApproval(request.id, request.method, request.params);
this.sendApprovalResponse(request.id, decision);
} else {
// Default: auto-accept all approvals
this.sendApprovalResponse(request.id, "accept");
}
} catch {
// On any error (listener throw or handler rejection), decline the request
this.sendApprovalResponse(request.id, "decline");
}
}
private handleProcessExit(code: number | null, signal: string | null): void {
this.initialized = false;
// Close readline to release the event listener on the closed stdout stream
if (this.readline) {
this.readline.close();
this.readline = null;
}
// Reject all pending requests
const exitMsg = `codex app-server exited (code=${code}, signal=${signal})`;
for (const [id, pending] of this.pending) {
clearTimeout(pending.timer);
pending.reject(new Error(exitMsg));
this.pending.delete(id);
}
this.emit("exit", code, signal);
}
private handleProcessError(err: Error): void {
this.initialized = false;
// Close readline to release the event listener on the closed stdout stream
if (this.readline) {
this.readline.close();
this.readline = null;
}
// Reject all pending requests before emitting "error" — emit("error")
// with no listeners throws synchronously, which would skip cleanup.
for (const [id, pending] of this.pending) {
clearTimeout(pending.timer);
pending.reject(err);
this.pending.delete(id);
}
this.emit("error", err);
}
}