im_wower
·
2026-03-25
daemon.ts
1import { spawn } from "node:child_process";
2import { access } from "node:fs/promises";
3
4import {
5 CodexAppServerClient,
6 createCodexAppServerTextInput,
7 createCodexAppServerWebSocketTransport,
8 type CodexAppServerAdapter,
9 type CodexAppServerApprovalPolicy,
10 type CodexAppServerEvent,
11 type CodexAppServerInitializeResult,
12 type CodexAppServerSandboxMode,
13 type CodexAppServerSandboxPolicy,
14 type CodexAppServerThreadResumeParams,
15 type CodexAppServerThreadSession,
16 type CodexAppServerThreadStartParams,
17 type CodexAppServerTurnId,
18 type CodexAppServerTurnStartParams,
19 type CodexAppServerTurnStartResult,
20 type CodexAppServerTurnSteerParams,
21 type CodexAppServerTurnSteerResult,
22 type CodexAppServerUserInput,
23 type JsonValue
24} from "../../../packages/codex-app-server/src/index.js";
25import {
26 CODEX_EXEC_PURPOSES,
27 CODEX_EXEC_SANDBOX_MODES,
28 runCodexExec,
29 type CodexExecPurpose,
30 type CodexExecRunRequest,
31 type CodexExecRunResponse,
32 type CodexExecSandboxMode
33} from "../../../packages/codex-exec/src/index.js";
34import {
35 createCodexdAppServerStdioTransport,
36 type CodexdAppServerTransportCloseDiagnostic,
37 type CodexdAppServerProcessLike
38} from "./app-server-transport.js";
39import type {
40 CodexdEnvironment,
41 CodexdManagedChildState,
42 CodexdRecentEvent,
43 CodexdResolvedConfig,
44 CodexdRunRecord,
45 CodexdSessionPurpose,
46 CodexdSessionRecord,
47 CodexdSmokeCheck,
48 CodexdSmokeResult,
49 CodexdStatusSnapshot
50} from "./contracts.js";
51import { CodexdStateStore, type CodexdStateStoreOptions } from "./state-store.js";
52
53export interface CodexdProcessOutput {
54 on(event: "data", listener: (chunk: string | Uint8Array) => void): this;
55 on(event: "end", listener: () => void): this;
56 on(event: "error", listener: (error: Error) => void): this;
57 setEncoding?(encoding: string): this;
58}
59
60export interface CodexdProcessInput {
61 end(chunk?: string | Uint8Array): unknown;
62 write(chunk: string | Uint8Array): boolean;
63}
64
65export interface CodexdChildProcessLike extends CodexdAppServerProcessLike {
66 stdin?: CodexdProcessInput;
67 stderr?: CodexdProcessOutput;
68 stdout?: CodexdProcessOutput;
69 kill(signal?: string): boolean;
70 on(event: "error", listener: (error: Error) => void): this;
71 on(event: "exit", listener: (code: number | null, signal: string | null) => void): this;
72 on(event: "spawn", listener: () => void): this;
73 once(event: "error", listener: (error: Error) => void): this;
74 once(event: "exit", listener: (code: number | null, signal: string | null) => void): this;
75 once(event: "spawn", listener: () => void): this;
76}
77
78export interface CodexdSpawnOptions {
79 cwd: string;
80 env: CodexdEnvironment;
81}
82
83export interface CodexdSpawner {
84 spawn(command: string, args: readonly string[], options: CodexdSpawnOptions): CodexdChildProcessLike;
85}
86
87export interface CodexdAppServerClientFactoryContext {
88 child: CodexdChildProcessLike | null;
89 config: CodexdResolvedConfig;
90}
91
92export interface CodexdAppServerClientFactory {
93 create(context: CodexdAppServerClientFactoryContext): Promise<CodexAppServerAdapter> | CodexAppServerAdapter;
94}
95
96export interface CodexdCreateSessionInput {
97 approvalPolicy?: CodexAppServerApprovalPolicy | null;
98 baseInstructions?: string | null;
99 config?: Record<string, JsonValue | undefined> | null;
100 cwd?: string | null;
101 developerInstructions?: string | null;
102 ephemeral?: boolean | null;
103 metadata?: Record<string, string>;
104 model?: string | null;
105 modelProvider?: string | null;
106 personality?: string | null;
107 purpose: CodexdSessionPurpose;
108 sandbox?: CodexAppServerSandboxMode | null;
109 serviceTier?: string | null;
110 threadId?: string | null;
111}
112
113export interface CodexdSessionInput {
114 metadata?: Record<string, string>;
115 purpose: CodexdSessionPurpose;
116 threadId?: string | null;
117}
118
119export interface CodexdTurnInput {
120 approvalPolicy?: CodexAppServerApprovalPolicy | null;
121 collaborationMode?: JsonValue | null;
122 cwd?: string | null;
123 effort?: string | null;
124 expectedTurnId?: string | null;
125 input: CodexAppServerUserInput[] | string;
126 model?: string | null;
127 outputSchema?: JsonValue | null;
128 personality?: string | null;
129 sandboxPolicy?: CodexAppServerSandboxPolicy | null;
130 serviceTier?: string | null;
131 sessionId: string;
132 summary?: JsonValue | null;
133}
134
135export interface CodexdTurnResponse {
136 accepted: true;
137 session: CodexdSessionRecord;
138 turnId: string;
139}
140
141export interface CodexdRunInput {
142 additionalWritableDirectories?: string[];
143 config?: string[];
144 cwd?: string;
145 env?: Record<string, string | undefined>;
146 images?: string[];
147 metadata?: Record<string, string>;
148 model?: string;
149 profile?: string;
150 prompt: string;
151 purpose?: string | null;
152 sandbox?: string | null;
153 sessionId?: string | null;
154 skipGitRepoCheck?: boolean;
155 timeoutMs?: number;
156}
157
158export interface CodexdRuntimeEventSubscription {
159 unsubscribe(): void;
160}
161
162export type CodexdRuntimeEventListener = (event: CodexdRecentEvent) => void;
163export type CodexdRunExecutor = (request: CodexExecRunRequest) => Promise<CodexExecRunResponse>;
164
165export interface CodexdDaemonOptions extends CodexdStateStoreOptions {
166 appServerClientFactory?: CodexdAppServerClientFactory;
167 env?: CodexdEnvironment;
168 runExecutor?: CodexdRunExecutor;
169 spawner?: CodexdSpawner;
170}
171
172const MAX_CHILD_OUTPUT_PREVIEW = 160;
173const STOP_TIMEOUT_MS = 1_000;
174const DEFAULT_APP_SERVER_VERSION = "0.0.0";
175
176export class CodexdDaemon {
177 private appServerClient: CodexAppServerAdapter | null = null;
178 private appServerInitializeResult: CodexAppServerInitializeResult | null = null;
179 private appServerInitializationPromise: Promise<CodexAppServerAdapter> | null = null;
180 private child: CodexdChildProcessLike | null = null;
181 private readonly env: CodexdEnvironment;
182 private readonly eventListeners = new Set<CodexdRuntimeEventListener>();
183 private readonly appServerClientFactory: CodexdAppServerClientFactory;
184 private readonly runExecutor: CodexdRunExecutor;
185 private readonly stateStore: CodexdStateStore;
186 private readonly spawner: CodexdSpawner;
187 private started = false;
188
189 constructor(
190 private readonly config: CodexdResolvedConfig,
191 options: CodexdDaemonOptions = {}
192 ) {
193 const forwardEvent = options.onEvent;
194
195 this.env = options.env ?? (typeof process !== "undefined" ? process.env : {});
196 this.spawner = options.spawner ?? {
197 spawn(command, args, spawnOptions) {
198 return spawn(command, [...args], {
199 cwd: spawnOptions.cwd,
200 env: spawnOptions.env,
201 stdio: ["pipe", "pipe", "pipe"]
202 }) as unknown as CodexdChildProcessLike;
203 }
204 };
205 this.appServerClientFactory = options.appServerClientFactory ?? {
206 create: async (context) => this.createDefaultAppServerClient(context)
207 };
208 this.runExecutor = options.runExecutor ?? runCodexExec;
209 this.stateStore = new CodexdStateStore(config, {
210 ...options,
211 onEvent: (event) => {
212 forwardEvent?.(event);
213 this.emitRuntimeEvent(event);
214 }
215 });
216 }
217
218 async start(): Promise<CodexdStatusSnapshot> {
219 await this.stateStore.initialize();
220
221 if (this.started) {
222 return this.stateStore.getSnapshot();
223 }
224
225 await this.stateStore.markDaemonStarted();
226 await this.stateStore.recordEvent({
227 level: "info",
228 type: "daemon.started",
229 message: `codexd started in ${this.config.server.mode} mode.`,
230 detail: {
231 endpoint: this.config.server.endpoint,
232 localApiBase: this.config.service.localApiBase,
233 strategy: this.config.server.childStrategy
234 }
235 });
236
237 if (this.config.server.childStrategy === "external") {
238 await this.stateStore.updateChildState({
239 status: "external",
240 pid: null,
241 startedAt: new Date().toISOString(),
242 exitedAt: null,
243 exitCode: null,
244 signal: null,
245 lastError: null
246 });
247 await this.stateStore.recordEvent({
248 level: "info",
249 type: "child.external",
250 message: `codexd is pointing at external endpoint ${this.config.server.endpoint}.`
251 });
252 this.started = true;
253 return this.stateStore.getSnapshot();
254 }
255
256 await this.stateStore.updateChildState({
257 status: "starting",
258 pid: null,
259 startedAt: null,
260 exitedAt: null,
261 exitCode: null,
262 signal: null,
263 lastError: null
264 });
265
266 const child = this.spawner.spawn(
267 this.config.server.childCommand,
268 this.config.server.childArgs,
269 {
270 cwd: this.config.server.childCwd,
271 env: {
272 ...this.env,
273 BAA_CODEXD_DAEMON_ID: this.stateStore.getSnapshot().identity.daemonId,
274 BAA_CODEXD_SERVER_ENDPOINT: this.config.server.endpoint
275 }
276 }
277 );
278 this.child = child;
279 this.attachChildListeners(child);
280
281 try {
282 await waitForChildSpawn(child);
283 } catch (error) {
284 await this.stateStore.updateChildState({
285 status: "failed",
286 pid: null,
287 exitedAt: new Date().toISOString(),
288 lastError: formatErrorMessage(error)
289 });
290 await this.stateStore.recordEvent({
291 level: "error",
292 type: "child.spawn.failed",
293 message: formatErrorMessage(error)
294 });
295 this.child = null;
296 throw error;
297 }
298
299 await this.stateStore.updateChildState({
300 status: "running",
301 pid: child.pid ?? null,
302 startedAt: new Date().toISOString(),
303 exitedAt: null,
304 exitCode: null,
305 signal: null,
306 lastError: null
307 });
308 await this.stateStore.recordEvent({
309 level: "info",
310 type: "child.started",
311 message: `Started Codex child process ${child.pid ?? "unknown"}.`,
312 detail: {
313 args: this.config.server.childArgs,
314 command: this.config.server.childCommand
315 }
316 });
317
318 this.started = true;
319 return this.stateStore.getSnapshot();
320 }
321
322 async stop(): Promise<CodexdStatusSnapshot> {
323 await this.stateStore.initialize();
324 await this.closeAppServerClient();
325
326 if (this.child != null) {
327 const child = this.child;
328 this.child = null;
329
330 const exited = waitForChildExit(child, STOP_TIMEOUT_MS);
331
332 try {
333 child.kill("SIGTERM");
334 } catch (error) {
335 await this.stateStore.recordEvent({
336 level: "warn",
337 type: "child.kill.failed",
338 message: formatErrorMessage(error)
339 });
340 }
341
342 await exited;
343 } else {
344 const currentChildState = this.stateStore.getChildState();
345
346 if (currentChildState.status === "external") {
347 await this.stateStore.updateChildState({
348 status: "idle",
349 pid: null,
350 exitCode: null,
351 signal: null,
352 exitedAt: new Date().toISOString()
353 });
354 }
355 }
356
357 await this.stateStore.markDaemonStopped();
358 await this.stateStore.recordEvent({
359 level: "info",
360 type: "daemon.stopped",
361 message: "codexd stopped."
362 });
363
364 this.started = false;
365 return this.stateStore.getSnapshot();
366 }
367
368 getRun(runId: string): CodexdRunRecord | null {
369 return this.stateStore.getRun(runId);
370 }
371
372 getSession(sessionId: string): CodexdSessionRecord | null {
373 return this.stateStore.getSession(sessionId);
374 }
375
376 getStatusSnapshot(): CodexdStatusSnapshot {
377 return this.stateStore.getSnapshot();
378 }
379
380 listRuns(): CodexdRunRecord[] {
381 return this.stateStore.listRuns();
382 }
383
384 listSessions(): CodexdSessionRecord[] {
385 return this.stateStore.listSessions();
386 }
387
388 subscribe(listener: CodexdRuntimeEventListener): CodexdRuntimeEventSubscription {
389 this.eventListeners.add(listener);
390
391 return {
392 unsubscribe: () => {
393 this.eventListeners.delete(listener);
394 }
395 };
396 }
397
398 async createRun(input: CodexdRunInput): Promise<CodexdRunRecord> {
399 await this.stateStore.initialize();
400
401 if (input.sessionId != null && this.stateStore.getSession(input.sessionId) == null) {
402 throw new Error(`Unknown codexd session "${input.sessionId}".`);
403 }
404
405 const now = new Date().toISOString();
406 const run: CodexdRunRecord = {
407 runId: createRunId(),
408 sessionId: input.sessionId ?? null,
409 status: "queued",
410 adapter: "codex-exec",
411 prompt: normalizeRequiredString(input.prompt, "prompt"),
412 purpose: normalizeRunPurpose(input.purpose ?? "fallback-worker"),
413 cwd: normalizeOptionalString(input.cwd),
414 createdAt: now,
415 updatedAt: now,
416 startedAt: null,
417 finishedAt: null,
418 error: null,
419 summary: null,
420 metadata: normalizeStringRecord(input.metadata),
421 result: null
422 };
423
424 await this.stateStore.upsertRun(run);
425 await this.stateStore.recordEvent({
426 level: "info",
427 type: "run.queued",
428 message: `Queued run ${run.runId}.`,
429 detail: {
430 cwd: run.cwd,
431 purpose: run.purpose,
432 runId: run.runId,
433 sessionId: run.sessionId
434 }
435 });
436
437 void this.executeRun(run, input);
438 return run;
439 }
440
441 async createSession(input: CodexdCreateSessionInput): Promise<CodexdSessionRecord> {
442 await this.stateStore.initialize();
443
444 if (this.config.server.mode !== "app-server") {
445 throw new Error("codexd sessions require app-server mode.");
446 }
447
448 const client = await this.ensureAppServerClient();
449 const sessionResult =
450 input.threadId != null
451 ? await client.threadResume(buildThreadResumeParams(input))
452 : await client.threadStart(buildThreadStartParams(input));
453 const session = buildSessionRecord(input, sessionResult, this.stateStore.getChildState().pid);
454
455 await this.stateStore.upsertSession(session);
456 await this.stateStore.recordEvent({
457 level: "info",
458 type: input.threadId != null ? "session.resumed" : "session.created",
459 message:
460 input.threadId != null
461 ? `Resumed session ${session.sessionId} for thread ${session.threadId}.`
462 : `Created session ${session.sessionId} for thread ${session.threadId}.`,
463 detail: {
464 purpose: session.purpose,
465 sessionId: session.sessionId,
466 threadId: session.threadId
467 }
468 });
469
470 return session;
471 }
472
473 async createTurn(input: CodexdTurnInput): Promise<CodexdTurnResponse> {
474 await this.stateStore.initialize();
475
476 if (this.config.server.mode !== "app-server") {
477 throw new Error("codexd turns require app-server mode.");
478 }
479
480 const current = this.stateStore.getSession(input.sessionId);
481
482 if (current == null) {
483 throw new Error(`Unknown codexd session "${input.sessionId}".`);
484 }
485
486 if (current.threadId == null) {
487 throw new Error(`Session "${input.sessionId}" does not have an app-server thread.`);
488 }
489
490 if (current.status !== "active") {
491 throw new Error(`Session "${input.sessionId}" is not active.`);
492 }
493
494 const client = await this.ensureAppServerClient();
495 const items = normalizeTurnInputItems(input.input);
496 let turnId: CodexAppServerTurnId;
497
498 if (input.expectedTurnId != null) {
499 const result = await client.turnSteer(
500 buildTurnSteerParams(current.threadId, input.expectedTurnId, items)
501 );
502 turnId = extractTurnId(result);
503 } else {
504 const result = await client.turnStart(buildTurnStartParams(current.threadId, input, items));
505 turnId = extractTurnId(result);
506 }
507
508 const updatedSession = this.stateStore.getSession(input.sessionId) ?? current;
509 await this.stateStore.recordEvent({
510 level: "info",
511 type: "turn.accepted",
512 message: `Accepted turn ${turnId} for session ${current.sessionId}.`,
513 detail: {
514 sessionId: current.sessionId,
515 threadId: current.threadId,
516 turnId
517 }
518 });
519
520 return {
521 accepted: true,
522 session: updatedSession,
523 turnId
524 };
525 }
526
527 async registerSession(input: CodexdSessionInput): Promise<CodexdSessionRecord> {
528 await this.stateStore.initialize();
529 const now = new Date().toISOString();
530 const session: CodexdSessionRecord = {
531 sessionId: createSessionId(),
532 purpose: input.purpose,
533 threadId: input.threadId ?? null,
534 status: "active",
535 endpoint: this.config.server.endpoint,
536 childPid: this.stateStore.getChildState().pid,
537 createdAt: now,
538 updatedAt: now,
539 cwd: null,
540 model: null,
541 modelProvider: null,
542 serviceTier: null,
543 reasoningEffort: null,
544 currentTurnId: null,
545 lastTurnId: null,
546 lastTurnStatus: null,
547 metadata: normalizeStringRecord(input.metadata)
548 };
549
550 await this.stateStore.upsertSession(session);
551 await this.stateStore.recordEvent({
552 level: "info",
553 type: "session.registered",
554 message: `Registered ${input.purpose} session ${session.sessionId}.`,
555 detail: {
556 sessionId: session.sessionId,
557 threadId: session.threadId
558 }
559 });
560
561 return session;
562 }
563
564 async closeSession(sessionId: string): Promise<CodexdSessionRecord | null> {
565 await this.stateStore.initialize();
566 const session = await this.stateStore.closeSession(sessionId);
567
568 if (session != null) {
569 await this.stateStore.recordEvent({
570 level: "info",
571 type: "session.closed",
572 message: `Closed session ${sessionId}.`,
573 detail: {
574 sessionId
575 }
576 });
577 }
578
579 return session;
580 }
581
582 private emitRuntimeEvent(event: CodexdRecentEvent): void {
583 for (const listener of this.eventListeners) {
584 listener(event);
585 }
586 }
587
588 private async ensureAppServerClient(): Promise<CodexAppServerAdapter> {
589 if (this.appServerClient != null && this.appServerInitializeResult != null) {
590 return this.appServerClient;
591 }
592
593 if (this.appServerInitializationPromise != null) {
594 return await this.appServerInitializationPromise;
595 }
596
597 this.appServerInitializationPromise = (async () => {
598 const client = this.appServerClient
599 ?? await this.appServerClientFactory.create({
600 child: this.child,
601 config: this.config
602 });
603
604 if (this.appServerClient !== client) {
605 this.appServerClient = client;
606 client.events.subscribe((event) => {
607 void this.handleAppServerEvent(event);
608 });
609 }
610
611 try {
612 this.appServerInitializeResult = await client.initialize();
613 await this.stateStore.recordEvent({
614 level: "info",
615 type: "app-server.connected",
616 message: `Connected codexd to app-server ${this.config.server.endpoint}.`,
617 detail: {
618 endpoint: this.config.server.endpoint,
619 platformFamily: this.appServerInitializeResult.platformFamily,
620 platformOs: this.appServerInitializeResult.platformOs,
621 userAgent: this.appServerInitializeResult.userAgent
622 }
623 });
624 } catch (error) {
625 this.appServerClient = null;
626 this.appServerInitializeResult = null;
627 await this.stateStore.recordEvent({
628 level: "error",
629 type: "app-server.connect.failed",
630 message: formatErrorMessage(error),
631 detail: {
632 endpoint: this.config.server.endpoint
633 }
634 });
635 throw error;
636 }
637
638 return client;
639 })();
640
641 try {
642 return await this.appServerInitializationPromise;
643 } finally {
644 this.appServerInitializationPromise = null;
645 }
646 }
647
648 private async closeAppServerClient(): Promise<void> {
649 this.appServerInitializeResult = null;
650 this.appServerInitializationPromise = null;
651
652 const client = this.appServerClient;
653 this.appServerClient = null;
654
655 if (client == null) {
656 return;
657 }
658
659 try {
660 await client.close();
661 } catch (error) {
662 await this.stateStore.recordEvent({
663 level: "warn",
664 type: "app-server.close.failed",
665 message: formatErrorMessage(error)
666 });
667 }
668 }
669
670 private async executeRun(initialRun: CodexdRunRecord, input: CodexdRunInput): Promise<void> {
671 const startedAt = new Date().toISOString();
672 let run: CodexdRunRecord = {
673 ...initialRun,
674 status: "running",
675 startedAt,
676 updatedAt: startedAt
677 };
678
679 await this.stateStore.upsertRun(run);
680 await this.stateStore.recordEvent({
681 level: "info",
682 type: "run.started",
683 message: `Started run ${run.runId}.`,
684 detail: {
685 runId: run.runId
686 }
687 });
688
689 try {
690 const response = await this.runExecutor({
691 additionalWritableDirectories: input.additionalWritableDirectories,
692 config: input.config,
693 cwd: input.cwd,
694 env: input.env,
695 images: input.images,
696 model: input.model,
697 profile: input.profile,
698 prompt: run.prompt,
699 purpose: normalizeRunPurpose(run.purpose),
700 sandbox: normalizeRunSandbox(input.sandbox),
701 skipGitRepoCheck: input.skipGitRepoCheck,
702 timeoutMs: input.timeoutMs
703 });
704 const finishedAt = new Date().toISOString();
705
706 if (response.ok) {
707 run = {
708 ...run,
709 status: "completed",
710 finishedAt,
711 updatedAt: finishedAt,
712 summary: response.result.lastMessage ?? `exit_code:${String(response.result.exitCode)}`,
713 result: toJsonRecord(response)
714 };
715 await this.stateStore.upsertRun(run);
716 await this.stateStore.recordEvent({
717 level: "info",
718 type: "run.completed",
719 message: `Completed run ${run.runId}.`,
720 detail: {
721 runId: run.runId
722 }
723 });
724 return;
725 }
726
727 run = {
728 ...run,
729 status: "failed",
730 finishedAt,
731 updatedAt: finishedAt,
732 error: response.error.message,
733 summary: response.error.message,
734 result: toJsonRecord(response)
735 };
736 await this.stateStore.upsertRun(run);
737 await this.stateStore.recordEvent({
738 level: "error",
739 type: "run.failed",
740 message: `Run ${run.runId} failed: ${response.error.message}`,
741 detail: {
742 runId: run.runId
743 }
744 });
745 } catch (error) {
746 const finishedAt = new Date().toISOString();
747 run = {
748 ...run,
749 status: "failed",
750 finishedAt,
751 updatedAt: finishedAt,
752 error: formatErrorMessage(error),
753 summary: formatErrorMessage(error),
754 result: {
755 error: formatErrorMessage(error)
756 }
757 };
758 await this.stateStore.upsertRun(run);
759 await this.stateStore.recordEvent({
760 level: "error",
761 type: "run.failed",
762 message: `Run ${run.runId} failed: ${formatErrorMessage(error)}`,
763 detail: {
764 runId: run.runId
765 }
766 });
767 }
768 }
769
770 private async handleAppServerEvent(event: CodexAppServerEvent): Promise<void> {
771 switch (event.type) {
772 case "thread.status.changed":
773 await this.patchSessionsByThreadId(event.threadId, (session) => ({
774 ...session,
775 updatedAt: new Date().toISOString()
776 }));
777 break;
778
779 case "turn.started":
780 await this.patchSessionsByThreadId(event.threadId, (session) => ({
781 ...session,
782 currentTurnId: event.turn.id,
783 lastTurnId: event.turn.id,
784 lastTurnStatus: event.turn.status,
785 updatedAt: new Date().toISOString()
786 }));
787 break;
788
789 case "turn.completed":
790 await this.patchSessionsByThreadId(event.threadId, (session) => ({
791 ...session,
792 currentTurnId: session.currentTurnId === event.turn.id ? null : session.currentTurnId,
793 lastTurnId: event.turn.id,
794 lastTurnStatus: event.turn.status,
795 updatedAt: new Date().toISOString()
796 }));
797 break;
798
799 case "turn.error":
800 await this.patchSessionsByThreadId(event.threadId, (session) => ({
801 ...session,
802 currentTurnId: event.willRetry
803 ? session.currentTurnId ?? event.turnId
804 : session.currentTurnId === event.turnId
805 ? null
806 : session.currentTurnId,
807 lastTurnId: event.turnId,
808 lastTurnStatus: event.willRetry ? "inProgress" : "failed",
809 updatedAt: new Date().toISOString()
810 }));
811 break;
812
813 default:
814 break;
815 }
816
817 await this.stateStore.recordEvent(mapAppServerEventToRecentEvent(event));
818 }
819
820 private async handleAppServerTransportClosed(
821 diagnostic: CodexdAppServerTransportCloseDiagnostic
822 ): Promise<void> {
823 this.appServerClient = null;
824 this.appServerInitializeResult = null;
825 this.appServerInitializationPromise = null;
826
827 await this.stateStore.recordEvent({
828 level: "warn",
829 type: "app-server.transport.closed",
830 message: `App-server transport closed via ${diagnostic.source}: ${diagnostic.message}`,
831 detail: {
832 exitCode: diagnostic.exitCode ?? null,
833 flushedTrailingMessage: diagnostic.flushedTrailingMessage,
834 signal: diagnostic.signal ?? null,
835 source: diagnostic.source,
836 trailingMessageLength: diagnostic.trailingMessageLength
837 }
838 });
839
840 const childState = this.stateStore.getChildState();
841 const interruptedSessions = this.stateStore
842 .listSessions()
843 .filter(
844 (session) =>
845 session.status === "active" && session.threadId != null && session.currentTurnId != null
846 );
847
848 for (const session of interruptedSessions) {
849 const turnId = session.currentTurnId ?? session.lastTurnId;
850
851 if (turnId == null) {
852 continue;
853 }
854
855 await this.stateStore.upsertSession({
856 ...session,
857 currentTurnId: null,
858 lastTurnId: turnId,
859 lastTurnStatus: "failed",
860 updatedAt: new Date().toISOString()
861 });
862 await this.stateStore.recordEvent({
863 level: "error",
864 type: "app-server.turn.completed.missing",
865 message: `App-server transport closed before turn ${turnId} completed.`,
866 detail: {
867 childExitCode: diagnostic.exitCode ?? childState.exitCode,
868 childPid: childState.pid,
869 childSignal: diagnostic.signal ?? childState.signal,
870 childStatus: childState.status,
871 failureClass: "transport_closed_before_turn_completed",
872 flushedTrailingMessage: diagnostic.flushedTrailingMessage,
873 sessionId: session.sessionId,
874 threadId: session.threadId,
875 trailingMessageLength: diagnostic.trailingMessageLength,
876 transportCloseMessage: diagnostic.message,
877 transportCloseSource: diagnostic.source,
878 turnId,
879 turnStatusAtClose: session.lastTurnStatus
880 }
881 });
882 }
883 }
884
885 private async patchSessionsByThreadId(
886 threadId: string,
887 update: (session: CodexdSessionRecord) => CodexdSessionRecord
888 ): Promise<void> {
889 const sessions = this.stateStore
890 .listSessions()
891 .filter((session) => session.threadId === threadId);
892
893 for (const session of sessions) {
894 await this.stateStore.upsertSession(update(session));
895 }
896 }
897
898 private attachChildListeners(child: CodexdChildProcessLike): void {
899 child.stdout?.on("data", (chunk) => {
900 void this.handleChildOutput("stdout", chunk);
901 });
902 child.stderr?.on("data", (chunk) => {
903 void this.handleChildOutput("stderr", chunk);
904 });
905 child.on("error", (error) => {
906 void this.handleChildError(error);
907 });
908 child.on("exit", (code, signal) => {
909 void this.handleChildExit(code, signal);
910 });
911 }
912
913 private async handleChildError(error: Error): Promise<void> {
914 await this.stateStore.updateChildState({
915 status: "failed",
916 lastError: error.message
917 });
918 await this.stateStore.recordEvent({
919 level: "error",
920 type: "child.error",
921 message: error.message
922 });
923 }
924
925 private async handleChildExit(code: number | null, signal: string | null): Promise<void> {
926 const stoppedAt = new Date().toISOString();
927 const status = code == null || code === 0 ? "stopped" : "failed";
928
929 await this.stateStore.updateChildState({
930 status,
931 pid: null,
932 exitedAt: stoppedAt,
933 exitCode: code,
934 signal,
935 lastError: status === "failed" ? `Child exited with code ${String(code)}.` : null
936 });
937 await this.stateStore.recordEvent({
938 level: status === "failed" ? "error" : "info",
939 type: "child.exited",
940 message:
941 status === "failed"
942 ? `Codex child exited with code ${String(code)}.`
943 : `Codex child exited${signal ? ` after ${signal}` : ""}.`,
944 detail: {
945 code,
946 signal
947 }
948 });
949 }
950
951 private async handleChildOutput(
952 stream: "stderr" | "stdout",
953 chunk: string | Uint8Array
954 ): Promise<void> {
955 const text = normalizeOutputChunk(chunk);
956
957 if (text === "") {
958 return;
959 }
960
961 await this.stateStore.appendChildOutput(stream, text);
962
963 if (this.config.server.mode === "app-server" && stream === "stdout") {
964 return;
965 }
966
967 await this.stateStore.recordEvent({
968 level: stream === "stderr" ? "warn" : "info",
969 type: `child.${stream}`,
970 message: `${stream}: ${createOutputPreview(text)}`,
971 detail: {
972 bytes: text.length,
973 stream
974 }
975 });
976 }
977
978 private async createDefaultAppServerClient(
979 context: CodexdAppServerClientFactoryContext
980 ): Promise<CodexAppServerAdapter> {
981 if (this.config.server.mode !== "app-server") {
982 throw new Error("codexd app-server client requested while running in exec mode.");
983 }
984
985 if (this.config.server.childStrategy === "spawn") {
986 if (context.child == null) {
987 throw new Error("codexd app-server child is not available yet.");
988 }
989
990 return new CodexAppServerClient({
991 clientInfo: {
992 name: "baa-conductor-codexd",
993 title: "codexd local service",
994 version: this.config.version ?? DEFAULT_APP_SERVER_VERSION
995 },
996 transport: createCodexdAppServerStdioTransport({
997 onCloseDiagnostic: (diagnostic) => {
998 void this.handleAppServerTransportClosed(diagnostic);
999 },
1000 process: context.child
1001 })
1002 });
1003 }
1004
1005 const wsUrl = resolveAppServerWebSocketUrl(this.config.server.endpoint);
1006
1007 return new CodexAppServerClient({
1008 clientInfo: {
1009 name: "baa-conductor-codexd",
1010 title: "codexd local service",
1011 version: this.config.version ?? DEFAULT_APP_SERVER_VERSION
1012 },
1013 transport: createCodexAppServerWebSocketTransport({
1014 url: wsUrl
1015 })
1016 });
1017 }
1018}
1019
1020export async function runCodexdSmoke(
1021 baseConfig: CodexdResolvedConfig,
1022 options: CodexdDaemonOptions = {}
1023): Promise<CodexdSmokeResult> {
1024 const smokeConfig: CodexdResolvedConfig = {
1025 ...baseConfig,
1026 server: {
1027 ...baseConfig.server,
1028 childStrategy: "spawn",
1029 childCommand: typeof process !== "undefined" ? process.execPath : "node",
1030 childArgs: ["-e", EMBEDDED_SMOKE_PROGRAM],
1031 endpoint: "stdio://embedded-smoke-child"
1032 }
1033 };
1034 const daemon = new CodexdDaemon(smokeConfig, options);
1035
1036 await daemon.start();
1037 const session = await daemon.registerSession({
1038 purpose: "smoke",
1039 metadata: {
1040 source: "embedded-smoke"
1041 }
1042 });
1043
1044 await sleep(Math.max(smokeConfig.smokeLifetimeMs, 75));
1045 await daemon.closeSession(session.sessionId);
1046 const snapshot = await daemon.stop();
1047 const checks: CodexdSmokeCheck[] = [
1048 await buildFileCheck("structured_event_log", smokeConfig.paths.structuredEventLogPath),
1049 await buildFileCheck("stdout_log", smokeConfig.paths.stdoutLogPath),
1050 await buildFileCheck("stderr_log", smokeConfig.paths.stderrLogPath),
1051 await buildFileCheck("daemon_state", smokeConfig.paths.daemonStatePath),
1052 {
1053 name: "recent_event_cache",
1054 status: snapshot.recentEvents.events.length > 0 ? "ok" : "failed",
1055 detail: `${snapshot.recentEvents.events.length} cached events`
1056 },
1057 {
1058 name: "session_registry",
1059 status: hasClosedSmokeSession(snapshot.recentEvents.events, snapshot.daemon.child, snapshot)
1060 ? "ok"
1061 : "failed",
1062 detail: `${snapshot.sessionRegistry.sessions.length} recorded sessions`
1063 }
1064 ];
1065
1066 return {
1067 checks,
1068 snapshot
1069 };
1070}
1071
1072async function buildFileCheck(name: string, path: string): Promise<CodexdSmokeCheck> {
1073 try {
1074 await access(path);
1075 return {
1076 name,
1077 status: "ok",
1078 detail: path
1079 };
1080 } catch {
1081 return {
1082 name,
1083 status: "failed",
1084 detail: `missing: ${path}`
1085 };
1086 }
1087}
1088
1089function buildSessionRecord(
1090 input: CodexdCreateSessionInput,
1091 session: CodexAppServerThreadSession,
1092 childPid: number | null
1093): CodexdSessionRecord {
1094 const now = new Date().toISOString();
1095 const lastTurn = session.thread.turns?.[session.thread.turns.length - 1] ?? null;
1096
1097 return {
1098 sessionId: createSessionId(),
1099 purpose: input.purpose,
1100 threadId: session.thread.id,
1101 status: "active",
1102 endpoint: session.cwd,
1103 childPid,
1104 createdAt: now,
1105 updatedAt: now,
1106 cwd: session.cwd,
1107 model: session.model,
1108 modelProvider: session.modelProvider,
1109 serviceTier: session.serviceTier,
1110 reasoningEffort: session.reasoningEffort,
1111 currentTurnId: null,
1112 lastTurnId: lastTurn?.id ?? null,
1113 lastTurnStatus: lastTurn?.status ?? null,
1114 metadata: normalizeStringRecord(input.metadata)
1115 };
1116}
1117
1118function buildThreadResumeParams(
1119 input: CodexdCreateSessionInput
1120): CodexAppServerThreadResumeParams {
1121 const threadId = normalizeOptionalString(input.threadId);
1122
1123 if (threadId == null) {
1124 throw new Error("threadId is required when resuming an app-server session.");
1125 }
1126
1127 return {
1128 threadId,
1129 approvalPolicy: input.approvalPolicy ?? null,
1130 baseInstructions: normalizeOptionalString(input.baseInstructions),
1131 config: input.config ?? null,
1132 cwd: normalizeOptionalString(input.cwd),
1133 developerInstructions: normalizeOptionalString(input.developerInstructions),
1134 model: normalizeOptionalString(input.model),
1135 modelProvider: normalizeOptionalString(input.modelProvider),
1136 personality: normalizeOptionalString(input.personality),
1137 sandbox: input.sandbox ?? null,
1138 serviceTier: normalizeOptionalString(input.serviceTier)
1139 };
1140}
1141
1142function buildThreadStartParams(
1143 input: CodexdCreateSessionInput
1144): CodexAppServerThreadStartParams {
1145 return {
1146 approvalPolicy: input.approvalPolicy ?? null,
1147 baseInstructions: normalizeOptionalString(input.baseInstructions),
1148 config: input.config ?? null,
1149 cwd: normalizeOptionalString(input.cwd),
1150 developerInstructions: normalizeOptionalString(input.developerInstructions),
1151 ephemeral: input.ephemeral ?? null,
1152 model: normalizeOptionalString(input.model),
1153 modelProvider: normalizeOptionalString(input.modelProvider),
1154 personality: normalizeOptionalString(input.personality),
1155 sandbox: input.sandbox ?? null,
1156 serviceTier: normalizeOptionalString(input.serviceTier)
1157 };
1158}
1159
1160function buildTurnStartParams(
1161 threadId: string,
1162 input: CodexdTurnInput,
1163 items: CodexAppServerUserInput[]
1164): CodexAppServerTurnStartParams {
1165 return {
1166 threadId,
1167 approvalPolicy: input.approvalPolicy ?? null,
1168 collaborationMode: input.collaborationMode ?? null,
1169 cwd: normalizeOptionalString(input.cwd),
1170 effort: normalizeOptionalString(input.effort),
1171 input: items,
1172 model: normalizeOptionalString(input.model),
1173 outputSchema: input.outputSchema ?? null,
1174 personality: normalizeOptionalString(input.personality),
1175 sandboxPolicy: input.sandboxPolicy ?? null,
1176 serviceTier: normalizeOptionalString(input.serviceTier),
1177 summary: input.summary ?? null
1178 };
1179}
1180
1181function buildTurnSteerParams(
1182 threadId: string,
1183 expectedTurnId: string,
1184 items: CodexAppServerUserInput[]
1185): CodexAppServerTurnSteerParams {
1186 return {
1187 expectedTurnId,
1188 input: items,
1189 threadId
1190 };
1191}
1192
1193function extractTurnId(result: CodexAppServerTurnStartResult | CodexAppServerTurnSteerResult): string {
1194 if ("turn" in result) {
1195 return result.turn.id;
1196 }
1197
1198 return result.turnId;
1199}
1200
1201function hasClosedSmokeSession(
1202 events: readonly CodexdRecentEvent[],
1203 childState: CodexdManagedChildState,
1204 snapshot: CodexdStatusSnapshot
1205): boolean {
1206 return (
1207 snapshot.sessionRegistry.sessions.some(
1208 (session) => session.purpose === "smoke" && session.status === "closed"
1209 ) &&
1210 (events.length > 0 || childState.exitedAt != null)
1211 );
1212}
1213
1214function mapAppServerEventToRecentEvent(event: CodexAppServerEvent): {
1215 detail?: Record<string, unknown> | null;
1216 level: "error" | "info" | "warn";
1217 message: string;
1218 type: string;
1219} {
1220 switch (event.type) {
1221 case "thread.started":
1222 return {
1223 level: "info",
1224 type: "app-server.thread.started",
1225 message: `App-server thread ${event.thread.id} started.`,
1226 detail: {
1227 threadId: event.thread.id
1228 }
1229 };
1230
1231 case "thread.status.changed":
1232 return {
1233 level: "info",
1234 type: "app-server.thread.status.changed",
1235 message: `App-server thread ${event.threadId} changed status.`,
1236 detail: {
1237 status: event.status,
1238 threadId: event.threadId
1239 }
1240 };
1241
1242 case "turn.started":
1243 return {
1244 level: "info",
1245 type: "app-server.turn.started",
1246 message: `App-server turn ${event.turn.id} started.`,
1247 detail: {
1248 threadId: event.threadId,
1249 turnId: event.turn.id
1250 }
1251 };
1252
1253 case "turn.completed":
1254 return {
1255 level: "info",
1256 type: "app-server.turn.completed",
1257 message: `App-server turn ${event.turn.id} completed.`,
1258 detail: {
1259 status: event.turn.status,
1260 threadId: event.threadId,
1261 turnId: event.turn.id
1262 }
1263 };
1264
1265 case "turn.diff.updated":
1266 return {
1267 level: "info",
1268 type: "app-server.turn.diff.updated",
1269 message: `App-server diff updated for turn ${event.turnId}.`,
1270 detail: {
1271 diff: event.diff,
1272 threadId: event.threadId,
1273 turnId: event.turnId
1274 }
1275 };
1276
1277 case "turn.plan.updated":
1278 return {
1279 level: "info",
1280 type: "app-server.turn.plan.updated",
1281 message: `App-server plan updated for turn ${event.turnId}.`,
1282 detail: {
1283 explanation: event.explanation,
1284 plan: event.plan,
1285 threadId: event.threadId,
1286 turnId: event.turnId
1287 }
1288 };
1289
1290 case "turn.message.delta":
1291 return {
1292 level: "info",
1293 type: "app-server.turn.message.delta",
1294 message: `App-server emitted delta for turn ${event.turnId}: ${createOutputPreview(event.delta)}`,
1295 detail: {
1296 delta: event.delta,
1297 itemId: event.itemId,
1298 threadId: event.threadId,
1299 turnId: event.turnId
1300 }
1301 };
1302
1303 case "turn.plan.delta":
1304 return {
1305 level: "info",
1306 type: "app-server.turn.plan.delta",
1307 message: `App-server emitted plan delta for turn ${event.turnId}.`,
1308 detail: {
1309 delta: event.delta,
1310 itemId: event.itemId,
1311 threadId: event.threadId,
1312 turnId: event.turnId
1313 }
1314 };
1315
1316 case "turn.error":
1317 return {
1318 level: "error",
1319 type: "app-server.turn.error",
1320 message: `App-server turn ${event.turnId} failed: ${event.error.message}`,
1321 detail: {
1322 error: event.error,
1323 threadId: event.threadId,
1324 turnId: event.turnId,
1325 willRetry: event.willRetry
1326 }
1327 };
1328
1329 case "command.output.delta":
1330 return {
1331 level: event.stream === "stderr" ? "warn" : "info",
1332 type: "app-server.command.output.delta",
1333 message: `App-server command ${event.processId} emitted ${event.stream} output.`,
1334 detail: {
1335 capReached: event.capReached,
1336 deltaBase64: event.deltaBase64,
1337 processId: event.processId,
1338 stream: event.stream
1339 }
1340 };
1341
1342 case "notification":
1343 return {
1344 level: "info",
1345 type: "app-server.notification",
1346 message: `App-server notification ${event.notificationMethod}.`,
1347 detail: {
1348 notificationMethod: event.notificationMethod,
1349 params: event.params
1350 }
1351 };
1352 }
1353}
1354
1355function createOutputPreview(text: string): string {
1356 const flattened = text.replace(/\s+/gu, " ").trim();
1357 return flattened.length <= MAX_CHILD_OUTPUT_PREVIEW
1358 ? flattened
1359 : `${flattened.slice(0, MAX_CHILD_OUTPUT_PREVIEW - 3)}...`;
1360}
1361
1362function createRunId(): string {
1363 return `run-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1364}
1365
1366function createSessionId(): string {
1367 return `session-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1368}
1369
1370function formatErrorMessage(error: unknown): string {
1371 if (error instanceof Error) {
1372 return error.message;
1373 }
1374
1375 return String(error);
1376}
1377
1378function normalizeOptionalString(value: string | null | undefined): string | null {
1379 if (value == null) {
1380 return null;
1381 }
1382
1383 const normalized = value.trim();
1384 return normalized === "" ? null : normalized;
1385}
1386
1387function normalizeOutputChunk(chunk: string | Uint8Array): string {
1388 return typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk);
1389}
1390
1391function normalizeRequiredString(value: string, field: string): string {
1392 const normalized = value.trim();
1393
1394 if (normalized === "") {
1395 throw new Error(`${field} must be a non-empty string.`);
1396 }
1397
1398 return normalized;
1399}
1400
1401function normalizeRunPurpose(value: string | null | undefined): CodexExecPurpose {
1402 const normalized = normalizeOptionalString(value);
1403
1404 if (normalized == null) {
1405 return "fallback-worker";
1406 }
1407
1408 if (CODEX_EXEC_PURPOSES.includes(normalized as CodexExecPurpose)) {
1409 return normalized as CodexExecPurpose;
1410 }
1411
1412 throw new Error(`Unsupported codexd run purpose "${value}".`);
1413}
1414
1415function normalizeRunSandbox(value: string | null | undefined): CodexExecSandboxMode | undefined {
1416 const normalized = normalizeOptionalString(value);
1417
1418 if (normalized == null) {
1419 return undefined;
1420 }
1421
1422 if (CODEX_EXEC_SANDBOX_MODES.includes(normalized as CodexExecSandboxMode)) {
1423 return normalized as CodexExecSandboxMode;
1424 }
1425
1426 throw new Error(`Unsupported codexd sandbox mode "${value}".`);
1427}
1428
1429function normalizeStringRecord(input: Record<string, string> | undefined): Record<string, string> {
1430 if (input == null) {
1431 return {};
1432 }
1433
1434 const normalized: Record<string, string> = {};
1435
1436 for (const [key, value] of Object.entries(input)) {
1437 const normalizedKey = normalizeOptionalString(key);
1438 const normalizedValue = normalizeOptionalString(value);
1439
1440 if (normalizedKey != null && normalizedValue != null) {
1441 normalized[normalizedKey] = normalizedValue;
1442 }
1443 }
1444
1445 return normalized;
1446}
1447
1448function normalizeTurnInputItems(input: CodexAppServerUserInput[] | string): CodexAppServerUserInput[] {
1449 if (typeof input === "string") {
1450 const prompt = normalizeRequiredString(input, "input");
1451 return [createCodexAppServerTextInput(prompt)];
1452 }
1453
1454 if (!Array.isArray(input) || input.length === 0) {
1455 throw new Error("turn input must be a non-empty string or non-empty item array.");
1456 }
1457
1458 return input;
1459}
1460
1461function resolveAppServerWebSocketUrl(endpoint: string): string {
1462 if (endpoint.startsWith("ws://") || endpoint.startsWith("wss://")) {
1463 return endpoint;
1464 }
1465
1466 if (endpoint.startsWith("http://") || endpoint.startsWith("https://")) {
1467 const url = new URL(endpoint);
1468 url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
1469 return url.toString();
1470 }
1471
1472 throw new Error(
1473 `Unsupported external app-server endpoint "${endpoint}". Use ws://, wss://, http://, or https://.`
1474 );
1475}
1476
1477function sleep(ms: number): Promise<void> {
1478 return new Promise((resolve) => {
1479 setTimeout(resolve, ms);
1480 });
1481}
1482
1483function toJsonRecord(value: unknown): Record<string, unknown> | null {
1484 if (value == null) {
1485 return null;
1486 }
1487
1488 return JSON.parse(JSON.stringify(value)) as Record<string, unknown>;
1489}
1490
1491function waitForChildExit(child: CodexdChildProcessLike, timeoutMs: number): Promise<void> {
1492 return new Promise((resolve) => {
1493 let settled = false;
1494 const complete = () => {
1495 if (settled) {
1496 return;
1497 }
1498
1499 settled = true;
1500 resolve();
1501 };
1502
1503 child.once("exit", () => {
1504 complete();
1505 });
1506 setTimeout(() => {
1507 complete();
1508 }, timeoutMs);
1509 });
1510}
1511
1512function waitForChildSpawn(child: CodexdChildProcessLike): Promise<void> {
1513 return new Promise((resolve, reject) => {
1514 let settled = false;
1515 const finish = (callback: () => void) => {
1516 if (settled) {
1517 return;
1518 }
1519
1520 settled = true;
1521 callback();
1522 };
1523
1524 child.once("spawn", () => {
1525 finish(resolve);
1526 });
1527 child.once("error", (error) => {
1528 finish(() => reject(error));
1529 });
1530 child.once("exit", (code, signal) => {
1531 finish(() => reject(new Error(`Child exited before spawn completion (code=${String(code)}, signal=${String(signal)}).`)));
1532 });
1533 });
1534}
1535
1536const EMBEDDED_SMOKE_PROGRAM = [
1537 'process.stdout.write("codexd smoke ready\\n");',
1538 'process.stderr.write("codexd smoke stderr\\n");',
1539 'setTimeout(() => {',
1540 ' process.stdout.write("codexd smoke done\\n");',
1541 ' process.exit(0);',
1542 "}, 25);"
1543].join(" ");