baa-conductor


baa-conductor / packages / codex-app-server / src
im_wower  ·  2026-03-22

client.ts

  1declare function clearTimeout(handle: unknown): void;
  2declare function setTimeout(callback: () => void, delay?: number): unknown;
  3
  4import {
  5  type CodexAppServerEvent,
  6  type CodexAppServerInitializeCapabilities,
  7  type CodexAppServerInitializeResult,
  8  type CodexAppServerNotificationEnvelope,
  9  type CodexAppServerPlanStep,
 10  type CodexAppServerRequestId,
 11  type CodexAppServerRpcFailure,
 12  type CodexAppServerRpcMessage,
 13  type CodexAppServerRpcSuccess,
 14  type CodexAppServerThreadSession,
 15  type CodexAppServerThreadStartParams,
 16  type CodexAppServerThreadResumeParams,
 17  type CodexAppServerThreadStatus,
 18  type CodexAppServerTurn,
 19  type CodexAppServerTurnError,
 20  type CodexAppServerTurnInterruptParams,
 21  type CodexAppServerTurnStartParams,
 22  type CodexAppServerTurnStartResult,
 23  type CodexAppServerTurnSteerParams,
 24  type CodexAppServerTurnSteerResult,
 25  type CodexAppServerClientInfo,
 26  type JsonValue
 27} from "./contracts.js";
 28import { CodexAppServerEventStream } from "./events.js";
 29import type { CodexAppServerTransport, CodexAppServerTransportHandlers } from "./transport.js";
 30
 31const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
 32
 33interface PendingRequest {
 34  requestMethod: string;
 35  reject(error: Error): void;
 36  resolve(result: unknown): void;
 37  timeoutHandle: unknown;
 38}
 39
 40function isRecord(value: unknown): value is Record<string, unknown> {
 41  return typeof value === "object" && value !== null;
 42}
 43
 44function isRpcSuccess(value: unknown): value is CodexAppServerRpcSuccess {
 45  return isRecord(value) && "id" in value && "result" in value;
 46}
 47
 48function isRpcFailure(value: unknown): value is CodexAppServerRpcFailure {
 49  return isRecord(value) && "id" in value && "error" in value;
 50}
 51
 52function isNotification(value: unknown): value is CodexAppServerNotificationEnvelope {
 53  return isRecord(value) && typeof value.method === "string" && !("id" in value);
 54}
 55
 56function toError(cause: unknown, fallback: string): Error {
 57  if (cause instanceof Error) {
 58    return cause;
 59  }
 60
 61  if (typeof cause === "string" && cause !== "") {
 62    return new Error(cause);
 63  }
 64
 65  return new Error(fallback);
 66}
 67
 68function normalizeThreadStartParams(
 69  params: CodexAppServerThreadStartParams
 70): Record<string, unknown> {
 71  return {
 72    ...params,
 73    experimentalRawEvents: params.experimentalRawEvents ?? false,
 74    persistExtendedHistory: params.persistExtendedHistory ?? false
 75  };
 76}
 77
 78function normalizeThreadResumeParams(
 79  params: CodexAppServerThreadResumeParams
 80): Record<string, unknown> {
 81  return {
 82    ...params,
 83    persistExtendedHistory: params.persistExtendedHistory ?? false
 84  };
 85}
 86
 87function mapNotificationToEvent(notification: CodexAppServerNotificationEnvelope): CodexAppServerEvent {
 88  const params = notification.params;
 89
 90  switch (notification.method) {
 91    case "thread/started":
 92      return {
 93        type: "thread.started",
 94        notificationMethod: "thread/started",
 95        thread: (params as { thread: CodexAppServerThreadSession["thread"] }).thread
 96      };
 97
 98    case "thread/status/changed":
 99      return {
100        type: "thread.status.changed",
101        notificationMethod: "thread/status/changed",
102        threadId: (params as { threadId: string }).threadId,
103        status: (params as { status: CodexAppServerThreadStatus }).status
104      };
105
106    case "turn/started":
107      return {
108        type: "turn.started",
109        notificationMethod: "turn/started",
110        threadId: (params as { threadId: string }).threadId,
111        turn: (params as { turn: CodexAppServerTurn }).turn
112      };
113
114    case "turn/completed":
115      return {
116        type: "turn.completed",
117        notificationMethod: "turn/completed",
118        threadId: (params as { threadId: string }).threadId,
119        turn: (params as { turn: CodexAppServerTurn }).turn
120      };
121
122    case "turn/diff/updated":
123      return {
124        type: "turn.diff.updated",
125        notificationMethod: "turn/diff/updated",
126        threadId: (params as { threadId: string }).threadId,
127        turnId: (params as { turnId: string }).turnId,
128        diff: (params as { diff: string }).diff
129      };
130
131    case "turn/plan/updated":
132      return {
133        type: "turn.plan.updated",
134        notificationMethod: "turn/plan/updated",
135        threadId: (params as { threadId: string }).threadId,
136        turnId: (params as { turnId: string }).turnId,
137        explanation: (params as { explanation: string | null }).explanation,
138        plan: (params as { plan: CodexAppServerPlanStep[] }).plan
139      };
140
141    case "item/agentMessage/delta":
142      return {
143        type: "turn.message.delta",
144        notificationMethod: "item/agentMessage/delta",
145        threadId: (params as { threadId: string }).threadId,
146        turnId: (params as { turnId: string }).turnId,
147        itemId: (params as { itemId: string }).itemId,
148        delta: (params as { delta: string }).delta
149      };
150
151    case "item/plan/delta":
152      return {
153        type: "turn.plan.delta",
154        notificationMethod: "item/plan/delta",
155        threadId: (params as { threadId: string }).threadId,
156        turnId: (params as { turnId: string }).turnId,
157        itemId: (params as { itemId: string }).itemId,
158        delta: (params as { delta: string }).delta
159      };
160
161    case "error":
162      return {
163        type: "turn.error",
164        notificationMethod: "error",
165        threadId: (params as { threadId: string }).threadId,
166        turnId: (params as { turnId: string }).turnId,
167        error: (params as { error: CodexAppServerTurnError }).error,
168        willRetry: (params as { willRetry: boolean }).willRetry
169      };
170
171    case "command/exec/outputDelta":
172      return {
173        type: "command.output.delta",
174        notificationMethod: "command/exec/outputDelta",
175        processId: (params as { processId: string }).processId,
176        stream: (params as { stream: "stdout" | "stderr" }).stream,
177        deltaBase64: (params as { deltaBase64: string }).deltaBase64,
178        capReached: (params as { capReached: boolean }).capReached
179      };
180
181    default:
182      return {
183        type: "notification",
184        notificationMethod: notification.method,
185        params
186      };
187  }
188}
189
190export interface CodexAppServerInitializeOptions {
191  capabilities?: CodexAppServerInitializeCapabilities | null;
192  clientInfo?: CodexAppServerClientInfo;
193}
194
195export interface CodexAppServerClientConfig {
196  clientInfo: CodexAppServerClientInfo;
197  transport: CodexAppServerTransport;
198  capabilities?: CodexAppServerInitializeCapabilities | null;
199  createRequestId?: () => CodexAppServerRequestId;
200  requestTimeoutMs?: number;
201}
202
203export interface CodexAppServerAdapter {
204  readonly events: CodexAppServerEventStream;
205  close(): Promise<void>;
206  initialize(options?: CodexAppServerInitializeOptions): Promise<CodexAppServerInitializeResult>;
207  threadResume(params: CodexAppServerThreadResumeParams): Promise<CodexAppServerThreadSession>;
208  threadStart(params?: CodexAppServerThreadStartParams): Promise<CodexAppServerThreadSession>;
209  turnInterrupt(params: CodexAppServerTurnInterruptParams): Promise<void>;
210  turnStart(params: CodexAppServerTurnStartParams): Promise<CodexAppServerTurnStartResult>;
211  turnSteer(params: CodexAppServerTurnSteerParams): Promise<CodexAppServerTurnSteerResult>;
212}
213
214export class CodexAppServerRpcError extends Error {
215  readonly code: number;
216  readonly data?: JsonValue;
217  readonly requestMethod: string;
218
219  constructor(requestMethod: string, payload: CodexAppServerRpcFailure["error"]) {
220    super(`Codex app-server request failed for ${requestMethod}: ${payload.message}`);
221    this.name = "CodexAppServerRpcError";
222    this.code = payload.code;
223    this.data = payload.data;
224    this.requestMethod = requestMethod;
225  }
226}
227
228export class CodexAppServerClientClosedError extends Error {
229  constructor(message = "Codex app-server client is closed.") {
230    super(message);
231    this.name = "CodexAppServerClientClosedError";
232  }
233}
234
235export class CodexAppServerRequestTimeoutError extends Error {
236  readonly requestMethod: string;
237  readonly timeoutMs: number;
238
239  constructor(requestMethod: string, timeoutMs: number) {
240    super(`Codex app-server request timed out after ${timeoutMs}ms: ${requestMethod}`);
241    this.name = "CodexAppServerRequestTimeoutError";
242    this.requestMethod = requestMethod;
243    this.timeoutMs = timeoutMs;
244  }
245}
246
247export class CodexAppServerClient implements CodexAppServerAdapter {
248  readonly events = new CodexAppServerEventStream();
249
250  private closed = false;
251  private connectPromise: Promise<void> | null = null;
252  private readonly pending = new Map<CodexAppServerRequestId, PendingRequest>();
253  private nextRequestId = 1;
254  private readonly requestTimeoutMs: number;
255  private readonly transportHandlers: CodexAppServerTransportHandlers;
256
257  constructor(private readonly config: CodexAppServerClientConfig) {
258    this.requestTimeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
259    this.transportHandlers = {
260      onClose: (error?: Error) => {
261        this.handleTransportClosed(error);
262      },
263      onMessage: (message: string) => {
264        this.handleTransportMessage(message);
265      }
266    };
267  }
268
269  async initialize(
270    options?: CodexAppServerInitializeOptions
271  ): Promise<CodexAppServerInitializeResult> {
272    return await this.request("initialize", {
273      capabilities: options?.capabilities ?? this.config.capabilities ?? null,
274      clientInfo: options?.clientInfo ?? this.config.clientInfo
275    });
276  }
277
278  async threadStart(
279    params: CodexAppServerThreadStartParams = {}
280  ): Promise<CodexAppServerThreadSession> {
281    return await this.request("thread/start", normalizeThreadStartParams(params));
282  }
283
284  async threadResume(
285    params: CodexAppServerThreadResumeParams
286  ): Promise<CodexAppServerThreadSession> {
287    return await this.request("thread/resume", normalizeThreadResumeParams(params));
288  }
289
290  async turnStart(params: CodexAppServerTurnStartParams): Promise<CodexAppServerTurnStartResult> {
291    return await this.request("turn/start", params);
292  }
293
294  async turnSteer(
295    params: CodexAppServerTurnSteerParams
296  ): Promise<CodexAppServerTurnSteerResult> {
297    return await this.request("turn/steer", params);
298  }
299
300  async turnInterrupt(params: CodexAppServerTurnInterruptParams): Promise<void> {
301    await this.request("turn/interrupt", params);
302  }
303
304  async close(): Promise<void> {
305    if (this.closed) {
306      return;
307    }
308
309    this.closed = true;
310    this.rejectPending(new CodexAppServerClientClosedError());
311    this.events.close();
312    await this.config.transport.close();
313  }
314
315  private async ensureConnected(): Promise<void> {
316    if (this.closed) {
317      throw new CodexAppServerClientClosedError();
318    }
319
320    if (this.connectPromise === null) {
321      this.connectPromise = this.config.transport.connect(this.transportHandlers).catch((error) => {
322        this.connectPromise = null;
323        throw error;
324      });
325    }
326
327    await this.connectPromise;
328  }
329
330  private createRequestId(): CodexAppServerRequestId {
331    if (typeof this.config.createRequestId === "function") {
332      return this.config.createRequestId();
333    }
334
335    const nextId = this.nextRequestId;
336    this.nextRequestId += 1;
337    return nextId;
338  }
339
340  private async request<TResult>(
341    method: string,
342    params: unknown
343  ): Promise<TResult> {
344    await this.ensureConnected();
345
346    const id = this.createRequestId();
347
348    return await new Promise<TResult>((resolve, reject) => {
349      const timeoutHandle = setTimeout(() => {
350        this.pending.delete(id);
351        reject(new CodexAppServerRequestTimeoutError(method, this.requestTimeoutMs));
352      }, this.requestTimeoutMs);
353
354      this.pending.set(id, {
355        requestMethod: method,
356        timeoutHandle,
357        resolve,
358        reject
359      });
360
361      const payload = JSON.stringify({
362        id,
363        method,
364        params
365      });
366
367      this.config.transport.send(payload).catch((error) => {
368        clearTimeout(timeoutHandle);
369        this.pending.delete(id);
370        reject(toError(error, `Failed to send Codex app-server request: ${method}`));
371      });
372    });
373  }
374
375  private handleTransportMessage(message: string): void {
376    let parsed: CodexAppServerRpcMessage;
377
378    try {
379      parsed = JSON.parse(message) as CodexAppServerRpcMessage;
380    } catch (error) {
381      this.rejectPending(toError(error, "Failed to parse Codex app-server message."));
382      return;
383    }
384
385    if (isRpcSuccess(parsed)) {
386      const pending = this.pending.get(parsed.id);
387
388      if (pending === undefined) {
389        return;
390      }
391
392      clearTimeout(pending.timeoutHandle);
393      this.pending.delete(parsed.id);
394      pending.resolve(parsed.result);
395      return;
396    }
397
398    if (isRpcFailure(parsed)) {
399      const requestId = parsed.id;
400
401      if (requestId === null) {
402        return;
403      }
404
405      const pending = this.pending.get(requestId);
406
407      if (pending === undefined) {
408        return;
409      }
410
411      clearTimeout(pending.timeoutHandle);
412      this.pending.delete(requestId);
413      pending.reject(new CodexAppServerRpcError(pending.requestMethod, parsed.error));
414      return;
415    }
416
417    if (isNotification(parsed)) {
418      this.events.emit(mapNotificationToEvent(parsed));
419    }
420  }
421
422  private handleTransportClosed(error?: Error): void {
423    if (this.closed) {
424      return;
425    }
426
427    this.closed = true;
428    this.rejectPending(error ?? new CodexAppServerClientClosedError("Codex app-server transport closed."));
429    this.events.close();
430  }
431
432  private rejectPending(error: Error): void {
433    for (const pending of this.pending.values()) {
434      clearTimeout(pending.timeoutHandle);
435      pending.reject(error);
436    }
437
438    this.pending.clear();
439  }
440}