baa-conductor

git clone 

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
A apps/claude-coded/package.json
+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+}
A apps/claude-coded/src/cli.ts
+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+}
A apps/claude-coded/src/config.ts
+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+}
A apps/claude-coded/src/contracts.ts
+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+}
A apps/claude-coded/src/daemon.ts
+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+}
A apps/claude-coded/src/index.ts
+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+}
A apps/claude-coded/src/local-service.ts
+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+}
A apps/claude-coded/src/node-shims.d.ts
+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+}
A apps/claude-coded/src/stream-json-transport.ts
+200, -0
  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+}
A apps/claude-coded/tsconfig.json
+10, -0
 1@@ -0,0 +1,10 @@
 2+{
 3+  "extends": "../../tsconfig.base.json",
 4+  "files": ["src/node-shims.d.ts"],
 5+  "compilerOptions": {
 6+    "lib": ["ES2022", "DOM"],
 7+    "rootDir": "../..",
 8+    "outDir": "dist"
 9+  },
10+  "include": ["src/**/*.ts", "src/**/*.d.ts"]
11+}
M apps/conductor-daemon/src/index.ts
+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       },
M apps/conductor-daemon/src/local-api.ts
+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,
A ops/launchd/so.makefile.baa-claude-coded.plist
+80, -0
 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>
M pnpm-lock.yaml
+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:
M scripts/runtime/common.sh
+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       ;;
A scripts/runtime/install-claude-coded.sh
+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+  "$@"
M scripts/runtime/install-launchd.sh
+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
M tasks/T-S043.md
+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+