- commit
- 036f352
- parent
- 77573ae
- author
- im_wower
- date
- 2026-03-22 22:47:10 +0800 CST
feat(codexd): add local service surface
12 files changed,
+2954,
-156
+159,
-0
1@@ -0,0 +1,159 @@
2+import type { CodexAppServerTransport, CodexAppServerTransportHandlers } from "../../../packages/codex-app-server/src/transport.js";
3+
4+export interface CodexdAppServerWritableStream {
5+ end(chunk?: string | Uint8Array): unknown;
6+ write(chunk: string | Uint8Array): boolean;
7+}
8+
9+export interface CodexdAppServerReadableStream {
10+ on(event: "data", listener: (chunk: string | Uint8Array) => void): this;
11+ on(event: "end", listener: () => void): this;
12+ on(event: "error", listener: (error: Error) => void): this;
13+ setEncoding?(encoding: string): this;
14+}
15+
16+export interface CodexdAppServerProcessLike {
17+ pid?: number;
18+ stdin?: CodexdAppServerWritableStream;
19+ stderr?: CodexdAppServerReadableStream;
20+ stdout?: CodexdAppServerReadableStream;
21+ on(event: "error", listener: (error: Error) => void): this;
22+ on(event: "exit", listener: (code: number | null, signal: string | null) => void): this;
23+}
24+
25+export interface CodexdAppServerStdioTransportConfig {
26+ endStdinOnClose?: boolean;
27+ process: CodexdAppServerProcessLike;
28+}
29+
30+function toError(cause: unknown, fallback: string): Error {
31+ if (cause instanceof Error) {
32+ return cause;
33+ }
34+
35+ if (typeof cause === "string" && cause !== "") {
36+ return new Error(cause);
37+ }
38+
39+ return new Error(fallback);
40+}
41+
42+export function createCodexdAppServerStdioTransport(
43+ config: CodexdAppServerStdioTransportConfig
44+): CodexAppServerTransport {
45+ let buffer = "";
46+ let closed = false;
47+ let connected = false;
48+ let handlers: CodexAppServerTransportHandlers | null = null;
49+
50+ return {
51+ async connect(nextHandlers: CodexAppServerTransportHandlers): Promise<void> {
52+ if (closed) {
53+ throw new Error("Codex app-server stdio transport is already closed.");
54+ }
55+
56+ if (connected) {
57+ handlers = nextHandlers;
58+ return;
59+ }
60+
61+ const stdout = config.process.stdout;
62+ const stdin = config.process.stdin;
63+
64+ if (stdout == null || stdin == null) {
65+ throw new Error("Codex app-server stdio transport requires child stdin and stdout.");
66+ }
67+
68+ handlers = nextHandlers;
69+ connected = true;
70+ stdout.setEncoding?.("utf8");
71+ stdout.on("data", (chunk) => {
72+ buffer += typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk);
73+
74+ while (true) {
75+ const newlineIndex = buffer.indexOf("\n");
76+
77+ if (newlineIndex < 0) {
78+ return;
79+ }
80+
81+ const line = buffer.slice(0, newlineIndex).trim();
82+ buffer = buffer.slice(newlineIndex + 1);
83+
84+ if (line !== "") {
85+ handlers?.onMessage(line);
86+ }
87+ }
88+ });
89+ stdout.on("end", () => {
90+ if (closed) {
91+ return;
92+ }
93+
94+ closed = true;
95+ connected = false;
96+ handlers?.onClose(new Error("Codex app-server stdio stdout ended."));
97+ });
98+ stdout.on("error", (error) => {
99+ if (closed) {
100+ return;
101+ }
102+
103+ closed = true;
104+ connected = false;
105+ handlers?.onClose(error);
106+ });
107+ config.process.on("error", (error) => {
108+ if (closed) {
109+ return;
110+ }
111+
112+ closed = true;
113+ connected = false;
114+ handlers?.onClose(error);
115+ });
116+ config.process.on("exit", (code, signal) => {
117+ if (closed) {
118+ return;
119+ }
120+
121+ closed = true;
122+ connected = false;
123+ handlers?.onClose(
124+ new Error(
125+ `Codex app-server stdio child exited (code=${String(code)}, signal=${String(signal)}).`
126+ )
127+ );
128+ });
129+ },
130+
131+ async send(message: string): Promise<void> {
132+ if (closed || !connected || config.process.stdin == null) {
133+ throw new Error("Codex app-server stdio transport is not connected.");
134+ }
135+
136+ const ok = config.process.stdin.write(`${message}\n`);
137+
138+ if (!ok) {
139+ throw new Error("Codex app-server stdio transport failed to write request.");
140+ }
141+ },
142+
143+ async close(): Promise<void> {
144+ if (closed) {
145+ return;
146+ }
147+
148+ closed = true;
149+ connected = false;
150+
151+ if (config.endStdinOnClose ?? false) {
152+ try {
153+ config.process.stdin?.end();
154+ } catch (error) {
155+ throw toError(error, "Failed to close Codex app-server stdin.");
156+ }
157+ }
158+ }
159+ };
160+}
+20,
-8
1@@ -8,6 +8,7 @@ import {
2 runCodexdSmoke,
3 type CodexdDaemonOptions
4 } from "./daemon.js";
5+import { CodexdLocalService, type CodexdLocalServiceStatus } from "./local-service.js";
6 import type {
7 CodexdEnvironment,
8 CodexdSmokeResult,
9@@ -94,37 +95,37 @@ export async function runCodexdCli(options: RunCodexdCliOptions = {}): Promise<n
10 throw new Error(`Unsupported codexd request action "${request.action}".`);
11 }
12
13- const daemon = new CodexdDaemon(request.config, {
14+ const service = new CodexdLocalService(request.config, {
15 ...options,
16 env
17 });
18- const snapshot = await daemon.start();
19+ const started = await service.start();
20
21 if (!request.runOnce) {
22 if (request.printJson) {
23- writeLine(stdout, JSON.stringify(snapshot, null, 2));
24+ writeLine(stdout, JSON.stringify(started, null, 2));
25 } else {
26- writeLine(stdout, formatCodexdStatusText(snapshot));
27+ writeLine(stdout, formatCodexdLocalServiceText(started));
28 }
29
30 const signal = await waitForShutdownSignal(processLike);
31- const stopped = await daemon.stop();
32+ const stopped = await service.stop();
33
34 if (!request.printJson) {
35 writeLine(stdout, `codexd stopped${signal ? ` after ${signal}` : ""}`);
36- writeLine(stdout, formatCodexdStatusText(stopped));
37+ writeLine(stdout, formatCodexdLocalServiceText(stopped));
38 }
39
40 return 0;
41 }
42
43 await sleep(request.lifetimeMs);
44- const stopped = await daemon.stop();
45+ const stopped = await service.stop();
46
47 if (request.printJson) {
48 writeLine(stdout, JSON.stringify(stopped, null, 2));
49 } else {
50- writeLine(stdout, formatCodexdStatusText(stopped));
51+ writeLine(stdout, formatCodexdLocalServiceText(stopped));
52 }
53
54 return 0;
55@@ -147,13 +148,24 @@ function formatCodexdStatusText(snapshot: CodexdStatusSnapshot): string {
56 `strategy=${snapshot.config.server.childStrategy}`,
57 `mode=${snapshot.config.server.mode}`,
58 `endpoint=${snapshot.config.server.endpoint}`,
59+ `local_api_base=${snapshot.config.service.localApiBase}`,
60+ `event_stream_path=${snapshot.config.service.eventStreamPath}`,
61 `sessions=${snapshot.sessionRegistry.sessions.length}`,
62+ `runs=${snapshot.runRegistry.runs.length}`,
63 `recent_events=${snapshot.recentEvents.events.length}`,
64 `logs_dir=${snapshot.config.paths.logsDir}`,
65 `state_dir=${snapshot.config.paths.stateDir}`
66 ].join(" ");
67 }
68
69+function formatCodexdLocalServiceText(status: CodexdLocalServiceStatus): string {
70+ return [
71+ formatCodexdStatusText(status.snapshot),
72+ `resolved_base=${status.service.resolvedBaseUrl ?? "not-listening"}`,
73+ `ws_url=${status.service.eventStreamUrl ?? "not-listening"}`
74+ ].join(" ");
75+}
76+
77 function getProcessLike(): CodexdProcessLike | undefined {
78 return (globalThis as { process?: CodexdProcessLike }).process;
79 }
+32,
-0
1@@ -10,6 +10,8 @@ import type {
2
3 export interface CodexdConfigInput {
4 eventCacheSize?: number;
5+ eventStreamPath?: string;
6+ localApiBase?: string;
7 logsDir?: string;
8 mode?: string;
9 nodeId?: string;
10@@ -42,6 +44,8 @@ export type CodexdCliRequest =
11 };
12
13 const DEFAULT_EVENT_CACHE_SIZE = 50;
14+const DEFAULT_EVENT_STREAM_PATH = "/v1/codexd/events";
15+const DEFAULT_LOCAL_API_BASE = "http://127.0.0.1:4319";
16 const DEFAULT_NODE_ID = "mini-main";
17 const DEFAULT_SERVER_ARGS = ["app-server"];
18 const DEFAULT_SERVER_COMMAND = "codex";
19@@ -67,6 +71,11 @@ export function resolveCodexdConfig(input: CodexdConfigInput = {}): CodexdResolv
20 "smoke lifetime"
21 ),
22 paths,
23+ service: {
24+ eventStreamPath:
25+ getOptionalString(input.eventStreamPath) ?? DEFAULT_EVENT_STREAM_PATH,
26+ localApiBase: getOptionalString(input.localApiBase) ?? DEFAULT_LOCAL_API_BASE
27+ },
28 server
29 };
30 }
31@@ -81,6 +90,8 @@ export function parseCodexdCliRequest(
32 let printJson = false;
33 let runOnce = false;
34 let eventCacheSize = parseOptionalInteger(env.BAA_CODEXD_EVENT_CACHE_SIZE);
35+ let eventStreamPath = env.BAA_CODEXD_EVENT_STREAM_PATH;
36+ let localApiBase = env.BAA_CODEXD_LOCAL_API_BASE;
37 let logsDir = env.BAA_CODEXD_LOGS_DIR ?? env.BAA_LOGS_DIR;
38 let mode = env.BAA_CODEXD_MODE;
39 let nodeId = env.BAA_NODE_ID;
40@@ -140,6 +151,18 @@ export function parseCodexdCliRequest(
41 continue;
42 }
43
44+ if (token === "--local-api-base") {
45+ localApiBase = readCliValue(tokens, index, "--local-api-base");
46+ index += 1;
47+ continue;
48+ }
49+
50+ if (token === "--event-stream-path") {
51+ eventStreamPath = readCliValue(tokens, index, "--event-stream-path");
52+ index += 1;
53+ continue;
54+ }
55+
56 if (token === "--state-dir") {
57 stateDir = readCliValue(tokens, index, "--state-dir");
58 index += 1;
59@@ -222,6 +245,8 @@ export function parseCodexdCliRequest(
60
61 const config = resolveCodexdConfig({
62 eventCacheSize,
63+ eventStreamPath,
64+ localApiBase,
65 logsDir,
66 mode,
67 nodeId,
68@@ -263,6 +288,8 @@ export function formatCodexdConfigText(config: CodexdResolvedConfig): string {
69 `child_command: ${config.server.childCommand}`,
70 `child_args: ${config.server.childArgs.join(" ") || "(none)"}`,
71 `child_cwd: ${config.server.childCwd}`,
72+ `local_api_base: ${config.service.localApiBase}`,
73+ `event_stream_path: ${config.service.eventStreamPath}`,
74 `logs_dir: ${config.paths.logsDir}`,
75 `state_dir: ${config.paths.stateDir}`,
76 `event_cache_size: ${config.eventCacheSize}`,
77@@ -284,6 +311,8 @@ export function getCodexdUsageText(): string {
78 " --node-id <id>",
79 " --mode <app-server|exec>",
80 " --logs-dir <path>",
81+ " --local-api-base <http://127.0.0.1:4319>",
82+ " --event-stream-path <path>",
83 " --state-dir <path>",
84 " --server-endpoint <url-or-stdio>",
85 " --server-strategy <spawn|external>",
86@@ -305,6 +334,8 @@ export function getCodexdUsageText(): string {
87 " BAA_CODEXD_MODE",
88 " BAA_CODEXD_LOGS_DIR",
89 " BAA_CODEXD_STATE_DIR",
90+ " BAA_CODEXD_LOCAL_API_BASE",
91+ " BAA_CODEXD_EVENT_STREAM_PATH",
92 " BAA_CODEXD_SERVER_ENDPOINT",
93 " BAA_CODEXD_SERVER_STRATEGY",
94 " BAA_CODEXD_SERVER_COMMAND",
95@@ -363,6 +394,7 @@ function resolveCodexdRuntimePaths(
96 identityPath: resolve(stateDir, "identity.json"),
97 daemonStatePath: resolve(stateDir, "daemon-state.json"),
98 sessionRegistryPath: resolve(stateDir, "session-registry.json"),
99+ runRegistryPath: resolve(stateDir, "run-registry.json"),
100 recentEventsPath: resolve(stateDir, "recent-events.json")
101 };
102 }
+40,
-0
1@@ -2,6 +2,7 @@ export type CodexdCliAction = "config" | "help" | "smoke" | "start" | "status";
2 export type CodexdChildStrategy = "external" | "spawn";
3 export type CodexdManagedChildStatus = "external" | "failed" | "idle" | "running" | "starting" | "stopped";
4 export type CodexdEventLevel = "error" | "info" | "warn";
5+export type CodexdRunStatus = "completed" | "failed" | "queued" | "running";
6 export type CodexdServerMode = "app-server" | "exec";
7 export type CodexdSessionPurpose = "duplex" | "smoke" | "worker";
8 export type CodexdSessionStatus = "active" | "closed";
9@@ -20,6 +21,7 @@ export interface CodexdRuntimePaths {
10 identityPath: string;
11 daemonStatePath: string;
12 sessionRegistryPath: string;
13+ runRegistryPath: string;
14 recentEventsPath: string;
15 }
16
17@@ -32,12 +34,18 @@ export interface CodexdServerConfig {
18 childCwd: string;
19 }
20
21+export interface CodexdLocalServiceConfig {
22+ eventStreamPath: string;
23+ localApiBase: string;
24+}
25+
26 export interface CodexdResolvedConfig {
27 nodeId: string;
28 version: string | null;
29 eventCacheSize: number;
30 smokeLifetimeMs: number;
31 paths: CodexdRuntimePaths;
32+ service: CodexdLocalServiceConfig;
33 server: CodexdServerConfig;
34 }
35
36@@ -83,6 +91,14 @@ export interface CodexdSessionRecord {
37 childPid: number | null;
38 createdAt: string;
39 updatedAt: string;
40+ cwd: string | null;
41+ model: string | null;
42+ modelProvider: string | null;
43+ serviceTier: string | null;
44+ reasoningEffort: string | null;
45+ currentTurnId: string | null;
46+ lastTurnId: string | null;
47+ lastTurnStatus: string | null;
48 metadata: Record<string, string>;
49 }
50
51@@ -91,6 +107,29 @@ export interface CodexdSessionRegistryState {
52 sessions: CodexdSessionRecord[];
53 }
54
55+export interface CodexdRunRecord {
56+ runId: string;
57+ sessionId: string | null;
58+ status: CodexdRunStatus;
59+ adapter: string;
60+ prompt: string;
61+ purpose: string | null;
62+ cwd: string | null;
63+ createdAt: string;
64+ updatedAt: string;
65+ startedAt: string | null;
66+ finishedAt: string | null;
67+ error: string | null;
68+ summary: string | null;
69+ metadata: Record<string, string>;
70+ result: Record<string, unknown> | null;
71+}
72+
73+export interface CodexdRunRegistryState {
74+ updatedAt: string | null;
75+ runs: CodexdRunRecord[];
76+}
77+
78 export interface CodexdRecentEvent {
79 seq: number;
80 createdAt: string;
81@@ -111,6 +150,7 @@ export interface CodexdStatusSnapshot {
82 identity: CodexdDaemonIdentity;
83 daemon: CodexdDaemonState;
84 sessionRegistry: CodexdSessionRegistryState;
85+ runRegistry: CodexdRunRegistryState;
86 recentEvents: CodexdRecentEventCacheState;
87 }
88
+979,
-22
1@@ -1,11 +1,46 @@
2 import { spawn } from "node:child_process";
3 import { access } from "node:fs/promises";
4
5+import {
6+ CodexAppServerClient,
7+ createCodexAppServerTextInput,
8+ createCodexAppServerWebSocketTransport,
9+ type CodexAppServerAdapter,
10+ type CodexAppServerApprovalPolicy,
11+ type CodexAppServerEvent,
12+ type CodexAppServerInitializeResult,
13+ type CodexAppServerSandboxMode,
14+ type CodexAppServerSandboxPolicy,
15+ type CodexAppServerThreadResumeParams,
16+ type CodexAppServerThreadSession,
17+ type CodexAppServerThreadStartParams,
18+ type CodexAppServerTurnId,
19+ type CodexAppServerTurnStartParams,
20+ type CodexAppServerTurnStartResult,
21+ type CodexAppServerTurnSteerParams,
22+ type CodexAppServerTurnSteerResult,
23+ type CodexAppServerUserInput,
24+ type JsonValue
25+} from "../../../packages/codex-app-server/src/index.js";
26+import {
27+ CODEX_EXEC_PURPOSES,
28+ CODEX_EXEC_SANDBOX_MODES,
29+ runCodexExec,
30+ type CodexExecPurpose,
31+ type CodexExecRunRequest,
32+ type CodexExecRunResponse,
33+ type CodexExecSandboxMode
34+} from "../../../packages/codex-exec/src/index.js";
35+import {
36+ createCodexdAppServerStdioTransport,
37+ type CodexdAppServerProcessLike
38+} from "./app-server-transport.js";
39 import type {
40 CodexdEnvironment,
41 CodexdManagedChildState,
42 CodexdRecentEvent,
43 CodexdResolvedConfig,
44+ CodexdRunRecord,
45 CodexdSessionPurpose,
46 CodexdSessionRecord,
47 CodexdSmokeCheck,
48@@ -15,11 +50,19 @@ import type {
49 import { CodexdStateStore, type CodexdStateStoreOptions } from "./state-store.js";
50
51 export interface CodexdProcessOutput {
52- on(event: "data", listener: (chunk: string | Uint8Array) => void): unknown;
53+ on(event: "data", listener: (chunk: string | Uint8Array) => void): this;
54+ on(event: "end", listener: () => void): this;
55+ on(event: "error", listener: (error: Error) => void): this;
56+ setEncoding?(encoding: string): this;
57+}
58+
59+export interface CodexdProcessInput {
60+ end(chunk?: string | Uint8Array): unknown;
61+ write(chunk: string | Uint8Array): boolean;
62 }
63
64-export interface CodexdChildProcessLike {
65- pid?: number;
66+export interface CodexdChildProcessLike extends CodexdAppServerProcessLike {
67+ stdin?: CodexdProcessInput;
68 stderr?: CodexdProcessOutput;
69 stdout?: CodexdProcessOutput;
70 kill(signal?: string): boolean;
71@@ -40,9 +83,30 @@ export interface CodexdSpawner {
72 spawn(command: string, args: readonly string[], options: CodexdSpawnOptions): CodexdChildProcessLike;
73 }
74
75-export interface CodexdDaemonOptions extends CodexdStateStoreOptions {
76- env?: CodexdEnvironment;
77- spawner?: CodexdSpawner;
78+export interface CodexdAppServerClientFactoryContext {
79+ child: CodexdChildProcessLike | null;
80+ config: CodexdResolvedConfig;
81+}
82+
83+export interface CodexdAppServerClientFactory {
84+ create(context: CodexdAppServerClientFactoryContext): Promise<CodexAppServerAdapter> | CodexAppServerAdapter;
85+}
86+
87+export interface CodexdCreateSessionInput {
88+ approvalPolicy?: CodexAppServerApprovalPolicy | null;
89+ baseInstructions?: string | null;
90+ config?: Record<string, JsonValue | undefined> | null;
91+ cwd?: string | null;
92+ developerInstructions?: string | null;
93+ ephemeral?: boolean | null;
94+ metadata?: Record<string, string>;
95+ model?: string | null;
96+ modelProvider?: string | null;
97+ personality?: string | null;
98+ purpose: CodexdSessionPurpose;
99+ sandbox?: CodexAppServerSandboxMode | null;
100+ serviceTier?: string | null;
101+ threadId?: string | null;
102 }
103
104 export interface CodexdSessionInput {
105@@ -51,12 +115,72 @@ export interface CodexdSessionInput {
106 threadId?: string | null;
107 }
108
109+export interface CodexdTurnInput {
110+ approvalPolicy?: CodexAppServerApprovalPolicy | null;
111+ collaborationMode?: JsonValue | null;
112+ cwd?: string | null;
113+ effort?: string | null;
114+ expectedTurnId?: string | null;
115+ input: CodexAppServerUserInput[] | string;
116+ model?: string | null;
117+ outputSchema?: JsonValue | null;
118+ personality?: string | null;
119+ sandboxPolicy?: CodexAppServerSandboxPolicy | null;
120+ serviceTier?: string | null;
121+ sessionId: string;
122+ summary?: JsonValue | null;
123+}
124+
125+export interface CodexdTurnResponse {
126+ accepted: true;
127+ session: CodexdSessionRecord;
128+ turnId: string;
129+}
130+
131+export interface CodexdRunInput {
132+ additionalWritableDirectories?: string[];
133+ config?: string[];
134+ cwd?: string;
135+ env?: Record<string, string | undefined>;
136+ images?: string[];
137+ metadata?: Record<string, string>;
138+ model?: string;
139+ profile?: string;
140+ prompt: string;
141+ purpose?: string | null;
142+ sandbox?: string | null;
143+ sessionId?: string | null;
144+ skipGitRepoCheck?: boolean;
145+ timeoutMs?: number;
146+}
147+
148+export interface CodexdRuntimeEventSubscription {
149+ unsubscribe(): void;
150+}
151+
152+export type CodexdRuntimeEventListener = (event: CodexdRecentEvent) => void;
153+export type CodexdRunExecutor = (request: CodexExecRunRequest) => Promise<CodexExecRunResponse>;
154+
155+export interface CodexdDaemonOptions extends CodexdStateStoreOptions {
156+ appServerClientFactory?: CodexdAppServerClientFactory;
157+ env?: CodexdEnvironment;
158+ runExecutor?: CodexdRunExecutor;
159+ spawner?: CodexdSpawner;
160+}
161+
162 const MAX_CHILD_OUTPUT_PREVIEW = 160;
163 const STOP_TIMEOUT_MS = 1_000;
164+const DEFAULT_APP_SERVER_VERSION = "0.0.0";
165
166 export class CodexdDaemon {
167+ private appServerClient: CodexAppServerAdapter | null = null;
168+ private appServerInitializeResult: CodexAppServerInitializeResult | null = null;
169+ private appServerInitializationPromise: Promise<CodexAppServerAdapter> | null = null;
170 private child: CodexdChildProcessLike | null = null;
171 private readonly env: CodexdEnvironment;
172+ private readonly eventListeners = new Set<CodexdRuntimeEventListener>();
173+ private readonly appServerClientFactory: CodexdAppServerClientFactory;
174+ private readonly runExecutor: CodexdRunExecutor;
175 private readonly stateStore: CodexdStateStore;
176 private readonly spawner: CodexdSpawner;
177 private started = false;
178@@ -65,17 +189,29 @@ export class CodexdDaemon {
179 private readonly config: CodexdResolvedConfig,
180 options: CodexdDaemonOptions = {}
181 ) {
182+ const forwardEvent = options.onEvent;
183+
184 this.env = options.env ?? (typeof process !== "undefined" ? process.env : {});
185 this.spawner = options.spawner ?? {
186 spawn(command, args, spawnOptions) {
187 return spawn(command, [...args], {
188 cwd: spawnOptions.cwd,
189 env: spawnOptions.env,
190- stdio: ["ignore", "pipe", "pipe"]
191- });
192+ stdio: ["pipe", "pipe", "pipe"]
193+ }) as unknown as CodexdChildProcessLike;
194 }
195 };
196- this.stateStore = new CodexdStateStore(config, options);
197+ this.appServerClientFactory = options.appServerClientFactory ?? {
198+ create: async (context) => this.createDefaultAppServerClient(context)
199+ };
200+ this.runExecutor = options.runExecutor ?? runCodexExec;
201+ this.stateStore = new CodexdStateStore(config, {
202+ ...options,
203+ onEvent: (event) => {
204+ forwardEvent?.(event);
205+ this.emitRuntimeEvent(event);
206+ }
207+ });
208 }
209
210 async start(): Promise<CodexdStatusSnapshot> {
211@@ -92,6 +228,7 @@ export class CodexdDaemon {
212 message: `codexd started in ${this.config.server.mode} mode.`,
213 detail: {
214 endpoint: this.config.server.endpoint,
215+ localApiBase: this.config.service.localApiBase,
216 strategy: this.config.server.childStrategy
217 }
218 });
219@@ -125,14 +262,18 @@ export class CodexdDaemon {
220 lastError: null
221 });
222
223- const child = this.spawner.spawn(this.config.server.childCommand, this.config.server.childArgs, {
224- cwd: this.config.server.childCwd,
225- env: {
226- ...this.env,
227- BAA_CODEXD_DAEMON_ID: this.stateStore.getSnapshot().identity.daemonId,
228- BAA_CODEXD_SERVER_ENDPOINT: this.config.server.endpoint
229+ const child = this.spawner.spawn(
230+ this.config.server.childCommand,
231+ this.config.server.childArgs,
232+ {
233+ cwd: this.config.server.childCwd,
234+ env: {
235+ ...this.env,
236+ BAA_CODEXD_DAEMON_ID: this.stateStore.getSnapshot().identity.daemonId,
237+ BAA_CODEXD_SERVER_ENDPOINT: this.config.server.endpoint
238+ }
239 }
240- });
241+ );
242 this.child = child;
243 this.attachChildListeners(child);
244
245@@ -168,8 +309,8 @@ export class CodexdDaemon {
246 type: "child.started",
247 message: `Started Codex child process ${child.pid ?? "unknown"}.`,
248 detail: {
249- command: this.config.server.childCommand,
250- args: this.config.server.childArgs
251+ args: this.config.server.childArgs,
252+ command: this.config.server.childCommand
253 }
254 });
255
256@@ -179,12 +320,14 @@ export class CodexdDaemon {
257
258 async stop(): Promise<CodexdStatusSnapshot> {
259 await this.stateStore.initialize();
260+ await this.closeAppServerClient();
261
262 if (this.child != null) {
263 const child = this.child;
264 this.child = null;
265
266 const exited = waitForChildExit(child, STOP_TIMEOUT_MS);
267+
268 try {
269 child.kill("SIGTERM");
270 } catch (error) {
271@@ -221,10 +364,165 @@ export class CodexdDaemon {
272 return this.stateStore.getSnapshot();
273 }
274
275+ getRun(runId: string): CodexdRunRecord | null {
276+ return this.stateStore.getRun(runId);
277+ }
278+
279+ getSession(sessionId: string): CodexdSessionRecord | null {
280+ return this.stateStore.getSession(sessionId);
281+ }
282+
283 getStatusSnapshot(): CodexdStatusSnapshot {
284 return this.stateStore.getSnapshot();
285 }
286
287+ listRuns(): CodexdRunRecord[] {
288+ return this.stateStore.listRuns();
289+ }
290+
291+ listSessions(): CodexdSessionRecord[] {
292+ return this.stateStore.listSessions();
293+ }
294+
295+ subscribe(listener: CodexdRuntimeEventListener): CodexdRuntimeEventSubscription {
296+ this.eventListeners.add(listener);
297+
298+ return {
299+ unsubscribe: () => {
300+ this.eventListeners.delete(listener);
301+ }
302+ };
303+ }
304+
305+ async createRun(input: CodexdRunInput): Promise<CodexdRunRecord> {
306+ await this.stateStore.initialize();
307+
308+ if (input.sessionId != null && this.stateStore.getSession(input.sessionId) == null) {
309+ throw new Error(`Unknown codexd session "${input.sessionId}".`);
310+ }
311+
312+ const now = new Date().toISOString();
313+ const run: CodexdRunRecord = {
314+ runId: createRunId(),
315+ sessionId: input.sessionId ?? null,
316+ status: "queued",
317+ adapter: "codex-exec",
318+ prompt: normalizeRequiredString(input.prompt, "prompt"),
319+ purpose: normalizeRunPurpose(input.purpose ?? "fallback-worker"),
320+ cwd: normalizeOptionalString(input.cwd),
321+ createdAt: now,
322+ updatedAt: now,
323+ startedAt: null,
324+ finishedAt: null,
325+ error: null,
326+ summary: null,
327+ metadata: normalizeStringRecord(input.metadata),
328+ result: null
329+ };
330+
331+ await this.stateStore.upsertRun(run);
332+ await this.stateStore.recordEvent({
333+ level: "info",
334+ type: "run.queued",
335+ message: `Queued run ${run.runId}.`,
336+ detail: {
337+ cwd: run.cwd,
338+ purpose: run.purpose,
339+ runId: run.runId,
340+ sessionId: run.sessionId
341+ }
342+ });
343+
344+ void this.executeRun(run, input);
345+ return run;
346+ }
347+
348+ async createSession(input: CodexdCreateSessionInput): Promise<CodexdSessionRecord> {
349+ await this.stateStore.initialize();
350+
351+ if (this.config.server.mode !== "app-server") {
352+ throw new Error("codexd sessions require app-server mode.");
353+ }
354+
355+ const client = await this.ensureAppServerClient();
356+ const sessionResult =
357+ input.threadId != null
358+ ? await client.threadResume(buildThreadResumeParams(input))
359+ : await client.threadStart(buildThreadStartParams(input));
360+ const session = buildSessionRecord(input, sessionResult, this.stateStore.getChildState().pid);
361+
362+ await this.stateStore.upsertSession(session);
363+ await this.stateStore.recordEvent({
364+ level: "info",
365+ type: input.threadId != null ? "session.resumed" : "session.created",
366+ message:
367+ input.threadId != null
368+ ? `Resumed session ${session.sessionId} for thread ${session.threadId}.`
369+ : `Created session ${session.sessionId} for thread ${session.threadId}.`,
370+ detail: {
371+ purpose: session.purpose,
372+ sessionId: session.sessionId,
373+ threadId: session.threadId
374+ }
375+ });
376+
377+ return session;
378+ }
379+
380+ async createTurn(input: CodexdTurnInput): Promise<CodexdTurnResponse> {
381+ await this.stateStore.initialize();
382+
383+ if (this.config.server.mode !== "app-server") {
384+ throw new Error("codexd turns require app-server mode.");
385+ }
386+
387+ const current = this.stateStore.getSession(input.sessionId);
388+
389+ if (current == null) {
390+ throw new Error(`Unknown codexd session "${input.sessionId}".`);
391+ }
392+
393+ if (current.threadId == null) {
394+ throw new Error(`Session "${input.sessionId}" does not have an app-server thread.`);
395+ }
396+
397+ if (current.status !== "active") {
398+ throw new Error(`Session "${input.sessionId}" is not active.`);
399+ }
400+
401+ const client = await this.ensureAppServerClient();
402+ const items = normalizeTurnInputItems(input.input);
403+ let turnId: CodexAppServerTurnId;
404+
405+ if (input.expectedTurnId != null) {
406+ const result = await client.turnSteer(
407+ buildTurnSteerParams(current.threadId, input.expectedTurnId, items)
408+ );
409+ turnId = extractTurnId(result);
410+ } else {
411+ const result = await client.turnStart(buildTurnStartParams(current.threadId, input, items));
412+ turnId = extractTurnId(result);
413+ }
414+
415+ const updatedSession = this.stateStore.getSession(input.sessionId) ?? current;
416+ await this.stateStore.recordEvent({
417+ level: "info",
418+ type: "turn.accepted",
419+ message: `Accepted turn ${turnId} for session ${current.sessionId}.`,
420+ detail: {
421+ sessionId: current.sessionId,
422+ threadId: current.threadId,
423+ turnId
424+ }
425+ });
426+
427+ return {
428+ accepted: true,
429+ session: updatedSession,
430+ turnId
431+ };
432+ }
433+
434 async registerSession(input: CodexdSessionInput): Promise<CodexdSessionRecord> {
435 await this.stateStore.initialize();
436 const now = new Date().toISOString();
437@@ -237,9 +535,15 @@ export class CodexdDaemon {
438 childPid: this.stateStore.getChildState().pid,
439 createdAt: now,
440 updatedAt: now,
441- metadata: {
442- ...(input.metadata ?? {})
443- }
444+ cwd: null,
445+ model: null,
446+ modelProvider: null,
447+ serviceTier: null,
448+ reasoningEffort: null,
449+ currentTurnId: null,
450+ lastTurnId: null,
451+ lastTurnStatus: null,
452+ metadata: normalizeStringRecord(input.metadata)
453 };
454
455 await this.stateStore.upsertSession(session);
456@@ -274,6 +578,253 @@ export class CodexdDaemon {
457 return session;
458 }
459
460+ private emitRuntimeEvent(event: CodexdRecentEvent): void {
461+ for (const listener of this.eventListeners) {
462+ listener(event);
463+ }
464+ }
465+
466+ private async ensureAppServerClient(): Promise<CodexAppServerAdapter> {
467+ if (this.appServerClient != null && this.appServerInitializeResult != null) {
468+ return this.appServerClient;
469+ }
470+
471+ if (this.appServerInitializationPromise != null) {
472+ return await this.appServerInitializationPromise;
473+ }
474+
475+ this.appServerInitializationPromise = (async () => {
476+ const client = this.appServerClient
477+ ?? await this.appServerClientFactory.create({
478+ child: this.child,
479+ config: this.config
480+ });
481+
482+ if (this.appServerClient !== client) {
483+ this.appServerClient = client;
484+ client.events.subscribe((event) => {
485+ void this.handleAppServerEvent(event);
486+ });
487+ }
488+
489+ try {
490+ this.appServerInitializeResult = await client.initialize();
491+ await this.stateStore.recordEvent({
492+ level: "info",
493+ type: "app-server.connected",
494+ message: `Connected codexd to app-server ${this.config.server.endpoint}.`,
495+ detail: {
496+ endpoint: this.config.server.endpoint,
497+ platformFamily: this.appServerInitializeResult.platformFamily,
498+ platformOs: this.appServerInitializeResult.platformOs,
499+ userAgent: this.appServerInitializeResult.userAgent
500+ }
501+ });
502+ } catch (error) {
503+ this.appServerClient = null;
504+ this.appServerInitializeResult = null;
505+ await this.stateStore.recordEvent({
506+ level: "error",
507+ type: "app-server.connect.failed",
508+ message: formatErrorMessage(error),
509+ detail: {
510+ endpoint: this.config.server.endpoint
511+ }
512+ });
513+ throw error;
514+ }
515+
516+ return client;
517+ })();
518+
519+ try {
520+ return await this.appServerInitializationPromise;
521+ } finally {
522+ this.appServerInitializationPromise = null;
523+ }
524+ }
525+
526+ private async closeAppServerClient(): Promise<void> {
527+ this.appServerInitializeResult = null;
528+ this.appServerInitializationPromise = null;
529+
530+ const client = this.appServerClient;
531+ this.appServerClient = null;
532+
533+ if (client == null) {
534+ return;
535+ }
536+
537+ try {
538+ await client.close();
539+ } catch (error) {
540+ await this.stateStore.recordEvent({
541+ level: "warn",
542+ type: "app-server.close.failed",
543+ message: formatErrorMessage(error)
544+ });
545+ }
546+ }
547+
548+ private async executeRun(initialRun: CodexdRunRecord, input: CodexdRunInput): Promise<void> {
549+ const startedAt = new Date().toISOString();
550+ let run: CodexdRunRecord = {
551+ ...initialRun,
552+ status: "running",
553+ startedAt,
554+ updatedAt: startedAt
555+ };
556+
557+ await this.stateStore.upsertRun(run);
558+ await this.stateStore.recordEvent({
559+ level: "info",
560+ type: "run.started",
561+ message: `Started run ${run.runId}.`,
562+ detail: {
563+ runId: run.runId
564+ }
565+ });
566+
567+ try {
568+ const response = await this.runExecutor({
569+ additionalWritableDirectories: input.additionalWritableDirectories,
570+ config: input.config,
571+ cwd: input.cwd,
572+ env: input.env,
573+ images: input.images,
574+ model: input.model,
575+ profile: input.profile,
576+ prompt: run.prompt,
577+ purpose: normalizeRunPurpose(run.purpose),
578+ sandbox: normalizeRunSandbox(input.sandbox),
579+ skipGitRepoCheck: input.skipGitRepoCheck,
580+ timeoutMs: input.timeoutMs
581+ });
582+ const finishedAt = new Date().toISOString();
583+
584+ if (response.ok) {
585+ run = {
586+ ...run,
587+ status: "completed",
588+ finishedAt,
589+ updatedAt: finishedAt,
590+ summary: response.result.lastMessage ?? `exit_code:${String(response.result.exitCode)}`,
591+ result: toJsonRecord(response)
592+ };
593+ await this.stateStore.upsertRun(run);
594+ await this.stateStore.recordEvent({
595+ level: "info",
596+ type: "run.completed",
597+ message: `Completed run ${run.runId}.`,
598+ detail: {
599+ runId: run.runId
600+ }
601+ });
602+ return;
603+ }
604+
605+ run = {
606+ ...run,
607+ status: "failed",
608+ finishedAt,
609+ updatedAt: finishedAt,
610+ error: response.error.message,
611+ summary: response.error.message,
612+ result: toJsonRecord(response)
613+ };
614+ await this.stateStore.upsertRun(run);
615+ await this.stateStore.recordEvent({
616+ level: "error",
617+ type: "run.failed",
618+ message: `Run ${run.runId} failed: ${response.error.message}`,
619+ detail: {
620+ runId: run.runId
621+ }
622+ });
623+ } catch (error) {
624+ const finishedAt = new Date().toISOString();
625+ run = {
626+ ...run,
627+ status: "failed",
628+ finishedAt,
629+ updatedAt: finishedAt,
630+ error: formatErrorMessage(error),
631+ summary: formatErrorMessage(error),
632+ result: {
633+ error: formatErrorMessage(error)
634+ }
635+ };
636+ await this.stateStore.upsertRun(run);
637+ await this.stateStore.recordEvent({
638+ level: "error",
639+ type: "run.failed",
640+ message: `Run ${run.runId} failed: ${formatErrorMessage(error)}`,
641+ detail: {
642+ runId: run.runId
643+ }
644+ });
645+ }
646+ }
647+
648+ private async handleAppServerEvent(event: CodexAppServerEvent): Promise<void> {
649+ switch (event.type) {
650+ case "thread.status.changed":
651+ await this.patchSessionsByThreadId(event.threadId, (session) => ({
652+ ...session,
653+ updatedAt: new Date().toISOString()
654+ }));
655+ break;
656+
657+ case "turn.started":
658+ await this.patchSessionsByThreadId(event.threadId, (session) => ({
659+ ...session,
660+ currentTurnId: event.turn.id,
661+ lastTurnId: event.turn.id,
662+ lastTurnStatus: event.turn.status,
663+ updatedAt: new Date().toISOString()
664+ }));
665+ break;
666+
667+ case "turn.completed":
668+ await this.patchSessionsByThreadId(event.threadId, (session) => ({
669+ ...session,
670+ currentTurnId: session.currentTurnId === event.turn.id ? null : session.currentTurnId,
671+ lastTurnId: event.turn.id,
672+ lastTurnStatus: event.turn.status,
673+ updatedAt: new Date().toISOString()
674+ }));
675+ break;
676+
677+ case "turn.error":
678+ await this.patchSessionsByThreadId(event.threadId, (session) => ({
679+ ...session,
680+ currentTurnId: session.currentTurnId === event.turnId ? null : session.currentTurnId,
681+ lastTurnId: event.turnId,
682+ lastTurnStatus: "failed",
683+ updatedAt: new Date().toISOString()
684+ }));
685+ break;
686+
687+ default:
688+ break;
689+ }
690+
691+ await this.stateStore.recordEvent(mapAppServerEventToRecentEvent(event));
692+ }
693+
694+ private async patchSessionsByThreadId(
695+ threadId: string,
696+ update: (session: CodexdSessionRecord) => CodexdSessionRecord
697+ ): Promise<void> {
698+ const sessions = this.stateStore
699+ .listSessions()
700+ .filter((session) => session.threadId === threadId);
701+
702+ for (const session of sessions) {
703+ await this.stateStore.upsertSession(update(session));
704+ }
705+ }
706+
707 private attachChildListeners(child: CodexdChildProcessLike): void {
708 child.stdout?.on("data", (chunk) => {
709 void this.handleChildOutput("stdout", chunk);
710@@ -327,7 +878,10 @@ export class CodexdDaemon {
711 });
712 }
713
714- private async handleChildOutput(stream: "stderr" | "stdout", chunk: string | Uint8Array): Promise<void> {
715+ private async handleChildOutput(
716+ stream: "stderr" | "stdout",
717+ chunk: string | Uint8Array
718+ ): Promise<void> {
719 const text = normalizeOutputChunk(chunk);
720
721 if (text === "") {
722@@ -335,6 +889,11 @@ export class CodexdDaemon {
723 }
724
725 await this.stateStore.appendChildOutput(stream, text);
726+
727+ if (this.config.server.mode === "app-server" && stream === "stdout") {
728+ return;
729+ }
730+
731 await this.stateStore.recordEvent({
732 level: stream === "stderr" ? "warn" : "info",
733 type: `child.${stream}`,
734@@ -345,6 +904,44 @@ export class CodexdDaemon {
735 }
736 });
737 }
738+
739+ private async createDefaultAppServerClient(
740+ context: CodexdAppServerClientFactoryContext
741+ ): Promise<CodexAppServerAdapter> {
742+ if (this.config.server.mode !== "app-server") {
743+ throw new Error("codexd app-server client requested while running in exec mode.");
744+ }
745+
746+ if (this.config.server.childStrategy === "spawn") {
747+ if (context.child == null) {
748+ throw new Error("codexd app-server child is not available yet.");
749+ }
750+
751+ return new CodexAppServerClient({
752+ clientInfo: {
753+ name: "baa-conductor-codexd",
754+ title: "codexd local service",
755+ version: this.config.version ?? DEFAULT_APP_SERVER_VERSION
756+ },
757+ transport: createCodexdAppServerStdioTransport({
758+ process: context.child
759+ })
760+ });
761+ }
762+
763+ const wsUrl = resolveAppServerWebSocketUrl(this.config.server.endpoint);
764+
765+ return new CodexAppServerClient({
766+ clientInfo: {
767+ name: "baa-conductor-codexd",
768+ title: "codexd local service",
769+ version: this.config.version ?? DEFAULT_APP_SERVER_VERSION
770+ },
771+ transport: createCodexAppServerWebSocketTransport({
772+ url: wsUrl
773+ })
774+ });
775+ }
776 }
777
778 export async function runCodexdSmoke(
779@@ -416,6 +1013,118 @@ async function buildFileCheck(name: string, path: string): Promise<CodexdSmokeCh
780 }
781 }
782
783+function buildSessionRecord(
784+ input: CodexdCreateSessionInput,
785+ session: CodexAppServerThreadSession,
786+ childPid: number | null
787+): CodexdSessionRecord {
788+ const now = new Date().toISOString();
789+ const lastTurn = session.thread.turns?.[session.thread.turns.length - 1] ?? null;
790+
791+ return {
792+ sessionId: createSessionId(),
793+ purpose: input.purpose,
794+ threadId: session.thread.id,
795+ status: "active",
796+ endpoint: session.cwd,
797+ childPid,
798+ createdAt: now,
799+ updatedAt: now,
800+ cwd: session.cwd,
801+ model: session.model,
802+ modelProvider: session.modelProvider,
803+ serviceTier: session.serviceTier,
804+ reasoningEffort: session.reasoningEffort,
805+ currentTurnId: null,
806+ lastTurnId: lastTurn?.id ?? null,
807+ lastTurnStatus: lastTurn?.status ?? null,
808+ metadata: normalizeStringRecord(input.metadata)
809+ };
810+}
811+
812+function buildThreadResumeParams(
813+ input: CodexdCreateSessionInput
814+): CodexAppServerThreadResumeParams {
815+ const threadId = normalizeOptionalString(input.threadId);
816+
817+ if (threadId == null) {
818+ throw new Error("threadId is required when resuming an app-server session.");
819+ }
820+
821+ return {
822+ threadId,
823+ approvalPolicy: input.approvalPolicy ?? null,
824+ baseInstructions: normalizeOptionalString(input.baseInstructions),
825+ config: input.config ?? null,
826+ cwd: normalizeOptionalString(input.cwd),
827+ developerInstructions: normalizeOptionalString(input.developerInstructions),
828+ model: normalizeOptionalString(input.model),
829+ modelProvider: normalizeOptionalString(input.modelProvider),
830+ personality: normalizeOptionalString(input.personality),
831+ sandbox: input.sandbox ?? null,
832+ serviceTier: normalizeOptionalString(input.serviceTier)
833+ };
834+}
835+
836+function buildThreadStartParams(
837+ input: CodexdCreateSessionInput
838+): CodexAppServerThreadStartParams {
839+ return {
840+ approvalPolicy: input.approvalPolicy ?? null,
841+ baseInstructions: normalizeOptionalString(input.baseInstructions),
842+ config: input.config ?? null,
843+ cwd: normalizeOptionalString(input.cwd),
844+ developerInstructions: normalizeOptionalString(input.developerInstructions),
845+ ephemeral: input.ephemeral ?? null,
846+ model: normalizeOptionalString(input.model),
847+ modelProvider: normalizeOptionalString(input.modelProvider),
848+ personality: normalizeOptionalString(input.personality),
849+ sandbox: input.sandbox ?? null,
850+ serviceTier: normalizeOptionalString(input.serviceTier)
851+ };
852+}
853+
854+function buildTurnStartParams(
855+ threadId: string,
856+ input: CodexdTurnInput,
857+ items: CodexAppServerUserInput[]
858+): CodexAppServerTurnStartParams {
859+ return {
860+ threadId,
861+ approvalPolicy: input.approvalPolicy ?? null,
862+ collaborationMode: input.collaborationMode ?? null,
863+ cwd: normalizeOptionalString(input.cwd),
864+ effort: normalizeOptionalString(input.effort),
865+ input: items,
866+ model: normalizeOptionalString(input.model),
867+ outputSchema: input.outputSchema ?? null,
868+ personality: normalizeOptionalString(input.personality),
869+ sandboxPolicy: input.sandboxPolicy ?? null,
870+ serviceTier: normalizeOptionalString(input.serviceTier),
871+ summary: input.summary ?? null
872+ };
873+}
874+
875+function buildTurnSteerParams(
876+ threadId: string,
877+ expectedTurnId: string,
878+ items: CodexAppServerUserInput[]
879+): CodexAppServerTurnSteerParams {
880+ return {
881+ expectedTurnId,
882+ input: items,
883+ threadId
884+ };
885+}
886+
887+function extractTurnId(result: CodexAppServerTurnStartResult | CodexAppServerTurnSteerResult): string {
888+ if ("turn" in result) {
889+ return result.turn.id;
890+ }
891+
892+ return result.turnId;
893+}
894+
895 function hasClosedSmokeSession(
896 events: readonly CodexdRecentEvent[],
897 childState: CodexdManagedChildState,
898@@ -429,6 +1138,147 @@ function hasClosedSmokeSession(
899 );
900 }
901
902+function mapAppServerEventToRecentEvent(event: CodexAppServerEvent): {
903+ detail?: Record<string, unknown> | null;
904+ level: "error" | "info" | "warn";
905+ message: string;
906+ type: string;
907+} {
908+ switch (event.type) {
909+ case "thread.started":
910+ return {
911+ level: "info",
912+ type: "app-server.thread.started",
913+ message: `App-server thread ${event.thread.id} started.`,
914+ detail: {
915+ threadId: event.thread.id
916+ }
917+ };
918+
919+ case "thread.status.changed":
920+ return {
921+ level: "info",
922+ type: "app-server.thread.status.changed",
923+ message: `App-server thread ${event.threadId} changed status.`,
924+ detail: {
925+ status: event.status,
926+ threadId: event.threadId
927+ }
928+ };
929+
930+ case "turn.started":
931+ return {
932+ level: "info",
933+ type: "app-server.turn.started",
934+ message: `App-server turn ${event.turn.id} started.`,
935+ detail: {
936+ threadId: event.threadId,
937+ turnId: event.turn.id
938+ }
939+ };
940+
941+ case "turn.completed":
942+ return {
943+ level: "info",
944+ type: "app-server.turn.completed",
945+ message: `App-server turn ${event.turn.id} completed.`,
946+ detail: {
947+ status: event.turn.status,
948+ threadId: event.threadId,
949+ turnId: event.turn.id
950+ }
951+ };
952+
953+ case "turn.diff.updated":
954+ return {
955+ level: "info",
956+ type: "app-server.turn.diff.updated",
957+ message: `App-server diff updated for turn ${event.turnId}.`,
958+ detail: {
959+ diff: event.diff,
960+ threadId: event.threadId,
961+ turnId: event.turnId
962+ }
963+ };
964+
965+ case "turn.plan.updated":
966+ return {
967+ level: "info",
968+ type: "app-server.turn.plan.updated",
969+ message: `App-server plan updated for turn ${event.turnId}.`,
970+ detail: {
971+ explanation: event.explanation,
972+ plan: event.plan,
973+ threadId: event.threadId,
974+ turnId: event.turnId
975+ }
976+ };
977+
978+ case "turn.message.delta":
979+ return {
980+ level: "info",
981+ type: "app-server.turn.message.delta",
982+ message: `App-server emitted delta for turn ${event.turnId}: ${createOutputPreview(event.delta)}`,
983+ detail: {
984+ delta: event.delta,
985+ itemId: event.itemId,
986+ threadId: event.threadId,
987+ turnId: event.turnId
988+ }
989+ };
990+
991+ case "turn.plan.delta":
992+ return {
993+ level: "info",
994+ type: "app-server.turn.plan.delta",
995+ message: `App-server emitted plan delta for turn ${event.turnId}.`,
996+ detail: {
997+ delta: event.delta,
998+ itemId: event.itemId,
999+ threadId: event.threadId,
1000+ turnId: event.turnId
1001+ }
1002+ };
1003+
1004+ case "turn.error":
1005+ return {
1006+ level: "error",
1007+ type: "app-server.turn.error",
1008+ message: `App-server turn ${event.turnId} failed: ${event.error.message}`,
1009+ detail: {
1010+ error: event.error,
1011+ threadId: event.threadId,
1012+ turnId: event.turnId,
1013+ willRetry: event.willRetry
1014+ }
1015+ };
1016+
1017+ case "command.output.delta":
1018+ return {
1019+ level: event.stream === "stderr" ? "warn" : "info",
1020+ type: "app-server.command.output.delta",
1021+ message: `App-server command ${event.processId} emitted ${event.stream} output.`,
1022+ detail: {
1023+ capReached: event.capReached,
1024+ deltaBase64: event.deltaBase64,
1025+ processId: event.processId,
1026+ stream: event.stream
1027+ }
1028+ };
1029+
1030+ case "notification":
1031+ return {
1032+ level: "info",
1033+ type: "app-server.notification",
1034+ message: `App-server notification ${event.notificationMethod}.`,
1035+ detail: {
1036+ notificationMethod: event.notificationMethod,
1037+ params: event.params
1038+ }
1039+ };
1040+ }
1041+}
1042+
1043 function createOutputPreview(text: string): string {
1044 const flattened = text.replace(/\s+/gu, " ").trim();
1045 return flattened.length <= MAX_CHILD_OUTPUT_PREVIEW
1046@@ -436,6 +1286,10 @@ function createOutputPreview(text: string): string {
1047 : `${flattened.slice(0, MAX_CHILD_OUTPUT_PREVIEW - 3)}...`;
1048 }
1049
1050+function createRunId(): string {
1051+ return `run-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1052+}
1053+
1054 function createSessionId(): string {
1055 return `session-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1056 }
1057@@ -448,16 +1302,119 @@ function formatErrorMessage(error: unknown): string {
1058 return String(error);
1059 }
1060
1061+function normalizeOptionalString(value: string | null | undefined): string | null {
1062+ if (value == null) {
1063+ return null;
1064+ }
1065+
1066+ const normalized = value.trim();
1067+ return normalized === "" ? null : normalized;
1068+}
1069+
1070 function normalizeOutputChunk(chunk: string | Uint8Array): string {
1071 return typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk);
1072 }
1073
1074+function normalizeRequiredString(value: string, field: string): string {
1075+ const normalized = value.trim();
1076+
1077+ if (normalized === "") {
1078+ throw new Error(`${field} must be a non-empty string.`);
1079+ }
1080+
1081+ return normalized;
1082+}
1083+
1084+function normalizeRunPurpose(value: string | null | undefined): CodexExecPurpose {
1085+ const normalized = normalizeOptionalString(value);
1086+
1087+ if (normalized == null) {
1088+ return "fallback-worker";
1089+ }
1090+
1091+ if (CODEX_EXEC_PURPOSES.includes(normalized as CodexExecPurpose)) {
1092+ return normalized as CodexExecPurpose;
1093+ }
1094+
1095+ throw new Error(`Unsupported codexd run purpose "${value}".`);
1096+}
1097+
1098+function normalizeRunSandbox(value: string | null | undefined): CodexExecSandboxMode | undefined {
1099+ const normalized = normalizeOptionalString(value);
1100+
1101+ if (normalized == null) {
1102+ return undefined;
1103+ }
1104+
1105+ if (CODEX_EXEC_SANDBOX_MODES.includes(normalized as CodexExecSandboxMode)) {
1106+ return normalized as CodexExecSandboxMode;
1107+ }
1108+
1109+ throw new Error(`Unsupported codexd sandbox mode "${value}".`);
1110+}
1111+
1112+function normalizeStringRecord(input: Record<string, string> | undefined): Record<string, string> {
1113+ if (input == null) {
1114+ return {};
1115+ }
1116+
1117+ const normalized: Record<string, string> = {};
1118+
1119+ for (const [key, value] of Object.entries(input)) {
1120+ const normalizedKey = normalizeOptionalString(key);
1121+ const normalizedValue = normalizeOptionalString(value);
1122+
1123+ if (normalizedKey != null && normalizedValue != null) {
1124+ normalized[normalizedKey] = normalizedValue;
1125+ }
1126+ }
1127+
1128+ return normalized;
1129+}
1130+
1131+function normalizeTurnInputItems(input: CodexAppServerUserInput[] | string): CodexAppServerUserInput[] {
1132+ if (typeof input === "string") {
1133+ const prompt = normalizeRequiredString(input, "input");
1134+ return [createCodexAppServerTextInput(prompt)];
1135+ }
1136+
1137+ if (!Array.isArray(input) || input.length === 0) {
1138+ throw new Error("turn input must be a non-empty string or non-empty item array.");
1139+ }
1140+
1141+ return input;
1142+}
1143+
1144+function resolveAppServerWebSocketUrl(endpoint: string): string {
1145+ if (endpoint.startsWith("ws://") || endpoint.startsWith("wss://")) {
1146+ return endpoint;
1147+ }
1148+
1149+ if (endpoint.startsWith("http://") || endpoint.startsWith("https://")) {
1150+ const url = new URL(endpoint);
1151+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
1152+ return url.toString();
1153+ }
1154+
1155+ throw new Error(
1156+ `Unsupported external app-server endpoint "${endpoint}". Use ws://, wss://, http://, or https://.`
1157+ );
1158+}
1159+
1160 function sleep(ms: number): Promise<void> {
1161 return new Promise((resolve) => {
1162 setTimeout(resolve, ms);
1163 });
1164 }
1165
1166+function toJsonRecord(value: unknown): Record<string, unknown> | null {
1167+ if (value == null) {
1168+ return null;
1169+ }
1170+
1171+ return JSON.parse(JSON.stringify(value)) as Record<string, unknown>;
1172+}
1173+
1174 function waitForChildExit(child: CodexdChildProcessLike, timeoutMs: number): Promise<void> {
1175 return new Promise((resolve) => {
1176 let settled = false;
+336,
-4
1@@ -4,7 +4,33 @@ import { tmpdir } from "node:os";
2 import { join } from "node:path";
3 import test from "node:test";
4
5-import { CodexdDaemon, resolveCodexdConfig } from "../dist/index.js";
6+import {
7+ CodexdDaemon,
8+ CodexdLocalService,
9+ resolveCodexdConfig
10+} from "../dist/index.js";
11+
12+class FakeEventStream {
13+ constructor() {
14+ this.listeners = new Set();
15+ }
16+
17+ emit(event) {
18+ for (const listener of this.listeners) {
19+ listener(event);
20+ }
21+ }
22+
23+ subscribe(listener) {
24+ this.listeners.add(listener);
25+
26+ return {
27+ unsubscribe: () => {
28+ this.listeners.delete(listener);
29+ }
30+ };
31+ }
32+}
33
34 class FakeStream {
35 constructor() {
36@@ -29,6 +55,12 @@ class FakeStream {
37 class FakeChild {
38 constructor() {
39 this.pid = 4242;
40+ this.stdin = {
41+ end() {},
42+ write() {
43+ return true;
44+ }
45+ };
46 this.stdout = new FakeStream();
47 this.stderr = new FakeStream();
48 this.listeners = new Map();
49@@ -63,11 +95,139 @@ class FakeChild {
50 }
51 }
52
53+class FakeAppServerAdapter {
54+ constructor(defaultCwd) {
55+ this.defaultCwd = defaultCwd;
56+ this.events = new FakeEventStream();
57+ this.nextThreadId = 1;
58+ this.nextTurnId = 1;
59+ this.sessions = new Map();
60+ }
61+
62+ async close() {}
63+
64+ async initialize() {
65+ return {
66+ platformFamily: "unix",
67+ platformOs: "macos",
68+ userAgent: "codex-cli test"
69+ };
70+ }
71+
72+ async threadResume(params) {
73+ const session = this.sessions.get(params.threadId);
74+
75+ if (session == null) {
76+ throw new Error(`unknown thread ${params.threadId}`);
77+ }
78+
79+ return session;
80+ }
81+
82+ async threadStart(params = {}) {
83+ const threadId = `thread-${this.nextThreadId}`;
84+ this.nextThreadId += 1;
85+ const session = {
86+ thread: {
87+ cliVersion: "test",
88+ createdAt: Date.now(),
89+ cwd: params.cwd ?? this.defaultCwd,
90+ ephemeral: params.ephemeral ?? true,
91+ id: threadId,
92+ modelProvider: params.modelProvider ?? "openai",
93+ name: null,
94+ preview: "fake session",
95+ source: { custom: "codexd-test" },
96+ status: { type: "idle" },
97+ turns: [],
98+ updatedAt: Date.now()
99+ },
100+ approvalPolicy: params.approvalPolicy ?? "never",
101+ cwd: params.cwd ?? this.defaultCwd,
102+ model: params.model ?? "gpt-5.4",
103+ modelProvider: params.modelProvider ?? "openai",
104+ reasoningEffort: "medium",
105+ sandbox: { type: "dangerFullAccess" },
106+ serviceTier: params.serviceTier ?? null
107+ };
108+
109+ this.sessions.set(threadId, session);
110+ this.events.emit({
111+ notificationMethod: "thread/started",
112+ thread: session.thread,
113+ type: "thread.started"
114+ });
115+ return session;
116+ }
117+
118+ async turnInterrupt() {}
119+
120+ async turnStart(params) {
121+ const session = this.sessions.get(params.threadId);
122+
123+ if (session == null) {
124+ throw new Error(`unknown thread ${params.threadId}`);
125+ }
126+
127+ const turnId = `turn-${this.nextTurnId}`;
128+ this.nextTurnId += 1;
129+ const turn = {
130+ error: null,
131+ id: turnId,
132+ status: "inProgress"
133+ };
134+
135+ session.thread.turns = [...(session.thread.turns ?? []), turn];
136+
137+ queueMicrotask(() => {
138+ this.events.emit({
139+ notificationMethod: "turn/started",
140+ threadId: params.threadId,
141+ turn,
142+ type: "turn.started"
143+ });
144+ this.events.emit({
145+ delta: "hello from fake adapter",
146+ itemId: "item-1",
147+ notificationMethod: "item/agentMessage/delta",
148+ threadId: params.threadId,
149+ turnId,
150+ type: "turn.message.delta"
151+ });
152+
153+ const completedTurn = {
154+ ...turn,
155+ status: "completed"
156+ };
157+
158+ session.thread.turns = session.thread.turns.map((entry) =>
159+ entry.id === completedTurn.id ? completedTurn : entry
160+ );
161+ this.events.emit({
162+ notificationMethod: "turn/completed",
163+ threadId: params.threadId,
164+ turn: completedTurn,
165+ type: "turn.completed"
166+ });
167+ });
168+
169+ return {
170+ turn
171+ };
172+ }
173+
174+ async turnSteer(params) {
175+ return {
176+ turnId: params.expectedTurnId
177+ };
178+ }
179+}
180+
181 test("CodexdDaemon persists daemon identity, child state, session registry, and recent events", async () => {
182 const repoRoot = mkdtempSync(join(tmpdir(), "codexd-daemon-test-"));
183 const config = resolveCodexdConfig({
184- repoRoot,
185 logsDir: join(repoRoot, "logs"),
186+ repoRoot,
187 stateDir: join(repoRoot, "state")
188 });
189 const fakeChild = new FakeChild();
190@@ -97,10 +257,10 @@ test("CodexdDaemon persists daemon identity, child state, session registry, and
191 assert.equal(started.daemon.child.pid, 4242);
192
193 const session = await daemon.registerSession({
194- purpose: "worker",
195 metadata: {
196 runId: "run-1"
197- }
198+ },
199+ purpose: "worker"
200 });
201 assert.equal(session.status, "active");
202
203@@ -109,18 +269,190 @@ test("CodexdDaemon persists daemon identity, child state, session registry, and
204
205 const stopped = await daemon.stop();
206 assert.equal(stopped.daemon.started, false);
207+ assert.equal(stopped.runRegistry.runs.length, 0);
208 assert.equal(stopped.sessionRegistry.sessions.length, 1);
209 assert.equal(stopped.sessionRegistry.sessions[0].status, "closed");
210 assert.ok(stopped.recentEvents.events.length >= 4);
211
212 const daemonState = JSON.parse(readFileSync(config.paths.daemonStatePath, "utf8"));
213 const sessionRegistry = JSON.parse(readFileSync(config.paths.sessionRegistryPath, "utf8"));
214+ const runRegistry = JSON.parse(readFileSync(config.paths.runRegistryPath, "utf8"));
215 const recentEvents = JSON.parse(readFileSync(config.paths.recentEventsPath, "utf8"));
216 const eventLog = readFileSync(config.paths.structuredEventLogPath, "utf8");
217
218 assert.equal(daemonState.child.status, "stopped");
219+ assert.equal(runRegistry.runs.length, 0);
220 assert.equal(sessionRegistry.sessions[0].status, "closed");
221 assert.ok(recentEvents.events.length >= 4);
222 assert.match(eventLog, /child\.started/);
223 assert.match(eventLog, /session\.registered/);
224 });
225+
226+test("CodexdLocalService starts the local HTTP surface and supports status, sessions, turns, and runs", async () => {
227+ const repoRoot = mkdtempSync(join(tmpdir(), "codexd-service-test-"));
228+ const config = resolveCodexdConfig({
229+ localApiBase: "http://127.0.0.1:0",
230+ logsDir: join(repoRoot, "logs"),
231+ repoRoot,
232+ serverEndpoint: "ws://127.0.0.1:9999/codex-app-server",
233+ serverStrategy: "external",
234+ stateDir: join(repoRoot, "state")
235+ });
236+ const adapter = new FakeAppServerAdapter(repoRoot);
237+ const service = new CodexdLocalService(config, {
238+ appServerClientFactory: {
239+ create() {
240+ return adapter;
241+ }
242+ },
243+ env: {
244+ HOME: repoRoot
245+ },
246+ runExecutor: async (request) => {
247+ await sleep(10);
248+
249+ return {
250+ invocation: {
251+ additionalWritableDirectories: request.additionalWritableDirectories ?? [],
252+ args: ["exec"],
253+ color: "never",
254+ command: "codex",
255+ config: request.config ?? [],
256+ cwd: request.cwd ?? repoRoot,
257+ ephemeral: true,
258+ images: request.images ?? [],
259+ json: true,
260+ prompt: request.prompt,
261+ purpose: request.purpose ?? "fallback-worker",
262+ skipGitRepoCheck: request.skipGitRepoCheck ?? false,
263+ timeoutMs: request.timeoutMs ?? 100
264+ },
265+ ok: true,
266+ result: {
267+ durationMs: 10,
268+ exitCode: 0,
269+ finishedAt: new Date().toISOString(),
270+ jsonEvents: null,
271+ jsonParseErrors: [],
272+ lastMessage: `completed ${request.prompt}`,
273+ signal: null,
274+ startedAt: new Date().toISOString(),
275+ stderr: "",
276+ stdout: "ok",
277+ timedOut: false
278+ }
279+ };
280+ }
281+ });
282+
283+ try {
284+ const started = await service.start();
285+ assert.equal(started.service.listening, true);
286+ assert.match(started.service.resolvedBaseUrl, /^http:\/\/127\.0\.0\.1:\d+$/u);
287+ assert.match(started.service.eventStreamUrl, /^ws:\/\/127\.0\.0\.1:\d+\/v1\/codexd\/events$/u);
288+
289+ const baseUrl = started.service.resolvedBaseUrl;
290+ assert.ok(baseUrl);
291+
292+ const healthz = await fetchJson(`${baseUrl}/healthz`);
293+ assert.equal(healthz.status, 200);
294+ assert.equal(healthz.json.ok, true);
295+
296+ const status = await fetchJson(`${baseUrl}/v1/codexd/status`);
297+ assert.equal(status.status, 200);
298+ assert.equal(status.json.ok, true);
299+ assert.equal(status.json.data.snapshot.daemon.started, true);
300+
301+ const createdSession = await postJson(`${baseUrl}/v1/codexd/sessions`, {
302+ cwd: repoRoot,
303+ model: "gpt-5.4",
304+ purpose: "duplex"
305+ });
306+ assert.equal(createdSession.status, 201);
307+ const session = createdSession.json.data.session;
308+ assert.equal(session.purpose, "duplex");
309+ assert.match(session.threadId, /^thread-/u);
310+
311+ const sessions = await fetchJson(`${baseUrl}/v1/codexd/sessions`);
312+ assert.equal(sessions.status, 200);
313+ assert.equal(sessions.json.data.sessions.length, 1);
314+
315+ const readSession = await fetchJson(`${baseUrl}/v1/codexd/sessions/${session.sessionId}`);
316+ assert.equal(readSession.status, 200);
317+ assert.equal(readSession.json.data.session.sessionId, session.sessionId);
318+
319+ const createdTurn = await postJson(`${baseUrl}/v1/codexd/turn`, {
320+ input: "Say hello.",
321+ sessionId: session.sessionId
322+ });
323+ assert.equal(createdTurn.status, 202);
324+ assert.match(createdTurn.json.data.turnId, /^turn-/u);
325+
326+ const completedSession = await waitFor(async () => {
327+ const current = await fetchJson(`${baseUrl}/v1/codexd/sessions/${session.sessionId}`);
328+ return current.json.data.session.lastTurnStatus === "completed" ? current : null;
329+ });
330+ assert.equal(completedSession.json.data.session.currentTurnId, null);
331+ assert.equal(completedSession.json.data.session.lastTurnStatus, "completed");
332+
333+ const createdRun = await postJson(`${baseUrl}/v1/codexd/runs`, {
334+ cwd: repoRoot,
335+ prompt: "Inspect repo"
336+ });
337+ assert.equal(createdRun.status, 202);
338+ const runId = createdRun.json.data.run.runId;
339+
340+ const completedRun = await waitFor(async () => {
341+ const current = await fetchJson(`${baseUrl}/v1/codexd/runs/${runId}`);
342+ return current.json.data.run.status === "completed" ? current : null;
343+ });
344+ assert.equal(completedRun.json.data.run.summary, "completed Inspect repo");
345+
346+ const runs = await fetchJson(`${baseUrl}/v1/codexd/runs`);
347+ assert.equal(runs.status, 200);
348+ assert.ok(runs.json.data.runs.some((run) => run.runId === runId));
349+ } finally {
350+ await service.stop();
351+ }
352+});
353+
354+async function fetchJson(url, init) {
355+ const response = await fetch(url, init);
356+
357+ return {
358+ json: await response.json(),
359+ status: response.status
360+ };
361+}
362+
363+async function postJson(url, body) {
364+ return await fetchJson(url, {
365+ body: JSON.stringify(body),
366+ headers: {
367+ "content-type": "application/json"
368+ },
369+ method: "POST"
370+ });
371+}
372+
373+async function sleep(ms) {
374+ await new Promise((resolve) => {
375+ setTimeout(resolve, ms);
376+ });
377+}
378+
379+async function waitFor(loader, timeoutMs = 2_000) {
380+ const startedAt = Date.now();
381+
382+ while (Date.now() - startedAt < timeoutMs) {
383+ const value = await loader();
384+
385+ if (value != null) {
386+ return value;
387+ }
388+
389+ await sleep(20);
390+ }
391+
392+ throw new Error("timed out waiting for expected condition");
393+}
+3,
-0
1@@ -2,6 +2,9 @@ export * from "./contracts.js";
2 export * from "./config.js";
3 export * from "./state-store.js";
4 export * from "./daemon.js";
5+export * from "./app-server-transport.js";
6+export * from "./websocket.js";
7+export * from "./local-service.js";
8 export * from "./cli.js";
9
10 import { runCodexdCli } from "./cli.js";
+661,
-0
1@@ -0,0 +1,661 @@
2+import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
3+import type { AddressInfo } from "node:net";
4+
5+import {
6+ CodexdDaemon,
7+ type CodexdCreateSessionInput,
8+ type CodexdDaemonOptions,
9+ type CodexdRunInput,
10+ type CodexdTurnInput
11+} from "./daemon.js";
12+import type {
13+ CodexdResolvedConfig,
14+ CodexdRunRecord,
15+ CodexdSessionPurpose,
16+ CodexdSessionRecord,
17+ CodexdStatusSnapshot
18+} from "./contracts.js";
19+import { CodexdWebSocketServer, type CodexdWebSocketConnection } from "./websocket.js";
20+
21+interface CodexdHttpResponse {
22+ body: string;
23+ headers: Record<string, string>;
24+ status: number;
25+}
26+
27+type JsonRecord = Record<string, unknown>;
28+
29+export interface CodexdLocalServiceRuntimeInfo {
30+ configuredBaseUrl: string;
31+ eventStreamPath: string;
32+ eventStreamUrl: string | null;
33+ listening: boolean;
34+ resolvedBaseUrl: string | null;
35+ websocketClients: number;
36+}
37+
38+export interface CodexdLocalServiceStatus {
39+ service: CodexdLocalServiceRuntimeInfo;
40+ snapshot: CodexdStatusSnapshot;
41+}
42+
43+class CodexdHttpError extends Error {
44+ constructor(
45+ readonly status: number,
46+ message: string
47+ ) {
48+ super(message);
49+ this.name = "CodexdHttpError";
50+ }
51+}
52+
53+export class CodexdLocalService {
54+ private readonly daemon: CodexdDaemon;
55+ private resolvedBaseUrl: string | null = null;
56+ private server: Server | null = null;
57+ private readonly websocketServer;
58+
59+ constructor(
60+ private readonly config: CodexdResolvedConfig,
61+ options: CodexdDaemonOptions = {}
62+ ) {
63+ this.websocketServer = new CodexdWebSocketServer({
64+ onClientMessage: (connection, message) => {
65+ this.handleWebSocketMessage(connection, message);
66+ },
67+ onConnected: (connection) => {
68+ this.handleWebSocketConnected(connection);
69+ },
70+ path: config.service.eventStreamPath
71+ });
72+ this.daemon = new CodexdDaemon(config, options);
73+ this.daemon.subscribe((event) => {
74+ this.websocketServer.broadcast({
75+ event,
76+ type: "event"
77+ });
78+ });
79+ }
80+
81+ getDaemon(): CodexdDaemon {
82+ return this.daemon;
83+ }
84+
85+ getStatus(): CodexdLocalServiceStatus {
86+ return {
87+ service: this.getRuntimeInfo(),
88+ snapshot: this.daemon.getStatusSnapshot()
89+ };
90+ }
91+
92+ async start(): Promise<CodexdLocalServiceStatus> {
93+ if (this.server != null) {
94+ return this.getStatus();
95+ }
96+
97+ await this.daemon.start();
98+
99+ const listenConfig = resolveLocalListenConfig(this.config.service.localApiBase);
100+ const server = createServer((request, response) => {
101+ void this.handleRequest(request, response);
102+ });
103+
104+ server.on("upgrade", (request, socket, head) => {
105+ this.websocketServer.handleUpgrade(request, socket, head);
106+ });
107+
108+ try {
109+ await new Promise<void>((resolve, reject) => {
110+ const onError = (error: Error) => {
111+ server.off("listening", onListening);
112+ reject(error);
113+ };
114+ const onListening = () => {
115+ server.off("error", onError);
116+ resolve();
117+ };
118+
119+ server.once("error", onError);
120+ server.once("listening", onListening);
121+ server.listen({
122+ host: listenConfig.host,
123+ port: listenConfig.port
124+ });
125+ });
126+ } catch (error) {
127+ await this.daemon.stop();
128+ throw error;
129+ }
130+
131+ const address = server.address();
132+
133+ if (address == null || typeof address === "string") {
134+ server.close();
135+ await this.daemon.stop();
136+ throw new Error("codexd local service started without a TCP address.");
137+ }
138+
139+ this.server = server;
140+ this.resolvedBaseUrl = formatLocalApiBaseUrl(address.address, (address as AddressInfo).port);
141+ return this.getStatus();
142+ }
143+
144+ async stop(): Promise<CodexdLocalServiceStatus> {
145+ if (this.server != null) {
146+ const server = this.server;
147+ this.server = null;
148+ await this.websocketServer.stop();
149+
150+ await new Promise<void>((resolve, reject) => {
151+ server.close((error) => {
152+ if (error) {
153+ reject(error);
154+ return;
155+ }
156+
157+ resolve();
158+ });
159+ server.closeAllConnections?.();
160+ });
161+ }
162+
163+ const snapshot = await this.daemon.stop();
164+ this.resolvedBaseUrl = null;
165+
166+ return {
167+ service: this.getRuntimeInfo(),
168+ snapshot
169+ };
170+ }
171+
172+ private getRuntimeInfo(): CodexdLocalServiceRuntimeInfo {
173+ return {
174+ configuredBaseUrl: this.config.service.localApiBase,
175+ eventStreamPath: this.config.service.eventStreamPath,
176+ eventStreamUrl:
177+ this.resolvedBaseUrl == null
178+ ? null
179+ : buildEventStreamUrl(this.resolvedBaseUrl, this.config.service.eventStreamPath),
180+ listening: this.server != null,
181+ resolvedBaseUrl: this.resolvedBaseUrl,
182+ websocketClients: this.websocketServer.getConnectionCount()
183+ };
184+ }
185+
186+ private handleWebSocketConnected(connection: CodexdWebSocketConnection): void {
187+ connection.sendJson({
188+ recentEvents: this.daemon.getStatusSnapshot().recentEvents.events,
189+ service: this.getRuntimeInfo(),
190+ snapshot: this.daemon.getStatusSnapshot(),
191+ type: "hello"
192+ });
193+ }
194+
195+ private handleWebSocketMessage(
196+ connection: CodexdWebSocketConnection,
197+ message: JsonRecord
198+ ): void {
199+ const type = readOptionalString(message.type);
200+
201+ switch (type) {
202+ case "ping":
203+ connection.sendJson({
204+ type: "pong"
205+ });
206+ return;
207+
208+ case "status":
209+ connection.sendJson({
210+ service: this.getRuntimeInfo(),
211+ snapshot: this.daemon.getStatusSnapshot(),
212+ type: "status"
213+ });
214+ return;
215+
216+ default:
217+ connection.sendJson({
218+ message: `Unsupported websocket message type: ${type ?? "unknown"}.`,
219+ type: "error"
220+ });
221+ }
222+ }
223+
224+ private async handleRequest(
225+ request: IncomingMessage,
226+ response: ServerResponse<IncomingMessage>
227+ ): Promise<void> {
228+ try {
229+ const payload = await this.routeHttpRequest({
230+ body: await readIncomingRequestBody(request),
231+ method: request.method ?? "GET",
232+ path: request.url ?? "/"
233+ });
234+ writeHttpResponse(response, payload);
235+ } catch (error) {
236+ const status = error instanceof CodexdHttpError ? error.status : 500;
237+ writeHttpResponse(
238+ response,
239+ jsonResponse(status, {
240+ error: status >= 500 ? "internal_error" : "bad_request",
241+ message: error instanceof Error ? error.message : String(error),
242+ ok: false
243+ })
244+ );
245+ }
246+ }
247+
248+ private async routeHttpRequest(input: {
249+ body: string | null;
250+ method: string;
251+ path: string;
252+ }): Promise<CodexdHttpResponse> {
253+ const method = input.method.toUpperCase();
254+ const url = new URL(input.path, "http://127.0.0.1");
255+ const pathname = normalizePathname(url.pathname);
256+ const body = parseJsonObject(input.body);
257+
258+ if (method === "GET" && pathname === "/healthz") {
259+ return jsonResponse(200, {
260+ ok: true,
261+ service: this.getRuntimeInfo(),
262+ status: "ok"
263+ });
264+ }
265+
266+ if (method === "GET" && pathname === "/v1/codexd/status") {
267+ return jsonResponse(200, {
268+ data: this.getStatus(),
269+ ok: true
270+ });
271+ }
272+
273+ if (method === "GET" && pathname === "/v1/codexd/sessions") {
274+ return jsonResponse(200, {
275+ data: {
276+ sessions: this.daemon.listSessions()
277+ },
278+ ok: true
279+ });
280+ }
281+
282+ if (method === "POST" && pathname === "/v1/codexd/sessions") {
283+ const session = await this.daemon.createSession(parseCreateSessionInput(body));
284+ return jsonResponse(201, {
285+ data: {
286+ session
287+ },
288+ ok: true
289+ });
290+ }
291+
292+ if (method === "POST" && pathname === "/v1/codexd/turn") {
293+ const result = await this.daemon.createTurn(parseCreateTurnInput(body));
294+ return jsonResponse(202, {
295+ data: result,
296+ ok: true
297+ });
298+ }
299+
300+ if (method === "GET" && pathname === "/v1/codexd/runs") {
301+ return jsonResponse(200, {
302+ data: {
303+ runs: this.daemon.listRuns()
304+ },
305+ ok: true
306+ });
307+ }
308+
309+ if (method === "POST" && pathname === "/v1/codexd/runs") {
310+ const run = await this.daemon.createRun(parseCreateRunInput(body));
311+ return jsonResponse(202, {
312+ data: {
313+ run
314+ },
315+ ok: true
316+ });
317+ }
318+
319+ const sessionMatch = pathname.match(/^\/v1\/codexd\/sessions\/([^/]+)$/u);
320+
321+ if (method === "GET" && sessionMatch?.[1] != null) {
322+ const sessionId = decodeURIComponent(sessionMatch[1]);
323+ const session = this.daemon.getSession(sessionId);
324+
325+ if (session == null) {
326+ throw new CodexdHttpError(404, `Unknown codexd session "${sessionId}".`);
327+ }
328+
329+ return jsonResponse(200, {
330+ data: {
331+ recentEvents: findEventsForSession(this.daemon.getStatusSnapshot(), session),
332+ session
333+ },
334+ ok: true
335+ });
336+ }
337+
338+ const runMatch = pathname.match(/^\/v1\/codexd\/runs\/([^/]+)$/u);
339+
340+ if (method === "GET" && runMatch?.[1] != null) {
341+ const runId = decodeURIComponent(runMatch[1]);
342+ const run = this.daemon.getRun(runId);
343+
344+ if (run == null) {
345+ throw new CodexdHttpError(404, `Unknown codexd run "${runId}".`);
346+ }
347+
348+ return jsonResponse(200, {
349+ data: {
350+ run
351+ },
352+ ok: true
353+ });
354+ }
355+
356+ throw new CodexdHttpError(404, `Unknown codexd route ${method} ${pathname}.`);
357+ }
358+}
359+
360+function asRecord(value: unknown): JsonRecord | null {
361+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
362+ return null;
363+ }
364+
365+ return value as JsonRecord;
366+}
367+
368+function buildEventStreamUrl(baseUrl: string, path: string): string {
369+ const url = new URL(baseUrl);
370+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
371+ url.pathname = normalizePathname(path);
372+ url.search = "";
373+ url.hash = "";
374+ return url.toString();
375+}
376+
377+function findEventsForSession(
378+ snapshot: CodexdStatusSnapshot,
379+ session: CodexdSessionRecord
380+): ReturnType<CodexdStatusSnapshot["recentEvents"]["events"]["slice"]> {
381+ return snapshot.recentEvents.events.filter((event) => {
382+ const detail = event.detail;
383+
384+ if (detail == null) {
385+ return false;
386+ }
387+
388+ return detail.sessionId === session.sessionId || detail.threadId === session.threadId;
389+ });
390+}
391+
392+function formatLocalApiBaseUrl(hostname: string, port: number): string {
393+ const formattedHost = hostname.includes(":") ? `[${hostname}]` : hostname;
394+ return `http://${formattedHost}${port === 80 ? "" : `:${port}`}`;
395+}
396+
397+function isLoopbackHost(hostname: string): boolean {
398+ return hostname === "127.0.0.1" || hostname === "::1" || hostname === "localhost";
399+}
400+
401+function jsonResponse(status: number, payload: JsonRecord): CodexdHttpResponse {
402+ return {
403+ body: `${JSON.stringify(payload, null, 2)}\n`,
404+ headers: {
405+ "cache-control": "no-store",
406+ "content-type": "application/json; charset=utf-8"
407+ },
408+ status
409+ };
410+}
411+
412+function normalizePathname(value: string): string {
413+ const normalized = value.replace(/\/+$/u, "");
414+ return normalized === "" ? "/" : normalized;
415+}
416+
417+function parseCreateRunInput(body: JsonRecord): CodexdRunInput {
418+ return {
419+ additionalWritableDirectories: readStringArray(body.additionalWritableDirectories),
420+ config: readStringArray(body.config),
421+ cwd: readOptionalString(body.cwd) ?? undefined,
422+ env: readStringMap(body.env),
423+ images: readStringArray(body.images),
424+ metadata: readStringMap(body.metadata),
425+ model: readOptionalString(body.model) ?? undefined,
426+ profile: readOptionalString(body.profile) ?? undefined,
427+ prompt: readRequiredString(body.prompt, "prompt"),
428+ purpose: readOptionalString(body.purpose),
429+ sandbox: readOptionalString(body.sandbox),
430+ sessionId: readOptionalString(body.sessionId),
431+ skipGitRepoCheck: readOptionalBoolean(body.skipGitRepoCheck),
432+ timeoutMs: readOptionalInteger(body.timeoutMs)
433+ };
434+}
435+
436+function parseCreateSessionInput(body: JsonRecord): CodexdCreateSessionInput {
437+ return {
438+ approvalPolicy: (body.approvalPolicy as CodexdCreateSessionInput["approvalPolicy"]) ?? null,
439+ baseInstructions: readOptionalString(body.baseInstructions),
440+ config: (asRecord(body.config) as CodexdCreateSessionInput["config"]) ?? null,
441+ cwd: readOptionalString(body.cwd),
442+ developerInstructions: readOptionalString(body.developerInstructions),
443+ ephemeral:
444+ typeof body.ephemeral === "boolean"
445+ ? body.ephemeral
446+ : null,
447+ metadata: readStringMap(body.metadata),
448+ model: readOptionalString(body.model),
449+ modelProvider: readOptionalString(body.modelProvider),
450+ personality: readOptionalString(body.personality),
451+ purpose: readSessionPurpose(body.purpose),
452+ sandbox: (body.sandbox as CodexdCreateSessionInput["sandbox"]) ?? null,
453+ serviceTier: readOptionalString(body.serviceTier),
454+ threadId: readOptionalString(body.threadId)
455+ };
456+}
457+
458+function parseCreateTurnInput(body: JsonRecord): CodexdTurnInput {
459+ const rawInput = body.input ?? body.prompt;
460+ const inputValue =
461+ typeof rawInput === "string" || Array.isArray(rawInput)
462+ ? rawInput
463+ : null;
464+
465+ if (inputValue == null) {
466+ throw new CodexdHttpError(400, "turn requires input as a string or item array.");
467+ }
468+
469+ return {
470+ approvalPolicy: (body.approvalPolicy as CodexdTurnInput["approvalPolicy"]) ?? null,
471+ collaborationMode: (body.collaborationMode as CodexdTurnInput["collaborationMode"]) ?? null,
472+ cwd: readOptionalString(body.cwd),
473+ effort: readOptionalString(body.effort),
474+ expectedTurnId: readOptionalString(body.expectedTurnId),
475+ input: inputValue as CodexdTurnInput["input"],
476+ model: readOptionalString(body.model),
477+ outputSchema: (body.outputSchema as CodexdTurnInput["outputSchema"]) ?? null,
478+ personality: readOptionalString(body.personality),
479+ sandboxPolicy: (body.sandboxPolicy as CodexdTurnInput["sandboxPolicy"]) ?? null,
480+ serviceTier: readOptionalString(body.serviceTier),
481+ sessionId: readRequiredString(body.sessionId, "sessionId"),
482+ summary: (body.summary as CodexdTurnInput["summary"]) ?? null
483+ };
484+}
485+
486+function parseJsonObject(body: string | null): JsonRecord {
487+ if (body == null || body.trim() === "") {
488+ return {};
489+ }
490+
491+ let parsed: unknown;
492+
493+ try {
494+ parsed = JSON.parse(body);
495+ } catch {
496+ throw new CodexdHttpError(400, "Request body must be valid JSON.");
497+ }
498+
499+ const record = asRecord(parsed);
500+
501+ if (record == null) {
502+ throw new CodexdHttpError(400, "Request body must be a JSON object.");
503+ }
504+
505+ return record;
506+}
507+
508+function readHttpPort(url: URL): number {
509+ return url.port === "" ? 80 : Number.parseInt(url.port, 10);
510+}
511+
512+async function readIncomingRequestBody(request: IncomingMessage): Promise<string | null> {
513+ if (request.method == null || request.method.toUpperCase() === "GET") {
514+ return null;
515+ }
516+
517+ return await new Promise((resolve, reject) => {
518+ let body = "";
519+ request.setEncoding?.("utf8");
520+ request.on?.("data", (chunk) => {
521+ body += typeof chunk === "string" ? chunk : String(chunk);
522+ });
523+ request.on?.("end", () => {
524+ resolve(body === "" ? null : body);
525+ });
526+ request.on?.("error", (error) => {
527+ reject(error);
528+ });
529+ });
530+}
531+
532+function readOptionalBoolean(value: unknown): boolean | undefined {
533+ return typeof value === "boolean" ? value : undefined;
534+}
535+
536+function readOptionalInteger(value: unknown): number | undefined {
537+ return typeof value === "number" && Number.isInteger(value) ? value : undefined;
538+}
539+
540+function readOptionalString(value: unknown): string | null {
541+ if (typeof value !== "string") {
542+ return null;
543+ }
544+
545+ const normalized = value.trim();
546+ return normalized === "" ? null : normalized;
547+}
548+
549+function readRequiredString(value: unknown, field: string): string {
550+ const normalized = readOptionalString(value);
551+
552+ if (normalized == null) {
553+ throw new CodexdHttpError(400, `${field} must be a non-empty string.`);
554+ }
555+
556+ return normalized;
557+}
558+
559+function readSessionPurpose(value: unknown): CodexdSessionPurpose {
560+ const normalized = readOptionalString(value) ?? "duplex";
561+
562+ if (normalized === "duplex" || normalized === "smoke" || normalized === "worker") {
563+ return normalized;
564+ }
565+
566+ throw new CodexdHttpError(400, `Unsupported session purpose "${String(value)}".`);
567+}
568+
569+function readStringArray(value: unknown): string[] | undefined {
570+ if (value == null) {
571+ return undefined;
572+ }
573+
574+ if (!Array.isArray(value)) {
575+ throw new CodexdHttpError(400, "Expected an array of strings.");
576+ }
577+
578+ const items: string[] = [];
579+
580+ for (const entry of value) {
581+ const item = readOptionalString(entry);
582+
583+ if (item == null) {
584+ throw new CodexdHttpError(400, "Expected an array of strings.");
585+ }
586+
587+ items.push(item);
588+ }
589+
590+ return items;
591+}
592+
593+function readStringMap(value: unknown): Record<string, string> | undefined {
594+ if (value == null) {
595+ return undefined;
596+ }
597+
598+ const record = asRecord(value);
599+
600+ if (record == null) {
601+ throw new CodexdHttpError(400, "Expected a string map.");
602+ }
603+
604+ const result: Record<string, string> = {};
605+
606+ for (const [key, entry] of Object.entries(record)) {
607+ const normalizedKey = readOptionalString(key);
608+ const normalizedValue = readOptionalString(entry);
609+
610+ if (normalizedKey == null || normalizedValue == null) {
611+ throw new CodexdHttpError(400, "Expected a string map.");
612+ }
613+
614+ result[normalizedKey] = normalizedValue;
615+ }
616+
617+ return result;
618+}
619+
620+function resolveLocalListenConfig(localApiBase: string): { host: string; port: number } {
621+ let url: URL;
622+
623+ try {
624+ url = new URL(localApiBase);
625+ } catch {
626+ throw new Error("codexd localApiBase must be a valid absolute http:// URL.");
627+ }
628+
629+ if (url.protocol !== "http:") {
630+ throw new Error("codexd localApiBase must use the http:// scheme.");
631+ }
632+
633+ if (!isLoopbackHost(url.hostname)) {
634+ throw new Error("codexd localApiBase must use a loopback host.");
635+ }
636+
637+ if (url.pathname !== "/" || url.search !== "" || url.hash !== "") {
638+ throw new Error("codexd localApiBase must not include path, query, or hash.");
639+ }
640+
641+ if (url.username !== "" || url.password !== "") {
642+ throw new Error("codexd localApiBase must not include credentials.");
643+ }
644+
645+ return {
646+ host: url.hostname === "localhost" ? "127.0.0.1" : url.hostname,
647+ port: readHttpPort(url)
648+ };
649+}
650+
651+function writeHttpResponse(
652+ response: ServerResponse<IncomingMessage>,
653+ payload: CodexdHttpResponse
654+): void {
655+ response.statusCode = payload.status;
656+
657+ for (const [name, value] of Object.entries(payload.headers)) {
658+ response.setHeader(name, value);
659+ }
660+
661+ response.end(payload.body);
662+}
+108,
-18
1@@ -1,17 +1,32 @@
2 declare function setTimeout(callback: () => void, delay?: number): unknown;
3+declare function clearTimeout(handle: unknown): void;
4+declare function setInterval(callback: () => void, delay?: number): unknown;
5+declare function clearInterval(handle: unknown): void;
6
7-declare const process:
8- | {
9- argv: string[];
10- cwd(): string;
11- env: Record<string, string | undefined>;
12- execPath: string;
13- exitCode?: number;
14- off?(event: string, listener: () => void): unknown;
15- on?(event: string, listener: () => void): unknown;
16- pid?: number;
17- }
18- | undefined;
19+declare class Buffer extends Uint8Array {
20+ static alloc(size: number): Buffer;
21+ static allocUnsafe(size: number): Buffer;
22+ static concat(chunks: readonly Uint8Array[]): Buffer;
23+ static from(value: string, encoding?: string): Buffer;
24+ copy(target: Uint8Array, targetStart?: number): number;
25+ readBigUInt64BE(offset?: number): bigint;
26+ readUInt16BE(offset?: number): number;
27+ subarray(start?: number, end?: number): Buffer;
28+ toString(encoding?: string): string;
29+ writeBigUInt64BE(value: bigint, offset?: number): number;
30+ writeUInt16BE(value: number, offset?: number): number;
31+}
32+
33+declare const process: {
34+ argv: string[];
35+ cwd(): string;
36+ env: Record<string, string | undefined>;
37+ execPath: string;
38+ exitCode?: number;
39+ off?(event: string, listener: () => void): unknown;
40+ on?(event: string, listener: () => void): unknown;
41+ pid?: number;
42+};
43
44 declare module "node:child_process" {
45 export interface SpawnOptions {
46@@ -20,14 +35,23 @@ declare module "node:child_process" {
47 stdio?: readonly string[] | string;
48 }
49
50+ export interface WritableStreamLike {
51+ end(chunk?: string | Uint8Array): unknown;
52+ write(chunk: string | Uint8Array): boolean;
53+ }
54+
55+ export interface ReadableStreamLike {
56+ on(event: "data", listener: (chunk: string | Uint8Array) => void): this;
57+ on(event: "end", listener: () => void): this;
58+ on(event: "error", listener: (error: Error) => void): this;
59+ setEncoding?(encoding: string): this;
60+ }
61+
62 export interface ChildProcess {
63 pid?: number;
64- stderr?: {
65- on(event: "data", listener: (chunk: string | Uint8Array) => void): unknown;
66- };
67- stdout?: {
68- on(event: "data", listener: (chunk: string | Uint8Array) => void): unknown;
69- };
70+ stdin?: WritableStreamLike;
71+ stderr?: ReadableStreamLike;
72+ stdout?: ReadableStreamLike;
73 kill(signal?: string): boolean;
74 on(event: "error", listener: (error: Error) => void): this;
75 on(event: "exit", listener: (code: number | null, signal: string | null) => void): this;
76@@ -41,6 +65,13 @@ declare module "node:child_process" {
77 }
78
79 declare module "node:crypto" {
80+ export function createHash(
81+ algorithm: string
82+ ): {
83+ digest(encoding: string): string;
84+ update(value: string): { digest(encoding: string): string };
85+ };
86+
87 export function randomUUID(): string;
88 }
89
90@@ -56,3 +87,62 @@ declare module "node:path" {
91 export function join(...paths: string[]): string;
92 export function resolve(...paths: string[]): string;
93 }
94+
95+declare module "node:net" {
96+ export interface AddressInfo {
97+ address: string;
98+ family: string;
99+ port: number;
100+ }
101+
102+ export interface Socket {
103+ destroy(error?: Error): this;
104+ destroySoon?(): void;
105+ end(chunk?: string | Uint8Array): this;
106+ on(event: "close", listener: () => void): this;
107+ on(event: "data", listener: (chunk: Buffer) => void): this;
108+ on(event: "end", listener: () => void): this;
109+ on(event: "error", listener: (error: Error) => void): this;
110+ setNoDelay(noDelay?: boolean): this;
111+ write(chunk: string | Uint8Array): boolean;
112+ }
113+}
114+
115+declare module "node:http" {
116+ import type { AddressInfo } from "node:net";
117+
118+ export interface IncomingMessage {
119+ headers: Record<string, string | string[] | undefined>;
120+ method?: string;
121+ on?(event: "data", listener: (chunk: string | Uint8Array) => void): this;
122+ on?(event: "end", listener: () => void): this;
123+ on?(event: "error", listener: (error: Error) => void): this;
124+ setEncoding?(encoding: string): void;
125+ url?: string;
126+ }
127+
128+ export interface ServerResponse<Request extends IncomingMessage = IncomingMessage> {
129+ end(chunk?: string): void;
130+ setHeader(name: string, value: string): this;
131+ statusCode: number;
132+ }
133+
134+ export interface Server {
135+ address(): AddressInfo | string | null;
136+ close(callback?: (error?: Error) => void): this;
137+ closeAllConnections?(): void;
138+ listen(options: { host: string; port: number }): this;
139+ on(
140+ event: "upgrade",
141+ listener: (request: IncomingMessage, socket: import("node:net").Socket, head: Buffer) => void
142+ ): this;
143+ off(event: "error", listener: (error: Error) => void): this;
144+ off(event: "listening", listener: () => void): this;
145+ once(event: "error", listener: (error: Error) => void): this;
146+ once(event: "listening", listener: () => void): this;
147+ }
148+
149+ export function createServer(
150+ handler: (request: IncomingMessage, response: ServerResponse<IncomingMessage>) => void
151+ ): Server;
152+}
+226,
-103
1@@ -9,6 +9,8 @@ import type {
2 CodexdRecentEvent,
3 CodexdRecentEventCacheState,
4 CodexdResolvedConfig,
5+ CodexdRunRecord,
6+ CodexdRunRegistryState,
7 CodexdSessionRecord,
8 CodexdSessionRegistryState,
9 CodexdStatusSnapshot
10@@ -16,6 +18,7 @@ import type {
11
12 export interface CodexdStateStoreOptions {
13 now?: () => string;
14+ onEvent?: (event: CodexdRecentEvent) => void;
15 processId?: () => number | null;
16 uuid?: () => string;
17 }
18@@ -31,11 +34,14 @@ export class CodexdStateStore {
19 private daemonState: CodexdDaemonState | null = null;
20 private identity: CodexdDaemonIdentity | null = null;
21 private initialized = false;
22+ private mutationQueue: Promise<void> = Promise.resolve();
23 private nextEventSeq = 1;
24 private recentEvents: CodexdRecentEventCacheState | null = null;
25+ private runRegistry: CodexdRunRegistryState | null = null;
26 private sessionRegistry: CodexdSessionRegistryState | null = null;
27
28 private readonly now: () => string;
29+ private readonly onEvent: (event: CodexdRecentEvent) => void;
30 private readonly processId: () => number | null;
31 private readonly uuid: () => string;
32
33@@ -44,6 +50,7 @@ export class CodexdStateStore {
34 options: CodexdStateStoreOptions = {}
35 ) {
36 this.now = options.now ?? defaultNow;
37+ this.onEvent = options.onEvent ?? (() => {});
38 this.processId = options.processId ?? defaultProcessId;
39 this.uuid = options.uuid ?? randomUUID;
40 }
41@@ -68,6 +75,10 @@ export class CodexdStateStore {
42 this.config.paths.sessionRegistryPath,
43 null
44 );
45+ const runRegistry = await readJsonOrDefault<CodexdRunRegistryState | null>(
46+ this.config.paths.runRegistryPath,
47+ null
48+ );
49 const recentEvents = await readJsonOrDefault<CodexdRecentEventCacheState | null>(
50 this.config.paths.recentEventsPath,
51 null
52@@ -76,6 +87,7 @@ export class CodexdStateStore {
53 this.identity = identity ?? createDaemonIdentity(this.config, this.uuid(), this.now());
54 this.daemonState = normalizeDaemonState(daemonState, this.config, this.now());
55 this.sessionRegistry = normalizeSessionRegistry(sessionRegistry);
56+ this.runRegistry = normalizeRunRegistry(runRegistry);
57 this.recentEvents = normalizeRecentEvents(recentEvents, this.config.eventCacheSize);
58 this.nextEventSeq = getNextEventSeq(this.recentEvents.events);
59 this.initialized = true;
60@@ -83,6 +95,7 @@ export class CodexdStateStore {
61 await this.persistIdentity();
62 await this.persistDaemonState();
63 await this.persistSessionRegistry();
64+ await this.persistRunRegistry();
65 await this.persistRecentEvents();
66
67 return this.getSnapshot();
68@@ -96,6 +109,7 @@ export class CodexdStateStore {
69 identity: cloneJson(this.identity!),
70 daemon: cloneJson(this.daemonState!),
71 sessionRegistry: cloneJson(this.sessionRegistry!),
72+ runRegistry: cloneJson(this.runRegistry!),
73 recentEvents: cloneJson(this.recentEvents!)
74 };
75 }
76@@ -105,146 +119,225 @@ export class CodexdStateStore {
77 return cloneJson(this.daemonState!.child);
78 }
79
80- async markDaemonStarted(): Promise<CodexdDaemonState> {
81+ getRun(runId: string): CodexdRunRecord | null {
82 this.assertInitialized();
83- const now = this.now();
84+ return (
85+ cloneJson(
86+ this.runRegistry!.runs.find((entry) => entry.runId === runId) ?? null
87+ )
88+ );
89+ }
90
91- this.daemonState = {
92- ...this.daemonState!,
93- started: true,
94- startedAt: now,
95- stoppedAt: null,
96- updatedAt: now,
97- pid: this.processId()
98- };
99+ getSession(sessionId: string): CodexdSessionRecord | null {
100+ this.assertInitialized();
101+ return (
102+ cloneJson(
103+ this.sessionRegistry!.sessions.find((entry) => entry.sessionId === sessionId) ?? null
104+ )
105+ );
106+ }
107
108- await this.persistDaemonState();
109- return this.daemonState!;
110+ listRuns(): CodexdRunRecord[] {
111+ this.assertInitialized();
112+ return cloneJson(this.runRegistry!.runs);
113 }
114
115- async markDaemonStopped(): Promise<CodexdDaemonState> {
116+ listSessions(): CodexdSessionRecord[] {
117 this.assertInitialized();
118- const now = this.now();
119+ return cloneJson(this.sessionRegistry!.sessions);
120+ }
121
122- this.daemonState = {
123- ...this.daemonState!,
124- started: false,
125- stoppedAt: now,
126- updatedAt: now,
127- pid: null
128- };
129+ async markDaemonStarted(): Promise<CodexdDaemonState> {
130+ return await this.enqueueMutation(async () => {
131+ this.assertInitialized();
132+ const now = this.now();
133+
134+ this.daemonState = {
135+ ...this.daemonState!,
136+ started: true,
137+ startedAt: now,
138+ stoppedAt: null,
139+ updatedAt: now,
140+ pid: this.processId()
141+ };
142+
143+ await this.persistDaemonState();
144+ return cloneJson(this.daemonState!);
145+ });
146+ }
147
148- await this.persistDaemonState();
149- return this.daemonState!;
150+ async markDaemonStopped(): Promise<CodexdDaemonState> {
151+ return await this.enqueueMutation(async () => {
152+ this.assertInitialized();
153+ const now = this.now();
154+
155+ this.daemonState = {
156+ ...this.daemonState!,
157+ started: false,
158+ stoppedAt: now,
159+ updatedAt: now,
160+ pid: null
161+ };
162+
163+ await this.persistDaemonState();
164+ return cloneJson(this.daemonState!);
165+ });
166 }
167
168 async updateChildState(patch: Partial<CodexdManagedChildState>): Promise<CodexdManagedChildState> {
169- this.assertInitialized();
170-
171- this.daemonState = {
172- ...this.daemonState!,
173- updatedAt: this.now(),
174- child: {
175- ...this.daemonState!.child,
176- ...cloneJson(patch)
177- }
178- };
179-
180- await this.persistDaemonState();
181- return this.daemonState!.child;
182+ return await this.enqueueMutation(async () => {
183+ this.assertInitialized();
184+
185+ this.daemonState = {
186+ ...this.daemonState!,
187+ updatedAt: this.now(),
188+ child: {
189+ ...this.daemonState!.child,
190+ ...cloneJson(patch)
191+ }
192+ };
193+
194+ await this.persistDaemonState();
195+ return cloneJson(this.daemonState!.child);
196+ });
197 }
198
199 async upsertSession(record: CodexdSessionRecord): Promise<CodexdSessionRegistryState> {
200- this.assertInitialized();
201- const sessions = [...this.sessionRegistry!.sessions];
202- const index = sessions.findIndex((entry) => entry.sessionId === record.sessionId);
203+ return await this.enqueueMutation(async () => {
204+ this.assertInitialized();
205+ const sessions = [...this.sessionRegistry!.sessions];
206+ const index = sessions.findIndex((entry) => entry.sessionId === record.sessionId);
207+
208+ if (index >= 0) {
209+ sessions[index] = cloneJson(record);
210+ } else {
211+ sessions.push(cloneJson(record));
212+ }
213
214- if (index >= 0) {
215- sessions[index] = cloneJson(record);
216- } else {
217- sessions.push(cloneJson(record));
218- }
219+ this.sessionRegistry = {
220+ updatedAt: this.now(),
221+ sessions
222+ };
223
224- this.sessionRegistry = {
225- updatedAt: this.now(),
226- sessions
227- };
228-
229- await this.persistSessionRegistry();
230- return this.sessionRegistry!;
231+ await this.persistSessionRegistry();
232+ return cloneJson(this.sessionRegistry!);
233+ });
234 }
235
236 async closeSession(sessionId: string): Promise<CodexdSessionRecord | null> {
237- this.assertInitialized();
238- const sessions = [...this.sessionRegistry!.sessions];
239- const index = sessions.findIndex((entry) => entry.sessionId === sessionId);
240-
241- if (index < 0) {
242- return null;
243- }
244+ return await this.enqueueMutation(async () => {
245+ this.assertInitialized();
246+ const sessions = [...this.sessionRegistry!.sessions];
247+ const index = sessions.findIndex((entry) => entry.sessionId === sessionId);
248
249- const existing = sessions[index];
250+ if (index < 0) {
251+ return null;
252+ }
253
254- if (existing == null) {
255- return null;
256- }
257+ const existing = sessions[index];
258
259- const updated: CodexdSessionRecord = {
260- ...existing,
261- status: "closed",
262- updatedAt: this.now()
263- };
264- sessions[index] = updated;
265- this.sessionRegistry = {
266- updatedAt: updated.updatedAt,
267- sessions
268- };
269+ if (existing == null) {
270+ return null;
271+ }
272
273- await this.persistSessionRegistry();
274- return updated;
275+ const updated: CodexdSessionRecord = {
276+ ...existing,
277+ status: "closed",
278+ currentTurnId: null,
279+ updatedAt: this.now()
280+ };
281+ sessions[index] = updated;
282+ this.sessionRegistry = {
283+ updatedAt: updated.updatedAt,
284+ sessions
285+ };
286+
287+ await this.persistSessionRegistry();
288+ return cloneJson(updated);
289+ });
290 }
291
292- async recordEvent(input: CodexdEventInput): Promise<CodexdRecentEvent> {
293- this.assertInitialized();
294- const entry: CodexdRecentEvent = {
295- seq: this.nextEventSeq,
296- createdAt: this.now(),
297- level: input.level,
298- type: input.type,
299- message: input.message,
300- detail: input.detail ?? null
301- };
302-
303- this.nextEventSeq += 1;
304- await appendFile(this.config.paths.structuredEventLogPath, `${JSON.stringify(entry)}\n`, "utf8");
305+ async upsertRun(record: CodexdRunRecord): Promise<CodexdRunRegistryState> {
306+ return await this.enqueueMutation(async () => {
307+ this.assertInitialized();
308+ const runs = [...this.runRegistry!.runs];
309+ const index = runs.findIndex((entry) => entry.runId === record.runId);
310
311- this.recentEvents = {
312- maxEntries: this.config.eventCacheSize,
313- updatedAt: entry.createdAt,
314- events: [...this.recentEvents!.events, entry].slice(-this.config.eventCacheSize)
315- };
316+ if (index >= 0) {
317+ runs[index] = cloneJson(record);
318+ } else {
319+ runs.push(cloneJson(record));
320+ }
321
322- this.daemonState = {
323- ...this.daemonState!,
324- updatedAt: entry.createdAt
325- };
326+ this.runRegistry = {
327+ updatedAt: this.now(),
328+ runs
329+ };
330
331- await this.persistRecentEvents();
332- await this.persistDaemonState();
333+ await this.persistRunRegistry();
334+ return cloneJson(this.runRegistry!);
335+ });
336+ }
337
338+ async recordEvent(input: CodexdEventInput): Promise<CodexdRecentEvent> {
339+ const entry = await this.enqueueMutation(async () => {
340+ this.assertInitialized();
341+ const nextEntry: CodexdRecentEvent = {
342+ seq: this.nextEventSeq,
343+ createdAt: this.now(),
344+ level: input.level,
345+ type: input.type,
346+ message: input.message,
347+ detail: input.detail ?? null
348+ };
349+
350+ this.nextEventSeq += 1;
351+ await appendFile(
352+ this.config.paths.structuredEventLogPath,
353+ `${JSON.stringify(nextEntry)}\n`,
354+ "utf8"
355+ );
356+
357+ this.recentEvents = {
358+ maxEntries: this.config.eventCacheSize,
359+ updatedAt: nextEntry.createdAt,
360+ events: [...this.recentEvents!.events, nextEntry].slice(-this.config.eventCacheSize)
361+ };
362+
363+ this.daemonState = {
364+ ...this.daemonState!,
365+ updatedAt: nextEntry.createdAt
366+ };
367+
368+ await this.persistRecentEvents();
369+ await this.persistDaemonState();
370+
371+ return cloneJson(nextEntry);
372+ });
373+
374+ this.onEvent(entry);
375 return entry;
376 }
377
378 async appendChildOutput(stream: "stderr" | "stdout", text: string): Promise<void> {
379- this.assertInitialized();
380- const path =
381- stream === "stdout" ? this.config.paths.stdoutLogPath : this.config.paths.stderrLogPath;
382+ await this.enqueueMutation(async () => {
383+ this.assertInitialized();
384+ const path =
385+ stream === "stdout" ? this.config.paths.stdoutLogPath : this.config.paths.stderrLogPath;
386
387- await appendFile(path, text, "utf8");
388+ await appendFile(path, text, "utf8");
389+ });
390 }
391
392 private assertInitialized(): void {
393- if (!this.initialized || this.identity == null || this.daemonState == null || this.sessionRegistry == null || this.recentEvents == null) {
394+ if (
395+ !this.initialized
396+ || this.identity == null
397+ || this.daemonState == null
398+ || this.sessionRegistry == null
399+ || this.runRegistry == null
400+ || this.recentEvents == null
401+ ) {
402 throw new Error("CodexdStateStore is not initialized.");
403 }
404 }
405@@ -264,10 +357,24 @@ export class CodexdStateStore {
406 await writeJsonFile(this.config.paths.recentEventsPath, this.recentEvents!);
407 }
408
409+ private async persistRunRegistry(): Promise<void> {
410+ this.assertInitialized();
411+ await writeJsonFile(this.config.paths.runRegistryPath, this.runRegistry!);
412+ }
413+
414 private async persistSessionRegistry(): Promise<void> {
415 this.assertInitialized();
416 await writeJsonFile(this.config.paths.sessionRegistryPath, this.sessionRegistry!);
417 }
418+
419+ private async enqueueMutation<T>(action: () => Promise<T>): Promise<T> {
420+ const task = this.mutationQueue.then(action);
421+ this.mutationQueue = task.then(
422+ () => undefined,
423+ () => undefined
424+ );
425+ return await task;
426+ }
427 }
428
429 function createDaemonIdentity(
430@@ -367,6 +474,22 @@ function normalizeSessionRegistry(
431 };
432 }
433
434+function normalizeRunRegistry(
435+ value: CodexdRunRegistryState | null
436+): CodexdRunRegistryState {
437+ if (value == null) {
438+ return {
439+ updatedAt: null,
440+ runs: []
441+ };
442+ }
443+
444+ return {
445+ updatedAt: value.updatedAt,
446+ runs: [...value.runs]
447+ };
448+}
449+
450 async function readJsonOrDefault<T>(path: string, fallback: T): Promise<T> {
451 try {
452 const source = await readFile(path, "utf8");
+389,
-0
1@@ -0,0 +1,389 @@
2+import { createHash, randomUUID } from "node:crypto";
3+import type { IncomingMessage } from "node:http";
4+import type { Socket } from "node:net";
5+
6+const NORMAL_CLOSE_CODE = 1000;
7+const INVALID_MESSAGE_CLOSE_CODE = 4002;
8+const UNSUPPORTED_DATA_CLOSE_CODE = 1003;
9+const MESSAGE_TOO_LARGE_CLOSE_CODE = 1009;
10+const MAX_FRAME_PAYLOAD_BYTES = 1024 * 1024;
11+const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
12+
13+type JsonRecord = Record<string, unknown>;
14+
15+function asRecord(value: unknown): JsonRecord | null {
16+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
17+ return null;
18+ }
19+
20+ return value as JsonRecord;
21+}
22+
23+function normalizeNonEmptyString(value: unknown): string | null {
24+ if (typeof value !== "string") {
25+ return null;
26+ }
27+
28+ const normalized = value.trim();
29+ return normalized === "" ? null : normalized;
30+}
31+
32+function normalizePathname(value: string): string {
33+ const normalized = value.replace(/\/+$/u, "");
34+ return normalized === "" ? "/" : normalized;
35+}
36+
37+function buildWebSocketAcceptValue(key: string): string {
38+ return createHash("sha1").update(`${key}${WS_GUID}`).digest("base64");
39+}
40+
41+function buildClosePayload(code: number, reason: string): Buffer {
42+ const reasonBuffer = Buffer.from(reason, "utf8");
43+ const payload = Buffer.allocUnsafe(2 + reasonBuffer.length);
44+ payload.writeUInt16BE(code, 0);
45+ reasonBuffer.copy(payload, 2);
46+ return payload;
47+}
48+
49+function buildFrame(opcode: number, payload: Buffer = Buffer.alloc(0)): Buffer {
50+ let header: Buffer;
51+
52+ if (payload.length < 126) {
53+ header = Buffer.allocUnsafe(2);
54+ header[0] = 0x80 | opcode;
55+ header[1] = payload.length;
56+ return Buffer.concat([header, payload]);
57+ }
58+
59+ if (payload.length <= 0xffff) {
60+ header = Buffer.allocUnsafe(4);
61+ header[0] = 0x80 | opcode;
62+ header[1] = 126;
63+ header.writeUInt16BE(payload.length, 2);
64+ return Buffer.concat([header, payload]);
65+ }
66+
67+ header = Buffer.allocUnsafe(10);
68+ header[0] = 0x80 | opcode;
69+ header[1] = 127;
70+ header.writeBigUInt64BE(BigInt(payload.length), 2);
71+ return Buffer.concat([header, payload]);
72+}
73+
74+export class CodexdWebSocketConnection {
75+ readonly id = randomUUID();
76+ private buffer = Buffer.alloc(0);
77+ private closed = false;
78+
79+ constructor(
80+ private readonly socket: Socket,
81+ private readonly onMessage: (connection: CodexdWebSocketConnection, message: JsonRecord) => void,
82+ private readonly onClose: (connection: CodexdWebSocketConnection) => void
83+ ) {
84+ this.socket.setNoDelay(true);
85+ this.socket.on("data", (chunk) => {
86+ this.handleData(chunk);
87+ });
88+ this.socket.on("close", () => {
89+ this.handleClosed();
90+ });
91+ this.socket.on("end", () => {
92+ this.handleClosed();
93+ });
94+ this.socket.on("error", () => {
95+ this.handleClosed();
96+ });
97+ }
98+
99+ attachHead(head: Buffer): void {
100+ if (head.length > 0) {
101+ this.handleData(head);
102+ }
103+ }
104+
105+ sendJson(payload: JsonRecord): boolean {
106+ if (this.closed) {
107+ return false;
108+ }
109+
110+ try {
111+ this.socket.write(buildFrame(0x1, Buffer.from(`${JSON.stringify(payload)}\n`, "utf8")));
112+ return true;
113+ } catch {
114+ this.handleClosed();
115+ return false;
116+ }
117+ }
118+
119+ close(code = NORMAL_CLOSE_CODE, reason = ""): void {
120+ if (this.closed) {
121+ return;
122+ }
123+
124+ this.closed = true;
125+
126+ try {
127+ this.socket.write(buildFrame(0x8, buildClosePayload(code, reason)));
128+ } catch {
129+ // Best effort.
130+ }
131+
132+ this.socket.end();
133+ this.socket.destroySoon?.();
134+ this.onClose(this);
135+ }
136+
137+ private handleClosed(): void {
138+ if (this.closed) {
139+ return;
140+ }
141+
142+ this.closed = true;
143+ this.socket.destroy();
144+ this.onClose(this);
145+ }
146+
147+ private handleData(chunk: Buffer): void {
148+ if (this.closed) {
149+ return;
150+ }
151+
152+ this.buffer = Buffer.concat([this.buffer, chunk]);
153+
154+ while (true) {
155+ const frame = this.readFrame();
156+
157+ if (frame == null) {
158+ return;
159+ }
160+
161+ if (frame.opcode === 0x8) {
162+ this.close();
163+ return;
164+ }
165+
166+ if (frame.opcode === 0x9) {
167+ this.socket.write(buildFrame(0xA, frame.payload));
168+ continue;
169+ }
170+
171+ if (frame.opcode === 0xA) {
172+ continue;
173+ }
174+
175+ if (frame.opcode !== 0x1) {
176+ this.close(UNSUPPORTED_DATA_CLOSE_CODE, "Only text websocket frames are supported.");
177+ return;
178+ }
179+
180+ let payload: JsonRecord | null = null;
181+
182+ try {
183+ payload = asRecord(JSON.parse(frame.payload.toString("utf8")));
184+ } catch {
185+ payload = null;
186+ }
187+
188+ if (payload == null) {
189+ this.close(INVALID_MESSAGE_CLOSE_CODE, "WS payload must be a JSON object.");
190+ return;
191+ }
192+
193+ this.onMessage(this, payload);
194+ }
195+ }
196+
197+ private readFrame(): { opcode: number; payload: Buffer } | null {
198+ if (this.buffer.length < 2) {
199+ return null;
200+ }
201+
202+ const firstByte = this.buffer[0];
203+ const secondByte = this.buffer[1];
204+
205+ if (firstByte == null || secondByte == null) {
206+ return null;
207+ }
208+
209+ const fin = (firstByte & 0x80) !== 0;
210+ const opcode = firstByte & 0x0f;
211+ const masked = (secondByte & 0x80) !== 0;
212+ let payloadLength = secondByte & 0x7f;
213+ let offset = 2;
214+
215+ if (!fin) {
216+ this.close(UNSUPPORTED_DATA_CLOSE_CODE, "Fragmented websocket frames are not supported.");
217+ return null;
218+ }
219+
220+ if (!masked) {
221+ this.close(INVALID_MESSAGE_CLOSE_CODE, "Client websocket frames must be masked.");
222+ return null;
223+ }
224+
225+ if (payloadLength === 126) {
226+ if (this.buffer.length < offset + 2) {
227+ return null;
228+ }
229+
230+ payloadLength = this.buffer.readUInt16BE(offset);
231+ offset += 2;
232+ } else if (payloadLength === 127) {
233+ if (this.buffer.length < offset + 8) {
234+ return null;
235+ }
236+
237+ const extendedLength = this.buffer.readBigUInt64BE(offset);
238+
239+ if (extendedLength > BigInt(MAX_FRAME_PAYLOAD_BYTES)) {
240+ this.close(MESSAGE_TOO_LARGE_CLOSE_CODE, "WS payload is too large.");
241+ return null;
242+ }
243+
244+ payloadLength = Number(extendedLength);
245+ offset += 8;
246+ }
247+
248+ if (payloadLength > MAX_FRAME_PAYLOAD_BYTES) {
249+ this.close(MESSAGE_TOO_LARGE_CLOSE_CODE, "WS payload is too large.");
250+ return null;
251+ }
252+
253+ if (this.buffer.length < offset + 4 + payloadLength) {
254+ return null;
255+ }
256+
257+ const mask = this.buffer.subarray(offset, offset + 4);
258+ offset += 4;
259+ const payload = this.buffer.subarray(offset, offset + payloadLength);
260+ const unmasked = Buffer.allocUnsafe(payloadLength);
261+
262+ for (let index = 0; index < payloadLength; index += 1) {
263+ const maskByte = mask[index % 4];
264+ const payloadByte = payload[index];
265+
266+ if (maskByte == null || payloadByte == null) {
267+ this.close(INVALID_MESSAGE_CLOSE_CODE, "Malformed websocket payload.");
268+ return null;
269+ }
270+
271+ unmasked[index] = payloadByte ^ maskByte;
272+ }
273+
274+ this.buffer = this.buffer.subarray(offset + payloadLength);
275+
276+ return {
277+ opcode,
278+ payload: unmasked
279+ };
280+ }
281+}
282+
283+export interface CodexdWebSocketServerOptions {
284+ onClientMessage?: (connection: CodexdWebSocketConnection, message: JsonRecord) => void;
285+ onConnected?: (connection: CodexdWebSocketConnection) => void;
286+ path: string;
287+}
288+
289+export class CodexdWebSocketServer {
290+ private readonly connections = new Set<CodexdWebSocketConnection>();
291+ private readonly onClientMessage: (
292+ connection: CodexdWebSocketConnection,
293+ message: JsonRecord
294+ ) => void;
295+ private readonly onConnected: (connection: CodexdWebSocketConnection) => void;
296+
297+ constructor(private readonly options: CodexdWebSocketServerOptions) {
298+ this.onClientMessage = options.onClientMessage ?? defaultOnClientMessage;
299+ this.onConnected = options.onConnected ?? (() => {});
300+ }
301+
302+ broadcast(payload: JsonRecord): void {
303+ for (const connection of [...this.connections]) {
304+ connection.sendJson(payload);
305+ }
306+ }
307+
308+ getConnectionCount(): number {
309+ return this.connections.size;
310+ }
311+
312+ handleUpgrade(request: IncomingMessage, socket: Socket, head: Buffer): boolean {
313+ const pathname = normalizePathname(new URL(request.url ?? "/", "http://127.0.0.1").pathname);
314+
315+ if (pathname !== normalizePathname(this.options.path)) {
316+ socket.write("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
317+ socket.destroy();
318+ return false;
319+ }
320+
321+ const upgrade = normalizeNonEmptyString(request.headers.upgrade);
322+ const key = normalizeNonEmptyString(request.headers["sec-websocket-key"]);
323+ const version = normalizeNonEmptyString(request.headers["sec-websocket-version"]);
324+
325+ if (request.method !== "GET" || upgrade?.toLowerCase() !== "websocket" || key == null) {
326+ socket.write("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
327+ socket.destroy();
328+ return false;
329+ }
330+
331+ if (version !== "13") {
332+ socket.write(
333+ "HTTP/1.1 426 Upgrade Required\r\nSec-WebSocket-Version: 13\r\nConnection: close\r\n\r\n"
334+ );
335+ socket.destroy();
336+ return false;
337+ }
338+
339+ socket.write(
340+ [
341+ "HTTP/1.1 101 Switching Protocols",
342+ "Upgrade: websocket",
343+ "Connection: Upgrade",
344+ `Sec-WebSocket-Accept: ${buildWebSocketAcceptValue(key)}`,
345+ "",
346+ ""
347+ ].join("\r\n")
348+ );
349+
350+ const connection = new CodexdWebSocketConnection(
351+ socket,
352+ (nextConnection, message) => {
353+ this.onClientMessage(nextConnection, message);
354+ },
355+ (nextConnection) => {
356+ this.unregister(nextConnection);
357+ }
358+ );
359+
360+ this.connections.add(connection);
361+ connection.attachHead(head);
362+ this.onConnected(connection);
363+ return true;
364+ }
365+
366+ async stop(): Promise<void> {
367+ for (const connection of [...this.connections]) {
368+ connection.close(1001, "server shutdown");
369+ }
370+
371+ this.connections.clear();
372+ }
373+
374+ private unregister(connection: CodexdWebSocketConnection): void {
375+ this.connections.delete(connection);
376+ }
377+}
378+
379+function defaultOnClientMessage(
380+ connection: CodexdWebSocketConnection,
381+ message: JsonRecord
382+): void {
383+ const type = normalizeNonEmptyString(message.type);
384+
385+ if (type === "ping") {
386+ connection.sendJson({
387+ type: "pong"
388+ });
389+ }
390+}
+1,
-1
1@@ -1,6 +1,6 @@
2 {
3 "extends": "../../tsconfig.base.json",
4- "files": ["src/node-shims.d.ts"],
5+ "files": ["src/node-shims.d.ts", "../../packages/codex-exec/src/node-shims.d.ts"],
6 "compilerOptions": {
7 "lib": ["ES2022", "DOM"],
8 "rootDir": "../..",