baa-conductor


baa-conductor / apps / codexd / src
im_wower  ·  2026-03-22

config.ts

  1import { resolve } from "node:path";
  2
  3import type {
  4  CodexdCliAction,
  5  CodexdEnvironment,
  6  CodexdResolvedConfig,
  7  CodexdRuntimePaths,
  8  CodexdServerConfig
  9} from "./contracts.js";
 10
 11export interface CodexdConfigInput {
 12  eventCacheSize?: number;
 13  eventStreamPath?: string;
 14  localApiBase?: string;
 15  logsDir?: string;
 16  mode?: string;
 17  nodeId?: string;
 18  repoRoot?: string;
 19  serverArgs?: string[];
 20  serverCommand?: string;
 21  serverCwd?: string;
 22  serverEndpoint?: string;
 23  serverStrategy?: string;
 24  smokeLifetimeMs?: number;
 25  stateDir?: string;
 26  version?: string | null;
 27}
 28
 29export type CodexdCliRequest =
 30  | {
 31      action: "help";
 32    }
 33  | {
 34      action: Exclude<CodexdCliAction, "help" | "start">;
 35      config: CodexdResolvedConfig;
 36      printJson: boolean;
 37    }
 38  | {
 39      action: "start";
 40      config: CodexdResolvedConfig;
 41      printJson: boolean;
 42      runOnce: boolean;
 43      lifetimeMs: number;
 44    };
 45
 46const DEFAULT_EVENT_CACHE_SIZE = 50;
 47const DEFAULT_EVENT_STREAM_PATH = "/v1/codexd/events";
 48const DEFAULT_LOCAL_API_BASE = "http://127.0.0.1:4319";
 49const DEFAULT_NODE_ID = "mini-main";
 50const DEFAULT_SERVER_ARGS = ["app-server"];
 51const DEFAULT_SERVER_COMMAND = "codex";
 52const DEFAULT_SERVER_ENDPOINT = "stdio://codex-app-server";
 53const DEFAULT_SERVER_MODE = "app-server";
 54const DEFAULT_SERVER_STRATEGY = "spawn";
 55const DEFAULT_SMOKE_LIFETIME_MS = 100;
 56
 57export function resolveCodexdConfig(input: CodexdConfigInput = {}): CodexdResolvedConfig {
 58  const repoRoot = resolve(input.repoRoot ?? getDefaultRepoRoot());
 59  const logsRootDir = resolve(getOptionalString(input.logsDir) ?? resolve(repoRoot, "logs"));
 60  const stateRootDir = resolve(getOptionalString(input.stateDir) ?? resolve(repoRoot, "state"));
 61  const paths = resolveCodexdRuntimePaths(repoRoot, logsRootDir, stateRootDir);
 62  const server = resolveServerConfig(input, repoRoot);
 63
 64  return {
 65    nodeId: getOptionalString(input.nodeId) ?? DEFAULT_NODE_ID,
 66    version: getOptionalString(input.version),
 67    eventCacheSize: normalizePositiveInteger(input.eventCacheSize, DEFAULT_EVENT_CACHE_SIZE, "event cache"),
 68    smokeLifetimeMs: normalizePositiveInteger(
 69      input.smokeLifetimeMs,
 70      DEFAULT_SMOKE_LIFETIME_MS,
 71      "smoke lifetime"
 72    ),
 73    paths,
 74    service: {
 75      eventStreamPath:
 76        getOptionalString(input.eventStreamPath) ?? DEFAULT_EVENT_STREAM_PATH,
 77      localApiBase: getOptionalString(input.localApiBase) ?? DEFAULT_LOCAL_API_BASE
 78    },
 79    server
 80  };
 81}
 82
 83export function parseCodexdCliRequest(
 84  argv: readonly string[],
 85  env: CodexdEnvironment = {}
 86): CodexdCliRequest {
 87  const tokens = argv.slice(2);
 88  let action: CodexdCliAction = "start";
 89  let actionSet = false;
 90  let printJson = false;
 91  let runOnce = false;
 92  let eventCacheSize = parseOptionalInteger(env.BAA_CODEXD_EVENT_CACHE_SIZE);
 93  let eventStreamPath = env.BAA_CODEXD_EVENT_STREAM_PATH;
 94  let localApiBase = env.BAA_CODEXD_LOCAL_API_BASE;
 95  let logsDir = env.BAA_CODEXD_LOGS_DIR ?? env.BAA_LOGS_DIR;
 96  let mode = env.BAA_CODEXD_MODE;
 97  let nodeId = env.BAA_NODE_ID;
 98  let repoRoot = env.BAA_CODEXD_REPO_ROOT;
 99  let serverArgs: string[] | undefined = parseArgumentList(env.BAA_CODEXD_SERVER_ARGS);
100  let serverArgsSet = serverArgs !== undefined;
101  let serverCommand = env.BAA_CODEXD_SERVER_COMMAND;
102  let serverCwd = env.BAA_CODEXD_SERVER_CWD;
103  let serverEndpoint = env.BAA_CODEXD_SERVER_ENDPOINT;
104  let serverStrategy = env.BAA_CODEXD_SERVER_STRATEGY;
105  let smokeLifetimeMs = parseOptionalInteger(env.BAA_CODEXD_SMOKE_LIFETIME_MS);
106  let stateDir = env.BAA_CODEXD_STATE_DIR ?? env.BAA_STATE_DIR;
107  let version = env.BAA_CODEXD_VERSION ?? null;
108
109  for (let index = 0; index < tokens.length; index += 1) {
110    const token = tokens[index];
111
112    if (token == null) {
113      continue;
114    }
115
116    if (token === "--help" || token === "-h" || token === "help") {
117      return { action: "help" };
118    }
119
120    if (token === "--json") {
121      printJson = true;
122      continue;
123    }
124
125    if (token === "--run-once") {
126      runOnce = true;
127      continue;
128    }
129
130    if (token === "--repo-root") {
131      repoRoot = readCliValue(tokens, index, "--repo-root");
132      index += 1;
133      continue;
134    }
135
136    if (token === "--node-id") {
137      nodeId = readCliValue(tokens, index, "--node-id");
138      index += 1;
139      continue;
140    }
141
142    if (token === "--mode") {
143      mode = readCliValue(tokens, index, "--mode");
144      index += 1;
145      continue;
146    }
147
148    if (token === "--logs-dir") {
149      logsDir = readCliValue(tokens, index, "--logs-dir");
150      index += 1;
151      continue;
152    }
153
154    if (token === "--local-api-base") {
155      localApiBase = readCliValue(tokens, index, "--local-api-base");
156      index += 1;
157      continue;
158    }
159
160    if (token === "--event-stream-path") {
161      eventStreamPath = readCliValue(tokens, index, "--event-stream-path");
162      index += 1;
163      continue;
164    }
165
166    if (token === "--state-dir") {
167      stateDir = readCliValue(tokens, index, "--state-dir");
168      index += 1;
169      continue;
170    }
171
172    if (token === "--server-endpoint") {
173      serverEndpoint = readCliValue(tokens, index, "--server-endpoint");
174      index += 1;
175      continue;
176    }
177
178    if (token === "--server-strategy") {
179      serverStrategy = readCliValue(tokens, index, "--server-strategy");
180      index += 1;
181      continue;
182    }
183
184    if (token === "--server-command") {
185      serverCommand = readCliValue(tokens, index, "--server-command");
186      index += 1;
187      continue;
188    }
189
190    if (token === "--server-arg") {
191      const serverArg = readCliValue(tokens, index, "--server-arg");
192
193      if (!serverArgsSet || serverArgs == null) {
194        serverArgs = [];
195        serverArgsSet = true;
196      }
197
198      serverArgs.push(serverArg);
199      index += 1;
200      continue;
201    }
202
203    if (token === "--server-cwd") {
204      serverCwd = readCliValue(tokens, index, "--server-cwd");
205      index += 1;
206      continue;
207    }
208
209    if (token === "--event-cache-size") {
210      eventCacheSize = parseStrictInteger(readCliValue(tokens, index, "--event-cache-size"), "--event-cache-size");
211      index += 1;
212      continue;
213    }
214
215    if (token === "--smoke-lifetime-ms") {
216      smokeLifetimeMs = parseStrictInteger(
217        readCliValue(tokens, index, "--smoke-lifetime-ms"),
218        "--smoke-lifetime-ms"
219      );
220      index += 1;
221      continue;
222    }
223
224    if (token === "--version") {
225      version = readCliValue(tokens, index, "--version");
226      index += 1;
227      continue;
228    }
229
230    if (token.startsWith("--")) {
231      throw new Error(`Unknown codexd flag "${token}".`);
232    }
233
234    if (actionSet) {
235      throw new Error(`Unexpected extra codexd argument "${token}".`);
236    }
237
238    if (!isCodexdCliAction(token)) {
239      throw new Error(`Unknown codexd action "${token}".`);
240    }
241
242    action = token;
243    actionSet = true;
244  }
245
246  const config = resolveCodexdConfig({
247    eventCacheSize,
248    eventStreamPath,
249    localApiBase,
250    logsDir,
251    mode,
252    nodeId,
253    repoRoot,
254    serverArgs,
255    serverCommand,
256    serverCwd,
257    serverEndpoint,
258    serverStrategy,
259    smokeLifetimeMs,
260    stateDir,
261    version
262  });
263
264  if (action === "start") {
265    return {
266      action,
267      config,
268      printJson,
269      runOnce,
270      lifetimeMs: config.smokeLifetimeMs
271    };
272  }
273
274  return {
275    action,
276    config,
277    printJson
278  };
279}
280
281export function formatCodexdConfigText(config: CodexdResolvedConfig): string {
282  return [
283    `node_id: ${config.nodeId}`,
284    `version: ${config.version ?? "not-set"}`,
285    `mode: ${config.server.mode}`,
286    `endpoint: ${config.server.endpoint}`,
287    `child_strategy: ${config.server.childStrategy}`,
288    `child_command: ${config.server.childCommand}`,
289    `child_args: ${config.server.childArgs.join(" ") || "(none)"}`,
290    `child_cwd: ${config.server.childCwd}`,
291    `local_api_base: ${config.service.localApiBase}`,
292    `event_stream_path: ${config.service.eventStreamPath}`,
293    `logs_dir: ${config.paths.logsDir}`,
294    `state_dir: ${config.paths.stateDir}`,
295    `event_cache_size: ${config.eventCacheSize}`,
296    `smoke_lifetime_ms: ${config.smokeLifetimeMs}`
297  ].join("\n");
298}
299
300export function getCodexdUsageText(): string {
301  return [
302    "Usage:",
303    "  node apps/codexd/dist/index.js [start] [options]",
304    "  node apps/codexd/dist/index.js status [--json]",
305    "  node apps/codexd/dist/index.js config [--json]",
306    "  node apps/codexd/dist/index.js smoke [--json]",
307    "  node apps/codexd/dist/index.js help",
308    "",
309    "Options:",
310    "  --repo-root <path>",
311    "  --node-id <id>",
312    "  --mode <app-server|exec>",
313    "  --logs-dir <path>",
314    "  --local-api-base <http://127.0.0.1:4319>",
315    "  --event-stream-path <path>",
316    "  --state-dir <path>",
317    "  --server-endpoint <url-or-stdio>",
318    "  --server-strategy <spawn|external>",
319    "  --server-command <command>",
320    "  --server-arg <arg>",
321    "  --server-cwd <path>",
322    "  --event-cache-size <integer>",
323    "  --smoke-lifetime-ms <integer>",
324    "  --version <string>",
325    "  --run-once",
326    "  --json",
327    "  --help",
328    "",
329    "Environment:",
330    "  BAA_NODE_ID",
331    "  BAA_LOGS_DIR",
332    "  BAA_STATE_DIR",
333    "  BAA_CODEXD_REPO_ROOT",
334    "  BAA_CODEXD_MODE",
335    "  BAA_CODEXD_LOGS_DIR",
336    "  BAA_CODEXD_STATE_DIR",
337    "  BAA_CODEXD_LOCAL_API_BASE",
338    "  BAA_CODEXD_EVENT_STREAM_PATH",
339    "  BAA_CODEXD_SERVER_ENDPOINT",
340    "  BAA_CODEXD_SERVER_STRATEGY",
341    "  BAA_CODEXD_SERVER_COMMAND",
342    "  BAA_CODEXD_SERVER_ARGS",
343    "  BAA_CODEXD_SERVER_CWD",
344    "  BAA_CODEXD_EVENT_CACHE_SIZE",
345    "  BAA_CODEXD_SMOKE_LIFETIME_MS",
346    "  BAA_CODEXD_VERSION",
347    "",
348    "Notes:",
349    "  start manages one configured Codex child process or an external endpoint placeholder.",
350    "  smoke always uses an embedded stub child so the scaffold can be verified without Codex CLI."
351  ].join("\n");
352}
353
354function getDefaultRepoRoot(): string {
355  if (typeof process !== "undefined" && typeof process.cwd === "function") {
356    return process.cwd();
357  }
358
359  return ".";
360}
361
362function resolveServerConfig(input: CodexdConfigInput, repoRoot: string): CodexdServerConfig {
363  const mode = normalizeServerMode(input.mode);
364  const childStrategy = normalizeChildStrategy(input.serverStrategy);
365  const childArgs = input.serverArgs == null ? [...DEFAULT_SERVER_ARGS] : [...input.serverArgs];
366
367  return {
368    mode,
369    endpoint: getOptionalString(input.serverEndpoint) ?? DEFAULT_SERVER_ENDPOINT,
370    childStrategy,
371    childCommand: getOptionalString(input.serverCommand) ?? DEFAULT_SERVER_COMMAND,
372    childArgs,
373    childCwd: resolve(getOptionalString(input.serverCwd) ?? repoRoot)
374  };
375}
376
377function resolveCodexdRuntimePaths(
378  repoRoot: string,
379  logsRootDir: string,
380  stateRootDir: string
381): CodexdRuntimePaths {
382  const logsDir = resolve(logsRootDir, "codexd");
383  const stateDir = resolve(stateRootDir, "codexd");
384
385  return {
386    repoRoot,
387    logsRootDir,
388    stateRootDir,
389    logsDir,
390    stateDir,
391    structuredEventLogPath: resolve(logsDir, "events.jsonl"),
392    stdoutLogPath: resolve(logsDir, "stdout.log"),
393    stderrLogPath: resolve(logsDir, "stderr.log"),
394    identityPath: resolve(stateDir, "identity.json"),
395    daemonStatePath: resolve(stateDir, "daemon-state.json"),
396    sessionRegistryPath: resolve(stateDir, "session-registry.json"),
397    runRegistryPath: resolve(stateDir, "run-registry.json"),
398    recentEventsPath: resolve(stateDir, "recent-events.json")
399  };
400}
401
402function isCodexdCliAction(value: string): value is Exclude<CodexdCliAction, "help"> {
403  return value === "config" || value === "smoke" || value === "start" || value === "status";
404}
405
406function normalizeChildStrategy(value: string | undefined): CodexdServerConfig["childStrategy"] {
407  switch (value ?? DEFAULT_SERVER_STRATEGY) {
408    case "external":
409      return "external";
410    case "spawn":
411      return "spawn";
412    default:
413      throw new Error(`Unsupported codexd child strategy "${value}".`);
414  }
415}
416
417function normalizeServerMode(value: string | undefined): CodexdServerConfig["mode"] {
418  switch (value ?? DEFAULT_SERVER_MODE) {
419    case "app-server":
420      return "app-server";
421    case "exec":
422      return "exec";
423    default:
424      throw new Error(`Unsupported codexd mode "${value}".`);
425  }
426}
427
428function parseArgumentList(value: string | undefined): string[] | undefined {
429  if (value == null) {
430    return undefined;
431  }
432
433  const trimmed = value.trim();
434
435  if (trimmed === "") {
436    return [];
437  }
438
439  if (trimmed.startsWith("[")) {
440    const parsed = JSON.parse(trimmed) as unknown;
441
442    if (!Array.isArray(parsed) || parsed.some((entry) => typeof entry !== "string")) {
443      throw new Error("BAA_CODEXD_SERVER_ARGS must be a JSON string array when JSON syntax is used.");
444    }
445
446    return [...parsed];
447  }
448
449  return trimmed.split(/\s+/u);
450}
451
452function parseOptionalInteger(value: string | undefined): number | undefined {
453  if (value == null || value.trim() === "") {
454    return undefined;
455  }
456
457  return parseStrictInteger(value, "integer value");
458}
459
460function parseStrictInteger(value: string, label: string): number {
461  const parsed = Number(value);
462
463  if (!Number.isInteger(parsed)) {
464    throw new Error(`Invalid ${label} "${value}".`);
465  }
466
467  return parsed;
468}
469
470function normalizePositiveInteger(value: number | undefined, fallback: number, label: string): number {
471  const candidate = value ?? fallback;
472
473  if (!Number.isInteger(candidate) || candidate < 0) {
474    throw new Error(`Invalid ${label} value "${String(value)}".`);
475  }
476
477  return candidate;
478}
479
480function getOptionalString(value: string | null | undefined): string | null {
481  if (value == null) {
482    return null;
483  }
484
485  const trimmed = value.trim();
486  return trimmed === "" ? null : trimmed;
487}
488
489function readCliValue(tokens: readonly string[], index: number, flag: string): string {
490  const value = tokens[index + 1];
491
492  if (value == null || value.startsWith("--")) {
493    throw new Error(`Missing value for ${flag}.`);
494  }
495
496  return value;
497}