- commit
- 284efd8
- parent
- a02204e
- author
- im_wower
- date
- 2026-03-28 19:06:19 +0800 CST
feat: add claude-coded daemon for Claude Code CLI duplex integration Implement a new independent daemon (apps/claude-coded) that spawns and manages a Claude Code CLI child process via stdio stream-json duplex protocol, exposing an HTTP API for prompt submission and status reads. Architecture mirrors codexd: daemon → stream-json-transport → local-service → cli → index. The child process runs with -p --input-format stream-json --output-format stream-json --verbose --permission-mode bypassPermissions. HTTP surface: - GET /healthz — health probe - GET /describe — service discovery - GET /v1/claude-coded/status — daemon + child state - POST /v1/claude-coded/ask — synchronous prompt → response - POST /v1/claude-coded/ask/stream — SSE streamed events Also adds conductor proxy routes (/v1/claude-coded, /v1/claude-coded/ask), runtime service registration in common.sh, launchd plist template, and install-claude-coded.sh convenience script. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
18 files changed,
+2626,
-7
+15,
-0
1@@ -0,0 +1,15 @@
2+{
3+ "name": "@baa-conductor/claude-coded",
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/claude-coded/dist BAA_DIST_ENTRY=apps/claude-coded/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+ }
16+}
+193,
-0
1@@ -0,0 +1,193 @@
2+import {
3+ formatClaudeCodedConfigText,
4+ getClaudeCodedUsageText,
5+ parseClaudeCodedCliRequest
6+} from "./config.js";
7+import { ClaudeCodedDaemon } from "./daemon.js";
8+import { ClaudeCodedLocalService, type ClaudeCodedLocalServiceStatus } from "./local-service.js";
9+import type {
10+ ClaudeCodedEnvironment,
11+ ClaudeCodedStatusSnapshot
12+} from "./contracts.js";
13+
14+export interface ClaudeCodedTextWriter {
15+ write(chunk: string): unknown;
16+}
17+
18+export interface ClaudeCodedProcessLike {
19+ argv: string[];
20+ cwd?(): string;
21+ env: ClaudeCodedEnvironment;
22+ execPath?: string;
23+ exitCode?: number;
24+ off?(event: string, listener: () => void): unknown;
25+ on?(event: string, listener: () => void): unknown;
26+ pid?: number;
27+}
28+
29+export interface RunClaudeCodedCliOptions {
30+ argv?: readonly string[];
31+ env?: ClaudeCodedEnvironment;
32+ processLike?: ClaudeCodedProcessLike;
33+ stderr?: ClaudeCodedTextWriter;
34+ stdout?: ClaudeCodedTextWriter;
35+}
36+
37+type ClaudeCodedOutputWriter = ClaudeCodedTextWriter | typeof console;
38+
39+export async function runClaudeCodedCli(options: RunClaudeCodedCliOptions = {}): Promise<number> {
40+ const processLike = options.processLike ?? getProcessLike();
41+ const stdout = options.stdout ?? console;
42+ const stderr = options.stderr ?? console;
43+ const argv = options.argv ?? processLike?.argv ?? [];
44+ const env = options.env ?? processLike?.env ?? {};
45+ const request = parseClaudeCodedCliRequest(argv, env);
46+
47+ if (request.action === "help") {
48+ writeLine(stdout, getClaudeCodedUsageText());
49+ return 0;
50+ }
51+
52+ if (request.action === "config") {
53+ if (request.printJson) {
54+ writeLine(stdout, JSON.stringify(request.config, null, 2));
55+ } else {
56+ writeLine(stdout, formatClaudeCodedConfigText(request.config));
57+ }
58+
59+ return 0;
60+ }
61+
62+ if (request.action === "status") {
63+ const snapshot = await readStoredStatus(request.config);
64+
65+ if (request.printJson) {
66+ writeLine(stdout, JSON.stringify(snapshot, null, 2));
67+ } else {
68+ writeLine(stdout, formatClaudeCodedStatusText(snapshot));
69+ }
70+
71+ return 0;
72+ }
73+
74+ if (request.action !== "start") {
75+ throw new Error(`Unsupported claude-coded request action "${request.action}".`);
76+ }
77+
78+ const service = new ClaudeCodedLocalService(request.config, {
79+ env
80+ });
81+ const started = await service.start();
82+
83+ if (request.printJson) {
84+ writeLine(stdout, JSON.stringify(started, null, 2));
85+ } else {
86+ writeLine(stdout, formatClaudeCodedLocalServiceText(started));
87+ }
88+
89+ const signal = await waitForShutdownSignal(processLike);
90+ const stopped = await service.stop();
91+
92+ if (!request.printJson) {
93+ writeLine(stdout, `claude-coded stopped${signal ? ` after ${signal}` : ""}`);
94+ writeLine(stdout, formatClaudeCodedLocalServiceText(stopped));
95+ }
96+
97+ return 0;
98+}
99+
100+async function readStoredStatus(
101+ config: import("./contracts.js").ClaudeCodedResolvedConfig
102+): Promise<ClaudeCodedStatusSnapshot | null> {
103+ try {
104+ const { readFile } = await import("node:fs/promises");
105+ const daemonState = JSON.parse(await readFile(config.paths.daemonStatePath, "utf8"));
106+ const identity = JSON.parse(await readFile(config.paths.identityPath, "utf8"));
107+ return {
108+ config,
109+ identity,
110+ daemon: daemonState,
111+ recentEvents: { maxEntries: config.eventCacheSize, updatedAt: null, events: [] }
112+ };
113+ } catch {
114+ return null;
115+ }
116+}
117+
118+function formatClaudeCodedStatusText(snapshot: ClaudeCodedStatusSnapshot | null): string {
119+ if (snapshot == null) {
120+ return "claude-coded: no state found (not yet started?)";
121+ }
122+
123+ return [
124+ `identity=${snapshot.identity.daemonId}`,
125+ `node=${snapshot.identity.nodeId}`,
126+ `daemon=${snapshot.daemon.started ? "running" : "stopped"}`,
127+ `child=${snapshot.daemon.child.status}`,
128+ `child_command=${snapshot.config.child.command}`,
129+ `local_api_base=${snapshot.config.service.localApiBase}`,
130+ `logs_dir=${snapshot.config.paths.logsDir}`,
131+ `state_dir=${snapshot.config.paths.stateDir}`
132+ ].join(" ");
133+}
134+
135+function formatClaudeCodedLocalServiceText(status: ClaudeCodedLocalServiceStatus): string {
136+ const snapshot = status.snapshot;
137+
138+ return [
139+ `identity=${snapshot.identity.daemonId}`,
140+ `node=${snapshot.identity.nodeId}`,
141+ `daemon=${snapshot.daemon.started ? "running" : "stopped"}`,
142+ `child=${snapshot.daemon.child.status}`,
143+ `resolved_base=${status.service.resolvedBaseUrl ?? "not-listening"}`
144+ ].join(" ");
145+}
146+
147+function getProcessLike(): ClaudeCodedProcessLike | undefined {
148+ return (globalThis as { process?: ClaudeCodedProcessLike }).process;
149+}
150+
151+async function waitForShutdownSignal(processLike: ClaudeCodedProcessLike | undefined): Promise<string | null> {
152+ const subscribe = processLike?.on;
153+
154+ if (!subscribe || !processLike) {
155+ return null;
156+ }
157+
158+ return new Promise((resolve) => {
159+ const signals = ["SIGINT", "SIGTERM"] as const;
160+ const listeners: Partial<Record<(typeof signals)[number], () => void>> = {};
161+ const cleanup = () => {
162+ if (!processLike.off) {
163+ return;
164+ }
165+
166+ for (const signal of signals) {
167+ const listener = listeners[signal];
168+
169+ if (listener) {
170+ processLike.off(signal, listener);
171+ }
172+ }
173+ };
174+
175+ for (const signal of signals) {
176+ const listener = () => {
177+ cleanup();
178+ resolve(signal);
179+ };
180+
181+ listeners[signal] = listener;
182+ subscribe.call(processLike, signal, listener);
183+ }
184+ });
185+}
186+
187+function writeLine(writer: ClaudeCodedOutputWriter, line: string): void {
188+ if ("write" in writer) {
189+ writer.write(`${line}\n`);
190+ return;
191+ }
192+
193+ writer.log(line);
194+}
+370,
-0
1@@ -0,0 +1,370 @@
2+import { resolve } from "node:path";
3+
4+import type {
5+ ClaudeCodedCliAction,
6+ ClaudeCodedEnvironment,
7+ ClaudeCodedResolvedConfig,
8+ ClaudeCodedRuntimePaths
9+} from "./contracts.js";
10+
11+export interface ClaudeCodedConfigInput {
12+ childCommand?: string;
13+ childCwd?: string;
14+ eventCacheSize?: number;
15+ extraArgs?: string[];
16+ localApiBase?: string;
17+ logsDir?: string;
18+ model?: string | null;
19+ nodeId?: string;
20+ repoRoot?: string;
21+ stateDir?: string;
22+ turnTimeoutMs?: number;
23+ version?: string | null;
24+}
25+
26+export type ClaudeCodedCliRequest =
27+ | {
28+ action: "help";
29+ }
30+ | {
31+ action: Exclude<ClaudeCodedCliAction, "help" | "start">;
32+ config: ClaudeCodedResolvedConfig;
33+ printJson: boolean;
34+ }
35+ | {
36+ action: "start";
37+ config: ClaudeCodedResolvedConfig;
38+ printJson: boolean;
39+ };
40+
41+const DEFAULT_EVENT_CACHE_SIZE = 50;
42+const DEFAULT_LOCAL_API_BASE = "http://127.0.0.1:4320";
43+const DEFAULT_NODE_ID = "mini-main";
44+const DEFAULT_CHILD_COMMAND = "claude";
45+const DEFAULT_CHILD_ARGS = [
46+ "-p",
47+ "--input-format", "stream-json",
48+ "--output-format", "stream-json",
49+ "--verbose",
50+ "--replay-user-messages",
51+ "--permission-mode", "bypassPermissions"
52+];
53+const DEFAULT_TURN_TIMEOUT_MS = 300_000;
54+
55+export function resolveClaudeCodedConfig(input: ClaudeCodedConfigInput = {}): ClaudeCodedResolvedConfig {
56+ const repoRoot = resolve(input.repoRoot ?? getDefaultRepoRoot());
57+ const logsRootDir = resolve(getOptionalString(input.logsDir) ?? resolve(repoRoot, "logs"));
58+ const stateRootDir = resolve(getOptionalString(input.stateDir) ?? resolve(repoRoot, "state"));
59+ const paths = resolveRuntimePaths(repoRoot, logsRootDir, stateRootDir);
60+
61+ const model = getOptionalString(input.model);
62+ const extraArgs = input.extraArgs ?? [];
63+ const childArgs = [...DEFAULT_CHILD_ARGS];
64+ if (model != null) {
65+ childArgs.push("--model", model);
66+ }
67+ childArgs.push(...extraArgs);
68+
69+ return {
70+ nodeId: getOptionalString(input.nodeId) ?? DEFAULT_NODE_ID,
71+ version: getOptionalString(input.version),
72+ eventCacheSize: normalizePositiveInteger(input.eventCacheSize, DEFAULT_EVENT_CACHE_SIZE, "event cache"),
73+ turnTimeoutMs: normalizePositiveInteger(input.turnTimeoutMs, DEFAULT_TURN_TIMEOUT_MS, "turn timeout"),
74+ paths,
75+ service: {
76+ localApiBase: getOptionalString(input.localApiBase) ?? DEFAULT_LOCAL_API_BASE
77+ },
78+ child: {
79+ command: getOptionalString(input.childCommand) ?? DEFAULT_CHILD_COMMAND,
80+ args: childArgs,
81+ cwd: resolve(getOptionalString(input.childCwd) ?? repoRoot),
82+ model,
83+ extraArgs
84+ }
85+ };
86+}
87+
88+export function parseClaudeCodedCliRequest(
89+ argv: readonly string[],
90+ env: ClaudeCodedEnvironment = {}
91+): ClaudeCodedCliRequest {
92+ const tokens = argv.slice(2);
93+ let action: ClaudeCodedCliAction = "start";
94+ let actionSet = false;
95+ let printJson = false;
96+ let childCommand = env.BAA_CLAUDE_CODED_CHILD_COMMAND;
97+ let childCwd = env.BAA_CLAUDE_CODED_CHILD_CWD;
98+ let eventCacheSize = parseOptionalInteger(env.BAA_CLAUDE_CODED_EVENT_CACHE_SIZE);
99+ let localApiBase = env.BAA_CLAUDE_CODED_LOCAL_API_BASE;
100+ let logsDir = env.BAA_CLAUDE_CODED_LOGS_DIR ?? env.BAA_LOGS_DIR;
101+ let model: string | undefined = env.BAA_CLAUDE_CODED_MODEL;
102+ let nodeId = env.BAA_NODE_ID;
103+ let repoRoot = env.BAA_CLAUDE_CODED_REPO_ROOT;
104+ let stateDir = env.BAA_CLAUDE_CODED_STATE_DIR ?? env.BAA_STATE_DIR;
105+ let turnTimeoutMs = parseOptionalInteger(env.BAA_CLAUDE_CODED_TURN_TIMEOUT_MS);
106+ let version = env.BAA_CLAUDE_CODED_VERSION ?? null;
107+ let extraArgs: string[] = [];
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 === "--repo-root") {
126+ repoRoot = readCliValue(tokens, index, "--repo-root");
127+ index += 1;
128+ continue;
129+ }
130+
131+ if (token === "--node-id") {
132+ nodeId = readCliValue(tokens, index, "--node-id");
133+ index += 1;
134+ continue;
135+ }
136+
137+ if (token === "--logs-dir") {
138+ logsDir = readCliValue(tokens, index, "--logs-dir");
139+ index += 1;
140+ continue;
141+ }
142+
143+ if (token === "--local-api-base") {
144+ localApiBase = readCliValue(tokens, index, "--local-api-base");
145+ index += 1;
146+ continue;
147+ }
148+
149+ if (token === "--state-dir") {
150+ stateDir = readCliValue(tokens, index, "--state-dir");
151+ index += 1;
152+ continue;
153+ }
154+
155+ if (token === "--child-command") {
156+ childCommand = readCliValue(tokens, index, "--child-command");
157+ index += 1;
158+ continue;
159+ }
160+
161+ if (token === "--child-cwd") {
162+ childCwd = readCliValue(tokens, index, "--child-cwd");
163+ index += 1;
164+ continue;
165+ }
166+
167+ if (token === "--model") {
168+ model = readCliValue(tokens, index, "--model");
169+ index += 1;
170+ continue;
171+ }
172+
173+ if (token === "--extra-arg") {
174+ extraArgs.push(readCliValue(tokens, index, "--extra-arg"));
175+ index += 1;
176+ continue;
177+ }
178+
179+ if (token === "--event-cache-size") {
180+ eventCacheSize = parseStrictInteger(readCliValue(tokens, index, "--event-cache-size"), "--event-cache-size");
181+ index += 1;
182+ continue;
183+ }
184+
185+ if (token === "--turn-timeout-ms") {
186+ turnTimeoutMs = parseStrictInteger(readCliValue(tokens, index, "--turn-timeout-ms"), "--turn-timeout-ms");
187+ index += 1;
188+ continue;
189+ }
190+
191+ if (token === "--version") {
192+ version = readCliValue(tokens, index, "--version");
193+ index += 1;
194+ continue;
195+ }
196+
197+ if (token.startsWith("--")) {
198+ throw new Error(`Unknown claude-coded flag "${token}".`);
199+ }
200+
201+ if (actionSet) {
202+ throw new Error(`Unexpected extra claude-coded argument "${token}".`);
203+ }
204+
205+ if (!isClaudeCodedCliAction(token)) {
206+ throw new Error(`Unknown claude-coded action "${token}".`);
207+ }
208+
209+ action = token;
210+ actionSet = true;
211+ }
212+
213+ const config = resolveClaudeCodedConfig({
214+ childCommand,
215+ childCwd,
216+ eventCacheSize,
217+ extraArgs: extraArgs.length > 0 ? extraArgs : undefined,
218+ localApiBase,
219+ logsDir,
220+ model,
221+ nodeId,
222+ repoRoot,
223+ stateDir,
224+ turnTimeoutMs,
225+ version
226+ });
227+
228+ return {
229+ action,
230+ config,
231+ printJson
232+ };
233+}
234+
235+export function formatClaudeCodedConfigText(config: ClaudeCodedResolvedConfig): string {
236+ return [
237+ `node_id: ${config.nodeId}`,
238+ `version: ${config.version ?? "not-set"}`,
239+ `child_command: ${config.child.command}`,
240+ `child_args: ${config.child.args.join(" ") || "(none)"}`,
241+ `child_cwd: ${config.child.cwd}`,
242+ `model: ${config.child.model ?? "default"}`,
243+ `local_api_base: ${config.service.localApiBase}`,
244+ `logs_dir: ${config.paths.logsDir}`,
245+ `state_dir: ${config.paths.stateDir}`,
246+ `event_cache_size: ${config.eventCacheSize}`,
247+ `turn_timeout_ms: ${config.turnTimeoutMs}`
248+ ].join("\n");
249+}
250+
251+export function getClaudeCodedUsageText(): string {
252+ return [
253+ "Usage:",
254+ " node apps/claude-coded/dist/index.js [start] [options]",
255+ " node apps/claude-coded/dist/index.js status [--json]",
256+ " node apps/claude-coded/dist/index.js config [--json]",
257+ " node apps/claude-coded/dist/index.js help",
258+ "",
259+ "Options:",
260+ " --repo-root <path>",
261+ " --node-id <id>",
262+ " --logs-dir <path>",
263+ " --local-api-base <http://127.0.0.1:4320>",
264+ " --state-dir <path>",
265+ " --child-command <command>",
266+ " --child-cwd <path>",
267+ " --model <model>",
268+ " --extra-arg <arg>",
269+ " --event-cache-size <integer>",
270+ " --turn-timeout-ms <integer>",
271+ " --version <string>",
272+ " --json",
273+ " --help",
274+ "",
275+ "Environment:",
276+ " BAA_NODE_ID",
277+ " BAA_LOGS_DIR",
278+ " BAA_STATE_DIR",
279+ " BAA_CLAUDE_CODED_REPO_ROOT",
280+ " BAA_CLAUDE_CODED_LOGS_DIR",
281+ " BAA_CLAUDE_CODED_STATE_DIR",
282+ " BAA_CLAUDE_CODED_LOCAL_API_BASE",
283+ " BAA_CLAUDE_CODED_CHILD_COMMAND",
284+ " BAA_CLAUDE_CODED_CHILD_CWD",
285+ " BAA_CLAUDE_CODED_MODEL",
286+ " BAA_CLAUDE_CODED_EVENT_CACHE_SIZE",
287+ " BAA_CLAUDE_CODED_TURN_TIMEOUT_MS",
288+ " BAA_CLAUDE_CODED_VERSION"
289+ ].join("\n");
290+}
291+
292+function getDefaultRepoRoot(): string {
293+ if (typeof process !== "undefined" && typeof process.cwd === "function") {
294+ return process.cwd();
295+ }
296+
297+ return ".";
298+}
299+
300+function resolveRuntimePaths(
301+ repoRoot: string,
302+ logsRootDir: string,
303+ stateRootDir: string
304+): ClaudeCodedRuntimePaths {
305+ const logsDir = resolve(logsRootDir, "claude-coded");
306+ const stateDir = resolve(stateRootDir, "claude-coded");
307+
308+ return {
309+ repoRoot,
310+ logsRootDir,
311+ stateRootDir,
312+ logsDir,
313+ stateDir,
314+ structuredEventLogPath: resolve(logsDir, "events.jsonl"),
315+ stdoutLogPath: resolve(logsDir, "stdout.log"),
316+ stderrLogPath: resolve(logsDir, "stderr.log"),
317+ identityPath: resolve(stateDir, "identity.json"),
318+ daemonStatePath: resolve(stateDir, "daemon-state.json")
319+ };
320+}
321+
322+function isClaudeCodedCliAction(value: string): value is Exclude<ClaudeCodedCliAction, "help"> {
323+ return value === "config" || value === "start" || value === "status";
324+}
325+
326+function parseOptionalInteger(value: string | undefined): number | undefined {
327+ if (value == null || value.trim() === "") {
328+ return undefined;
329+ }
330+
331+ return parseStrictInteger(value, "integer value");
332+}
333+
334+function parseStrictInteger(value: string, label: string): number {
335+ const parsed = Number(value);
336+
337+ if (!Number.isInteger(parsed)) {
338+ throw new Error(`Invalid ${label} "${value}".`);
339+ }
340+
341+ return parsed;
342+}
343+
344+function normalizePositiveInteger(value: number | undefined, fallback: number, label: string): number {
345+ const candidate = value ?? fallback;
346+
347+ if (!Number.isInteger(candidate) || candidate < 0) {
348+ throw new Error(`Invalid ${label} value "${String(value)}".`);
349+ }
350+
351+ return candidate;
352+}
353+
354+function getOptionalString(value: string | null | undefined): string | null {
355+ if (value == null) {
356+ return null;
357+ }
358+
359+ const trimmed = value.trim();
360+ return trimmed === "" ? null : trimmed;
361+}
362+
363+function readCliValue(tokens: readonly string[], index: number, flag: string): string {
364+ const value = tokens[index + 1];
365+
366+ if (value == null || value.startsWith("--")) {
367+ throw new Error(`Missing value for ${flag}.`);
368+ }
369+
370+ return value;
371+}
+108,
-0
1@@ -0,0 +1,108 @@
2+export type ClaudeCodedCliAction = "config" | "help" | "start" | "status";
3+export type ClaudeCodedChildStatus = "failed" | "idle" | "running" | "starting" | "stopped";
4+export type ClaudeCodedEventLevel = "error" | "info" | "warn";
5+
6+export type ClaudeCodedEnvironment = Record<string, string | undefined>;
7+
8+export interface ClaudeCodedRuntimePaths {
9+ repoRoot: string;
10+ logsRootDir: string;
11+ stateRootDir: string;
12+ logsDir: string;
13+ stateDir: string;
14+ structuredEventLogPath: string;
15+ stdoutLogPath: string;
16+ stderrLogPath: string;
17+ identityPath: string;
18+ daemonStatePath: string;
19+}
20+
21+export interface ClaudeCodedChildConfig {
22+ command: string;
23+ args: string[];
24+ cwd: string;
25+ model: string | null;
26+ extraArgs: string[];
27+}
28+
29+export interface ClaudeCodedServiceConfig {
30+ localApiBase: string;
31+}
32+
33+export interface ClaudeCodedResolvedConfig {
34+ nodeId: string;
35+ version: string | null;
36+ eventCacheSize: number;
37+ turnTimeoutMs: number;
38+ paths: ClaudeCodedRuntimePaths;
39+ service: ClaudeCodedServiceConfig;
40+ child: ClaudeCodedChildConfig;
41+}
42+
43+export interface ClaudeCodedDaemonIdentity {
44+ daemonId: string;
45+ nodeId: string;
46+ repoRoot: string;
47+ createdAt: string;
48+ version: string | null;
49+}
50+
51+export interface ClaudeCodedManagedChildState {
52+ status: ClaudeCodedChildStatus;
53+ command: string | null;
54+ args: string[];
55+ cwd: string | null;
56+ pid: number | null;
57+ startedAt: string | null;
58+ exitedAt: string | null;
59+ exitCode: number | null;
60+ signal: string | null;
61+ lastError: string | null;
62+ restartCount: number;
63+}
64+
65+export interface ClaudeCodedDaemonState {
66+ started: boolean;
67+ startedAt: string | null;
68+ stoppedAt: string | null;
69+ updatedAt: string;
70+ pid: number | null;
71+ child: ClaudeCodedManagedChildState;
72+}
73+
74+export interface ClaudeCodedRecentEvent {
75+ seq: number;
76+ createdAt: string;
77+ level: ClaudeCodedEventLevel;
78+ type: string;
79+ message: string;
80+ detail: Record<string, unknown> | null;
81+}
82+
83+export interface ClaudeCodedRecentEventCache {
84+ maxEntries: number;
85+ updatedAt: string | null;
86+ events: ClaudeCodedRecentEvent[];
87+}
88+
89+export interface ClaudeCodedStatusSnapshot {
90+ config: ClaudeCodedResolvedConfig;
91+ identity: ClaudeCodedDaemonIdentity;
92+ daemon: ClaudeCodedDaemonState;
93+ recentEvents: ClaudeCodedRecentEventCache;
94+}
95+
96+export interface ClaudeCodedStreamEvent {
97+ type: string;
98+ [key: string]: unknown;
99+}
100+
101+export interface ClaudeCodedAskResult {
102+ ok: boolean;
103+ result: string | null;
104+ sessionId: string | null;
105+ costUsd: number | null;
106+ durationMs: number | null;
107+ isError: boolean;
108+ events: ClaudeCodedStreamEvent[];
109+}
+767,
-0
1@@ -0,0 +1,767 @@
2+import { spawn } from "node:child_process";
3+import { randomUUID } from "node:crypto";
4+import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
5+
6+import type {
7+ ClaudeCodedAskResult,
8+ ClaudeCodedDaemonIdentity,
9+ ClaudeCodedDaemonState,
10+ ClaudeCodedEnvironment,
11+ ClaudeCodedEventLevel,
12+ ClaudeCodedManagedChildState,
13+ ClaudeCodedRecentEvent,
14+ ClaudeCodedRecentEventCache,
15+ ClaudeCodedResolvedConfig,
16+ ClaudeCodedStatusSnapshot,
17+ ClaudeCodedStreamEvent
18+} from "./contracts.js";
19+import {
20+ createStreamJsonTransport,
21+ type StreamJsonTransport
22+} from "./stream-json-transport.js";
23+
24+export interface ClaudeCodedChildProcessLike {
25+ pid?: number;
26+ stdin?: { end(chunk?: string | Uint8Array): unknown; write(chunk: string | Uint8Array): boolean };
27+ stderr?: {
28+ on(event: "data", listener: (chunk: string | Uint8Array) => void): unknown;
29+ on(event: "end", listener: () => void): unknown;
30+ on(event: "error", listener: (error: Error) => void): unknown;
31+ setEncoding?(encoding: string): unknown;
32+ };
33+ stdout?: {
34+ on(event: "data", listener: (chunk: string | Uint8Array) => void): unknown;
35+ on(event: "end", listener: () => void): unknown;
36+ on(event: "error", listener: (error: Error) => void): unknown;
37+ setEncoding?(encoding: string): unknown;
38+ };
39+ kill(signal?: string): boolean;
40+ on(event: "error", listener: (error: Error) => void): unknown;
41+ on(event: "exit", listener: (code: number | null, signal: string | null) => void): unknown;
42+ on(event: "spawn", listener: () => void): unknown;
43+ once(event: "error", listener: (error: Error) => void): unknown;
44+ once(event: "exit", listener: (code: number | null, signal: string | null) => void): unknown;
45+ once(event: "spawn", listener: () => void): unknown;
46+}
47+
48+export type ClaudeCodedRuntimeEventListener = (event: ClaudeCodedRecentEvent) => void;
49+
50+export interface ClaudeCodedDaemonOptions {
51+ env?: ClaudeCodedEnvironment;
52+ spawner?: {
53+ spawn(
54+ command: string,
55+ args: readonly string[],
56+ options: { cwd: string; env: ClaudeCodedEnvironment }
57+ ): ClaudeCodedChildProcessLike;
58+ };
59+}
60+
61+type AskWaiter = {
62+ events: ClaudeCodedStreamEvent[];
63+ onEvent: ((event: ClaudeCodedStreamEvent) => void) | null;
64+ resolve: (result: ClaudeCodedAskResult) => void;
65+ reject: (error: Error) => void;
66+ timer: unknown;
67+};
68+
69+const STOP_TIMEOUT_MS = 5_000;
70+const RESTART_BASE_DELAY_MS = 1_000;
71+const RESTART_MAX_DELAY_MS = 60_000;
72+
73+export class ClaudeCodedDaemon {
74+ private child: ClaudeCodedChildProcessLike | null = null;
75+ private transport: StreamJsonTransport | null = null;
76+ private identity: ClaudeCodedDaemonIdentity | null = null;
77+ private daemonState: ClaudeCodedDaemonState | null = null;
78+ private recentEvents: ClaudeCodedRecentEventCache;
79+ private readonly env: ClaudeCodedEnvironment;
80+ private readonly eventListeners = new Set<ClaudeCodedRuntimeEventListener>();
81+ private readonly spawner: ClaudeCodedDaemonOptions["spawner"];
82+ private started = false;
83+ private initialized = false;
84+ private restartCount = 0;
85+ private restartTimer: unknown = null;
86+ private stopping = false;
87+ private pendingAsk: AskWaiter | null = null;
88+ private nextEventSeq = 1;
89+ private stderrBuffer = "";
90+
91+ constructor(
92+ private readonly config: ClaudeCodedResolvedConfig,
93+ options: ClaudeCodedDaemonOptions = {}
94+ ) {
95+ this.env = options.env ?? (typeof process !== "undefined" ? process.env : {});
96+ this.spawner = options.spawner ?? {
97+ spawn(command, args, spawnOptions) {
98+ return spawn(command, [...args], {
99+ cwd: spawnOptions.cwd,
100+ env: spawnOptions.env,
101+ stdio: ["pipe", "pipe", "pipe"]
102+ }) as unknown as ClaudeCodedChildProcessLike;
103+ }
104+ };
105+ this.recentEvents = {
106+ maxEntries: config.eventCacheSize,
107+ updatedAt: null,
108+ events: []
109+ };
110+ }
111+
112+ async start(): Promise<ClaudeCodedStatusSnapshot> {
113+ await this.initialize();
114+
115+ if (this.started) {
116+ return this.getStatusSnapshot();
117+ }
118+
119+ this.started = true;
120+ const now = new Date().toISOString();
121+ this.daemonState = {
122+ started: true,
123+ startedAt: now,
124+ stoppedAt: null,
125+ updatedAt: now,
126+ pid: typeof process !== "undefined" ? process.pid ?? null : null,
127+ child: createIdleChildState(this.config)
128+ };
129+ await this.persistDaemonState();
130+
131+ this.recordEvent({
132+ level: "info",
133+ type: "daemon.started",
134+ message: "claude-coded started."
135+ });
136+
137+ await this.spawnChild();
138+ return this.getStatusSnapshot();
139+ }
140+
141+ async stop(): Promise<ClaudeCodedStatusSnapshot> {
142+ this.stopping = true;
143+
144+ if (this.restartTimer != null) {
145+ clearTimeout(this.restartTimer as ReturnType<typeof setTimeout>);
146+ this.restartTimer = null;
147+ }
148+
149+ this.rejectPendingAsk(new Error("Daemon is stopping."));
150+
151+ if (this.transport != null) {
152+ this.transport.close();
153+ this.transport = null;
154+ }
155+
156+ if (this.child != null) {
157+ const child = this.child;
158+ this.child = null;
159+
160+ const exited = waitForChildExit(child, STOP_TIMEOUT_MS);
161+
162+ try {
163+ child.kill("SIGTERM");
164+ } catch {
165+ // ignore
166+ }
167+
168+ const didExit = await exited;
169+
170+ if (!didExit) {
171+ try {
172+ child.kill("SIGKILL");
173+ } catch {
174+ // ignore
175+ }
176+ }
177+ }
178+
179+ const now = new Date().toISOString();
180+
181+ if (this.daemonState != null) {
182+ this.daemonState.started = false;
183+ this.daemonState.stoppedAt = now;
184+ this.daemonState.updatedAt = now;
185+ this.daemonState.child.status = "stopped";
186+ await this.persistDaemonState();
187+ }
188+
189+ this.recordEvent({
190+ level: "info",
191+ type: "daemon.stopped",
192+ message: "claude-coded stopped."
193+ });
194+
195+ this.started = false;
196+ this.stopping = false;
197+ return this.getStatusSnapshot();
198+ }
199+
200+ getStatusSnapshot(): ClaudeCodedStatusSnapshot {
201+ return {
202+ config: this.config,
203+ identity: this.identity ?? createDefaultIdentity(this.config),
204+ daemon: this.daemonState ?? createDefaultDaemonState(this.config),
205+ recentEvents: { ...this.recentEvents }
206+ };
207+ }
208+
209+ subscribe(listener: ClaudeCodedRuntimeEventListener): { unsubscribe: () => void } {
210+ this.eventListeners.add(listener);
211+ return {
212+ unsubscribe: () => {
213+ this.eventListeners.delete(listener);
214+ }
215+ };
216+ }
217+
218+ async ask(prompt: string): Promise<ClaudeCodedAskResult> {
219+ if (!this.started || this.transport == null || this.transport.closed) {
220+ throw new Error("claude-coded child is not running.");
221+ }
222+
223+ if (this.pendingAsk != null) {
224+ throw new Error("A request is already in progress. claude-coded processes one request at a time.");
225+ }
226+
227+ return new Promise<ClaudeCodedAskResult>((resolve, reject) => {
228+ const timer = setTimeout(() => {
229+ this.rejectPendingAsk(new Error(`Ask timed out after ${this.config.turnTimeoutMs}ms.`));
230+ }, this.config.turnTimeoutMs);
231+
232+ this.pendingAsk = {
233+ events: [],
234+ onEvent: null,
235+ resolve,
236+ reject,
237+ timer
238+ };
239+
240+ try {
241+ this.transport!.send({
242+ type: "user",
243+ message: { role: "user", content: prompt },
244+ parent_tool_use_id: null,
245+ session_id: null
246+ });
247+ } catch (error) {
248+ this.rejectPendingAsk(error instanceof Error ? error : new Error(String(error)));
249+ }
250+ });
251+ }
252+
253+ askStream(prompt: string): {
254+ events: AsyncIterable<ClaudeCodedStreamEvent>;
255+ result: Promise<ClaudeCodedAskResult>;
256+ } {
257+ if (!this.started || this.transport == null || this.transport.closed) {
258+ throw new Error("claude-coded child is not running.");
259+ }
260+
261+ if (this.pendingAsk != null) {
262+ throw new Error("A request is already in progress. claude-coded processes one request at a time.");
263+ }
264+
265+ let eventResolve: ((value: IteratorResult<ClaudeCodedStreamEvent>) => void) | null = null;
266+ const eventQueue: ClaudeCodedStreamEvent[] = [];
267+ let done = false;
268+
269+ const events: AsyncIterable<ClaudeCodedStreamEvent> = {
270+ [Symbol.asyncIterator]() {
271+ return {
272+ next(): Promise<IteratorResult<ClaudeCodedStreamEvent>> {
273+ if (eventQueue.length > 0) {
274+ return Promise.resolve({ value: eventQueue.shift()!, done: false });
275+ }
276+ if (done) {
277+ return Promise.resolve({ value: undefined as unknown as ClaudeCodedStreamEvent, done: true });
278+ }
279+ return new Promise((resolve) => {
280+ eventResolve = resolve;
281+ });
282+ }
283+ };
284+ }
285+ };
286+
287+ const result = new Promise<ClaudeCodedAskResult>((resolve, reject) => {
288+ const timer = setTimeout(() => {
289+ done = true;
290+ if (eventResolve) {
291+ eventResolve({ value: undefined as unknown as ClaudeCodedStreamEvent, done: true });
292+ eventResolve = null;
293+ }
294+ this.rejectPendingAsk(new Error(`Ask timed out after ${this.config.turnTimeoutMs}ms.`));
295+ }, this.config.turnTimeoutMs);
296+
297+ this.pendingAsk = {
298+ events: [],
299+ onEvent: (event) => {
300+ if (eventResolve) {
301+ const r = eventResolve;
302+ eventResolve = null;
303+ r({ value: event, done: false });
304+ } else {
305+ eventQueue.push(event);
306+ }
307+ },
308+ resolve: (askResult) => {
309+ done = true;
310+ if (eventResolve) {
311+ eventResolve({ value: undefined as unknown as ClaudeCodedStreamEvent, done: true });
312+ eventResolve = null;
313+ }
314+ resolve(askResult);
315+ },
316+ reject: (error) => {
317+ done = true;
318+ if (eventResolve) {
319+ eventResolve({ value: undefined as unknown as ClaudeCodedStreamEvent, done: true });
320+ eventResolve = null;
321+ }
322+ reject(error);
323+ },
324+ timer
325+ };
326+
327+ try {
328+ this.transport!.send({
329+ type: "user",
330+ message: { role: "user", content: prompt },
331+ parent_tool_use_id: null,
332+ session_id: null
333+ });
334+ } catch (error) {
335+ done = true;
336+ if (eventResolve) {
337+ eventResolve({ value: undefined as unknown as ClaudeCodedStreamEvent, done: true });
338+ eventResolve = null;
339+ }
340+ this.rejectPendingAsk(error instanceof Error ? error : new Error(String(error)));
341+ }
342+ });
343+
344+ return { events, result };
345+ }
346+
347+ private async initialize(): Promise<void> {
348+ if (this.initialized) {
349+ return;
350+ }
351+
352+ await mkdir(this.config.paths.logsDir, { recursive: true });
353+ await mkdir(this.config.paths.stateDir, { recursive: true });
354+
355+ this.identity = await readJsonOrDefault<ClaudeCodedDaemonIdentity | null>(
356+ this.config.paths.identityPath,
357+ null
358+ );
359+
360+ if (this.identity == null) {
361+ this.identity = {
362+ daemonId: randomUUID(),
363+ nodeId: this.config.nodeId,
364+ repoRoot: this.config.paths.repoRoot,
365+ createdAt: new Date().toISOString(),
366+ version: this.config.version
367+ };
368+ await writeFile(
369+ this.config.paths.identityPath,
370+ JSON.stringify(this.identity, null, 2),
371+ "utf8"
372+ );
373+ }
374+
375+ this.daemonState = await readJsonOrDefault<ClaudeCodedDaemonState | null>(
376+ this.config.paths.daemonStatePath,
377+ null
378+ );
379+
380+ this.initialized = true;
381+ }
382+
383+ private async spawnChild(): Promise<void> {
384+ if (this.stopping) {
385+ return;
386+ }
387+
388+ const now = new Date().toISOString();
389+ this.updateChildState({
390+ status: "starting",
391+ pid: null,
392+ startedAt: null,
393+ exitedAt: null,
394+ exitCode: null,
395+ signal: null,
396+ lastError: null,
397+ restartCount: this.restartCount
398+ });
399+
400+ let child: ClaudeCodedChildProcessLike;
401+
402+ try {
403+ child = this.spawner!.spawn(
404+ this.config.child.command,
405+ this.config.child.args,
406+ {
407+ cwd: this.config.child.cwd,
408+ env: {
409+ ...this.env,
410+ BAA_CLAUDE_CODED_DAEMON_ID: this.identity?.daemonId ?? ""
411+ }
412+ }
413+ );
414+ } catch (error) {
415+ this.updateChildState({
416+ status: "failed",
417+ lastError: formatErrorMessage(error)
418+ });
419+ this.recordEvent({
420+ level: "error",
421+ type: "child.spawn.failed",
422+ message: formatErrorMessage(error)
423+ });
424+ this.scheduleRestart();
425+ return;
426+ }
427+
428+ this.child = child;
429+
430+ try {
431+ await waitForChildSpawn(child);
432+ } catch (error) {
433+ this.updateChildState({
434+ status: "failed",
435+ exitedAt: new Date().toISOString(),
436+ lastError: formatErrorMessage(error)
437+ });
438+ this.recordEvent({
439+ level: "error",
440+ type: "child.spawn.failed",
441+ message: formatErrorMessage(error)
442+ });
443+ this.child = null;
444+ this.scheduleRestart();
445+ return;
446+ }
447+
448+ this.updateChildState({
449+ status: "running",
450+ pid: child.pid ?? null,
451+ startedAt: new Date().toISOString()
452+ });
453+ this.recordEvent({
454+ level: "info",
455+ type: "child.started",
456+ message: `Started Claude Code child process ${child.pid ?? "unknown"}.`,
457+ detail: {
458+ args: this.config.child.args,
459+ command: this.config.child.command,
460+ cwd: this.config.child.cwd
461+ }
462+ });
463+
464+ this.restartCount = 0;
465+
466+ const transport = createStreamJsonTransport({
467+ process: child,
468+ onMessage: (message) => {
469+ this.handleChildMessage(message);
470+ },
471+ onClose: (error) => {
472+ this.handleChildClose(error);
473+ },
474+ onStderr: (text) => {
475+ this.stderrBuffer += text;
476+ if (this.stderrBuffer.length > 4096) {
477+ this.stderrBuffer = this.stderrBuffer.slice(-2048);
478+ }
479+ },
480+ onCloseDiagnostic: (diagnostic) => {
481+ this.recordEvent({
482+ level: "warn",
483+ type: "transport.closed",
484+ message: diagnostic.message,
485+ detail: {
486+ source: diagnostic.source,
487+ exitCode: diagnostic.exitCode,
488+ signal: diagnostic.signal
489+ }
490+ });
491+ }
492+ });
493+
494+ transport.connect();
495+ this.transport = transport;
496+
497+ await this.persistDaemonState();
498+ }
499+
500+ private handleChildMessage(message: Record<string, unknown>): void {
501+ const event: ClaudeCodedStreamEvent = message as ClaudeCodedStreamEvent;
502+ const messageType = typeof message.type === "string" ? message.type : "unknown";
503+
504+ if (this.pendingAsk != null) {
505+ this.pendingAsk.events.push(event);
506+ this.pendingAsk.onEvent?.(event);
507+
508+ if (messageType === "result") {
509+ const waiter = this.pendingAsk;
510+ this.pendingAsk = null;
511+ clearTimeout(waiter.timer as ReturnType<typeof setTimeout>);
512+
513+ const result: ClaudeCodedAskResult = {
514+ ok: message.is_error !== true,
515+ result: typeof message.result === "string" ? message.result : null,
516+ sessionId: typeof message.session_id === "string" ? message.session_id : null,
517+ costUsd: typeof message.total_cost_usd === "number" ? message.total_cost_usd : (typeof message.cost_usd === "number" ? message.cost_usd : null),
518+ durationMs: typeof message.duration_ms === "number" ? message.duration_ms : null,
519+ isError: message.is_error === true,
520+ events: waiter.events
521+ };
522+ waiter.resolve(result);
523+ }
524+ }
525+
526+ this.emitRuntimeEvent({
527+ level: "info",
528+ type: `stream.${messageType}`,
529+ message: `Claude Code stream event: ${messageType}`,
530+ detail: message as Record<string, unknown>
531+ });
532+ }
533+
534+ private handleChildClose(error: Error): void {
535+ const exitedAt = new Date().toISOString();
536+
537+ this.updateChildState({
538+ status: "failed",
539+ pid: null,
540+ exitedAt,
541+ lastError: error.message
542+ });
543+
544+ this.recordEvent({
545+ level: "error",
546+ type: "child.exited",
547+ message: error.message,
548+ detail: {
549+ stderrTail: this.stderrBuffer.slice(-512) || null
550+ }
551+ });
552+
553+ this.child = null;
554+ this.transport = null;
555+ this.stderrBuffer = "";
556+
557+ this.rejectPendingAsk(new Error(`Claude Code child exited: ${error.message}`));
558+
559+ if (this.started && !this.stopping) {
560+ this.scheduleRestart();
561+ }
562+
563+ void this.persistDaemonState();
564+ }
565+
566+ private scheduleRestart(): void {
567+ if (this.stopping || !this.started) {
568+ return;
569+ }
570+
571+ this.restartCount += 1;
572+ const delay = Math.min(
573+ RESTART_BASE_DELAY_MS * Math.pow(2, this.restartCount - 1),
574+ RESTART_MAX_DELAY_MS
575+ );
576+
577+ this.recordEvent({
578+ level: "info",
579+ type: "child.restart.scheduled",
580+ message: `Scheduling restart #${this.restartCount} in ${delay}ms.`
581+ });
582+
583+ this.restartTimer = setTimeout(() => {
584+ this.restartTimer = null;
585+ void this.spawnChild();
586+ }, delay);
587+ }
588+
589+ private rejectPendingAsk(error: Error): void {
590+ if (this.pendingAsk != null) {
591+ const waiter = this.pendingAsk;
592+ this.pendingAsk = null;
593+ clearTimeout(waiter.timer as ReturnType<typeof setTimeout>);
594+ waiter.reject(error);
595+ }
596+ }
597+
598+ private updateChildState(partial: Partial<ClaudeCodedManagedChildState>): void {
599+ if (this.daemonState == null) {
600+ return;
601+ }
602+
603+ Object.assign(this.daemonState.child, partial);
604+ this.daemonState.updatedAt = new Date().toISOString();
605+ }
606+
607+ private recordEvent(input: {
608+ detail?: Record<string, unknown> | null;
609+ level: ClaudeCodedEventLevel;
610+ message: string;
611+ type: string;
612+ }): void {
613+ const event: ClaudeCodedRecentEvent = {
614+ seq: this.nextEventSeq++,
615+ createdAt: new Date().toISOString(),
616+ level: input.level,
617+ type: input.type,
618+ message: input.message,
619+ detail: input.detail ?? null
620+ };
621+
622+ this.recentEvents.events.push(event);
623+ this.recentEvents.updatedAt = event.createdAt;
624+
625+ while (this.recentEvents.events.length > this.recentEvents.maxEntries) {
626+ this.recentEvents.events.shift();
627+ }
628+
629+ this.emitRuntimeEvent(event);
630+
631+ void appendFile(
632+ this.config.paths.structuredEventLogPath,
633+ `${JSON.stringify(event)}\n`,
634+ "utf8"
635+ ).catch(() => {});
636+ }
637+
638+ private emitRuntimeEvent(event: ClaudeCodedRecentEvent | {
639+ level: ClaudeCodedEventLevel;
640+ type: string;
641+ message: string;
642+ detail?: Record<string, unknown> | null;
643+ }): void {
644+ const normalized: ClaudeCodedRecentEvent = "seq" in event
645+ ? event
646+ : {
647+ seq: this.nextEventSeq++,
648+ createdAt: new Date().toISOString(),
649+ level: event.level,
650+ type: event.type,
651+ message: event.message,
652+ detail: event.detail ?? null
653+ };
654+
655+ for (const listener of this.eventListeners) {
656+ try {
657+ listener(normalized);
658+ } catch {
659+ // ignore listener errors
660+ }
661+ }
662+ }
663+
664+ private async persistDaemonState(): Promise<void> {
665+ if (this.daemonState == null) {
666+ return;
667+ }
668+
669+ try {
670+ await writeFile(
671+ this.config.paths.daemonStatePath,
672+ JSON.stringify(this.daemonState, null, 2),
673+ "utf8"
674+ );
675+ } catch {
676+ // ignore persistence errors
677+ }
678+ }
679+}
680+
681+function createIdleChildState(config: ClaudeCodedResolvedConfig): ClaudeCodedManagedChildState {
682+ return {
683+ status: "idle",
684+ command: config.child.command,
685+ args: [...config.child.args],
686+ cwd: config.child.cwd,
687+ pid: null,
688+ startedAt: null,
689+ exitedAt: null,
690+ exitCode: null,
691+ signal: null,
692+ lastError: null,
693+ restartCount: 0
694+ };
695+}
696+
697+function createDefaultIdentity(config: ClaudeCodedResolvedConfig): ClaudeCodedDaemonIdentity {
698+ return {
699+ daemonId: "uninitialized",
700+ nodeId: config.nodeId,
701+ repoRoot: config.paths.repoRoot,
702+ createdAt: new Date().toISOString(),
703+ version: config.version
704+ };
705+}
706+
707+function createDefaultDaemonState(config: ClaudeCodedResolvedConfig): ClaudeCodedDaemonState {
708+ return {
709+ started: false,
710+ startedAt: null,
711+ stoppedAt: null,
712+ updatedAt: new Date().toISOString(),
713+ pid: null,
714+ child: createIdleChildState(config)
715+ };
716+}
717+
718+function formatErrorMessage(error: unknown): string {
719+ if (error instanceof Error) {
720+ return error.message;
721+ }
722+
723+ return String(error);
724+}
725+
726+function waitForChildSpawn(child: ClaudeCodedChildProcessLike): Promise<void> {
727+ return new Promise((resolve, reject) => {
728+ const onSpawn = () => {
729+ child.once("error", () => {});
730+ resolve();
731+ };
732+ const onError = (error: Error) => {
733+ reject(error);
734+ };
735+
736+ child.once("spawn", onSpawn);
737+ child.once("error", onError);
738+ });
739+}
740+
741+function waitForChildExit(child: ClaudeCodedChildProcessLike, timeoutMs: number): Promise<boolean> {
742+ return new Promise((resolve) => {
743+ let resolved = false;
744+ const timer = setTimeout(() => {
745+ if (!resolved) {
746+ resolved = true;
747+ resolve(false);
748+ }
749+ }, timeoutMs);
750+
751+ child.once("exit", () => {
752+ if (!resolved) {
753+ resolved = true;
754+ clearTimeout(timer as ReturnType<typeof setTimeout>);
755+ resolve(true);
756+ }
757+ });
758+ });
759+}
760+
761+async function readJsonOrDefault<T>(path: string, fallback: T): Promise<T> {
762+ try {
763+ const text = await readFile(path, "utf8");
764+ return JSON.parse(text) as T;
765+ } catch {
766+ return fallback;
767+ }
768+}
+61,
-0
1@@ -0,0 +1,61 @@
2+export * from "./contracts.js";
3+export * from "./config.js";
4+export * from "./stream-json-transport.js";
5+export * from "./daemon.js";
6+export * from "./local-service.js";
7+export * from "./cli.js";
8+
9+import { runClaudeCodedCli } from "./cli.js";
10+
11+if (shouldRunClaudeCodedCli(import.meta.url)) {
12+ try {
13+ const exitCode = await runClaudeCodedCli();
14+
15+ if (exitCode !== 0 && typeof process !== "undefined") {
16+ process.exitCode = exitCode;
17+ }
18+ } catch (error) {
19+ console.error(formatClaudeCodedCliError(error));
20+
21+ if (typeof process !== "undefined") {
22+ process.exitCode = 1;
23+ }
24+ }
25+}
26+
27+function shouldRunClaudeCodedCli(metaUrl: string): boolean {
28+ if (typeof process === "undefined") {
29+ return false;
30+ }
31+
32+ const executedPath = normalizeCliEntryPath(process.argv[1]);
33+
34+ if (executedPath == null) {
35+ return false;
36+ }
37+
38+ const sourceEntryPath = normalizeCliEntryPath(toFsPath(metaUrl));
39+ const distShimPath = normalizeCliEntryPath(toFsPath(new URL("../../../index.js", metaUrl).href));
40+
41+ return executedPath === sourceEntryPath || executedPath === distShimPath;
42+}
43+
44+function normalizeCliEntryPath(value: string | undefined): string | null {
45+ if (value == null || value === "") {
46+ return null;
47+ }
48+
49+ return value.endsWith("/") ? value.slice(0, -1) : value;
50+}
51+
52+function toFsPath(value: string): string {
53+ return decodeURIComponent(new URL(value).pathname);
54+}
55+
56+function formatClaudeCodedCliError(error: unknown): string {
57+ if (error instanceof Error) {
58+ return error.stack ?? `${error.name}: ${error.message}`;
59+ }
60+
61+ return `claude-coded startup failed: ${String(error)}`;
62+}
+458,
-0
1@@ -0,0 +1,458 @@
2+import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
3+import type { AddressInfo } from "node:net";
4+
5+import { ClaudeCodedDaemon, type ClaudeCodedDaemonOptions } from "./daemon.js";
6+import type {
7+ ClaudeCodedResolvedConfig,
8+ ClaudeCodedStatusSnapshot,
9+ ClaudeCodedStreamEvent
10+} from "./contracts.js";
11+
12+interface ClaudeCodedHttpResponse {
13+ body: string;
14+ headers: Record<string, string>;
15+ status: number;
16+}
17+
18+type JsonRecord = Record<string, unknown>;
19+
20+export interface ClaudeCodedDescribeRoute {
21+ description: string;
22+ method: "GET" | "POST";
23+ path: string;
24+}
25+
26+export interface ClaudeCodedDescribeResponse {
27+ ok: true;
28+ name: string;
29+ surface: string;
30+ description: string;
31+ mode: {
32+ daemon: string;
33+ supervisor: string;
34+ transport: string;
35+ conductor_role: string;
36+ };
37+ base_url: string;
38+ routes: ClaudeCodedDescribeRoute[];
39+ capabilities: {
40+ health_probe: boolean;
41+ ask: boolean;
42+ ask_stream: boolean;
43+ status: boolean;
44+ };
45+ notes: string[];
46+}
47+
48+const CLAUDE_CODED_FORMAL_ROUTES: ClaudeCodedDescribeRoute[] = [
49+ {
50+ description: "Lightweight health probe for the local daemon.",
51+ method: "GET",
52+ path: "/healthz"
53+ },
54+ {
55+ description: "Machine-readable description of the official claude-coded surface.",
56+ method: "GET",
57+ path: "/describe"
58+ },
59+ {
60+ description: "Current daemon and child process status.",
61+ method: "GET",
62+ path: "/v1/claude-coded/status"
63+ },
64+ {
65+ description: "Submit a prompt and wait for the complete response.",
66+ method: "POST",
67+ path: "/v1/claude-coded/ask"
68+ },
69+ {
70+ description: "Submit a prompt and receive SSE streamed events.",
71+ method: "POST",
72+ path: "/v1/claude-coded/ask/stream"
73+ }
74+];
75+
76+export interface ClaudeCodedLocalServiceRuntimeInfo {
77+ configuredBaseUrl: string;
78+ listening: boolean;
79+ resolvedBaseUrl: string | null;
80+}
81+
82+export interface ClaudeCodedLocalServiceStatus {
83+ service: ClaudeCodedLocalServiceRuntimeInfo;
84+ snapshot: ClaudeCodedStatusSnapshot;
85+}
86+
87+class ClaudeCodedHttpError extends Error {
88+ constructor(
89+ readonly status: number,
90+ message: string
91+ ) {
92+ super(message);
93+ this.name = "ClaudeCodedHttpError";
94+ }
95+}
96+
97+export class ClaudeCodedLocalService {
98+ private readonly daemon: ClaudeCodedDaemon;
99+ private resolvedBaseUrl: string | null = null;
100+ private server: Server | null = null;
101+
102+ constructor(
103+ private readonly config: ClaudeCodedResolvedConfig,
104+ options: ClaudeCodedDaemonOptions = {}
105+ ) {
106+ this.daemon = new ClaudeCodedDaemon(config, options);
107+ }
108+
109+ getDaemon(): ClaudeCodedDaemon {
110+ return this.daemon;
111+ }
112+
113+ getStatus(): ClaudeCodedLocalServiceStatus {
114+ return {
115+ service: this.getRuntimeInfo(),
116+ snapshot: this.daemon.getStatusSnapshot()
117+ };
118+ }
119+
120+ getDescribe(): ClaudeCodedDescribeResponse {
121+ const baseUrl = this.resolvedBaseUrl ?? this.config.service.localApiBase;
122+
123+ return {
124+ base_url: baseUrl,
125+ capabilities: {
126+ health_probe: true,
127+ ask: true,
128+ ask_stream: true,
129+ status: true
130+ },
131+ description:
132+ "Independent local Claude Code daemon for prompt submission, status reads, and SSE streaming.",
133+ mode: {
134+ conductor_role: "proxy",
135+ daemon: "independent",
136+ supervisor: "launchd",
137+ transport: "claude-code stream-json"
138+ },
139+ name: "claude-coded",
140+ notes: [
141+ "Use GET /describe first when an AI client needs to discover the official local claude-coded surface.",
142+ "claude-coded is the long-running Claude Code runtime; conductor-daemon only proxies this service.",
143+ "This surface is limited to health, status, and prompt ask/stream."
144+ ],
145+ ok: true,
146+ routes: CLAUDE_CODED_FORMAL_ROUTES.map((route) => ({ ...route })),
147+ surface: "local-api"
148+ };
149+ }
150+
151+ async start(): Promise<ClaudeCodedLocalServiceStatus> {
152+ if (this.server != null) {
153+ return this.getStatus();
154+ }
155+
156+ await this.daemon.start();
157+
158+ const listenConfig = resolveLocalListenConfig(this.config.service.localApiBase);
159+ const server = createServer((request, response) => {
160+ void this.handleRequest(request, response);
161+ });
162+
163+ try {
164+ await new Promise<void>((resolve, reject) => {
165+ const onError = (error: Error) => {
166+ server.off("listening", onListening);
167+ reject(error);
168+ };
169+ const onListening = () => {
170+ server.off("error", onError);
171+ resolve();
172+ };
173+
174+ server.once("error", onError);
175+ server.once("listening", onListening);
176+ server.listen({
177+ host: listenConfig.host,
178+ port: listenConfig.port
179+ });
180+ });
181+ } catch (error) {
182+ await this.daemon.stop();
183+ throw error;
184+ }
185+
186+ const address = server.address();
187+
188+ if (address == null || typeof address === "string") {
189+ server.close();
190+ await this.daemon.stop();
191+ throw new Error("claude-coded local service started without a TCP address.");
192+ }
193+
194+ this.server = server;
195+ this.resolvedBaseUrl = formatLocalApiBaseUrl(address.address, (address as AddressInfo).port);
196+ return this.getStatus();
197+ }
198+
199+ async stop(): Promise<ClaudeCodedLocalServiceStatus> {
200+ if (this.server != null) {
201+ const server = this.server;
202+ this.server = null;
203+
204+ await new Promise<void>((resolve, reject) => {
205+ server.close((error) => {
206+ if (error) {
207+ reject(error);
208+ return;
209+ }
210+
211+ resolve();
212+ });
213+ server.closeAllConnections?.();
214+ });
215+ }
216+
217+ const snapshot = await this.daemon.stop();
218+ this.resolvedBaseUrl = null;
219+
220+ return {
221+ service: this.getRuntimeInfo(),
222+ snapshot
223+ };
224+ }
225+
226+ private getRuntimeInfo(): ClaudeCodedLocalServiceRuntimeInfo {
227+ return {
228+ configuredBaseUrl: this.config.service.localApiBase,
229+ listening: this.server != null,
230+ resolvedBaseUrl: this.resolvedBaseUrl
231+ };
232+ }
233+
234+ private async handleRequest(
235+ request: IncomingMessage,
236+ response: ServerResponse<IncomingMessage>
237+ ): Promise<void> {
238+ try {
239+ const result = await this.routeHttpRequest({
240+ body: await readIncomingRequestBody(request),
241+ method: request.method ?? "GET",
242+ path: request.url ?? "/",
243+ response
244+ });
245+
246+ if (result != null) {
247+ writeHttpResponse(response, result);
248+ }
249+ } catch (error) {
250+ const status = error instanceof ClaudeCodedHttpError ? error.status : 500;
251+ writeHttpResponse(
252+ response,
253+ jsonResponse(status, {
254+ error: status >= 500 ? "internal_error" : "bad_request",
255+ message: error instanceof Error ? error.message : String(error),
256+ ok: false
257+ })
258+ );
259+ }
260+ }
261+
262+ private async routeHttpRequest(input: {
263+ body: string | null;
264+ method: string;
265+ path: string;
266+ response: ServerResponse<IncomingMessage>;
267+ }): Promise<ClaudeCodedHttpResponse | null> {
268+ const method = input.method.toUpperCase();
269+ const url = new URL(input.path, "http://127.0.0.1");
270+ const pathname = normalizePathname(url.pathname);
271+ const body = parseJsonObject(input.body);
272+
273+ if (method === "GET" && pathname === "/healthz") {
274+ return jsonResponse(200, {
275+ ok: true,
276+ service: this.getRuntimeInfo(),
277+ status: "ok"
278+ });
279+ }
280+
281+ if (method === "GET" && pathname === "/describe") {
282+ return jsonResponse(200, this.getDescribe());
283+ }
284+
285+ if (method === "GET" && pathname === "/v1/claude-coded/status") {
286+ return jsonResponse(200, {
287+ data: this.getStatus(),
288+ ok: true
289+ });
290+ }
291+
292+ if (method === "POST" && pathname === "/v1/claude-coded/ask") {
293+ const prompt = readRequiredString(body.prompt, "prompt");
294+ const result = await this.daemon.ask(prompt);
295+ return jsonResponse(200, {
296+ data: result,
297+ ok: true
298+ });
299+ }
300+
301+ if (method === "POST" && pathname === "/v1/claude-coded/ask/stream") {
302+ const prompt = readRequiredString(body.prompt, "prompt");
303+ await this.handleAskStream(input.response, prompt);
304+ return null;
305+ }
306+
307+ throw new ClaudeCodedHttpError(404, `Unknown claude-coded route ${method} ${pathname}.`);
308+ }
309+
310+ private async handleAskStream(
311+ response: ServerResponse<IncomingMessage>,
312+ prompt: string
313+ ): Promise<void> {
314+ response.statusCode = 200;
315+ response.setHeader("content-type", "text/event-stream; charset=utf-8");
316+ response.setHeader("cache-control", "no-store");
317+ response.setHeader("connection", "keep-alive");
318+ response.flushHeaders();
319+
320+ try {
321+ const { events, result } = this.daemon.askStream(prompt);
322+
323+ for await (const event of events) {
324+ const data = JSON.stringify(event);
325+ response.write(`data: ${data}\n\n`);
326+ }
327+
328+ const askResult = await result;
329+ response.write(`event: result\ndata: ${JSON.stringify(askResult)}\n\n`);
330+ } catch (error) {
331+ const errorData = JSON.stringify({
332+ error: error instanceof Error ? error.message : String(error),
333+ ok: false
334+ });
335+ response.write(`event: error\ndata: ${errorData}\n\n`);
336+ }
337+
338+ response.end();
339+ }
340+}
341+
342+function formatLocalApiBaseUrl(hostname: string, port: number): string {
343+ const formattedHost = hostname.includes(":") ? `[${hostname}]` : hostname;
344+ return `http://${formattedHost}${port === 80 ? "" : `:${port}`}`;
345+}
346+
347+function isLoopbackHost(hostname: string): boolean {
348+ return hostname === "127.0.0.1" || hostname === "::1" || hostname === "localhost";
349+}
350+
351+function jsonResponse(status: number, payload: unknown): ClaudeCodedHttpResponse {
352+ return {
353+ body: `${JSON.stringify(payload, null, 2)}\n`,
354+ headers: {
355+ "cache-control": "no-store",
356+ "content-type": "application/json; charset=utf-8"
357+ },
358+ status
359+ };
360+}
361+
362+function normalizePathname(value: string): string {
363+ const normalized = value.replace(/\/+$/u, "");
364+ return normalized === "" ? "/" : normalized;
365+}
366+
367+function parseJsonObject(body: string | null): JsonRecord {
368+ if (body == null || body.trim() === "") {
369+ return {};
370+ }
371+
372+ let parsed: unknown;
373+
374+ try {
375+ parsed = JSON.parse(body);
376+ } catch {
377+ throw new ClaudeCodedHttpError(400, "Request body must be valid JSON.");
378+ }
379+
380+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
381+ throw new ClaudeCodedHttpError(400, "Request body must be a JSON object.");
382+ }
383+
384+ return parsed as JsonRecord;
385+}
386+
387+function readRequiredString(value: unknown, field: string): string {
388+ if (typeof value !== "string") {
389+ throw new ClaudeCodedHttpError(400, `${field} must be a non-empty string.`);
390+ }
391+
392+ const normalized = value.trim();
393+
394+ if (normalized === "") {
395+ throw new ClaudeCodedHttpError(400, `${field} must be a non-empty string.`);
396+ }
397+
398+ return normalized;
399+}
400+
401+async function readIncomingRequestBody(request: IncomingMessage): Promise<string | null> {
402+ if (request.method == null || request.method.toUpperCase() === "GET") {
403+ return null;
404+ }
405+
406+ return await new Promise((resolve, reject) => {
407+ let body = "";
408+ request.setEncoding?.("utf8");
409+ request.on?.("data", (chunk) => {
410+ body += typeof chunk === "string" ? chunk : String(chunk);
411+ });
412+ request.on?.("end", () => {
413+ resolve(body === "" ? null : body);
414+ });
415+ request.on?.("error", (error) => {
416+ reject(error);
417+ });
418+ });
419+}
420+
421+function resolveLocalListenConfig(localApiBase: string): { host: string; port: number } {
422+ let url: URL;
423+
424+ try {
425+ url = new URL(localApiBase);
426+ } catch {
427+ throw new Error("claude-coded localApiBase must be a valid absolute http:// URL.");
428+ }
429+
430+ if (url.protocol !== "http:") {
431+ throw new Error("claude-coded localApiBase must use the http:// scheme.");
432+ }
433+
434+ if (!isLoopbackHost(url.hostname)) {
435+ throw new Error("claude-coded localApiBase must use a loopback host.");
436+ }
437+
438+ if (url.pathname !== "/" || url.search !== "" || url.hash !== "") {
439+ throw new Error("claude-coded localApiBase must not include path, query, or hash.");
440+ }
441+
442+ return {
443+ host: url.hostname === "localhost" ? "127.0.0.1" : url.hostname,
444+ port: url.port === "" ? 80 : Number.parseInt(url.port, 10)
445+ };
446+}
447+
448+function writeHttpResponse(
449+ response: ServerResponse<IncomingMessage>,
450+ payload: ClaudeCodedHttpResponse
451+): void {
452+ response.statusCode = payload.status;
453+
454+ for (const [name, value] of Object.entries(payload.headers)) {
455+ response.setHeader(name, value);
456+ }
457+
458+ response.end(payload.body);
459+}
+115,
-0
1@@ -0,0 +1,115 @@
2+declare function setTimeout(callback: () => void, delay?: number): unknown;
3+declare function clearTimeout(handle: unknown): void;
4+
5+declare class Buffer extends Uint8Array {
6+ static from(value: string, encoding?: string): Buffer;
7+ toString(encoding?: string): string;
8+}
9+
10+declare const process: {
11+ argv: string[];
12+ cwd(): string;
13+ env: Record<string, string | undefined>;
14+ execPath: string;
15+ exitCode?: number;
16+ off?(event: string, listener: () => void): unknown;
17+ on?(event: string, listener: () => void): unknown;
18+ pid?: number;
19+};
20+
21+declare module "node:child_process" {
22+ export interface SpawnOptions {
23+ cwd?: string;
24+ env?: Record<string, string | undefined>;
25+ stdio?: readonly string[] | string;
26+ }
27+
28+ export interface WritableStreamLike {
29+ end(chunk?: string | Uint8Array): unknown;
30+ write(chunk: string | Uint8Array): boolean;
31+ }
32+
33+ export interface ReadableStreamLike {
34+ on(event: "data", listener: (chunk: string | Uint8Array) => void): this;
35+ on(event: "end", listener: () => void): this;
36+ on(event: "error", listener: (error: Error) => void): this;
37+ setEncoding?(encoding: string): this;
38+ }
39+
40+ export interface ChildProcess {
41+ pid?: number;
42+ stdin?: WritableStreamLike;
43+ stderr?: ReadableStreamLike;
44+ stdout?: ReadableStreamLike;
45+ kill(signal?: string): boolean;
46+ on(event: "error", listener: (error: Error) => void): this;
47+ on(event: "exit", listener: (code: number | null, signal: string | null) => void): this;
48+ on(event: "spawn", listener: () => void): this;
49+ once(event: "error", listener: (error: Error) => void): this;
50+ once(event: "exit", listener: (code: number | null, signal: string | null) => void): this;
51+ once(event: "spawn", listener: () => void): this;
52+ }
53+
54+ export function spawn(command: string, args?: readonly string[], options?: SpawnOptions): ChildProcess;
55+}
56+
57+declare module "node:crypto" {
58+ export function randomUUID(): string;
59+}
60+
61+declare module "node:fs/promises" {
62+ export function mkdir(path: string, options?: { recursive?: boolean }): Promise<void>;
63+ export function readFile(path: string, encoding: string): Promise<string>;
64+ export function writeFile(path: string, data: string, encoding: string): Promise<void>;
65+ export function appendFile(path: string, data: string, encoding: string): Promise<void>;
66+}
67+
68+declare module "node:path" {
69+ export function join(...paths: string[]): string;
70+ export function resolve(...paths: string[]): string;
71+}
72+
73+declare module "node:net" {
74+ export interface AddressInfo {
75+ address: string;
76+ family: string;
77+ port: number;
78+ }
79+}
80+
81+declare module "node:http" {
82+ import type { AddressInfo } from "node:net";
83+
84+ export interface IncomingMessage {
85+ headers: Record<string, string | string[] | undefined>;
86+ method?: string;
87+ on?(event: "data", listener: (chunk: string | Uint8Array) => void): this;
88+ on?(event: "end", listener: () => void): this;
89+ on?(event: "error", listener: (error: Error) => void): this;
90+ setEncoding?(encoding: string): void;
91+ url?: string;
92+ }
93+
94+ export interface ServerResponse<Request extends IncomingMessage = IncomingMessage> {
95+ end(chunk?: string): void;
96+ setHeader(name: string, value: string): this;
97+ statusCode: number;
98+ write(chunk: string): boolean;
99+ flushHeaders(): void;
100+ }
101+
102+ export interface Server {
103+ address(): AddressInfo | string | null;
104+ close(callback?: (error?: Error) => void): this;
105+ closeAllConnections?(): void;
106+ listen(options: { host: string; port: number }): this;
107+ off(event: "error", listener: (error: Error) => void): this;
108+ off(event: "listening", listener: () => void): this;
109+ once(event: "error", listener: (error: Error) => void): this;
110+ once(event: "listening", listener: () => void): this;
111+ }
112+
113+ export function createServer(
114+ handler: (request: IncomingMessage, response: ServerResponse<IncomingMessage>) => void
115+ ): Server;
116+}
1@@ -0,0 +1,200 @@
2+export interface StreamJsonWritableStream {
3+ end(chunk?: string | Uint8Array): unknown;
4+ write(chunk: string | Uint8Array): boolean;
5+}
6+
7+export interface StreamJsonReadableStream {
8+ on(event: "data", listener: (chunk: string | Uint8Array) => void): unknown;
9+ on(event: "end", listener: () => void): unknown;
10+ on(event: "error", listener: (error: Error) => void): unknown;
11+ setEncoding?(encoding: string): unknown;
12+}
13+
14+export interface StreamJsonProcessLike {
15+ pid?: number;
16+ stdin?: StreamJsonWritableStream;
17+ stderr?: StreamJsonReadableStream;
18+ stdout?: StreamJsonReadableStream;
19+ on(event: "error", listener: (error: Error) => void): unknown;
20+ on(event: "exit", listener: (code: number | null, signal: string | null) => void): unknown;
21+}
22+
23+export type StreamJsonCloseSource =
24+ | "process.error"
25+ | "process.exit"
26+ | "stdout.end"
27+ | "stdout.error";
28+
29+export interface StreamJsonCloseDiagnostic {
30+ exitCode?: number | null;
31+ message: string;
32+ signal?: string | null;
33+ source: StreamJsonCloseSource;
34+}
35+
36+export type StreamJsonMessageListener = (message: Record<string, unknown>) => void;
37+export type StreamJsonCloseListener = (error: Error) => void;
38+export type StreamJsonStderrListener = (text: string) => void;
39+
40+export interface StreamJsonTransportConfig {
41+ onClose?: StreamJsonCloseListener;
42+ onCloseDiagnostic?: (diagnostic: StreamJsonCloseDiagnostic) => void;
43+ onMessage?: StreamJsonMessageListener;
44+ onStderr?: StreamJsonStderrListener;
45+ process: StreamJsonProcessLike;
46+}
47+
48+export interface StreamJsonTransport {
49+ close(): void;
50+ connect(): void;
51+ send(message: Record<string, unknown>): void;
52+ readonly closed: boolean;
53+ readonly connected: boolean;
54+}
55+
56+export function createStreamJsonTransport(config: StreamJsonTransportConfig): StreamJsonTransport {
57+ let buffer = "";
58+ let closed = false;
59+ let connected = false;
60+
61+ const emitBufferedMessages = (): void => {
62+ while (true) {
63+ const newlineIndex = buffer.indexOf("\n");
64+
65+ if (newlineIndex < 0) {
66+ return;
67+ }
68+
69+ const line = buffer.slice(0, newlineIndex).trim();
70+ buffer = buffer.slice(newlineIndex + 1);
71+
72+ if (line === "") {
73+ continue;
74+ }
75+
76+ let parsed: unknown;
77+
78+ try {
79+ parsed = JSON.parse(line);
80+ } catch {
81+ continue;
82+ }
83+
84+ if (parsed != null && typeof parsed === "object" && !Array.isArray(parsed)) {
85+ config.onMessage?.(parsed as Record<string, unknown>);
86+ }
87+ }
88+ };
89+
90+ const closeTransport = (
91+ error: Error,
92+ diagnostic: Omit<StreamJsonCloseDiagnostic, "message">
93+ ): void => {
94+ if (closed) {
95+ return;
96+ }
97+
98+ closed = true;
99+ connected = false;
100+ config.onCloseDiagnostic?.({
101+ ...diagnostic,
102+ message: error.message
103+ });
104+ config.onClose?.(error);
105+ };
106+
107+ return {
108+ get closed() {
109+ return closed;
110+ },
111+
112+ get connected() {
113+ return connected;
114+ },
115+
116+ connect(): void {
117+ if (closed || connected) {
118+ return;
119+ }
120+
121+ const stdout = config.process.stdout;
122+ const stdin = config.process.stdin;
123+ const stderr = config.process.stderr;
124+
125+ if (stdout == null || stdin == null) {
126+ throw new Error("stream-json transport requires child stdin and stdout.");
127+ }
128+
129+ connected = true;
130+ stdout.setEncoding?.("utf8");
131+ stdout.on("data", (chunk) => {
132+ buffer += typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk);
133+ emitBufferedMessages();
134+ });
135+ stdout.on("end", () => {
136+ closeTransport(new Error("Claude Code stdout ended."), {
137+ source: "stdout.end"
138+ });
139+ });
140+ stdout.on("error", (error) => {
141+ closeTransport(error, {
142+ source: "stdout.error"
143+ });
144+ });
145+
146+ if (stderr != null) {
147+ stderr.setEncoding?.("utf8");
148+ stderr.on("data", (chunk) => {
149+ const text = typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk);
150+ config.onStderr?.(text);
151+ });
152+ }
153+
154+ config.process.on("error", (error) => {
155+ closeTransport(error, {
156+ source: "process.error"
157+ });
158+ });
159+ config.process.on("exit", (code, signal) => {
160+ closeTransport(
161+ new Error(
162+ `Claude Code child exited (code=${String(code)}, signal=${String(signal)}).`
163+ ),
164+ {
165+ exitCode: code,
166+ signal,
167+ source: "process.exit"
168+ }
169+ );
170+ });
171+ },
172+
173+ send(message: Record<string, unknown>): void {
174+ if (closed || !connected || config.process.stdin == null) {
175+ throw new Error("stream-json transport is not connected.");
176+ }
177+
178+ const line = JSON.stringify(message);
179+ const ok = config.process.stdin.write(`${line}\n`);
180+
181+ if (!ok) {
182+ throw new Error("stream-json transport failed to write message.");
183+ }
184+ },
185+
186+ close(): void {
187+ if (closed) {
188+ return;
189+ }
190+
191+ closed = true;
192+ connected = false;
193+
194+ try {
195+ config.process.stdin?.end();
196+ } catch {
197+ // ignore close errors
198+ }
199+ }
200+ };
201+}
+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+}
+27,
-0
1@@ -158,6 +158,7 @@ export interface ConductorRuntimePaths {
2 export interface ConductorRuntimeConfig extends ConductorConfig {
3 artifactInlineThreshold?: number | null;
4 artifactSummaryLength?: number | null;
5+ claudeCodedLocalApiBase?: string | null;
6 codexdLocalApiBase?: string | null;
7 localApiAllowedHosts?: readonly string[] | string | null;
8 localApiBase?: string | null;
9@@ -169,6 +170,7 @@ export interface ResolvedConductorRuntimeConfig
10 extends Omit<ConductorConfig, "controlApiBase" | "publicApiBase"> {
11 artifactInlineThreshold: number;
12 artifactSummaryLength: number;
13+ claudeCodedLocalApiBase: string | null;
14 controlApiBase: string;
15 heartbeatIntervalMs: number;
16 leaseRenewIntervalMs: number;
17@@ -207,6 +209,9 @@ export interface ConductorStatusSnapshot {
18 }
19
20 export interface ConductorRuntimeSnapshot {
21+ claudeCoded: {
22+ localApiBase: string | null;
23+ };
24 daemon: ConductorStatusSnapshot;
25 identity: string;
26 loops: {
27@@ -354,6 +359,7 @@ interface LocalApiListenConfig {
28 interface CliValueOverrides {
29 artifactInlineThreshold?: string;
30 artifactSummaryLength?: string;
31+ claudeCodedLocalApiBase?: string;
32 codexdLocalApiBase?: string;
33 controlApiBase?: string;
34 heartbeatIntervalMs?: string;
35@@ -709,6 +715,7 @@ function normalizeIncomingRequestHeaders(
36 class ConductorLocalHttpServer {
37 private readonly artifactStore: ArtifactStore;
38 private readonly browserRequestPolicy: BrowserRequestPolicyController;
39+ private readonly claudeCodedLocalApiBase: string | null;
40 private readonly codexdLocalApiBase: string | null;
41 private readonly fetchImpl: typeof fetch;
42 private readonly firefoxWebSocketServer: ConductorFirefoxWebSocketServer;
43@@ -728,6 +735,7 @@ class ConductorLocalHttpServer {
44 artifactStore: ArtifactStore,
45 snapshotLoader: () => ConductorRuntimeSnapshot,
46 codexdLocalApiBase: string | null,
47+ claudeCodedLocalApiBase: string | null,
48 fetchImpl: typeof fetch,
49 sharedToken: string | null,
50 version: string | null,
51@@ -738,6 +746,7 @@ class ConductorLocalHttpServer {
52 ) {
53 this.artifactStore = artifactStore;
54 this.browserRequestPolicy = new BrowserRequestPolicyController(browserRequestPolicyOptions);
55+ this.claudeCodedLocalApiBase = claudeCodedLocalApiBase;
56 this.codexdLocalApiBase = codexdLocalApiBase;
57 this.fetchImpl = fetchImpl;
58 this.localApiBase = localApiBase;
59@@ -837,6 +846,7 @@ class ConductorLocalHttpServer {
60 this.firefoxWebSocketServer.getBridgeService() as unknown as BrowserBridgeController,
61 browserRequestPolicy: this.browserRequestPolicy,
62 browserStateLoader: () => this.firefoxWebSocketServer.getStateSnapshot(),
63+ claudeCodedLocalApiBase: this.claudeCodedLocalApiBase,
64 codexdLocalApiBase: this.codexdLocalApiBase,
65 fetchImpl: this.fetchImpl,
66 repository: this.repository,
67@@ -1670,6 +1680,7 @@ export function resolveConductorRuntimeConfig(
68 host,
69 role: parseConductorRole("Conductor role", config.role),
70 controlApiBase: normalizeBaseUrl(publicApiBase),
71+ claudeCodedLocalApiBase: resolveLocalApiBase(config.claudeCodedLocalApiBase),
72 codexdLocalApiBase: resolveLocalApiBase(config.codexdLocalApiBase),
73 heartbeatIntervalMs,
74 leaseRenewIntervalMs,
75@@ -1764,6 +1775,9 @@ function resolveRuntimeConfigFromSources(
76 overrides.renewFailureThreshold ?? env.BAA_CONDUCTOR_RENEW_FAILURE_THRESHOLD,
77 { minimum: 1 }
78 ),
79+ claudeCodedLocalApiBase: normalizeOptionalString(
80+ overrides.claudeCodedLocalApiBase ?? env.BAA_CLAUDE_CODED_LOCAL_API_BASE
81+ ),
82 codexdLocalApiBase: normalizeOptionalString(
83 overrides.codexdLocalApiBase ?? env.BAA_CODEXD_LOCAL_API_BASE
84 ),
85@@ -1846,6 +1860,10 @@ export function parseConductorCliRequest(
86 overrides.codexdLocalApiBase = readOptionValue(tokens, token, index);
87 index += 1;
88 break;
89+ case "--claude-coded-local-api":
90+ overrides.claudeCodedLocalApiBase = readOptionValue(tokens, token, index);
91+ index += 1;
92+ break;
93 case "--local-api":
94 overrides.localApiBase = readOptionValue(tokens, token, index);
95 index += 1;
96@@ -1961,6 +1979,10 @@ function buildRuntimeWarnings(config: ResolvedConductorRuntimeConfig): string[]
97 warnings.push("BAA_CODEXD_LOCAL_API_BASE is not configured; /v1/codex routes will stay unavailable.");
98 }
99
100+ if (config.claudeCodedLocalApiBase == null) {
101+ warnings.push("BAA_CLAUDE_CODED_LOCAL_API_BASE is not configured; /v1/claude-coded routes will stay unavailable.");
102+ }
103+
104 if (config.leaseRenewIntervalMs >= config.leaseTtlSec * 1_000) {
105 warnings.push("lease renew interval is >= lease TTL; leader renewals may race with lease expiry.");
106 }
107@@ -1989,6 +2011,7 @@ function formatConfigText(config: ResolvedConductorRuntimeConfig): string {
108 return [
109 `identity: ${config.nodeId}@${config.host}(${config.role})`,
110 `public_api_base: ${config.publicApiBase}`,
111+ `claude_coded_local_api_base: ${config.claudeCodedLocalApiBase ?? "not-configured"}`,
112 `codexd_local_api_base: ${config.codexdLocalApiBase ?? "not-configured"}`,
113 `local_api_base: ${config.localApiBase ?? "not-configured"}`,
114 `firefox_ws_url: ${buildFirefoxWebSocketUrl(config.localApiBase) ?? "not-configured"}`,
115@@ -2133,6 +2156,7 @@ export class ConductorRuntime {
116 this.artifactStore,
117 () => this.getRuntimeSnapshot(),
118 this.config.codexdLocalApiBase,
119+ this.config.claudeCodedLocalApiBase,
120 options.fetchImpl ?? globalThis.fetch,
121 this.config.sharedToken,
122 this.config.version,
123@@ -2190,6 +2214,9 @@ export class ConductorRuntime {
124 hasSharedToken: this.config.sharedToken != null,
125 usesPlaceholderToken: usesPlaceholderToken(this.config.sharedToken)
126 },
127+ claudeCoded: {
128+ localApiBase: this.config.claudeCodedLocalApiBase
129+ },
130 codexd: {
131 localApiBase: this.config.codexdLocalApiBase
132 },
+133,
-0
1@@ -78,6 +78,7 @@ const DEFAULT_BROWSER_REQUEST_POLICY_CONFIG = createDefaultBrowserRequestPolicyC
2 const TASK_STATUS_SET = new Set<TaskStatus>(TASK_STATUS_VALUES);
3 const BROWSER_LOGIN_STATUS_SET = new Set<BrowserLoginStateStatus>(["fresh", "stale", "lost"]);
4 const CODEXD_LOCAL_API_ENV = "BAA_CODEXD_LOCAL_API_BASE";
5+const CLAUDE_CODED_LOCAL_API_ENV = "BAA_CLAUDE_CODED_LOCAL_API_BASE";
6 const CODEX_ROUTE_IDS = new Set([
7 "codex.status",
8 "codex.sessions.list",
9@@ -85,6 +86,10 @@ const CODEX_ROUTE_IDS = new Set([
10 "codex.sessions.create",
11 "codex.turn.create"
12 ]);
13+const CLAUDE_CODED_ROUTE_IDS = new Set([
14+ "claude-coded.status",
15+ "claude-coded.ask"
16+]);
17 const HOST_OPERATIONS_ROUTE_IDS = new Set(["host.exec", "host.files.read", "host.files.write"]);
18 const HOST_OPERATIONS_AUTH_HEADER = "Authorization: Bearer <BAA_SHARED_TOKEN>";
19 const HOST_OPERATIONS_WWW_AUTHENTICATE = 'Bearer realm="baa-conductor-host-ops"';
20@@ -200,6 +205,7 @@ type UpstreamErrorEnvelope = JsonObject & {
21
22 interface LocalApiRequestContext {
23 artifactStore: ArtifactStore | null;
24+ claudeCodedLocalApiBase: string | null;
25 deliveryBridge: BaaBrowserDeliveryBridge | null;
26 browserBridge: BrowserBridgeController | null;
27 browserRequestPolicy: BrowserRequestPolicyController | null;
28@@ -217,6 +223,9 @@ interface LocalApiRequestContext {
29 }
30
31 export interface ConductorRuntimeApiSnapshot {
32+ claudeCoded: {
33+ localApiBase: string | null;
34+ };
35 codexd: {
36 localApiBase: string | null;
37 };
38@@ -249,6 +258,7 @@ export interface ConductorRuntimeApiSnapshot {
39
40 export interface ConductorLocalApiContext {
41 artifactStore?: ArtifactStore | null;
42+ claudeCodedLocalApiBase?: string | null;
43 deliveryBridge?: BaaBrowserDeliveryBridge | null;
44 browserBridge?: BrowserBridgeController | null;
45 browserRequestPolicy?: BrowserRequestPolicyController | null;
46@@ -400,6 +410,20 @@ const LOCAL_API_ROUTES: LocalApiRouteDefinition[] = [
47 pathPattern: "/v1/codex/turn",
48 summary: "向独立 codexd 会话提交 turn"
49 },
50+ {
51+ id: "claude-coded.status",
52+ kind: "read",
53+ method: "GET",
54+ pathPattern: "/v1/claude-coded",
55+ summary: "读取独立 claude-coded 代理状态摘要"
56+ },
57+ {
58+ id: "claude-coded.ask",
59+ kind: "write",
60+ method: "POST",
61+ pathPattern: "/v1/claude-coded/ask",
62+ summary: "通过 conductor 代理向 claude-coded 提交 prompt"
63+ },
64 {
65 id: "browser.status",
66 kind: "read",
67@@ -5705,6 +5729,109 @@ async function handleCodexTurnCreate(context: LocalApiRequestContext): Promise<C
68 return buildSuccessEnvelope(context.requestId, result.status, result.data);
69 }
70
71+async function handleClaudeCodedStatusRead(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
72+ const result = await requestClaudeCoded(context, {
73+ method: "GET",
74+ path: "/v1/claude-coded/status"
75+ });
76+
77+ return buildSuccessEnvelope(context.requestId, 200, result.data);
78+}
79+
80+async function handleClaudeCodedAsk(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
81+ const result = await requestClaudeCoded(context, {
82+ body: readBodyObject(context.request, true) as JsonObject,
83+ method: "POST",
84+ path: "/v1/claude-coded/ask"
85+ });
86+
87+ return buildSuccessEnvelope(context.requestId, result.status, result.data);
88+}
89+
90+async function requestClaudeCoded(
91+ context: LocalApiRequestContext,
92+ input: {
93+ body?: JsonObject;
94+ method: LocalApiRouteMethod;
95+ path: string;
96+ }
97+): Promise<{ data: JsonValue; status: number }> {
98+ const claudeCodedLocalApiBase =
99+ normalizeOptionalString(context.claudeCodedLocalApiBase) ?? context.snapshotLoader().claudeCoded.localApiBase;
100+
101+ if (claudeCodedLocalApiBase == null) {
102+ throw new LocalApiHttpError(
103+ 503,
104+ "claude_coded_not_configured",
105+ "Independent claude-coded local API is not configured for /v1/claude-coded routes.",
106+ {
107+ env_var: CLAUDE_CODED_LOCAL_API_ENV
108+ }
109+ );
110+ }
111+
112+ let response: Response;
113+
114+ try {
115+ response = await context.fetchImpl(`${claudeCodedLocalApiBase}${input.path}`, {
116+ method: input.method,
117+ headers: input.body
118+ ? {
119+ accept: "application/json",
120+ "content-type": "application/json"
121+ }
122+ : {
123+ accept: "application/json"
124+ },
125+ body: input.body ? JSON.stringify(input.body) : undefined
126+ });
127+ } catch (error) {
128+ throw new LocalApiHttpError(
129+ 503,
130+ "claude_coded_unavailable",
131+ `Independent claude-coded is unreachable at ${claudeCodedLocalApiBase}: ${error instanceof Error ? error.message : String(error)}.`,
132+ {
133+ target_base_url: claudeCodedLocalApiBase,
134+ upstream_path: input.path
135+ }
136+ );
137+ }
138+
139+ let data: JsonValue;
140+
141+ try {
142+ data = (await response.json()) as JsonValue;
143+ } catch {
144+ if (response.ok) {
145+ return { data: null, status: response.status };
146+ }
147+
148+ throw new LocalApiHttpError(
149+ response.status,
150+ response.status >= 500 ? "claude_coded_unavailable" : "claude_coded_proxy_error",
151+ `Independent claude-coded returned HTTP ${response.status} for ${input.method} ${input.path}.`,
152+ {
153+ target_base_url: claudeCodedLocalApiBase,
154+ upstream_path: input.path
155+ }
156+ );
157+ }
158+
159+ if (!response.ok) {
160+ throw new LocalApiHttpError(
161+ response.status,
162+ response.status >= 500 ? "claude_coded_unavailable" : "claude_coded_proxy_error",
163+ `Independent claude-coded returned HTTP ${response.status} for ${input.method} ${input.path}.`,
164+ {
165+ target_base_url: claudeCodedLocalApiBase,
166+ upstream_path: input.path
167+ }
168+ );
169+ }
170+
171+ return { data, status: response.status };
172+}
173+
174 async function handleControllersList(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
175 const repository = requireRepository(context.repository);
176 const limit = readPositiveIntegerQuery(context.url, "limit", DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
177@@ -5878,6 +6005,10 @@ async function dispatchBusinessRoute(
178 return handleCodexSessionCreate(context);
179 case "codex.turn.create":
180 return handleCodexTurnCreate(context);
181+ case "claude-coded.status":
182+ return handleClaudeCodedStatusRead(context);
183+ case "claude-coded.ask":
184+ return handleClaudeCodedAsk(context);
185 case "system.state":
186 return handleSystemStateRead(context);
187 case "status.view.json":
188@@ -6061,6 +6192,8 @@ export async function handleConductorHttpRequest(
189 browserBridge: context.browserBridge ?? null,
190 browserRequestPolicy: context.browserRequestPolicy ?? null,
191 browserStateLoader: context.browserStateLoader ?? (() => null),
192+ claudeCodedLocalApiBase:
193+ normalizeOptionalString(context.claudeCodedLocalApiBase) ?? context.snapshotLoader().claudeCoded.localApiBase,
194 codexdLocalApiBase:
195 normalizeOptionalString(context.codexdLocalApiBase) ?? context.snapshotLoader().codexd.localApiBase,
196 fetchImpl: context.fetchImpl ?? globalThis.fetch,
1@@ -0,0 +1,80 @@
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+ Default values target the formal mini claude-coded runtime at
7+ /Users/george/code/baa-conductor.
8+ Use scripts/runtime/install-claude-coded.sh to render the actual install copy.
9+ launchd is responsible for auto-start and hard restart; claude-coded itself
10+ manages Claude Code child health and reconnect with exponential backoff.
11+-->
12+<plist version="1.0">
13+ <dict>
14+ <key>Label</key>
15+ <string>so.makefile.baa-claude-coded</string>
16+
17+ <key>WorkingDirectory</key>
18+ <string>/Users/george/code/baa-conductor</string>
19+
20+ <key>EnvironmentVariables</key>
21+ <dict>
22+ <key>PATH</key>
23+ <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Users/george/.local/bin:/Users/george/bin</string>
24+ <key>HOME</key>
25+ <string>/Users/george</string>
26+ <key>LANG</key>
27+ <string>en_US.UTF-8</string>
28+ <key>LC_ALL</key>
29+ <string>en_US.UTF-8</string>
30+ <key>BAA_NODE_ID</key>
31+ <string>mini-main</string>
32+ <key>BAA_LOGS_DIR</key>
33+ <string>/Users/george/code/baa-conductor/logs</string>
34+ <key>BAA_STATE_DIR</key>
35+ <string>/Users/george/code/baa-conductor/state</string>
36+ <key>BAA_CLAUDE_CODED_REPO_ROOT</key>
37+ <string>/Users/george/code/baa-conductor</string>
38+ <key>BAA_CLAUDE_CODED_LOGS_DIR</key>
39+ <string>/Users/george/code/baa-conductor/logs/claude-coded</string>
40+ <key>BAA_CLAUDE_CODED_STATE_DIR</key>
41+ <string>/Users/george/code/baa-conductor/state/claude-coded</string>
42+ <key>BAA_CLAUDE_CODED_LOCAL_API_BASE</key>
43+ <string>http://127.0.0.1:4320</string>
44+ <key>BAA_CLAUDE_CODED_CHILD_COMMAND</key>
45+ <string>claude</string>
46+ <key>BAA_CLAUDE_CODED_CHILD_CWD</key>
47+ <string>/Users/george/code/baa-conductor</string>
48+ <key>BAA_CLAUDE_CODED_EVENT_CACHE_SIZE</key>
49+ <string>50</string>
50+ <key>BAA_CLAUDE_CODED_TURN_TIMEOUT_MS</key>
51+ <string>300000</string>
52+ <key>HTTPS_PROXY</key>
53+ <string>http://127.0.0.1:7890</string>
54+ <key>HTTP_PROXY</key>
55+ <string>http://127.0.0.1:7890</string>
56+ <key>ALL_PROXY</key>
57+ <string>socks5://127.0.0.1:7890</string>
58+ </dict>
59+
60+ <key>ProgramArguments</key>
61+ <array>
62+ <string>/usr/bin/env</string>
63+ <string>node</string>
64+ <string>/Users/george/code/baa-conductor/apps/claude-coded/dist/index.js</string>
65+ <string>start</string>
66+ </array>
67+
68+ <key>ProcessType</key>
69+ <string>Background</string>
70+ <key>RunAtLoad</key>
71+ <true/>
72+ <key>KeepAlive</key>
73+ <true/>
74+ <key>ThrottleInterval</key>
75+ <integer>10</integer>
76+ <key>StandardOutPath</key>
77+ <string>/Users/george/code/baa-conductor/logs/launchd/so.makefile.baa-claude-coded.out.log</string>
78+ <key>StandardErrorPath</key>
79+ <string>/Users/george/code/baa-conductor/logs/launchd/so.makefile.baa-claude-coded.err.log</string>
80+ </dict>
81+</plist>
+2,
-0
1@@ -12,6 +12,8 @@ importers:
2 specifier: ^5.8.2
3 version: 5.9.3
4
5+ apps/claude-coded: {}
6+
7 apps/codexd: {}
8
9 apps/conductor-daemon:
+16,
-3
1@@ -17,6 +17,7 @@ readonly BAA_RUNTIME_DEFAULT_CODEXD_SERVER_STRATEGY="spawn"
2 readonly BAA_RUNTIME_DEFAULT_CODEXD_SERVER_COMMAND="codex"
3 readonly BAA_RUNTIME_DEFAULT_CODEXD_SERVER_ARGS="app-server"
4 readonly BAA_RUNTIME_DEFAULT_CODEXD_SERVER_ENDPOINT="stdio://codex-app-server"
5+readonly BAA_RUNTIME_DEFAULT_CLAUDE_CODED_LOCAL_API="http://127.0.0.1:4320"
6 readonly BAA_RUNTIME_DEFAULT_STATUS_API="http://127.0.0.1:4318"
7 readonly BAA_RUNTIME_DEFAULT_LOCALE="en_US.UTF-8"
8
9@@ -55,7 +56,7 @@ contains_value() {
10
11 validate_service() {
12 case "$1" in
13- conductor | codexd | worker-runner | status-api) ;;
14+ conductor | codexd | claude-coded | worker-runner | status-api) ;;
15 *)
16 die "Unsupported service: $1"
17 ;;
18@@ -89,12 +90,12 @@ default_node_verification_services() {
19 }
20
21 all_services() {
22- printf '%s\n' conductor codexd worker-runner status-api
23+ printf '%s\n' conductor codexd claude-coded worker-runner status-api
24 }
25
26 service_requires_shared_token() {
27 case "$1" in
28- codexd)
29+ codexd | claude-coded)
30 return 1
31 ;;
32 *)
33@@ -126,6 +127,9 @@ service_label() {
34 codexd)
35 printf '%s\n' "so.makefile.baa-codexd"
36 ;;
37+ claude-coded)
38+ printf '%s\n' "so.makefile.baa-claude-coded"
39+ ;;
40 worker-runner)
41 printf '%s\n' "so.makefile.baa-worker-runner"
42 ;;
43@@ -143,6 +147,9 @@ service_dist_entry_relative() {
44 codexd)
45 printf '%s\n' "apps/codexd/dist/index.js"
46 ;;
47+ claude-coded)
48+ printf '%s\n' "apps/claude-coded/dist/index.js"
49+ ;;
50 worker-runner)
51 printf '%s\n' "apps/worker-runner/dist/index.js"
52 ;;
53@@ -160,6 +167,9 @@ service_default_port() {
54 codexd)
55 printf '%s\n' "4319"
56 ;;
57+ claude-coded)
58+ printf '%s\n' "4320"
59+ ;;
60 status-api)
61 printf '%s\n' "4318"
62 ;;
63@@ -185,6 +195,9 @@ service_process_match() {
64 codexd)
65 printf '%s start\n' "$dist_entry"
66 ;;
67+ claude-coded)
68+ printf '%s start\n' "$dist_entry"
69+ ;;
70 *)
71 printf '%s\n' "$dist_entry"
72 ;;
+13,
-0
1@@ -0,0 +1,13 @@
2+#!/usr/bin/env bash
3+# Convenience wrapper: install only the claude-coded launchd service.
4+# Delegates to install-launchd.sh with --service claude-coded.
5+set -euo pipefail
6+
7+script_dir="$(cd -- "$(dirname -- "$0")" && pwd)"
8+source "${script_dir}/common.sh"
9+
10+runtime_log "Installing claude-coded launchd service..."
11+
12+exec "${script_dir}/install-launchd.sh" \
13+ --service claude-coded \
14+ "$@"
+19,
-0
1@@ -66,6 +66,7 @@ public_api_base=""
2 legacy_control_api_base=""
3 local_api_base="http://100.71.210.78:4317"
4 local_api_allowed_hosts="${BAA_CONDUCTOR_LOCAL_API_ALLOWED_HOSTS:-100.71.210.78}"
5+claude_coded_local_api_base="${BAA_CLAUDE_CODED_LOCAL_API_BASE:-${BAA_RUNTIME_DEFAULT_CLAUDE_CODED_LOCAL_API}}"
6 codexd_local_api_base="${BAA_CODEXD_LOCAL_API_BASE:-${BAA_RUNTIME_DEFAULT_CODEXD_LOCAL_API}}"
7 codexd_event_stream_path="${BAA_CODEXD_EVENT_STREAM_PATH:-${BAA_RUNTIME_DEFAULT_CODEXD_EVENT_STREAM_PATH}}"
8 codexd_server_command="${BAA_CODEXD_SERVER_COMMAND:-${BAA_RUNTIME_DEFAULT_CODEXD_SERVER_COMMAND}}"
9@@ -136,6 +137,10 @@ while [[ $# -gt 0 ]]; do
10 local_api_allowed_hosts="$2"
11 shift 2
12 ;;
13+ --claude-coded-local-api-base)
14+ claude_coded_local_api_base="$2"
15+ shift 2
16+ ;;
17 --codexd-local-api-base)
18 codexd_local_api_base="$2"
19 shift 2
20@@ -231,6 +236,8 @@ worktrees_dir="${repo_dir}/worktrees"
21 logs_dir="${repo_dir}/logs"
22 logs_launchd_dir="${logs_dir}/launchd"
23 tmp_dir="${repo_dir}/tmp"
24+claude_coded_logs_dir="${logs_dir}/claude-coded"
25+claude_coded_state_dir="${state_dir}/claude-coded"
26 codexd_logs_dir="${logs_dir}/codexd"
27 codexd_state_dir="${state_dir}/codexd"
28
29@@ -242,6 +249,8 @@ assert_directory "$logs_launchd_dir"
30 assert_directory "$tmp_dir"
31
32 ensure_directory "$install_dir" "755"
33+ensure_directory "$claude_coded_logs_dir" "700"
34+ensure_directory "$claude_coded_state_dir" "700"
35 ensure_directory "$codexd_logs_dir" "700"
36 ensure_directory "$codexd_state_dir" "700"
37
38@@ -292,6 +301,7 @@ for service in "${services[@]}"; do
39 plist_set_string "$install_path" ":ProgramArguments:4" "$conductor_host"
40 plist_set_string "$install_path" ":ProgramArguments:6" "$conductor_role"
41 plist_set_string "$install_path" ":EnvironmentVariables:BAA_CODEXD_LOCAL_API_BASE" "$codexd_local_api_base"
42+ plist_set_string "$install_path" ":EnvironmentVariables:BAA_CLAUDE_CODED_LOCAL_API_BASE" "$claude_coded_local_api_base"
43 fi
44
45 if [[ "$service" == "codexd" ]]; then
46@@ -308,6 +318,15 @@ for service in "${services[@]}"; do
47 plist_set_string "$install_path" ":EnvironmentVariables:BAA_CODEXD_SERVER_ENDPOINT" "$BAA_RUNTIME_DEFAULT_CODEXD_SERVER_ENDPOINT"
48 fi
49
50+ if [[ "$service" == "claude-coded" ]]; then
51+ plist_set_string "$install_path" ":EnvironmentVariables:BAA_CLAUDE_CODED_REPO_ROOT" "$repo_dir"
52+ plist_set_string "$install_path" ":EnvironmentVariables:BAA_CLAUDE_CODED_LOGS_DIR" "$claude_coded_logs_dir"
53+ plist_set_string "$install_path" ":EnvironmentVariables:BAA_CLAUDE_CODED_STATE_DIR" "$claude_coded_state_dir"
54+ plist_set_string "$install_path" ":EnvironmentVariables:BAA_CLAUDE_CODED_LOCAL_API_BASE" "$claude_coded_local_api_base"
55+ plist_set_string "$install_path" ":EnvironmentVariables:BAA_CLAUDE_CODED_CHILD_COMMAND" "claude"
56+ plist_set_string "$install_path" ":EnvironmentVariables:BAA_CLAUDE_CODED_CHILD_CWD" "$repo_dir"
57+ fi
58+
59 if [[ "$service" == "status-api" ]]; then
60 plist_set_string "$install_path" ":EnvironmentVariables:BAA_STATUS_API_HOST" "$status_api_host"
61 fi
+39,
-4
1@@ -2,7 +2,7 @@
2
3 ## 状态
4
5-- 当前状态:`待开始`
6+- 当前状态:`已完成`
7 - 规模预估:`M`
8 - 依赖任务:无(独立于 T-S039~T-S042,可并行)
9 - 建议执行者:`Claude`(需要理解现有 codexd 架构并复刻,涉及 stdio 双工协议)
10@@ -185,17 +185,47 @@ stdin 写入 JSON 消息,stdout 读取 JSON 流式响应,进程常驻。接
11
12 ### 开始执行
13
14-- 执行者:
15-- 开始时间:
16+- 执行者:Claude Opus 4.6
17+- 开始时间:2026-03-28T10:45:00Z
18 - 状态变更:`待开始` → `进行中`
19
20 ### 完成摘要
21
22-- 完成时间:
23+- 完成时间:2026-03-28T11:10:00Z
24 - 状态变更:`进行中` → `已完成`
25 - 修改了哪些文件:
26+ - 新建 `apps/claude-coded/` 完整应用(package.json, tsconfig.json, src/*.ts)
27+ - `src/contracts.ts` — 类型定义
28+ - `src/config.ts` — CLI 参数解析和配置解析
29+ - `src/stream-json-transport.ts` — stdio JSON 双工传输层
30+ - `src/daemon.ts` — 子进程生命周期管理(spawn、重启、ask/askStream)
31+ - `src/local-service.ts` — HTTP API 层(healthz、describe、status、ask、ask/stream SSE)
32+ - `src/cli.ts` — CLI 入口处理
33+ - `src/index.ts` — 模块导出和 CLI 自动检测入口
34+ - `src/node-shims.d.ts` — Node.js 类型声明
35+ - 修改 `apps/conductor-daemon/src/local-api.ts` — 新增 claude-coded 代理路由
36+ - 修改 `apps/conductor-daemon/src/index.ts` — 注入 claudeCodedLocalApiBase 配置
37+ - 修改 `scripts/runtime/common.sh` — 注册 claude-coded 服务
38+ - 修改 `scripts/runtime/install-launchd.sh` — 支持 claude-coded 服务安装
39+ - 新建 `scripts/runtime/install-claude-coded.sh` — 便捷安装脚本
40+ - 新建 `ops/launchd/so.makefile.baa-claude-coded.plist` — launchd 模板
41 - 核心实现思路:
42+ - 复刻 codexd 架构,逐层对应:daemon → stream-json-transport → local-service → cli → index
43+ - Claude Code CLI 使用 `-p --input-format stream-json --output-format stream-json --verbose` 双工模式
44+ - 输入消息格式为 `{"type":"user","message":{"role":"user","content":"..."},"parent_tool_use_id":null,"session_id":null}`
45+ - 输出为 NDJSON 流,通过 `type: "result"` 事件判定单轮完成
46+ - 子进程异常退出时指数退避重启(1s → 2s → 4s → ... → 60s max)
47+ - 优雅关闭:SIGTERM → 等待 5s → SIGKILL
48+ - HTTP API 层提供同步 ask 和 SSE 流式 ask/stream 两种模式
49+ - conductor 代理路由通过 `BAA_CLAUDE_CODED_LOCAL_API_BASE` 环境变量发现 claude-coded
50 - 跑了哪些测试:
51+ - `pnpm exec tsc --noEmit` 类型检查通过
52+ - `pnpm run build` 编译通过
53+ - `node dist/index.js config --json` 配置输出正确
54+ - `node dist/index.js help` 帮助文本正确
55+ - 集成测试:启动 daemon → healthz 200 → status 显示 child running → ask "1+1" 返回 "2" → ask "2+3" 返回 "5" → ask/stream "3+4" SSE 流式返回 "7"
56+ - 多轮对话保持会话(同一 session_id)
57+ - costUsd 正确提取
58
59 ### 执行过程中遇到的问题
60
61@@ -203,3 +233,8 @@ stdin 写入 JSON 消息,stdout 读取 JSON 流式响应,进程常驻。接
62
63 ### 剩余风险
64
65+- `--input-format stream-json` 为 Claude Code CLI 未正式文档化的功能,未来版本可能变更消息格式
66+- `--permission-mode bypassPermissions` 仅在受信目录使用
67+- 单进程串行处理请求(pendingAsk 互斥),高并发场景需考虑队列或多实例
68+- WebSocket 事件流(WS /v1/claude-coded/events)标记为可选,本次未实现
69+