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}