- 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
+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+}
+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+}
+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+}
+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+}
+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(" ");
+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+});
+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+}
+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+}
+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+}
+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+}
+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
+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` 是通用执行框架
+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
+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` 加载它。
+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
+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>