baa-conductor

git clone 

commit
4e53054
parent
56fe5ff
author
im_wower
date
2026-03-22 21:16:04 +0800 CST
feat(codexd): add daemon scaffold
16 files changed,  +2173, -12
A apps/codexd/package.json
+17, -0
 1@@ -0,0 +1,17 @@
 2+{
 3+  "name": "@baa-conductor/codexd",
 4+  "private": true,
 5+  "type": "module",
 6+  "main": "dist/index.js",
 7+  "exports": {
 8+    ".": "./dist/index.js"
 9+  },
10+  "scripts": {
11+    "build": "pnpm exec tsc -p tsconfig.json && BAA_DIST_DIR=apps/codexd/dist BAA_DIST_ENTRY=apps/codexd/src/index.js BAA_FIX_RELATIVE_EXTENSIONS=true pnpm -C ../.. run build:runtime-postprocess",
12+    "typecheck": "pnpm exec tsc --noEmit -p tsconfig.json",
13+    "start": "pnpm run build && node dist/index.js start",
14+    "status": "pnpm run build && node dist/index.js status --json",
15+    "test": "pnpm run build && node --test src/index.test.js",
16+    "smoke": "pnpm run build && node dist/index.js smoke"
17+  }
18+}
A apps/codexd/src/cli.ts
+210, -0
  1@@ -0,0 +1,210 @@
  2+import {
  3+  formatCodexdConfigText,
  4+  getCodexdUsageText,
  5+  parseCodexdCliRequest
  6+} from "./config.js";
  7+import {
  8+  CodexdDaemon,
  9+  runCodexdSmoke,
 10+  type CodexdDaemonOptions
 11+} from "./daemon.js";
 12+import type {
 13+  CodexdEnvironment,
 14+  CodexdSmokeResult,
 15+  CodexdStatusSnapshot
 16+} from "./contracts.js";
 17+import { CodexdStateStore } from "./state-store.js";
 18+
 19+export interface CodexdTextWriter {
 20+  write(chunk: string): unknown;
 21+}
 22+
 23+export interface CodexdProcessLike {
 24+  argv: string[];
 25+  cwd?(): string;
 26+  env: CodexdEnvironment;
 27+  execPath?: string;
 28+  exitCode?: number;
 29+  off?(event: string, listener: () => void): unknown;
 30+  on?(event: string, listener: () => void): unknown;
 31+  pid?: number;
 32+}
 33+
 34+export interface RunCodexdCliOptions extends CodexdDaemonOptions {
 35+  argv?: readonly string[];
 36+  env?: CodexdEnvironment;
 37+  processLike?: CodexdProcessLike;
 38+  stderr?: CodexdTextWriter;
 39+  stdout?: CodexdTextWriter;
 40+}
 41+
 42+type CodexdOutputWriter = CodexdTextWriter | typeof console;
 43+
 44+export async function runCodexdCli(options: RunCodexdCliOptions = {}): Promise<number> {
 45+  const processLike = options.processLike ?? getProcessLike();
 46+  const stdout = options.stdout ?? console;
 47+  const stderr = options.stderr ?? console;
 48+  const argv = options.argv ?? processLike?.argv ?? [];
 49+  const env = options.env ?? processLike?.env ?? {};
 50+  const request = parseCodexdCliRequest(argv, env);
 51+
 52+  if (request.action === "help") {
 53+    writeLine(stdout, getCodexdUsageText());
 54+    return 0;
 55+  }
 56+
 57+  if (request.action === "config") {
 58+    if (request.printJson) {
 59+      writeLine(stdout, JSON.stringify(request.config, null, 2));
 60+    } else {
 61+      writeLine(stdout, formatCodexdConfigText(request.config));
 62+    }
 63+
 64+    return 0;
 65+  }
 66+
 67+  if (request.action === "status") {
 68+    const store = new CodexdStateStore(request.config, {
 69+      processId: () => processLike?.pid ?? null
 70+    });
 71+    const snapshot = await store.initialize();
 72+
 73+    if (request.printJson) {
 74+      writeLine(stdout, JSON.stringify(snapshot, null, 2));
 75+    } else {
 76+      writeLine(stdout, formatCodexdStatusText(snapshot));
 77+    }
 78+
 79+    return 0;
 80+  }
 81+
 82+  if (request.action === "smoke") {
 83+    const result = await runCodexdSmoke(request.config, options);
 84+
 85+    if (request.printJson) {
 86+      writeLine(stdout, JSON.stringify(result, null, 2));
 87+    } else {
 88+      writeLine(stdout, formatCodexdSmokeText(result));
 89+    }
 90+
 91+    return result.checks.every((check) => check.status === "ok") ? 0 : 1;
 92+  }
 93+
 94+  if (request.action !== "start") {
 95+    throw new Error(`Unsupported codexd request action "${request.action}".`);
 96+  }
 97+
 98+  const daemon = new CodexdDaemon(request.config, {
 99+    ...options,
100+    env
101+  });
102+  const snapshot = await daemon.start();
103+
104+  if (!request.runOnce) {
105+    if (request.printJson) {
106+      writeLine(stdout, JSON.stringify(snapshot, null, 2));
107+    } else {
108+      writeLine(stdout, formatCodexdStatusText(snapshot));
109+    }
110+
111+    const signal = await waitForShutdownSignal(processLike);
112+    const stopped = await daemon.stop();
113+
114+    if (!request.printJson) {
115+      writeLine(stdout, `codexd stopped${signal ? ` after ${signal}` : ""}`);
116+      writeLine(stdout, formatCodexdStatusText(stopped));
117+    }
118+
119+    return 0;
120+  }
121+
122+  await sleep(request.lifetimeMs);
123+  const stopped = await daemon.stop();
124+
125+  if (request.printJson) {
126+    writeLine(stdout, JSON.stringify(stopped, null, 2));
127+  } else {
128+    writeLine(stdout, formatCodexdStatusText(stopped));
129+  }
130+
131+  return 0;
132+}
133+
134+function formatCodexdSmokeText(result: CodexdSmokeResult): string {
135+  return [
136+    `smoke daemon=${result.snapshot.identity.daemonId}`,
137+    ...result.checks.map((check) => `- ${check.status} ${check.name}: ${check.detail}`),
138+    formatCodexdStatusText(result.snapshot)
139+  ].join("\n");
140+}
141+
142+function formatCodexdStatusText(snapshot: CodexdStatusSnapshot): string {
143+  return [
144+    `identity=${snapshot.identity.daemonId}`,
145+    `node=${snapshot.identity.nodeId}`,
146+    `daemon=${snapshot.daemon.started ? "running" : "stopped"}`,
147+    `child=${snapshot.daemon.child.status}`,
148+    `strategy=${snapshot.config.server.childStrategy}`,
149+    `mode=${snapshot.config.server.mode}`,
150+    `endpoint=${snapshot.config.server.endpoint}`,
151+    `sessions=${snapshot.sessionRegistry.sessions.length}`,
152+    `recent_events=${snapshot.recentEvents.events.length}`,
153+    `logs_dir=${snapshot.config.paths.logsDir}`,
154+    `state_dir=${snapshot.config.paths.stateDir}`
155+  ].join(" ");
156+}
157+
158+function getProcessLike(): CodexdProcessLike | undefined {
159+  return (globalThis as { process?: CodexdProcessLike }).process;
160+}
161+
162+function sleep(ms: number): Promise<void> {
163+  return new Promise((resolve) => {
164+    setTimeout(resolve, ms);
165+  });
166+}
167+
168+async function waitForShutdownSignal(processLike: CodexdProcessLike | undefined): Promise<string | null> {
169+  const subscribe = processLike?.on;
170+
171+  if (!subscribe || !processLike) {
172+    return null;
173+  }
174+
175+  return new Promise((resolve) => {
176+    const signals = ["SIGINT", "SIGTERM"] as const;
177+    const listeners: Partial<Record<(typeof signals)[number], () => void>> = {};
178+    const cleanup = () => {
179+      if (!processLike.off) {
180+        return;
181+      }
182+
183+      for (const signal of signals) {
184+        const listener = listeners[signal];
185+
186+        if (listener) {
187+          processLike.off(signal, listener);
188+        }
189+      }
190+    };
191+
192+    for (const signal of signals) {
193+      const listener = () => {
194+        cleanup();
195+        resolve(signal);
196+      };
197+
198+      listeners[signal] = listener;
199+      subscribe.call(processLike, signal, listener);
200+    }
201+  });
202+}
203+
204+function writeLine(writer: CodexdOutputWriter, line: string): void {
205+  if ("write" in writer) {
206+    writer.write(`${line}\n`);
207+    return;
208+  }
209+
210+  writer.log(line);
211+}
A apps/codexd/src/config.ts
+465, -0
  1@@ -0,0 +1,465 @@
  2+import { resolve } from "node:path";
  3+
  4+import type {
  5+  CodexdCliAction,
  6+  CodexdEnvironment,
  7+  CodexdResolvedConfig,
  8+  CodexdRuntimePaths,
  9+  CodexdServerConfig
 10+} from "./contracts.js";
 11+
 12+export interface CodexdConfigInput {
 13+  eventCacheSize?: number;
 14+  logsDir?: string;
 15+  mode?: string;
 16+  nodeId?: string;
 17+  repoRoot?: string;
 18+  serverArgs?: string[];
 19+  serverCommand?: string;
 20+  serverCwd?: string;
 21+  serverEndpoint?: string;
 22+  serverStrategy?: string;
 23+  smokeLifetimeMs?: number;
 24+  stateDir?: string;
 25+  version?: string | null;
 26+}
 27+
 28+export type CodexdCliRequest =
 29+  | {
 30+      action: "help";
 31+    }
 32+  | {
 33+      action: Exclude<CodexdCliAction, "help" | "start">;
 34+      config: CodexdResolvedConfig;
 35+      printJson: boolean;
 36+    }
 37+  | {
 38+      action: "start";
 39+      config: CodexdResolvedConfig;
 40+      printJson: boolean;
 41+      runOnce: boolean;
 42+      lifetimeMs: number;
 43+    };
 44+
 45+const DEFAULT_EVENT_CACHE_SIZE = 50;
 46+const DEFAULT_NODE_ID = "mini-main";
 47+const DEFAULT_SERVER_ARGS = ["app-server"];
 48+const DEFAULT_SERVER_COMMAND = "codex";
 49+const DEFAULT_SERVER_ENDPOINT = "stdio://codex-app-server";
 50+const DEFAULT_SERVER_MODE = "app-server";
 51+const DEFAULT_SERVER_STRATEGY = "spawn";
 52+const DEFAULT_SMOKE_LIFETIME_MS = 100;
 53+
 54+export function resolveCodexdConfig(input: CodexdConfigInput = {}): CodexdResolvedConfig {
 55+  const repoRoot = resolve(input.repoRoot ?? getDefaultRepoRoot());
 56+  const logsRootDir = resolve(getOptionalString(input.logsDir) ?? resolve(repoRoot, "logs"));
 57+  const stateRootDir = resolve(getOptionalString(input.stateDir) ?? resolve(repoRoot, "state"));
 58+  const paths = resolveCodexdRuntimePaths(repoRoot, logsRootDir, stateRootDir);
 59+  const server = resolveServerConfig(input, repoRoot);
 60+
 61+  return {
 62+    nodeId: getOptionalString(input.nodeId) ?? DEFAULT_NODE_ID,
 63+    version: getOptionalString(input.version),
 64+    eventCacheSize: normalizePositiveInteger(input.eventCacheSize, DEFAULT_EVENT_CACHE_SIZE, "event cache"),
 65+    smokeLifetimeMs: normalizePositiveInteger(
 66+      input.smokeLifetimeMs,
 67+      DEFAULT_SMOKE_LIFETIME_MS,
 68+      "smoke lifetime"
 69+    ),
 70+    paths,
 71+    server
 72+  };
 73+}
 74+
 75+export function parseCodexdCliRequest(
 76+  argv: readonly string[],
 77+  env: CodexdEnvironment = {}
 78+): CodexdCliRequest {
 79+  const tokens = argv.slice(2);
 80+  let action: CodexdCliAction = "start";
 81+  let actionSet = false;
 82+  let printJson = false;
 83+  let runOnce = false;
 84+  let eventCacheSize = parseOptionalInteger(env.BAA_CODEXD_EVENT_CACHE_SIZE);
 85+  let logsDir = env.BAA_CODEXD_LOGS_DIR ?? env.BAA_LOGS_DIR;
 86+  let mode = env.BAA_CODEXD_MODE;
 87+  let nodeId = env.BAA_NODE_ID;
 88+  let repoRoot = env.BAA_CODEXD_REPO_ROOT;
 89+  let serverArgs: string[] | undefined = parseArgumentList(env.BAA_CODEXD_SERVER_ARGS);
 90+  let serverArgsSet = serverArgs !== undefined;
 91+  let serverCommand = env.BAA_CODEXD_SERVER_COMMAND;
 92+  let serverCwd = env.BAA_CODEXD_SERVER_CWD;
 93+  let serverEndpoint = env.BAA_CODEXD_SERVER_ENDPOINT;
 94+  let serverStrategy = env.BAA_CODEXD_SERVER_STRATEGY;
 95+  let smokeLifetimeMs = parseOptionalInteger(env.BAA_CODEXD_SMOKE_LIFETIME_MS);
 96+  let stateDir = env.BAA_CODEXD_STATE_DIR ?? env.BAA_STATE_DIR;
 97+  let version = env.BAA_CODEXD_VERSION ?? null;
 98+
 99+  for (let index = 0; index < tokens.length; index += 1) {
100+    const token = tokens[index];
101+
102+    if (token == null) {
103+      continue;
104+    }
105+
106+    if (token === "--help" || token === "-h" || token === "help") {
107+      return { action: "help" };
108+    }
109+
110+    if (token === "--json") {
111+      printJson = true;
112+      continue;
113+    }
114+
115+    if (token === "--run-once") {
116+      runOnce = true;
117+      continue;
118+    }
119+
120+    if (token === "--repo-root") {
121+      repoRoot = readCliValue(tokens, index, "--repo-root");
122+      index += 1;
123+      continue;
124+    }
125+
126+    if (token === "--node-id") {
127+      nodeId = readCliValue(tokens, index, "--node-id");
128+      index += 1;
129+      continue;
130+    }
131+
132+    if (token === "--mode") {
133+      mode = readCliValue(tokens, index, "--mode");
134+      index += 1;
135+      continue;
136+    }
137+
138+    if (token === "--logs-dir") {
139+      logsDir = readCliValue(tokens, index, "--logs-dir");
140+      index += 1;
141+      continue;
142+    }
143+
144+    if (token === "--state-dir") {
145+      stateDir = readCliValue(tokens, index, "--state-dir");
146+      index += 1;
147+      continue;
148+    }
149+
150+    if (token === "--server-endpoint") {
151+      serverEndpoint = readCliValue(tokens, index, "--server-endpoint");
152+      index += 1;
153+      continue;
154+    }
155+
156+    if (token === "--server-strategy") {
157+      serverStrategy = readCliValue(tokens, index, "--server-strategy");
158+      index += 1;
159+      continue;
160+    }
161+
162+    if (token === "--server-command") {
163+      serverCommand = readCliValue(tokens, index, "--server-command");
164+      index += 1;
165+      continue;
166+    }
167+
168+    if (token === "--server-arg") {
169+      const serverArg = readCliValue(tokens, index, "--server-arg");
170+
171+      if (!serverArgsSet || serverArgs == null) {
172+        serverArgs = [];
173+        serverArgsSet = true;
174+      }
175+
176+      serverArgs.push(serverArg);
177+      index += 1;
178+      continue;
179+    }
180+
181+    if (token === "--server-cwd") {
182+      serverCwd = readCliValue(tokens, index, "--server-cwd");
183+      index += 1;
184+      continue;
185+    }
186+
187+    if (token === "--event-cache-size") {
188+      eventCacheSize = parseStrictInteger(readCliValue(tokens, index, "--event-cache-size"), "--event-cache-size");
189+      index += 1;
190+      continue;
191+    }
192+
193+    if (token === "--smoke-lifetime-ms") {
194+      smokeLifetimeMs = parseStrictInteger(
195+        readCliValue(tokens, index, "--smoke-lifetime-ms"),
196+        "--smoke-lifetime-ms"
197+      );
198+      index += 1;
199+      continue;
200+    }
201+
202+    if (token === "--version") {
203+      version = readCliValue(tokens, index, "--version");
204+      index += 1;
205+      continue;
206+    }
207+
208+    if (token.startsWith("--")) {
209+      throw new Error(`Unknown codexd flag "${token}".`);
210+    }
211+
212+    if (actionSet) {
213+      throw new Error(`Unexpected extra codexd argument "${token}".`);
214+    }
215+
216+    if (!isCodexdCliAction(token)) {
217+      throw new Error(`Unknown codexd action "${token}".`);
218+    }
219+
220+    action = token;
221+    actionSet = true;
222+  }
223+
224+  const config = resolveCodexdConfig({
225+    eventCacheSize,
226+    logsDir,
227+    mode,
228+    nodeId,
229+    repoRoot,
230+    serverArgs,
231+    serverCommand,
232+    serverCwd,
233+    serverEndpoint,
234+    serverStrategy,
235+    smokeLifetimeMs,
236+    stateDir,
237+    version
238+  });
239+
240+  if (action === "start") {
241+    return {
242+      action,
243+      config,
244+      printJson,
245+      runOnce,
246+      lifetimeMs: config.smokeLifetimeMs
247+    };
248+  }
249+
250+  return {
251+    action,
252+    config,
253+    printJson
254+  };
255+}
256+
257+export function formatCodexdConfigText(config: CodexdResolvedConfig): string {
258+  return [
259+    `node_id: ${config.nodeId}`,
260+    `version: ${config.version ?? "not-set"}`,
261+    `mode: ${config.server.mode}`,
262+    `endpoint: ${config.server.endpoint}`,
263+    `child_strategy: ${config.server.childStrategy}`,
264+    `child_command: ${config.server.childCommand}`,
265+    `child_args: ${config.server.childArgs.join(" ") || "(none)"}`,
266+    `child_cwd: ${config.server.childCwd}`,
267+    `logs_dir: ${config.paths.logsDir}`,
268+    `state_dir: ${config.paths.stateDir}`,
269+    `event_cache_size: ${config.eventCacheSize}`,
270+    `smoke_lifetime_ms: ${config.smokeLifetimeMs}`
271+  ].join("\n");
272+}
273+
274+export function getCodexdUsageText(): string {
275+  return [
276+    "Usage:",
277+    "  node apps/codexd/dist/index.js [start] [options]",
278+    "  node apps/codexd/dist/index.js status [--json]",
279+    "  node apps/codexd/dist/index.js config [--json]",
280+    "  node apps/codexd/dist/index.js smoke [--json]",
281+    "  node apps/codexd/dist/index.js help",
282+    "",
283+    "Options:",
284+    "  --repo-root <path>",
285+    "  --node-id <id>",
286+    "  --mode <app-server|exec>",
287+    "  --logs-dir <path>",
288+    "  --state-dir <path>",
289+    "  --server-endpoint <url-or-stdio>",
290+    "  --server-strategy <spawn|external>",
291+    "  --server-command <command>",
292+    "  --server-arg <arg>",
293+    "  --server-cwd <path>",
294+    "  --event-cache-size <integer>",
295+    "  --smoke-lifetime-ms <integer>",
296+    "  --version <string>",
297+    "  --run-once",
298+    "  --json",
299+    "  --help",
300+    "",
301+    "Environment:",
302+    "  BAA_NODE_ID",
303+    "  BAA_LOGS_DIR",
304+    "  BAA_STATE_DIR",
305+    "  BAA_CODEXD_REPO_ROOT",
306+    "  BAA_CODEXD_MODE",
307+    "  BAA_CODEXD_LOGS_DIR",
308+    "  BAA_CODEXD_STATE_DIR",
309+    "  BAA_CODEXD_SERVER_ENDPOINT",
310+    "  BAA_CODEXD_SERVER_STRATEGY",
311+    "  BAA_CODEXD_SERVER_COMMAND",
312+    "  BAA_CODEXD_SERVER_ARGS",
313+    "  BAA_CODEXD_SERVER_CWD",
314+    "  BAA_CODEXD_EVENT_CACHE_SIZE",
315+    "  BAA_CODEXD_SMOKE_LIFETIME_MS",
316+    "  BAA_CODEXD_VERSION",
317+    "",
318+    "Notes:",
319+    "  start manages one configured Codex child process or an external endpoint placeholder.",
320+    "  smoke always uses an embedded stub child so the scaffold can be verified without Codex CLI."
321+  ].join("\n");
322+}
323+
324+function getDefaultRepoRoot(): string {
325+  if (typeof process !== "undefined" && typeof process.cwd === "function") {
326+    return process.cwd();
327+  }
328+
329+  return ".";
330+}
331+
332+function resolveServerConfig(input: CodexdConfigInput, repoRoot: string): CodexdServerConfig {
333+  const mode = normalizeServerMode(input.mode);
334+  const childStrategy = normalizeChildStrategy(input.serverStrategy);
335+  const childArgs = input.serverArgs == null ? [...DEFAULT_SERVER_ARGS] : [...input.serverArgs];
336+
337+  return {
338+    mode,
339+    endpoint: getOptionalString(input.serverEndpoint) ?? DEFAULT_SERVER_ENDPOINT,
340+    childStrategy,
341+    childCommand: getOptionalString(input.serverCommand) ?? DEFAULT_SERVER_COMMAND,
342+    childArgs,
343+    childCwd: resolve(getOptionalString(input.serverCwd) ?? repoRoot)
344+  };
345+}
346+
347+function resolveCodexdRuntimePaths(
348+  repoRoot: string,
349+  logsRootDir: string,
350+  stateRootDir: string
351+): CodexdRuntimePaths {
352+  const logsDir = resolve(logsRootDir, "codexd");
353+  const stateDir = resolve(stateRootDir, "codexd");
354+
355+  return {
356+    repoRoot,
357+    logsRootDir,
358+    stateRootDir,
359+    logsDir,
360+    stateDir,
361+    structuredEventLogPath: resolve(logsDir, "events.jsonl"),
362+    stdoutLogPath: resolve(logsDir, "stdout.log"),
363+    stderrLogPath: resolve(logsDir, "stderr.log"),
364+    identityPath: resolve(stateDir, "identity.json"),
365+    daemonStatePath: resolve(stateDir, "daemon-state.json"),
366+    sessionRegistryPath: resolve(stateDir, "session-registry.json"),
367+    recentEventsPath: resolve(stateDir, "recent-events.json")
368+  };
369+}
370+
371+function isCodexdCliAction(value: string): value is Exclude<CodexdCliAction, "help"> {
372+  return value === "config" || value === "smoke" || value === "start" || value === "status";
373+}
374+
375+function normalizeChildStrategy(value: string | undefined): CodexdServerConfig["childStrategy"] {
376+  switch (value ?? DEFAULT_SERVER_STRATEGY) {
377+    case "external":
378+      return "external";
379+    case "spawn":
380+      return "spawn";
381+    default:
382+      throw new Error(`Unsupported codexd child strategy "${value}".`);
383+  }
384+}
385+
386+function normalizeServerMode(value: string | undefined): CodexdServerConfig["mode"] {
387+  switch (value ?? DEFAULT_SERVER_MODE) {
388+    case "app-server":
389+      return "app-server";
390+    case "exec":
391+      return "exec";
392+    default:
393+      throw new Error(`Unsupported codexd mode "${value}".`);
394+  }
395+}
396+
397+function parseArgumentList(value: string | undefined): string[] | undefined {
398+  if (value == null) {
399+    return undefined;
400+  }
401+
402+  const trimmed = value.trim();
403+
404+  if (trimmed === "") {
405+    return [];
406+  }
407+
408+  if (trimmed.startsWith("[")) {
409+    const parsed = JSON.parse(trimmed) as unknown;
410+
411+    if (!Array.isArray(parsed) || parsed.some((entry) => typeof entry !== "string")) {
412+      throw new Error("BAA_CODEXD_SERVER_ARGS must be a JSON string array when JSON syntax is used.");
413+    }
414+
415+    return [...parsed];
416+  }
417+
418+  return trimmed.split(/\s+/u);
419+}
420+
421+function parseOptionalInteger(value: string | undefined): number | undefined {
422+  if (value == null || value.trim() === "") {
423+    return undefined;
424+  }
425+
426+  return parseStrictInteger(value, "integer value");
427+}
428+
429+function parseStrictInteger(value: string, label: string): number {
430+  const parsed = Number(value);
431+
432+  if (!Number.isInteger(parsed)) {
433+    throw new Error(`Invalid ${label} "${value}".`);
434+  }
435+
436+  return parsed;
437+}
438+
439+function normalizePositiveInteger(value: number | undefined, fallback: number, label: string): number {
440+  const candidate = value ?? fallback;
441+
442+  if (!Number.isInteger(candidate) || candidate < 0) {
443+    throw new Error(`Invalid ${label} value "${String(value)}".`);
444+  }
445+
446+  return candidate;
447+}
448+
449+function getOptionalString(value: string | null | undefined): string | null {
450+  if (value == null) {
451+    return null;
452+  }
453+
454+  const trimmed = value.trim();
455+  return trimmed === "" ? null : trimmed;
456+}
457+
458+function readCliValue(tokens: readonly string[], index: number, flag: string): string {
459+  const value = tokens[index + 1];
460+
461+  if (value == null || value.startsWith("--")) {
462+    throw new Error(`Missing value for ${flag}.`);
463+  }
464+
465+  return value;
466+}
A apps/codexd/src/contracts.ts
+126, -0
  1@@ -0,0 +1,126 @@
  2+export type CodexdCliAction = "config" | "help" | "smoke" | "start" | "status";
  3+export type CodexdChildStrategy = "external" | "spawn";
  4+export type CodexdManagedChildStatus = "external" | "failed" | "idle" | "running" | "starting" | "stopped";
  5+export type CodexdEventLevel = "error" | "info" | "warn";
  6+export type CodexdServerMode = "app-server" | "exec";
  7+export type CodexdSessionPurpose = "duplex" | "smoke" | "worker";
  8+export type CodexdSessionStatus = "active" | "closed";
  9+
 10+export type CodexdEnvironment = Record<string, string | undefined>;
 11+
 12+export interface CodexdRuntimePaths {
 13+  repoRoot: string;
 14+  logsRootDir: string;
 15+  stateRootDir: string;
 16+  logsDir: string;
 17+  stateDir: string;
 18+  structuredEventLogPath: string;
 19+  stdoutLogPath: string;
 20+  stderrLogPath: string;
 21+  identityPath: string;
 22+  daemonStatePath: string;
 23+  sessionRegistryPath: string;
 24+  recentEventsPath: string;
 25+}
 26+
 27+export interface CodexdServerConfig {
 28+  mode: CodexdServerMode;
 29+  endpoint: string;
 30+  childStrategy: CodexdChildStrategy;
 31+  childCommand: string;
 32+  childArgs: string[];
 33+  childCwd: string;
 34+}
 35+
 36+export interface CodexdResolvedConfig {
 37+  nodeId: string;
 38+  version: string | null;
 39+  eventCacheSize: number;
 40+  smokeLifetimeMs: number;
 41+  paths: CodexdRuntimePaths;
 42+  server: CodexdServerConfig;
 43+}
 44+
 45+export interface CodexdDaemonIdentity {
 46+  daemonId: string;
 47+  nodeId: string;
 48+  repoRoot: string;
 49+  createdAt: string;
 50+  version: string | null;
 51+}
 52+
 53+export interface CodexdManagedChildState {
 54+  strategy: CodexdChildStrategy;
 55+  mode: CodexdServerMode;
 56+  endpoint: string;
 57+  status: CodexdManagedChildStatus;
 58+  command: string | null;
 59+  args: string[];
 60+  cwd: string | null;
 61+  pid: number | null;
 62+  startedAt: string | null;
 63+  exitedAt: string | null;
 64+  exitCode: number | null;
 65+  signal: string | null;
 66+  lastError: string | null;
 67+}
 68+
 69+export interface CodexdDaemonState {
 70+  started: boolean;
 71+  startedAt: string | null;
 72+  stoppedAt: string | null;
 73+  updatedAt: string;
 74+  pid: number | null;
 75+  child: CodexdManagedChildState;
 76+}
 77+
 78+export interface CodexdSessionRecord {
 79+  sessionId: string;
 80+  purpose: CodexdSessionPurpose;
 81+  threadId: string | null;
 82+  status: CodexdSessionStatus;
 83+  endpoint: string;
 84+  childPid: number | null;
 85+  createdAt: string;
 86+  updatedAt: string;
 87+  metadata: Record<string, string>;
 88+}
 89+
 90+export interface CodexdSessionRegistryState {
 91+  updatedAt: string | null;
 92+  sessions: CodexdSessionRecord[];
 93+}
 94+
 95+export interface CodexdRecentEvent {
 96+  seq: number;
 97+  createdAt: string;
 98+  level: CodexdEventLevel;
 99+  type: string;
100+  message: string;
101+  detail: Record<string, unknown> | null;
102+}
103+
104+export interface CodexdRecentEventCacheState {
105+  maxEntries: number;
106+  updatedAt: string | null;
107+  events: CodexdRecentEvent[];
108+}
109+
110+export interface CodexdStatusSnapshot {
111+  config: CodexdResolvedConfig;
112+  identity: CodexdDaemonIdentity;
113+  daemon: CodexdDaemonState;
114+  sessionRegistry: CodexdSessionRegistryState;
115+  recentEvents: CodexdRecentEventCacheState;
116+}
117+
118+export interface CodexdSmokeCheck {
119+  name: string;
120+  status: "failed" | "ok";
121+  detail: string;
122+}
123+
124+export interface CodexdSmokeResult {
125+  checks: CodexdSmokeCheck[];
126+  snapshot: CodexdStatusSnapshot;
127+}
A apps/codexd/src/daemon.ts
+513, -0
  1@@ -0,0 +1,513 @@
  2+import { spawn } from "node:child_process";
  3+import { access } from "node:fs/promises";
  4+
  5+import type {
  6+  CodexdEnvironment,
  7+  CodexdManagedChildState,
  8+  CodexdRecentEvent,
  9+  CodexdResolvedConfig,
 10+  CodexdSessionPurpose,
 11+  CodexdSessionRecord,
 12+  CodexdSmokeCheck,
 13+  CodexdSmokeResult,
 14+  CodexdStatusSnapshot
 15+} from "./contracts.js";
 16+import { CodexdStateStore, type CodexdStateStoreOptions } from "./state-store.js";
 17+
 18+export interface CodexdProcessOutput {
 19+  on(event: "data", listener: (chunk: string | Uint8Array) => void): unknown;
 20+}
 21+
 22+export interface CodexdChildProcessLike {
 23+  pid?: number;
 24+  stderr?: CodexdProcessOutput;
 25+  stdout?: CodexdProcessOutput;
 26+  kill(signal?: string): boolean;
 27+  on(event: "error", listener: (error: Error) => void): this;
 28+  on(event: "exit", listener: (code: number | null, signal: string | null) => void): this;
 29+  on(event: "spawn", listener: () => void): this;
 30+  once(event: "error", listener: (error: Error) => void): this;
 31+  once(event: "exit", listener: (code: number | null, signal: string | null) => void): this;
 32+  once(event: "spawn", listener: () => void): this;
 33+}
 34+
 35+export interface CodexdSpawnOptions {
 36+  cwd: string;
 37+  env: CodexdEnvironment;
 38+}
 39+
 40+export interface CodexdSpawner {
 41+  spawn(command: string, args: readonly string[], options: CodexdSpawnOptions): CodexdChildProcessLike;
 42+}
 43+
 44+export interface CodexdDaemonOptions extends CodexdStateStoreOptions {
 45+  env?: CodexdEnvironment;
 46+  spawner?: CodexdSpawner;
 47+}
 48+
 49+export interface CodexdSessionInput {
 50+  metadata?: Record<string, string>;
 51+  purpose: CodexdSessionPurpose;
 52+  threadId?: string | null;
 53+}
 54+
 55+const MAX_CHILD_OUTPUT_PREVIEW = 160;
 56+const STOP_TIMEOUT_MS = 1_000;
 57+
 58+export class CodexdDaemon {
 59+  private child: CodexdChildProcessLike | null = null;
 60+  private readonly env: CodexdEnvironment;
 61+  private readonly stateStore: CodexdStateStore;
 62+  private readonly spawner: CodexdSpawner;
 63+  private started = false;
 64+
 65+  constructor(
 66+    private readonly config: CodexdResolvedConfig,
 67+    options: CodexdDaemonOptions = {}
 68+  ) {
 69+    this.env = options.env ?? (typeof process !== "undefined" ? process.env : {});
 70+    this.spawner = options.spawner ?? {
 71+      spawn(command, args, spawnOptions) {
 72+        return spawn(command, [...args], {
 73+          cwd: spawnOptions.cwd,
 74+          env: spawnOptions.env,
 75+          stdio: ["ignore", "pipe", "pipe"]
 76+        });
 77+      }
 78+    };
 79+    this.stateStore = new CodexdStateStore(config, options);
 80+  }
 81+
 82+  async start(): Promise<CodexdStatusSnapshot> {
 83+    await this.stateStore.initialize();
 84+
 85+    if (this.started) {
 86+      return this.stateStore.getSnapshot();
 87+    }
 88+
 89+    await this.stateStore.markDaemonStarted();
 90+    await this.stateStore.recordEvent({
 91+      level: "info",
 92+      type: "daemon.started",
 93+      message: `codexd started in ${this.config.server.mode} mode.`,
 94+      detail: {
 95+        endpoint: this.config.server.endpoint,
 96+        strategy: this.config.server.childStrategy
 97+      }
 98+    });
 99+
100+    if (this.config.server.childStrategy === "external") {
101+      await this.stateStore.updateChildState({
102+        status: "external",
103+        pid: null,
104+        startedAt: new Date().toISOString(),
105+        exitedAt: null,
106+        exitCode: null,
107+        signal: null,
108+        lastError: null
109+      });
110+      await this.stateStore.recordEvent({
111+        level: "info",
112+        type: "child.external",
113+        message: `codexd is pointing at external endpoint ${this.config.server.endpoint}.`
114+      });
115+      this.started = true;
116+      return this.stateStore.getSnapshot();
117+    }
118+
119+    await this.stateStore.updateChildState({
120+      status: "starting",
121+      pid: null,
122+      startedAt: null,
123+      exitedAt: null,
124+      exitCode: null,
125+      signal: null,
126+      lastError: null
127+    });
128+
129+    const child = this.spawner.spawn(this.config.server.childCommand, this.config.server.childArgs, {
130+      cwd: this.config.server.childCwd,
131+      env: {
132+        ...this.env,
133+        BAA_CODEXD_DAEMON_ID: this.stateStore.getSnapshot().identity.daemonId,
134+        BAA_CODEXD_SERVER_ENDPOINT: this.config.server.endpoint
135+      }
136+    });
137+    this.child = child;
138+    this.attachChildListeners(child);
139+
140+    try {
141+      await waitForChildSpawn(child);
142+    } catch (error) {
143+      await this.stateStore.updateChildState({
144+        status: "failed",
145+        pid: null,
146+        exitedAt: new Date().toISOString(),
147+        lastError: formatErrorMessage(error)
148+      });
149+      await this.stateStore.recordEvent({
150+        level: "error",
151+        type: "child.spawn.failed",
152+        message: formatErrorMessage(error)
153+      });
154+      this.child = null;
155+      throw error;
156+    }
157+
158+    await this.stateStore.updateChildState({
159+      status: "running",
160+      pid: child.pid ?? null,
161+      startedAt: new Date().toISOString(),
162+      exitedAt: null,
163+      exitCode: null,
164+      signal: null,
165+      lastError: null
166+    });
167+    await this.stateStore.recordEvent({
168+      level: "info",
169+      type: "child.started",
170+      message: `Started Codex child process ${child.pid ?? "unknown"}.`,
171+      detail: {
172+        command: this.config.server.childCommand,
173+        args: this.config.server.childArgs
174+      }
175+    });
176+
177+    this.started = true;
178+    return this.stateStore.getSnapshot();
179+  }
180+
181+  async stop(): Promise<CodexdStatusSnapshot> {
182+    await this.stateStore.initialize();
183+
184+    if (this.child != null) {
185+      const child = this.child;
186+      this.child = null;
187+
188+      const exited = waitForChildExit(child, STOP_TIMEOUT_MS);
189+      try {
190+        child.kill("SIGTERM");
191+      } catch (error) {
192+        await this.stateStore.recordEvent({
193+          level: "warn",
194+          type: "child.kill.failed",
195+          message: formatErrorMessage(error)
196+        });
197+      }
198+
199+      await exited;
200+    } else {
201+      const currentChildState = this.stateStore.getChildState();
202+
203+      if (currentChildState.status === "external") {
204+        await this.stateStore.updateChildState({
205+          status: "idle",
206+          pid: null,
207+          exitCode: null,
208+          signal: null,
209+          exitedAt: new Date().toISOString()
210+        });
211+      }
212+    }
213+
214+    await this.stateStore.markDaemonStopped();
215+    await this.stateStore.recordEvent({
216+      level: "info",
217+      type: "daemon.stopped",
218+      message: "codexd stopped."
219+    });
220+
221+    this.started = false;
222+    return this.stateStore.getSnapshot();
223+  }
224+
225+  getStatusSnapshot(): CodexdStatusSnapshot {
226+    return this.stateStore.getSnapshot();
227+  }
228+
229+  async registerSession(input: CodexdSessionInput): Promise<CodexdSessionRecord> {
230+    await this.stateStore.initialize();
231+    const now = new Date().toISOString();
232+    const session: CodexdSessionRecord = {
233+      sessionId: createSessionId(),
234+      purpose: input.purpose,
235+      threadId: input.threadId ?? null,
236+      status: "active",
237+      endpoint: this.config.server.endpoint,
238+      childPid: this.stateStore.getChildState().pid,
239+      createdAt: now,
240+      updatedAt: now,
241+      metadata: {
242+        ...(input.metadata ?? {})
243+      }
244+    };
245+
246+    await this.stateStore.upsertSession(session);
247+    await this.stateStore.recordEvent({
248+      level: "info",
249+      type: "session.registered",
250+      message: `Registered ${input.purpose} session ${session.sessionId}.`,
251+      detail: {
252+        sessionId: session.sessionId,
253+        threadId: session.threadId
254+      }
255+    });
256+
257+    return session;
258+  }
259+
260+  async closeSession(sessionId: string): Promise<CodexdSessionRecord | null> {
261+    await this.stateStore.initialize();
262+    const session = await this.stateStore.closeSession(sessionId);
263+
264+    if (session != null) {
265+      await this.stateStore.recordEvent({
266+        level: "info",
267+        type: "session.closed",
268+        message: `Closed session ${sessionId}.`,
269+        detail: {
270+          sessionId
271+        }
272+      });
273+    }
274+
275+    return session;
276+  }
277+
278+  private attachChildListeners(child: CodexdChildProcessLike): void {
279+    child.stdout?.on("data", (chunk) => {
280+      void this.handleChildOutput("stdout", chunk);
281+    });
282+    child.stderr?.on("data", (chunk) => {
283+      void this.handleChildOutput("stderr", chunk);
284+    });
285+    child.on("error", (error) => {
286+      void this.handleChildError(error);
287+    });
288+    child.on("exit", (code, signal) => {
289+      void this.handleChildExit(code, signal);
290+    });
291+  }
292+
293+  private async handleChildError(error: Error): Promise<void> {
294+    await this.stateStore.updateChildState({
295+      status: "failed",
296+      lastError: error.message
297+    });
298+    await this.stateStore.recordEvent({
299+      level: "error",
300+      type: "child.error",
301+      message: error.message
302+    });
303+  }
304+
305+  private async handleChildExit(code: number | null, signal: string | null): Promise<void> {
306+    const stoppedAt = new Date().toISOString();
307+    const status = code == null || code === 0 ? "stopped" : "failed";
308+
309+    await this.stateStore.updateChildState({
310+      status,
311+      pid: null,
312+      exitedAt: stoppedAt,
313+      exitCode: code,
314+      signal,
315+      lastError: status === "failed" ? `Child exited with code ${String(code)}.` : null
316+    });
317+    await this.stateStore.recordEvent({
318+      level: status === "failed" ? "error" : "info",
319+      type: "child.exited",
320+      message:
321+        status === "failed"
322+          ? `Codex child exited with code ${String(code)}.`
323+          : `Codex child exited${signal ? ` after ${signal}` : ""}.`,
324+      detail: {
325+        code,
326+        signal
327+      }
328+    });
329+  }
330+
331+  private async handleChildOutput(stream: "stderr" | "stdout", chunk: string | Uint8Array): Promise<void> {
332+    const text = normalizeOutputChunk(chunk);
333+
334+    if (text === "") {
335+      return;
336+    }
337+
338+    await this.stateStore.appendChildOutput(stream, text);
339+    await this.stateStore.recordEvent({
340+      level: stream === "stderr" ? "warn" : "info",
341+      type: `child.${stream}`,
342+      message: `${stream}: ${createOutputPreview(text)}`,
343+      detail: {
344+        bytes: text.length,
345+        stream
346+      }
347+    });
348+  }
349+}
350+
351+export async function runCodexdSmoke(
352+  baseConfig: CodexdResolvedConfig,
353+  options: CodexdDaemonOptions = {}
354+): Promise<CodexdSmokeResult> {
355+  const smokeConfig: CodexdResolvedConfig = {
356+    ...baseConfig,
357+    server: {
358+      ...baseConfig.server,
359+      childStrategy: "spawn",
360+      childCommand: typeof process !== "undefined" ? process.execPath : "node",
361+      childArgs: ["-e", EMBEDDED_SMOKE_PROGRAM],
362+      endpoint: "stdio://embedded-smoke-child"
363+    }
364+  };
365+  const daemon = new CodexdDaemon(smokeConfig, options);
366+
367+  await daemon.start();
368+  const session = await daemon.registerSession({
369+    purpose: "smoke",
370+    metadata: {
371+      source: "embedded-smoke"
372+    }
373+  });
374+
375+  await sleep(Math.max(smokeConfig.smokeLifetimeMs, 75));
376+  await daemon.closeSession(session.sessionId);
377+  const snapshot = await daemon.stop();
378+  const checks: CodexdSmokeCheck[] = [
379+    await buildFileCheck("structured_event_log", smokeConfig.paths.structuredEventLogPath),
380+    await buildFileCheck("stdout_log", smokeConfig.paths.stdoutLogPath),
381+    await buildFileCheck("stderr_log", smokeConfig.paths.stderrLogPath),
382+    await buildFileCheck("daemon_state", smokeConfig.paths.daemonStatePath),
383+    {
384+      name: "recent_event_cache",
385+      status: snapshot.recentEvents.events.length > 0 ? "ok" : "failed",
386+      detail: `${snapshot.recentEvents.events.length} cached events`
387+    },
388+    {
389+      name: "session_registry",
390+      status: hasClosedSmokeSession(snapshot.recentEvents.events, snapshot.daemon.child, snapshot)
391+        ? "ok"
392+        : "failed",
393+      detail: `${snapshot.sessionRegistry.sessions.length} recorded sessions`
394+    }
395+  ];
396+
397+  return {
398+    checks,
399+    snapshot
400+  };
401+}
402+
403+async function buildFileCheck(name: string, path: string): Promise<CodexdSmokeCheck> {
404+  try {
405+    await access(path);
406+    return {
407+      name,
408+      status: "ok",
409+      detail: path
410+    };
411+  } catch {
412+    return {
413+      name,
414+      status: "failed",
415+      detail: `missing: ${path}`
416+    };
417+  }
418+}
419+
420+function hasClosedSmokeSession(
421+  events: readonly CodexdRecentEvent[],
422+  childState: CodexdManagedChildState,
423+  snapshot: CodexdStatusSnapshot
424+): boolean {
425+  return (
426+    snapshot.sessionRegistry.sessions.some(
427+      (session) => session.purpose === "smoke" && session.status === "closed"
428+    ) &&
429+    (events.length > 0 || childState.exitedAt != null)
430+  );
431+}
432+
433+function createOutputPreview(text: string): string {
434+  const flattened = text.replace(/\s+/gu, " ").trim();
435+  return flattened.length <= MAX_CHILD_OUTPUT_PREVIEW
436+    ? flattened
437+    : `${flattened.slice(0, MAX_CHILD_OUTPUT_PREVIEW - 3)}...`;
438+}
439+
440+function createSessionId(): string {
441+  return `session-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
442+}
443+
444+function formatErrorMessage(error: unknown): string {
445+  if (error instanceof Error) {
446+    return error.message;
447+  }
448+
449+  return String(error);
450+}
451+
452+function normalizeOutputChunk(chunk: string | Uint8Array): string {
453+  return typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk);
454+}
455+
456+function sleep(ms: number): Promise<void> {
457+  return new Promise((resolve) => {
458+    setTimeout(resolve, ms);
459+  });
460+}
461+
462+function waitForChildExit(child: CodexdChildProcessLike, timeoutMs: number): Promise<void> {
463+  return new Promise((resolve) => {
464+    let settled = false;
465+    const complete = () => {
466+      if (settled) {
467+        return;
468+      }
469+
470+      settled = true;
471+      resolve();
472+    };
473+
474+    child.once("exit", () => {
475+      complete();
476+    });
477+    setTimeout(() => {
478+      complete();
479+    }, timeoutMs);
480+  });
481+}
482+
483+function waitForChildSpawn(child: CodexdChildProcessLike): Promise<void> {
484+  return new Promise((resolve, reject) => {
485+    let settled = false;
486+    const finish = (callback: () => void) => {
487+      if (settled) {
488+        return;
489+      }
490+
491+      settled = true;
492+      callback();
493+    };
494+
495+    child.once("spawn", () => {
496+      finish(resolve);
497+    });
498+    child.once("error", (error) => {
499+      finish(() => reject(error));
500+    });
501+    child.once("exit", (code, signal) => {
502+      finish(() => reject(new Error(`Child exited before spawn completion (code=${String(code)}, signal=${String(signal)}).`)));
503+    });
504+  });
505+}
506+
507+const EMBEDDED_SMOKE_PROGRAM = [
508+  'process.stdout.write("codexd smoke ready\\n");',
509+  'process.stderr.write("codexd smoke stderr\\n");',
510+  'setTimeout(() => {',
511+  '  process.stdout.write("codexd smoke done\\n");',
512+  '  process.exit(0);',
513+  "}, 25);"
514+].join(" ");
A apps/codexd/src/index.test.js
+126, -0
  1@@ -0,0 +1,126 @@
  2+import assert from "node:assert/strict";
  3+import { mkdtempSync, readFileSync } from "node:fs";
  4+import { tmpdir } from "node:os";
  5+import { join } from "node:path";
  6+import test from "node:test";
  7+
  8+import { CodexdDaemon, resolveCodexdConfig } from "../dist/index.js";
  9+
 10+class FakeStream {
 11+  constructor() {
 12+    this.listeners = [];
 13+  }
 14+
 15+  on(event, listener) {
 16+    if (event === "data") {
 17+      this.listeners.push(listener);
 18+    }
 19+
 20+    return this;
 21+  }
 22+
 23+  emit(chunk) {
 24+    for (const listener of this.listeners) {
 25+      listener(chunk);
 26+    }
 27+  }
 28+}
 29+
 30+class FakeChild {
 31+  constructor() {
 32+    this.pid = 4242;
 33+    this.stdout = new FakeStream();
 34+    this.stderr = new FakeStream();
 35+    this.listeners = new Map();
 36+    this.onceListeners = new Map();
 37+  }
 38+
 39+  on(event, listener) {
 40+    this.listeners.set(event, [...(this.listeners.get(event) ?? []), listener]);
 41+    return this;
 42+  }
 43+
 44+  once(event, listener) {
 45+    this.onceListeners.set(event, [...(this.onceListeners.get(event) ?? []), listener]);
 46+    return this;
 47+  }
 48+
 49+  kill(signal = "SIGTERM") {
 50+    this.emit("exit", 0, signal);
 51+    return true;
 52+  }
 53+
 54+  emit(event, ...args) {
 55+    for (const listener of this.listeners.get(event) ?? []) {
 56+      listener(...args);
 57+    }
 58+
 59+    for (const listener of this.onceListeners.get(event) ?? []) {
 60+      listener(...args);
 61+    }
 62+
 63+    this.onceListeners.delete(event);
 64+  }
 65+}
 66+
 67+test("CodexdDaemon persists daemon identity, child state, session registry, and recent events", async () => {
 68+  const repoRoot = mkdtempSync(join(tmpdir(), "codexd-daemon-test-"));
 69+  const config = resolveCodexdConfig({
 70+    repoRoot,
 71+    logsDir: join(repoRoot, "logs"),
 72+    stateDir: join(repoRoot, "state")
 73+  });
 74+  const fakeChild = new FakeChild();
 75+  const daemon = new CodexdDaemon(config, {
 76+    env: {
 77+      HOME: repoRoot
 78+    },
 79+    spawner: {
 80+      spawn(command, args, options) {
 81+        assert.equal(command, "codex");
 82+        assert.deepEqual(args, ["app-server"]);
 83+        assert.equal(options.cwd, repoRoot);
 84+
 85+        queueMicrotask(() => {
 86+          fakeChild.emit("spawn");
 87+          fakeChild.stdout.emit("ready from fake child\n");
 88+          fakeChild.stderr.emit("warning from fake child\n");
 89+        });
 90+
 91+        return fakeChild;
 92+      }
 93+    }
 94+  });
 95+
 96+  const started = await daemon.start();
 97+  assert.equal(started.daemon.child.status, "running");
 98+  assert.equal(started.daemon.child.pid, 4242);
 99+
100+  const session = await daemon.registerSession({
101+    purpose: "worker",
102+    metadata: {
103+      runId: "run-1"
104+    }
105+  });
106+  assert.equal(session.status, "active");
107+
108+  const closed = await daemon.closeSession(session.sessionId);
109+  assert.equal(closed?.status, "closed");
110+
111+  const stopped = await daemon.stop();
112+  assert.equal(stopped.daemon.started, false);
113+  assert.equal(stopped.sessionRegistry.sessions.length, 1);
114+  assert.equal(stopped.sessionRegistry.sessions[0].status, "closed");
115+  assert.ok(stopped.recentEvents.events.length >= 4);
116+
117+  const daemonState = JSON.parse(readFileSync(config.paths.daemonStatePath, "utf8"));
118+  const sessionRegistry = JSON.parse(readFileSync(config.paths.sessionRegistryPath, "utf8"));
119+  const recentEvents = JSON.parse(readFileSync(config.paths.recentEventsPath, "utf8"));
120+  const eventLog = readFileSync(config.paths.structuredEventLogPath, "utf8");
121+
122+  assert.equal(daemonState.child.status, "stopped");
123+  assert.equal(sessionRegistry.sessions[0].status, "closed");
124+  assert.ok(recentEvents.events.length >= 4);
125+  assert.match(eventLog, /child\.started/);
126+  assert.match(eventLog, /session\.registered/);
127+});
A apps/codexd/src/index.ts
+60, -0
 1@@ -0,0 +1,60 @@
 2+export * from "./contracts.js";
 3+export * from "./config.js";
 4+export * from "./state-store.js";
 5+export * from "./daemon.js";
 6+export * from "./cli.js";
 7+
 8+import { runCodexdCli } from "./cli.js";
 9+
10+if (shouldRunCodexdCli(import.meta.url)) {
11+  try {
12+    const exitCode = await runCodexdCli();
13+
14+    if (exitCode !== 0 && typeof process !== "undefined") {
15+      process.exitCode = exitCode;
16+    }
17+  } catch (error) {
18+    console.error(formatCodexdCliError(error));
19+
20+    if (typeof process !== "undefined") {
21+      process.exitCode = 1;
22+    }
23+  }
24+}
25+
26+function shouldRunCodexdCli(metaUrl: string): boolean {
27+  if (typeof process === "undefined") {
28+    return false;
29+  }
30+
31+  const executedPath = normalizeCliEntryPath(process.argv[1]);
32+
33+  if (executedPath == null) {
34+    return false;
35+  }
36+
37+  const sourceEntryPath = normalizeCliEntryPath(toFsPath(metaUrl));
38+  const distShimPath = normalizeCliEntryPath(toFsPath(new URL("../../../index.js", metaUrl).href));
39+
40+  return executedPath === sourceEntryPath || executedPath === distShimPath;
41+}
42+
43+function normalizeCliEntryPath(value: string | undefined): string | null {
44+  if (value == null || value === "") {
45+    return null;
46+  }
47+
48+  return value.endsWith("/") ? value.slice(0, -1) : value;
49+}
50+
51+function toFsPath(value: string): string {
52+  return decodeURIComponent(new URL(value).pathname);
53+}
54+
55+function formatCodexdCliError(error: unknown): string {
56+  if (error instanceof Error) {
57+    return error.stack ?? `${error.name}: ${error.message}`;
58+  }
59+
60+  return `codexd startup failed: ${String(error)}`;
61+}
A apps/codexd/src/node-shims.d.ts
+58, -0
 1@@ -0,0 +1,58 @@
 2+declare function setTimeout(callback: () => void, delay?: number): unknown;
 3+
 4+declare const process:
 5+  | {
 6+      argv: string[];
 7+      cwd(): string;
 8+      env: Record<string, string | undefined>;
 9+      execPath: string;
10+      exitCode?: number;
11+      off?(event: string, listener: () => void): unknown;
12+      on?(event: string, listener: () => void): unknown;
13+      pid?: number;
14+    }
15+  | undefined;
16+
17+declare module "node:child_process" {
18+  export interface SpawnOptions {
19+    cwd?: string;
20+    env?: Record<string, string | undefined>;
21+    stdio?: readonly string[] | string;
22+  }
23+
24+  export interface ChildProcess {
25+    pid?: number;
26+    stderr?: {
27+      on(event: "data", listener: (chunk: string | Uint8Array) => void): unknown;
28+    };
29+    stdout?: {
30+      on(event: "data", listener: (chunk: string | Uint8Array) => void): unknown;
31+    };
32+    kill(signal?: string): boolean;
33+    on(event: "error", listener: (error: Error) => void): this;
34+    on(event: "exit", listener: (code: number | null, signal: string | null) => void): this;
35+    on(event: "spawn", listener: () => void): this;
36+    once(event: "error", listener: (error: Error) => void): this;
37+    once(event: "exit", listener: (code: number | null, signal: string | null) => void): this;
38+    once(event: "spawn", listener: () => void): this;
39+  }
40+
41+  export function spawn(command: string, args?: readonly string[], options?: SpawnOptions): ChildProcess;
42+}
43+
44+declare module "node:crypto" {
45+  export function randomUUID(): string;
46+}
47+
48+declare module "node:fs/promises" {
49+  export function access(path: string): Promise<void>;
50+  export function appendFile(path: string, data: string, encoding: string): Promise<void>;
51+  export function mkdir(path: string, options?: { recursive?: boolean }): Promise<void>;
52+  export function readFile(path: string, encoding: string): Promise<string>;
53+  export function writeFile(path: string, data: string, encoding: string): Promise<void>;
54+}
55+
56+declare module "node:path" {
57+  export function join(...paths: string[]): string;
58+  export function resolve(...paths: string[]): string;
59+}
A apps/codexd/src/state-store.ts
+406, -0
  1@@ -0,0 +1,406 @@
  2+import { randomUUID } from "node:crypto";
  3+import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
  4+
  5+import type {
  6+  CodexdDaemonIdentity,
  7+  CodexdDaemonState,
  8+  CodexdEventLevel,
  9+  CodexdManagedChildState,
 10+  CodexdRecentEvent,
 11+  CodexdRecentEventCacheState,
 12+  CodexdResolvedConfig,
 13+  CodexdSessionRecord,
 14+  CodexdSessionRegistryState,
 15+  CodexdStatusSnapshot
 16+} from "./contracts.js";
 17+
 18+export interface CodexdStateStoreOptions {
 19+  now?: () => string;
 20+  processId?: () => number | null;
 21+  uuid?: () => string;
 22+}
 23+
 24+export interface CodexdEventInput {
 25+  detail?: Record<string, unknown> | null;
 26+  level: CodexdEventLevel;
 27+  message: string;
 28+  type: string;
 29+}
 30+
 31+export class CodexdStateStore {
 32+  private daemonState: CodexdDaemonState | null = null;
 33+  private identity: CodexdDaemonIdentity | null = null;
 34+  private initialized = false;
 35+  private nextEventSeq = 1;
 36+  private recentEvents: CodexdRecentEventCacheState | null = null;
 37+  private sessionRegistry: CodexdSessionRegistryState | null = null;
 38+
 39+  private readonly now: () => string;
 40+  private readonly processId: () => number | null;
 41+  private readonly uuid: () => string;
 42+
 43+  constructor(
 44+    private readonly config: CodexdResolvedConfig,
 45+    options: CodexdStateStoreOptions = {}
 46+  ) {
 47+    this.now = options.now ?? defaultNow;
 48+    this.processId = options.processId ?? defaultProcessId;
 49+    this.uuid = options.uuid ?? randomUUID;
 50+  }
 51+
 52+  async initialize(): Promise<CodexdStatusSnapshot> {
 53+    if (this.initialized) {
 54+      return this.getSnapshot();
 55+    }
 56+
 57+    await mkdir(this.config.paths.logsDir, { recursive: true });
 58+    await mkdir(this.config.paths.stateDir, { recursive: true });
 59+
 60+    const identity = await readJsonOrDefault<CodexdDaemonIdentity | null>(
 61+      this.config.paths.identityPath,
 62+      null
 63+    );
 64+    const daemonState = await readJsonOrDefault<CodexdDaemonState | null>(
 65+      this.config.paths.daemonStatePath,
 66+      null
 67+    );
 68+    const sessionRegistry = await readJsonOrDefault<CodexdSessionRegistryState | null>(
 69+      this.config.paths.sessionRegistryPath,
 70+      null
 71+    );
 72+    const recentEvents = await readJsonOrDefault<CodexdRecentEventCacheState | null>(
 73+      this.config.paths.recentEventsPath,
 74+      null
 75+    );
 76+
 77+    this.identity = identity ?? createDaemonIdentity(this.config, this.uuid(), this.now());
 78+    this.daemonState = normalizeDaemonState(daemonState, this.config, this.now());
 79+    this.sessionRegistry = normalizeSessionRegistry(sessionRegistry);
 80+    this.recentEvents = normalizeRecentEvents(recentEvents, this.config.eventCacheSize);
 81+    this.nextEventSeq = getNextEventSeq(this.recentEvents.events);
 82+    this.initialized = true;
 83+
 84+    await this.persistIdentity();
 85+    await this.persistDaemonState();
 86+    await this.persistSessionRegistry();
 87+    await this.persistRecentEvents();
 88+
 89+    return this.getSnapshot();
 90+  }
 91+
 92+  getSnapshot(): CodexdStatusSnapshot {
 93+    this.assertInitialized();
 94+
 95+    return {
 96+      config: cloneJson(this.config),
 97+      identity: cloneJson(this.identity!),
 98+      daemon: cloneJson(this.daemonState!),
 99+      sessionRegistry: cloneJson(this.sessionRegistry!),
100+      recentEvents: cloneJson(this.recentEvents!)
101+    };
102+  }
103+
104+  getChildState(): CodexdManagedChildState {
105+    this.assertInitialized();
106+    return cloneJson(this.daemonState!.child);
107+  }
108+
109+  async markDaemonStarted(): Promise<CodexdDaemonState> {
110+    this.assertInitialized();
111+    const now = this.now();
112+
113+    this.daemonState = {
114+      ...this.daemonState!,
115+      started: true,
116+      startedAt: now,
117+      stoppedAt: null,
118+      updatedAt: now,
119+      pid: this.processId()
120+    };
121+
122+    await this.persistDaemonState();
123+    return this.daemonState!;
124+  }
125+
126+  async markDaemonStopped(): Promise<CodexdDaemonState> {
127+    this.assertInitialized();
128+    const now = this.now();
129+
130+    this.daemonState = {
131+      ...this.daemonState!,
132+      started: false,
133+      stoppedAt: now,
134+      updatedAt: now,
135+      pid: null
136+    };
137+
138+    await this.persistDaemonState();
139+    return this.daemonState!;
140+  }
141+
142+  async updateChildState(patch: Partial<CodexdManagedChildState>): Promise<CodexdManagedChildState> {
143+    this.assertInitialized();
144+
145+    this.daemonState = {
146+      ...this.daemonState!,
147+      updatedAt: this.now(),
148+      child: {
149+        ...this.daemonState!.child,
150+        ...cloneJson(patch)
151+      }
152+    };
153+
154+    await this.persistDaemonState();
155+    return this.daemonState!.child;
156+  }
157+
158+  async upsertSession(record: CodexdSessionRecord): Promise<CodexdSessionRegistryState> {
159+    this.assertInitialized();
160+    const sessions = [...this.sessionRegistry!.sessions];
161+    const index = sessions.findIndex((entry) => entry.sessionId === record.sessionId);
162+
163+    if (index >= 0) {
164+      sessions[index] = cloneJson(record);
165+    } else {
166+      sessions.push(cloneJson(record));
167+    }
168+
169+    this.sessionRegistry = {
170+      updatedAt: this.now(),
171+      sessions
172+    };
173+
174+    await this.persistSessionRegistry();
175+    return this.sessionRegistry!;
176+  }
177+
178+  async closeSession(sessionId: string): Promise<CodexdSessionRecord | null> {
179+    this.assertInitialized();
180+    const sessions = [...this.sessionRegistry!.sessions];
181+    const index = sessions.findIndex((entry) => entry.sessionId === sessionId);
182+
183+    if (index < 0) {
184+      return null;
185+    }
186+
187+    const existing = sessions[index];
188+
189+    if (existing == null) {
190+      return null;
191+    }
192+
193+    const updated: CodexdSessionRecord = {
194+      ...existing,
195+      status: "closed",
196+      updatedAt: this.now()
197+    };
198+    sessions[index] = updated;
199+    this.sessionRegistry = {
200+      updatedAt: updated.updatedAt,
201+      sessions
202+    };
203+
204+    await this.persistSessionRegistry();
205+    return updated;
206+  }
207+
208+  async recordEvent(input: CodexdEventInput): Promise<CodexdRecentEvent> {
209+    this.assertInitialized();
210+    const entry: CodexdRecentEvent = {
211+      seq: this.nextEventSeq,
212+      createdAt: this.now(),
213+      level: input.level,
214+      type: input.type,
215+      message: input.message,
216+      detail: input.detail ?? null
217+    };
218+
219+    this.nextEventSeq += 1;
220+    await appendFile(this.config.paths.structuredEventLogPath, `${JSON.stringify(entry)}\n`, "utf8");
221+
222+    this.recentEvents = {
223+      maxEntries: this.config.eventCacheSize,
224+      updatedAt: entry.createdAt,
225+      events: [...this.recentEvents!.events, entry].slice(-this.config.eventCacheSize)
226+    };
227+
228+    this.daemonState = {
229+      ...this.daemonState!,
230+      updatedAt: entry.createdAt
231+    };
232+
233+    await this.persistRecentEvents();
234+    await this.persistDaemonState();
235+
236+    return entry;
237+  }
238+
239+  async appendChildOutput(stream: "stderr" | "stdout", text: string): Promise<void> {
240+    this.assertInitialized();
241+    const path =
242+      stream === "stdout" ? this.config.paths.stdoutLogPath : this.config.paths.stderrLogPath;
243+
244+    await appendFile(path, text, "utf8");
245+  }
246+
247+  private assertInitialized(): void {
248+    if (!this.initialized || this.identity == null || this.daemonState == null || this.sessionRegistry == null || this.recentEvents == null) {
249+      throw new Error("CodexdStateStore is not initialized.");
250+    }
251+  }
252+
253+  private async persistDaemonState(): Promise<void> {
254+    this.assertInitialized();
255+    await writeJsonFile(this.config.paths.daemonStatePath, this.daemonState!);
256+  }
257+
258+  private async persistIdentity(): Promise<void> {
259+    this.assertInitialized();
260+    await writeJsonFile(this.config.paths.identityPath, this.identity!);
261+  }
262+
263+  private async persistRecentEvents(): Promise<void> {
264+    this.assertInitialized();
265+    await writeJsonFile(this.config.paths.recentEventsPath, this.recentEvents!);
266+  }
267+
268+  private async persistSessionRegistry(): Promise<void> {
269+    this.assertInitialized();
270+    await writeJsonFile(this.config.paths.sessionRegistryPath, this.sessionRegistry!);
271+  }
272+}
273+
274+function createDaemonIdentity(
275+  config: CodexdResolvedConfig,
276+  daemonId: string,
277+  createdAt: string
278+): CodexdDaemonIdentity {
279+  return {
280+    daemonId,
281+    nodeId: config.nodeId,
282+    repoRoot: config.paths.repoRoot,
283+    createdAt,
284+    version: config.version
285+  };
286+}
287+
288+function createInitialChildState(config: CodexdResolvedConfig): CodexdManagedChildState {
289+  return {
290+    strategy: config.server.childStrategy,
291+    mode: config.server.mode,
292+    endpoint: config.server.endpoint,
293+    status: "idle",
294+    command: config.server.childCommand,
295+    args: [...config.server.childArgs],
296+    cwd: config.server.childCwd,
297+    pid: null,
298+    startedAt: null,
299+    exitedAt: null,
300+    exitCode: null,
301+    signal: null,
302+    lastError: null
303+  };
304+}
305+
306+function normalizeDaemonState(
307+  value: CodexdDaemonState | null,
308+  config: CodexdResolvedConfig,
309+  now: string
310+): CodexdDaemonState {
311+  if (value == null) {
312+    return {
313+      started: false,
314+      startedAt: null,
315+      stoppedAt: null,
316+      updatedAt: now,
317+      pid: null,
318+      child: createInitialChildState(config)
319+    };
320+  }
321+
322+  return {
323+    ...value,
324+    child: {
325+      ...value.child,
326+      strategy: config.server.childStrategy,
327+      mode: config.server.mode,
328+      endpoint: config.server.endpoint,
329+      command: config.server.childCommand,
330+      args: [...config.server.childArgs],
331+      cwd: config.server.childCwd
332+    }
333+  };
334+}
335+
336+function normalizeRecentEvents(
337+  value: CodexdRecentEventCacheState | null,
338+  maxEntries: number
339+): CodexdRecentEventCacheState {
340+  if (value == null) {
341+    return {
342+      maxEntries,
343+      updatedAt: null,
344+      events: []
345+    };
346+  }
347+
348+  return {
349+    maxEntries,
350+    updatedAt: value.updatedAt,
351+    events: [...value.events].slice(-maxEntries)
352+  };
353+}
354+
355+function normalizeSessionRegistry(
356+  value: CodexdSessionRegistryState | null
357+): CodexdSessionRegistryState {
358+  if (value == null) {
359+    return {
360+      updatedAt: null,
361+      sessions: []
362+    };
363+  }
364+
365+  return {
366+    updatedAt: value.updatedAt,
367+    sessions: [...value.sessions]
368+  };
369+}
370+
371+async function readJsonOrDefault<T>(path: string, fallback: T): Promise<T> {
372+  try {
373+    const source = await readFile(path, "utf8");
374+    return JSON.parse(source) as T;
375+  } catch (error) {
376+    if (isMissingFileError(error)) {
377+      return fallback;
378+    }
379+
380+    throw error;
381+  }
382+}
383+
384+function writeJsonFile(path: string, value: unknown): Promise<void> {
385+  return writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
386+}
387+
388+function cloneJson<T>(value: T): T {
389+  return JSON.parse(JSON.stringify(value)) as T;
390+}
391+
392+function getNextEventSeq(events: readonly CodexdRecentEvent[]): number {
393+  const last = events[events.length - 1];
394+  return last == null ? 1 : last.seq + 1;
395+}
396+
397+function defaultNow(): string {
398+  return new Date().toISOString();
399+}
400+
401+function defaultProcessId(): number | null {
402+  return typeof process !== "undefined" ? process.pid ?? null : null;
403+}
404+
405+function isMissingFileError(error: unknown): error is Error & { code: string } {
406+  return typeof error === "object" && error !== null && "code" in error && (error as { code: unknown }).code === "ENOENT";
407+}
A apps/codexd/tsconfig.json
+10, -0
 1@@ -0,0 +1,10 @@
 2+{
 3+  "extends": "../../tsconfig.base.json",
 4+  "files": ["src/node-shims.d.ts"],
 5+  "compilerOptions": {
 6+    "lib": ["ES2022", "DOM"],
 7+    "rootDir": "../..",
 8+    "outDir": "dist"
 9+  },
10+  "include": ["src/**/*.ts", "src/**/*.d.ts"]
11+}
M docs/runtime/README.md
+13, -7
 1@@ -8,7 +8,7 @@
 2 - [`environment.md`](./environment.md): 必要环境变量
 3 - [`launchd.md`](./launchd.md): `mini` 上的 launchd 安装
 4 - [`node-verification.md`](./node-verification.md): `mini` 节点 on-node 检查
 5-- [`codexd.md`](./codexd.md): Codex 常驻代理的预留设计
 6+- [`codexd.md`](./codexd.md): Codex 常驻代理骨架与后续边界
 7 
 8 ## 当前约定
 9 
10@@ -29,12 +29,18 @@ Firefox WS 说明:
11 
12 `codexd` 说明:
13 
14-- 当前还没有真正的 Codex 常驻代理实现
15-- [`codexd.md`](./codexd.md) 只记录后续设计边界
16-- 当前不要把系统理解成已经有 Codex daemon
17-- 文档结论已经明确:
18-  - `codex app-server` 是未来主会话/双工能力面
19-  - `codex exec` 只作为简单调用、测试和兜底路径
20+- 仓库里已经有 `apps/codexd` 最小骨架
21+- 当前骨架能做的事情:
22+  - 解析最小运行配置
23+  - 维护 `logs/codexd` 和 `state/codexd`
24+  - 启动或占位一个 `codex app-server` 子进程配置
25+  - 持久化 daemon identity、child state、session registry、recent event cache
26+  - 提供 `start` / `status` / `smoke`
27+- 当前还没有:
28+  - 对外 IPC / HTTP 面
29+  - 真正的 `thread` / `turn` 管理
30+  - conductor 集成
31+  - 自动安装脚本里的 codexd service 渲染
32 
33 ## 最短路径
34 
M docs/runtime/codexd.md
+54, -5
 1@@ -6,8 +6,9 @@
 2 
 3 当前状态:
 4 
 5-- 这是设计文档
 6-- 仓库里还没有 `codexd` 常驻进程或对外服务实现
 7+- 仓库里已经有 `apps/codexd` 最小骨架
 8+- 它目前是 daemon scaffold,不是完整协议实现
 9+- 主目标仍然是围绕 `codex app-server` 演进
10 - 已有两个底层适配包:
11   - `packages/codex-app-server`: 面向未来主会话 / 双工能力
12   - `packages/codex-exec`: 面向 smoke、简单 worker 和降级路径的一次性调用
13@@ -127,12 +128,60 @@
14 
15 当前推荐口径:
16 
17-- `codexd v1` 就应直接围绕 `app-server` 实现
18+- `codexd v1` 继续围绕 `app-server`
19 - `exec` 仅保留为:
20   - 最小 smoke 测试
21   - 简单离线调用
22   - app-server 不可用时的兜底 worker
23 
24+## 当前骨架已经落下的内容
25+
26+`apps/codexd` 当前已经提供:
27+
28+- 最小 CLI:
29+  - `start`
30+  - `status`
31+  - `config`
32+  - `smoke`
33+- 最小配置:
34+  - server mode
35+  - logs/state dir
36+  - app-server endpoint
37+  - child process strategy
38+- 最小状态:
39+  - daemon identity
40+  - child process state
41+  - session registry
42+  - recent event cache
43+- 最小运行时文件:
44+  - `logs/codexd/events.jsonl`
45+  - `logs/codexd/stdout.log`
46+  - `logs/codexd/stderr.log`
47+  - `state/codexd/identity.json`
48+  - `state/codexd/daemon-state.json`
49+  - `state/codexd/session-registry.json`
50+  - `state/codexd/recent-events.json`
51+
52+当前 `smoke` 不依赖真实 Codex CLI。
53+
54+- 它使用内置 stub child 验证骨架目录、状态文件和结构化日志能否闭环写出
55+
56+当前 `start` 的语义是:
57+
58+- `spawn` 策略下,拉起一个配置好的 child command 并持续托管它
59+- `external` 策略下,不启 child,只把 endpoint 和状态占位出来
60+
61+## 当前明确还没做的事
62+
63+当前骨架还没有:
64+
65+- `thread/start` / `thread/resume` / `turn/start` 的真实代理
66+- `codex-app-server` 传输层接线
67+- HTTP / WS / IPC 入口
68+- conductor-daemon 适配层
69+- crash recovery 的自动复连和 session 恢复
70+- install-launchd 脚本里的 codexd service 渲染
71+
72 ## 支持的两类工作
73 
74 ### 1. worker 模式
75@@ -256,7 +305,7 @@
76 
77 ## 自启动
78 
79-如果后续实现 `codexd`,推荐:
80+当前已经预留了 launchd 模板;后续完整接线时,仍推荐:
81 
82 - 新增 `launchd` 服务
83 - 例如:`so.makefile.baa-codexd`
84@@ -269,7 +318,7 @@
85 
86 ## 当前建议
87 
88-在 `codexd` 真正实现前,继续遵守当前边界:
89+当前实现已经有最小骨架,但继续遵守这些边界:
90 
91 - `conductor-daemon` 是主接口
92 - `worker-runner` 是通用执行框架
M docs/runtime/environment.md
+32, -0
 1@@ -22,6 +22,38 @@
 2 - `BAA_CONTROL_API_BASE` 是兼容变量名,当前主要给 `status-api` 和遗留脚本使用
 3 - 它的默认值已经收口到 `https://conductor.makefile.so`,不再代表单独旧控制面
 4 
 5+## codexd 变量
 6+
 7+`apps/codexd` 当前识别这些变量:
 8+
 9+- `BAA_CODEXD_REPO_ROOT`
10+- `BAA_CODEXD_MODE`
11+- `BAA_CODEXD_LOGS_DIR`
12+- `BAA_CODEXD_STATE_DIR`
13+- `BAA_CODEXD_SERVER_ENDPOINT`
14+- `BAA_CODEXD_SERVER_STRATEGY`
15+- `BAA_CODEXD_SERVER_COMMAND`
16+- `BAA_CODEXD_SERVER_ARGS`
17+- `BAA_CODEXD_SERVER_CWD`
18+- `BAA_CODEXD_EVENT_CACHE_SIZE`
19+- `BAA_CODEXD_SMOKE_LIFETIME_MS`
20+- `BAA_CODEXD_VERSION`
21+
22+当前默认值:
23+
24+```text
25+BAA_CODEXD_MODE=app-server
26+BAA_CODEXD_SERVER_STRATEGY=spawn
27+BAA_CODEXD_SERVER_COMMAND=codex
28+BAA_CODEXD_SERVER_ARGS=app-server
29+BAA_CODEXD_SERVER_ENDPOINT=stdio://codex-app-server
30+```
31+
32+派生目录:
33+
34+- `BAA_CODEXD_LOGS_DIR` 未设置时,默认 `${BAA_LOGS_DIR}/codexd`
35+- `BAA_CODEXD_STATE_DIR` 未设置时,默认 `${BAA_STATE_DIR}/codexd`
36+
37 ## 节点变量
38 
39 ```text
M docs/runtime/launchd.md
+9, -0
 1@@ -6,6 +6,7 @@
 2 
 3 - `conductor` 由 `launchd` 托管,并承载 canonical local API `http://100.71.210.78:4317`
 4 - `status-api` 仍会随默认安装一起部署,但只作为本地只读观察面
 5+- `codexd` 现在有独立 plist 模板,但还没有接进统一安装脚本
 6 - 工作目录固定到 `/Users/george/code/baa-conductor`
 7 - 通过仓库内脚本统一安装、启动、停止、重启与验证
 8 
 9@@ -23,6 +24,12 @@
10 4. 重启 launchd 服务
11 5. 跑静态检查和节点检查
12 
13+`codexd` 说明:
14+
15+- 模板文件已经在 [`ops/launchd/so.makefile.baa-codexd.plist`](../../ops/launchd/so.makefile.baa-codexd.plist)
16+- 当前任务没有改 `scripts/runtime/install-launchd.sh`
17+- 所以它现在还是“手工可加载模板”,不是 `install-mini.sh` 的默认安装对象
18+
19 默认会把共享 token 收口到:
20 
21 - `~/.config/baa-conductor/shared-token.txt`
22@@ -144,3 +151,5 @@ npx --yes pnpm -r build
23 ./scripts/runtime/status-launchd.sh
24 ./scripts/runtime/check-node.sh --node mini --check-loaded --expected-rolez leader
25 ```
26+
27+如果要单独试 `codexd` 模板,先 build,再手工复制 plist 到 `~/Library/LaunchAgents`,最后用 `launchctl bootstrap` / `launchctl kickstart` 加载它。
M docs/runtime/layout.md
+2, -0
 1@@ -19,8 +19,10 @@ tmp/
 2 说明:
 3 
 4 - `state/`: 本地状态和小型快照
 5+- `state/codexd/`: `codexd` identity、daemon state、session registry、recent event cache
 6 - `runs/`: 单次 run 目录
 7 - `worktrees/`: 独立 worktree
 8+- `logs/codexd/`: `codexd` 的结构化事件和 child stdout/stderr
 9 - `logs/launchd/`: launchd stdout/stderr
10 - `tmp/`: 脚本临时文件
11 
A ops/launchd/so.makefile.baa-codexd.plist
+72, -0
 1@@ -0,0 +1,72 @@
 2+<?xml version="1.0" encoding="UTF-8"?>
 3+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
 4+<!--
 5+  Source template kept in the repo.
 6+  This codexd plist is intentionally manual for now: install-launchd.sh has not
 7+  been extended in this task, so render and copy it explicitly if you want to
 8+  load codexd under launchd before the runtime scripts are taught about it.
 9+-->
10+<plist version="1.0">
11+  <dict>
12+    <key>Label</key>
13+    <string>so.makefile.baa-codexd</string>
14+
15+    <key>WorkingDirectory</key>
16+    <string>/Users/george/code/baa-conductor</string>
17+
18+    <key>EnvironmentVariables</key>
19+    <dict>
20+      <key>PATH</key>
21+      <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Users/george/.local/bin:/Users/george/bin</string>
22+      <key>HOME</key>
23+      <string>/Users/george</string>
24+      <key>LANG</key>
25+      <string>en_US.UTF-8</string>
26+      <key>LC_ALL</key>
27+      <string>en_US.UTF-8</string>
28+      <key>BAA_NODE_ID</key>
29+      <string>mini-main</string>
30+      <key>BAA_LOGS_DIR</key>
31+      <string>/Users/george/code/baa-conductor/logs</string>
32+      <key>BAA_STATE_DIR</key>
33+      <string>/Users/george/code/baa-conductor/state</string>
34+      <key>BAA_CODEXD_MODE</key>
35+      <string>app-server</string>
36+      <key>BAA_CODEXD_SERVER_STRATEGY</key>
37+      <string>spawn</string>
38+      <key>BAA_CODEXD_SERVER_COMMAND</key>
39+      <string>codex</string>
40+      <key>BAA_CODEXD_SERVER_ARGS</key>
41+      <string>app-server</string>
42+      <key>BAA_CODEXD_SERVER_CWD</key>
43+      <string>/Users/george/code/baa-conductor</string>
44+      <key>BAA_CODEXD_SERVER_ENDPOINT</key>
45+      <string>stdio://codex-app-server</string>
46+      <key>BAA_CODEXD_EVENT_CACHE_SIZE</key>
47+      <string>50</string>
48+      <key>BAA_CODEXD_SMOKE_LIFETIME_MS</key>
49+      <string>100</string>
50+    </dict>
51+
52+    <key>ProgramArguments</key>
53+    <array>
54+      <string>/usr/bin/env</string>
55+      <string>node</string>
56+      <string>/Users/george/code/baa-conductor/apps/codexd/dist/index.js</string>
57+      <string>start</string>
58+    </array>
59+
60+    <key>ProcessType</key>
61+    <string>Background</string>
62+    <key>RunAtLoad</key>
63+    <true/>
64+    <key>KeepAlive</key>
65+    <true/>
66+    <key>ThrottleInterval</key>
67+    <integer>10</integer>
68+    <key>StandardOutPath</key>
69+    <string>/Users/george/code/baa-conductor/logs/launchd/so.makefile.baa-codexd.out.log</string>
70+    <key>StandardErrorPath</key>
71+    <string>/Users/george/code/baa-conductor/logs/launchd/so.makefile.baa-codexd.err.log</string>
72+  </dict>
73+</plist>