import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import { EventEmitter } from "node:events"; import readline from "node:readline"; import { ApprovalRequestId, EventId, ProviderItemId, ProviderRequestKind, ProviderSessionId, ProviderThreadId, ProviderTurnId, normalizeModelSlug, type ProviderApprovalDecision, type ProviderEvent, type ProviderSession, type ProviderSessionStartInput, type ProviderTurnStartResult, } from "@t3tools/contracts"; type PendingRequestKey = string; interface PendingRequest { method: string; timeout: ReturnType; resolve: (value: unknown) => void; reject: (error: Error) => void; } interface PendingApprovalRequest { requestId: ApprovalRequestId; jsonRpcId: string | number; method: "item/commandExecution/requestApproval" | "item/fileChange/requestApproval"; requestKind: ProviderRequestKind; threadId?: ProviderThreadId; turnId?: ProviderTurnId; itemId?: ProviderItemId; } interface CodexSessionContext { session: ProviderSession; child: ChildProcessWithoutNullStreams; output: readline.Interface; pending: Map; pendingApprovals: Map; nextRequestId: number; stopping: boolean; } interface JsonRpcError { code?: number; message?: string; } interface JsonRpcRequest { id: string | number; method: string; params?: unknown; } interface JsonRpcResponse { id: string | number; result?: unknown; error?: JsonRpcError; } interface JsonRpcNotification { method: string; params?: unknown; } export interface CodexAppServerSendTurnInput { readonly sessionId: ProviderSessionId; readonly input?: string; readonly attachments?: ReadonlyArray<{ type: "image"; url: string }>; readonly model?: string; readonly effort?: string; } export interface CodexThreadTurnSnapshot { id: ProviderTurnId; items: unknown[]; } export interface CodexThreadSnapshot { threadId: ProviderThreadId; turns: CodexThreadTurnSnapshot[]; } const ANSI_ESCAPE_CHAR = String.fromCharCode(27); const ANSI_ESCAPE_REGEX = new RegExp(`${ANSI_ESCAPE_CHAR}\\[[0-9;]*m`, "g"); const CODEX_STDERR_LOG_REGEX = /^\d{4}-\d{2}-\d{2}T\S+\s+(TRACE|DEBUG|INFO|WARN|ERROR)\s+\S+:\s+(.*)$/; const BENIGN_ERROR_LOG_SNIPPETS = [ "state db missing rollout path for thread", "state db record_discrepancy: find_thread_path_by_id_str_in_subdir, falling_back", ]; const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [ "not found", "missing thread", "no such thread", "unknown thread", "does not exist", ]; export function normalizeCodexModelSlug( model: string | undefined | null, preferredId?: string, ): string | undefined { const normalized = normalizeModelSlug(model); if (!normalized) { return undefined; } if (preferredId?.endsWith("-codex") && preferredId !== normalized) { return preferredId; } return normalized; } export function classifyCodexStderrLine(rawLine: string): { message: string } | null { const line = rawLine.replaceAll(ANSI_ESCAPE_REGEX, "").trim(); if (!line) { return null; } const match = line.match(CODEX_STDERR_LOG_REGEX); if (match) { const level = match[1]; if (level && level !== "ERROR") { return null; } const isBenignError = BENIGN_ERROR_LOG_SNIPPETS.some((snippet) => line.includes(snippet)); if (isBenignError) { return null; } } return { message: line }; } export function isRecoverableThreadResumeError(error: unknown): boolean { const message = (error instanceof Error ? error.message : String(error)).toLowerCase(); if (!message.includes("thread/resume")) { return false; } return RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS.some((snippet) => message.includes(snippet)); } export interface CodexAppServerManagerEvents { event: [event: ProviderEvent]; } export class CodexAppServerManager extends EventEmitter { private readonly sessions = new Map(); async startSession(input: ProviderSessionStartInput): Promise { const sessionId = ProviderSessionId.makeUnsafe(randomUUID()); const now = new Date().toISOString(); let context: CodexSessionContext | undefined; try { const resolvedCwd = input.cwd ?? process.cwd(); const session: ProviderSession = { sessionId, provider: "codex", status: "connecting", model: normalizeCodexModelSlug(input.model), cwd: resolvedCwd, createdAt: now, updatedAt: now, }; const codexBinaryPath = input.codexBinaryPath ?? "codex"; const codexHomePath = input.codexHomePath; const child = spawn(codexBinaryPath, ["app-server"], { cwd: resolvedCwd, env: { ...process.env, ...(codexHomePath ? { CODEX_HOME: codexHomePath } : {}), }, stdio: ["pipe", "pipe", "pipe"], }); const output = readline.createInterface({ input: child.stdout }); context = { session, child, output, pending: new Map(), pendingApprovals: new Map(), nextRequestId: 1, stopping: false, }; this.sessions.set(sessionId, context); this.attachProcessListeners(context); this.emitLifecycleEvent(context, "session/connecting", "Starting codex app-server"); await this.sendRequest(context, "initialize", { clientInfo: { name: "t3code_desktop", title: "T3 Code Desktop", version: "0.1.0", }, capabilities: { experimentalApi: false, }, }); this.writeMessage(context, { method: "initialized" }); const normalizedModel = normalizeCodexModelSlug(input.model); const sessionOverrides = { model: normalizedModel ?? null, cwd: input.cwd ?? null, approvalPolicy: input.approvalPolicy, sandbox: input.sandboxMode, }; const threadStartParams = { ...sessionOverrides, experimentalRawEvents: false, }; const resumeThreadId = readResumeThreadId(input); let threadOpenMethod: "thread/start" | "thread/resume" = "thread/start"; let threadOpenResponse: unknown; if (resumeThreadId) { try { threadOpenMethod = "thread/resume"; threadOpenResponse = await this.sendRequest(context, "thread/resume", { ...sessionOverrides, threadId: resumeThreadId, }); } catch (error) { if (!isRecoverableThreadResumeError(error)) { throw error; } threadOpenMethod = "thread/start"; this.emitLifecycleEvent( context, "session/threadResumeFallback", `Could not resume thread ${resumeThreadId}; started a new thread instead.`, ); threadOpenResponse = await this.sendRequest(context, "thread/start", threadStartParams); } } else { threadOpenMethod = "thread/start"; threadOpenResponse = await this.sendRequest(context, "thread/start", threadStartParams); } const threadOpenRecord = this.readObject(threadOpenResponse); const threadIdRaw = this.readString(this.readObject(threadOpenRecord, "thread"), "id") ?? this.readString(threadOpenRecord, "threadId"); if (!threadIdRaw) { throw new Error(`${threadOpenMethod} response did not include a thread id.`); } const threadId = ProviderThreadId.makeUnsafe(threadIdRaw); this.updateSession(context, { status: "ready", threadId, resumeCursor: { threadId }, }); this.emitLifecycleEvent(context, "session/ready", `Connected to thread ${threadId}`); return { ...context.session }; } catch (error) { const message = error instanceof Error ? error.message : "Failed to start Codex session."; if (context) { this.updateSession(context, { status: "error", lastError: message, }); this.emitErrorEvent(context, "session/startFailed", message); this.stopSession(sessionId); } else { this.emitEvent({ id: EventId.makeUnsafe(randomUUID()), kind: "error", provider: "codex", sessionId, createdAt: new Date().toISOString(), method: "session/startFailed", message, }); } throw new Error(message, { cause: error }); } } async sendTurn(input: CodexAppServerSendTurnInput): Promise { const context = this.requireSession(input.sessionId); if (!context.session.threadId) { throw new Error("Session is missing a thread id."); } const turnInput: Array< { type: "text"; text: string; text_elements: [] } | { type: "image"; url: string } > = []; if (input.input) { turnInput.push({ type: "text", text: input.input, text_elements: [], }); } for (const attachment of input.attachments ?? []) { if (attachment.type === "image") { turnInput.push({ type: "image", url: attachment.url, }); } } if (turnInput.length === 0) { throw new Error("Turn input must include text or attachments."); } const turnStartParams: { threadId: ProviderThreadId; input: Array< { type: "text"; text: string; text_elements: [] } | { type: "image"; url: string } >; model?: string; effort?: string; } = { threadId: context.session.threadId, input: turnInput, }; const normalizedModel = normalizeCodexModelSlug(input.model); if (normalizedModel) { turnStartParams.model = normalizedModel; } if (input.effort) { turnStartParams.effort = input.effort; } const response = await this.sendRequest(context, "turn/start", turnStartParams); const turn = this.readObject(this.readObject(response), "turn"); const turnIdRaw = this.readString(turn, "id"); if (!turnIdRaw) { throw new Error("turn/start response did not include a turn id."); } const turnId = ProviderTurnId.makeUnsafe(turnIdRaw); this.updateSession(context, { status: "running", activeTurnId: turnId, resumeCursor: context.session.resumeCursor ?? { threadId: context.session.threadId }, }); return { threadId: context.session.threadId, turnId, resumeCursor: context.session.resumeCursor ?? { threadId: context.session.threadId }, }; } async interruptTurn(sessionId: ProviderSessionId, turnId?: ProviderTurnId): Promise { const context = this.requireSession(sessionId); const effectiveTurnId = turnId ?? context.session.activeTurnId; if (!effectiveTurnId || !context.session.threadId) { return; } await this.sendRequest(context, "turn/interrupt", { threadId: context.session.threadId, turnId: effectiveTurnId, }); } async readThread(sessionId: ProviderSessionId): Promise { const context = this.requireSession(sessionId); const threadId = context.session.threadId; if (!threadId) { throw new Error("Session is missing a thread id."); } const response = await this.sendRequest(context, "thread/read", { threadId, includeTurns: true, }); return this.parseThreadSnapshot("thread/read", response); } async rollbackThread( sessionId: ProviderSessionId, numTurns: number, ): Promise { const context = this.requireSession(sessionId); const threadId = context.session.threadId; if (!threadId) { throw new Error("Session is missing a thread id."); } if (!Number.isInteger(numTurns) || numTurns < 1) { throw new Error("numTurns must be an integer >= 1."); } const response = await this.sendRequest(context, "thread/rollback", { threadId, numTurns, }); this.updateSession(context, { status: "ready", activeTurnId: undefined, }); return this.parseThreadSnapshot("thread/rollback", response); } async respondToRequest( sessionId: ProviderSessionId, requestId: ApprovalRequestId, decision: ProviderApprovalDecision, ): Promise { const context = this.requireSession(sessionId); const pendingRequest = context.pendingApprovals.get(requestId); if (!pendingRequest) { throw new Error(`Unknown pending approval request: ${requestId}`); } context.pendingApprovals.delete(requestId); this.writeMessage(context, { id: pendingRequest.jsonRpcId, result: { decision, }, }); this.emitEvent({ id: EventId.makeUnsafe(randomUUID()), kind: "notification", provider: "codex", sessionId: context.session.sessionId, createdAt: new Date().toISOString(), method: "item/requestApproval/decision", threadId: pendingRequest.threadId, turnId: pendingRequest.turnId, itemId: pendingRequest.itemId, requestId: pendingRequest.requestId, requestKind: pendingRequest.requestKind, payload: { requestId: pendingRequest.requestId, requestKind: pendingRequest.requestKind, decision, }, }); } stopSession(sessionId: ProviderSessionId): void { const context = this.sessions.get(sessionId); if (!context) { return; } context.stopping = true; for (const pending of context.pending.values()) { clearTimeout(pending.timeout); pending.reject(new Error("Session stopped before request completed.")); } context.pending.clear(); context.pendingApprovals.clear(); context.output.close(); if (!context.child.killed) { context.child.kill(); } this.updateSession(context, { status: "closed", activeTurnId: undefined, }); this.emitLifecycleEvent(context, "session/closed", "Session stopped"); this.sessions.delete(sessionId); } listSessions(): ProviderSession[] { return Array.from(this.sessions.values(), ({ session }) => ({ ...session, })); } hasSession(sessionId: ProviderSessionId): boolean { return this.sessions.has(sessionId); } stopAll(): void { for (const sessionId of this.sessions.keys()) { this.stopSession(sessionId); } } private requireSession(sessionId: ProviderSessionId): CodexSessionContext { const context = this.sessions.get(sessionId); if (!context) { throw new Error(`Unknown session: ${sessionId}`); } if (context.session.status === "closed") { throw new Error(`Session is closed: ${sessionId}`); } return context; } private attachProcessListeners(context: CodexSessionContext): void { context.output.on("line", (line) => { this.handleStdoutLine(context, line); }); context.child.stderr.on("data", (chunk: Buffer) => { const raw = chunk.toString(); const lines = raw.split(/\r?\n/g); for (const rawLine of lines) { const classified = classifyCodexStderrLine(rawLine); if (!classified) { continue; } this.emitErrorEvent(context, "process/stderr", classified.message); } }); context.child.on("error", (error) => { const message = error.message || "codex app-server process errored."; this.updateSession(context, { status: "error", lastError: message, }); this.emitErrorEvent(context, "process/error", message); }); context.child.on("exit", (code, signal) => { if (context.stopping) { return; } const message = `codex app-server exited (code=${code ?? "null"}, signal=${signal ?? "null"}).`; this.updateSession(context, { status: "closed", activeTurnId: undefined, lastError: code === 0 ? context.session.lastError : message, }); this.emitLifecycleEvent(context, "session/exited", message); this.sessions.delete(context.session.sessionId); }); } private handleStdoutLine(context: CodexSessionContext, line: string): void { let parsed: unknown; try { parsed = JSON.parse(line); } catch { this.emitErrorEvent( context, "protocol/parseError", "Received invalid JSON from codex app-server.", ); return; } if (!parsed || typeof parsed !== "object") { this.emitErrorEvent( context, "protocol/invalidMessage", "Received non-object protocol message.", ); return; } if (this.isServerRequest(parsed)) { this.handleServerRequest(context, parsed); return; } if (this.isServerNotification(parsed)) { this.handleServerNotification(context, parsed); return; } if (this.isResponse(parsed)) { this.handleResponse(context, parsed); return; } this.emitErrorEvent( context, "protocol/unrecognizedMessage", "Received protocol message in an unknown shape.", ); } private handleServerNotification( context: CodexSessionContext, notification: JsonRpcNotification, ): void { const route = this.readRouteFields(notification.params); const textDelta = notification.method === "item/agentMessage/delta" ? this.readString(notification.params, "delta") : undefined; this.emitEvent({ id: EventId.makeUnsafe(randomUUID()), kind: "notification", provider: "codex", sessionId: context.session.sessionId, createdAt: new Date().toISOString(), method: notification.method, threadId: route.threadId, turnId: route.turnId, itemId: route.itemId, textDelta, payload: notification.params, }); if (notification.method === "thread/started") { const threadId = toProviderThreadId( this.readString(this.readObject(notification.params)?.thread, "id"), ); if (threadId) { this.updateSession(context, { threadId }); } return; } if (notification.method === "turn/started") { const turnId = toProviderTurnId( this.readString(this.readObject(notification.params)?.turn, "id"), ); this.updateSession(context, { status: "running", activeTurnId: turnId, }); return; } if (notification.method === "turn/completed") { const turn = this.readObject(notification.params, "turn"); const status = this.readString(turn, "status"); const errorMessage = this.readString(this.readObject(turn, "error"), "message"); this.updateSession(context, { status: status === "failed" ? "error" : "ready", activeTurnId: undefined, lastError: errorMessage ?? context.session.lastError, }); return; } if (notification.method === "error") { const message = this.readString(this.readObject(notification.params)?.error, "message"); const willRetry = this.readBoolean(notification.params, "willRetry"); this.updateSession(context, { status: willRetry ? "running" : "error", lastError: message ?? context.session.lastError, }); } } private handleServerRequest(context: CodexSessionContext, request: JsonRpcRequest): void { const route = this.readRouteFields(request.params); const requestKind = this.requestKindForMethod(request.method); let requestId: ApprovalRequestId | undefined; if (requestKind) { requestId = ApprovalRequestId.makeUnsafe(randomUUID()); const pendingRequest: PendingApprovalRequest = { requestId, jsonRpcId: request.id, method: requestKind === "command" ? "item/commandExecution/requestApproval" : "item/fileChange/requestApproval", requestKind, ...(route.threadId ? { threadId: route.threadId } : {}), ...(route.turnId ? { turnId: route.turnId } : {}), ...(route.itemId ? { itemId: route.itemId } : {}), }; context.pendingApprovals.set(requestId, pendingRequest); } this.emitEvent({ id: EventId.makeUnsafe(randomUUID()), kind: "request", provider: "codex", sessionId: context.session.sessionId, createdAt: new Date().toISOString(), method: request.method, threadId: route.threadId, turnId: route.turnId, itemId: route.itemId, requestId, requestKind, payload: request.params, }); if (requestKind) { return; } if (request.method === "item/tool/requestUserInput") { this.writeMessage(context, { id: request.id, result: { answers: {} }, }); return; } this.writeMessage(context, { id: request.id, error: { code: -32601, message: `Unsupported server request: ${request.method}`, }, }); } private handleResponse(context: CodexSessionContext, response: JsonRpcResponse): void { const key = String(response.id); const pending = context.pending.get(key); if (!pending) { return; } clearTimeout(pending.timeout); context.pending.delete(key); if (response.error?.message) { pending.reject(new Error(`${pending.method} failed: ${String(response.error.message)}`)); return; } pending.resolve(response.result); } private async sendRequest( context: CodexSessionContext, method: string, params: unknown, timeoutMs = 20_000, ): Promise { const id = context.nextRequestId; context.nextRequestId += 1; const result = await new Promise((resolve, reject) => { const timeout = setTimeout(() => { context.pending.delete(String(id)); reject(new Error(`Timed out waiting for ${method}.`)); }, timeoutMs); context.pending.set(String(id), { method, timeout, resolve, reject, }); this.writeMessage(context, { method, id, params, }); }); return result as TResponse; } private writeMessage(context: CodexSessionContext, message: unknown): void { const encoded = JSON.stringify(message); if (!context.child.stdin.writable) { throw new Error("Cannot write to codex app-server stdin."); } context.child.stdin.write(`${encoded}\n`); } private emitLifecycleEvent(context: CodexSessionContext, method: string, message: string): void { this.emitEvent({ id: EventId.makeUnsafe(randomUUID()), kind: "session", provider: "codex", sessionId: context.session.sessionId, createdAt: new Date().toISOString(), method, message, }); } private emitErrorEvent(context: CodexSessionContext, method: string, message: string): void { this.emitEvent({ id: EventId.makeUnsafe(randomUUID()), kind: "error", provider: "codex", sessionId: context.session.sessionId, createdAt: new Date().toISOString(), method, message, }); } private emitEvent(event: ProviderEvent): void { this.emit("event", event); } private updateSession(context: CodexSessionContext, updates: Partial): void { context.session = { ...context.session, ...updates, updatedAt: new Date().toISOString(), }; } private requestKindForMethod(method: string): ProviderRequestKind | undefined { if (method === "item/commandExecution/requestApproval") { return "command"; } if (method === "item/fileChange/requestApproval") { return "file-change"; } return undefined; } private parseThreadSnapshot(method: string, response: unknown): CodexThreadSnapshot { const responseRecord = this.readObject(response); const thread = this.readObject(responseRecord, "thread"); const threadIdRaw = this.readString(thread, "id") ?? this.readString(responseRecord, "threadId"); if (!threadIdRaw) { throw new Error(`${method} response did not include a thread id.`); } const threadId = ProviderThreadId.makeUnsafe(threadIdRaw); const turnsRaw = this.readArray(thread, "turns") ?? this.readArray(responseRecord, "turns") ?? []; const turns = turnsRaw.map((turnValue, index) => { const turn = this.readObject(turnValue); const turnIdRaw = this.readString(turn, "id") ?? `${threadIdRaw}:turn:${index + 1}`; const turnId = ProviderTurnId.makeUnsafe(turnIdRaw); const items = this.readArray(turn, "items") ?? []; return { id: turnId, items, }; }); return { threadId, turns, }; } private isServerRequest(value: unknown): value is JsonRpcRequest { if (!value || typeof value !== "object") { return false; } const candidate = value as Record; return ( typeof candidate.method === "string" && (typeof candidate.id === "string" || typeof candidate.id === "number") ); } private isServerNotification(value: unknown): value is JsonRpcNotification { if (!value || typeof value !== "object") { return false; } const candidate = value as Record; return typeof candidate.method === "string" && !("id" in candidate); } private isResponse(value: unknown): value is JsonRpcResponse { if (!value || typeof value !== "object") { return false; } const candidate = value as Record; const hasId = typeof candidate.id === "string" || typeof candidate.id === "number"; const hasMethod = typeof candidate.method === "string"; return hasId && !hasMethod; } private readRouteFields(params: unknown): { threadId?: ProviderThreadId; turnId?: ProviderTurnId; itemId?: ProviderItemId; } { const route: { threadId?: ProviderThreadId; turnId?: ProviderTurnId; itemId?: ProviderItemId; } = {}; const threadId = toProviderThreadId( this.readString(params, "threadId") ?? this.readString(this.readObject(params, "thread"), "id"), ); const turnId = toProviderTurnId( this.readString(params, "turnId") ?? this.readString(this.readObject(params, "turn"), "id"), ); const itemId = toProviderItemId( this.readString(params, "itemId") ?? this.readString(this.readObject(params, "item"), "id"), ); if (threadId) { route.threadId = threadId; } if (turnId) { route.turnId = turnId; } if (itemId) { route.itemId = itemId; } return route; } private readObject(value: unknown, key?: string): Record | undefined { const target = key === undefined ? value : value && typeof value === "object" ? (value as Record)[key] : undefined; if (!target || typeof target !== "object") { return undefined; } return target as Record; } private readArray(value: unknown, key?: string): unknown[] | undefined { const target = key === undefined ? value : value && typeof value === "object" ? (value as Record)[key] : undefined; return Array.isArray(target) ? target : undefined; } private readString(value: unknown, key: string): string | undefined { if (!value || typeof value !== "object") { return undefined; } const candidate = (value as Record)[key]; return typeof candidate === "string" ? candidate : undefined; } private readBoolean(value: unknown, key: string): boolean | undefined { if (!value || typeof value !== "object") { return undefined; } const candidate = (value as Record)[key]; return typeof candidate === "boolean" ? candidate : undefined; } } function brandIfNonEmpty( value: string | undefined, maker: (value: string) => T, ): T | undefined { const normalized = value?.trim(); return normalized?.length ? maker(normalized) : undefined; } function toProviderThreadId(value: string | undefined): ProviderThreadId | undefined { return brandIfNonEmpty(value, ProviderThreadId.makeUnsafe); } function readResumeThreadId(input: ProviderSessionStartInput): ProviderThreadId | undefined { if ( !input.resumeCursor || typeof input.resumeCursor !== "object" || Array.isArray(input.resumeCursor) ) { return input.resumeThreadId; } const rawThreadId = (input.resumeCursor as Record).threadId; return typeof rawThreadId === "string" ? toProviderThreadId(rawThreadId) : input.resumeThreadId; } function toProviderTurnId(value: string | undefined): ProviderTurnId | undefined { return brandIfNonEmpty(value, ProviderTurnId.makeUnsafe); } function toProviderItemId(value: string | undefined): ProviderItemId | undefined { return brandIfNonEmpty(value, ProviderItemId.makeUnsafe); }