baa-conductor

git clone 

commit
9c76407
parent
62ac782
author
im_wower
date
2026-03-22 23:39:28 +0800 CST
feat(codexd): add local service surface
12 files changed,  +2954, -156
A apps/codexd/src/app-server-transport.ts
+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+}
M apps/codexd/src/cli.ts
+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 }
M apps/codexd/src/config.ts
+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 }
M apps/codexd/src/contracts.ts
+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 
M apps/codexd/src/daemon.ts
+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;
M apps/codexd/src/index.test.js
+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+}
M apps/codexd/src/index.ts
+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";
A apps/codexd/src/local-service.ts
+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+}
M apps/codexd/src/node-shims.d.ts
+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+}
M apps/codexd/src/state-store.ts
+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");
A apps/codexd/src/websocket.ts
+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+}
M apps/codexd/tsconfig.json
+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": "../..",