baa-conductor


baa-conductor / apps / conductor-daemon / src
codex@macbookpro  ·  2026-04-03

index.ts

   1import { mkdirSync } from "node:fs";
   2import {
   3  createServer,
   4  type IncomingMessage,
   5  type Server,
   6  type ServerResponse
   7} from "node:http";
   8import type { AddressInfo } from "node:net";
   9import { join, resolve } from "node:path";
  10import {
  11  ARTIFACTS_DIRNAME,
  12  ARTIFACT_DB_FILENAME,
  13  ArtifactStore,
  14  DEFAULT_SUMMARY_LENGTH
  15} from "../../../packages/artifact-db/dist/index.js";
  16import {
  17  DEFAULT_AUTOMATION_MODE,
  18  DEFAULT_BAA_EXECUTION_JOURNAL_LIMIT,
  19  type ControlPlaneRepository
  20} from "../../../packages/db/dist/index.js";
  21import {
  22  createD1SyncWorker,
  23  type D1SyncWorker
  24} from "../../../packages/d1-client/dist/index.js";
  25
  26import {
  27  type ConductorHttpRequest,
  28  type ConductorHttpResponse
  29} from "./http-types.js";
  30import {
  31  buildBrowserWebSocketUrl,
  32  ConductorFirefoxWebSocketServer,
  33  buildFirefoxWebSocketUrl
  34} from "./firefox-ws.js";
  35import { DEFAULT_BAA_DELIVERY_INLINE_THRESHOLD } from "./artifacts/upload-session.js";
  36import {
  37  BrowserRequestPolicyController,
  38  createArtifactStoreBrowserRequestPolicyPersistence,
  39  type BrowserRequestPolicyControllerOptions
  40} from "./browser-request-policy.js";
  41import type { BrowserBridgeController } from "./browser-types.js";
  42import type { FirefoxBridgeService } from "./firefox-bridge.js";
  43import { BaaLiveInstructionIngest } from "./instructions/ingest.js";
  44import { BaaInstructionCenter } from "./instructions/loop.js";
  45import {
  46  PersistentBaaInstructionDeduper,
  47  PersistentBaaLiveInstructionMessageDeduper,
  48  PersistentBaaLiveInstructionSnapshotStore
  49} from "./instructions/store.js";
  50import { handleConductorHttpRequest as handleConductorLocalHttpRequest } from "./local-api.js";
  51import {
  52  ConductorLocalControlPlane,
  53  resolveDefaultConductorStateDir
  54} from "./local-control-plane.js";
  55import {
  56  DEFAULT_UI_SESSION_TTL_SEC,
  57  UiSessionManager
  58} from "./ui-session.js";
  59import { createRenewalDispatcherRunner } from "./renewal/dispatcher.js";
  60import { createRenewalProjectorRunner } from "./renewal/projector.js";
  61import { ConductorTimedJobs } from "./timed-jobs/index.js";
  62
  63export type { ConductorHttpRequest, ConductorHttpResponse } from "./http-types.js";
  64export {
  65  FirefoxBridgeError,
  66  FirefoxBridgeService as ConductorFirefoxBridgeService,
  67  type FirefoxApiRequestCommandInput,
  68  type FirefoxBridgeApiResponse,
  69  type FirefoxBridgeCommandTarget,
  70  type FirefoxBridgeDispatchReceipt,
  71  type FirefoxInjectMessageCommandInput,
  72  type FirefoxOpenTabCommandInput,
  73  type FirefoxReloadCommandInput,
  74  type FirefoxSendMessageCommandInput,
  75  type FirefoxRequestCredentialsCommandInput
  76} from "./firefox-bridge.js";
  77export {
  78  BrowserRequestPolicyController,
  79  createArtifactStoreBrowserRequestPolicyPersistence,
  80  type BrowserRequestPolicyControllerOptions
  81} from "./browser-request-policy.js";
  82export { handleConductorHttpRequest } from "./local-api.js";
  83export * from "./artifacts/index.js";
  84export * from "./instructions/index.js";
  85export * from "./renewal/automation.js";
  86export * from "./renewal/dispatcher.js";
  87export * from "./renewal/projector.js";
  88export * from "./timed-jobs/index.js";
  89
  90export type ConductorRole = "primary" | "standby";
  91export type ConductorLeadershipRole = "leader" | "standby";
  92export type LeaseState = "leader" | "standby" | "degraded";
  93export type SchedulerDecision = "scheduled" | "skipped_not_leader";
  94export type TimerHandle = ReturnType<typeof globalThis.setInterval>;
  95export type LeaderLeaseOperation = "acquire" | "renew";
  96export type ConductorCliAction = "checklist" | "config" | "help" | "start";
  97
  98const DEFAULT_HEARTBEAT_INTERVAL_MS = 5_000;
  99const DEFAULT_LEASE_RENEW_INTERVAL_MS = 5_000;
 100const DEFAULT_LEASE_TTL_SEC = 30;
 101const DEFAULT_RENEW_FAILURE_THRESHOLD = 2;
 102const DEFAULT_CODE_ROOT_DIR = "/Users/george/code/";
 103const DEFAULT_TIMED_JOBS_INTERVAL_MS = 10_000;
 104const DEFAULT_TIMED_JOBS_MAX_MESSAGES_PER_TICK = 10;
 105const DEFAULT_TIMED_JOBS_MAX_TASKS_PER_TICK = 10;
 106const DEFAULT_TIMED_JOBS_SETTLE_DELAY_MS = 10_000;
 107const DEFAULT_RESTART_RENEWAL_RECOVERY_DELAY_MS = 60_000;
 108
 109const STARTUP_CHECKLIST: StartupChecklistItem[] = [
 110  { key: "register-controller", description: "注册 controller 并写入初始 heartbeat" },
 111  { key: "acquire-lease", description: "尝试获取或续租 leader lease" },
 112  { key: "load-runs", description: "扫描本地未完成 runs" },
 113  { key: "reconcile-runs", description: "对账本地过期 runs 与控制平面状态" },
 114  { key: "start-heartbeat-loop", description: "启动 controller heartbeat loop" },
 115  { key: "start-scheduler", description: "仅 leader 进入 scheduler loop,standby 拒绝调度" }
 116];
 117
 118export interface ControllerHeartbeatInput {
 119  controllerId: string;
 120  host: string;
 121  role: string;
 122  priority: number;
 123  status: string;
 124  version?: string | null;
 125  heartbeatAt?: number;
 126  startedAt?: number | null;
 127}
 128
 129export interface LeaderLeaseRecord {
 130  leaseName: string;
 131  holderId: string;
 132  holderHost: string;
 133  term: number;
 134  leaseExpiresAt: number;
 135  renewedAt: number;
 136  preferredHolderId: string | null;
 137  metadataJson: string | null;
 138}
 139
 140export interface LeaderLeaseAcquireInput {
 141  controllerId: string;
 142  host: string;
 143  ttlSec: number;
 144  preferred?: boolean;
 145  metadataJson?: string | null;
 146  now?: number;
 147}
 148
 149export interface LeaderLeaseAcquireResult {
 150  holderId: string;
 151  holderHost: string;
 152  term: number;
 153  leaseExpiresAt: number;
 154  renewedAt: number;
 155  isLeader: boolean;
 156  operation: LeaderLeaseOperation;
 157  lease: LeaderLeaseRecord;
 158}
 159
 160export interface ConductorConfig {
 161  nodeId: string;
 162  host: string;
 163  role: ConductorRole;
 164  publicApiBase?: string;
 165  controlApiBase?: string;
 166  priority?: number;
 167  version?: string | null;
 168  preferred?: boolean;
 169  heartbeatIntervalMs?: number;
 170  leaseRenewIntervalMs?: number;
 171  leaseTtlSec?: number;
 172  renewFailureThreshold?: number;
 173  startedAt?: number;
 174}
 175
 176export interface ConductorRuntimePaths {
 177  logsDir: string | null;
 178  runsDir: string | null;
 179  stateDir: string | null;
 180  tmpDir: string | null;
 181  worktreesDir: string | null;
 182}
 183
 184export interface ConductorRuntimeConfig extends ConductorConfig {
 185  artifactInlineThreshold?: number | null;
 186  artifactSummaryLength?: number | null;
 187  claudeCodedLocalApiBase?: string | null;
 188  codeRootDir?: string | null;
 189  codexdLocalApiBase?: string | null;
 190  localApiAllowedHosts?: readonly string[] | string | null;
 191  localApiBase?: string | null;
 192  paths?: Partial<ConductorRuntimePaths>;
 193  sharedToken?: string | null;
 194  timedJobsIntervalMs?: number;
 195  timedJobsMaxMessagesPerTick?: number;
 196  timedJobsMaxTasksPerTick?: number;
 197  timedJobsSettleDelayMs?: number;
 198  uiBrowserAdminPassword?: string | null;
 199  uiReadonlyPassword?: string | null;
 200  uiSessionTtlSec?: number | null;
 201}
 202
 203export interface ResolvedConductorRuntimeConfig
 204  extends Omit<ConductorConfig, "controlApiBase" | "publicApiBase"> {
 205  artifactInlineThreshold: number;
 206  artifactSummaryLength: number;
 207  claudeCodedLocalApiBase: string | null;
 208  codeRootDir: string;
 209  controlApiBase: string;
 210  heartbeatIntervalMs: number;
 211  leaseRenewIntervalMs: number;
 212  localApiAllowedHosts: string[];
 213  leaseTtlSec: number;
 214  codexdLocalApiBase: string | null;
 215  localApiBase: string | null;
 216  paths: ConductorRuntimePaths;
 217  preferred: boolean;
 218  priority: number;
 219  publicApiBase: string;
 220  renewFailureThreshold: number;
 221  sharedToken: string | null;
 222  timedJobsIntervalMs: number;
 223  timedJobsMaxMessagesPerTick: number;
 224  timedJobsMaxTasksPerTick: number;
 225  timedJobsSettleDelayMs: number;
 226  uiBrowserAdminPassword: string | null;
 227  uiReadonlyPassword: string | null;
 228  uiSessionTtlSec: number;
 229  version: string | null;
 230}
 231
 232export interface StartupChecklistItem {
 233  key: string;
 234  description: string;
 235}
 236
 237export interface ConductorStatusSnapshot {
 238  nodeId: string;
 239  host: string;
 240  role: ConductorRole;
 241  leaseState: LeaseState;
 242  schedulerEnabled: boolean;
 243  currentLeaderId: string | null;
 244  currentTerm: number | null;
 245  leaseExpiresAt: number | null;
 246  lastHeartbeatAt: number | null;
 247  lastLeaseOperation: LeaderLeaseOperation | null;
 248  nextLeaseOperation: LeaderLeaseOperation;
 249  consecutiveRenewFailures: number;
 250  lastError: string | null;
 251}
 252
 253export interface ConductorRuntimeSnapshot {
 254  claudeCoded: {
 255    localApiBase: string | null;
 256  };
 257  daemon: ConductorStatusSnapshot;
 258  identity: string;
 259  loops: {
 260    heartbeat: boolean;
 261    lease: boolean;
 262  };
 263  paths: ConductorRuntimePaths;
 264  controlApi: {
 265    baseUrl: string;
 266    browserWsUrl: string | null;
 267    firefoxWsUrl: string | null;
 268    localApiBase: string | null;
 269    hasSharedToken: boolean;
 270    usesPlaceholderToken: boolean;
 271  };
 272  codexd: {
 273    localApiBase: string | null;
 274  };
 275  runtime: {
 276    pid: number | null;
 277    started: boolean;
 278    startedAt: number;
 279  };
 280  startupChecklist: StartupChecklistItem[];
 281  warnings: string[];
 282}
 283
 284export interface SchedulerContext {
 285  controllerId: string;
 286  host: string;
 287  term: number;
 288}
 289
 290export interface ConductorControlApiClient {
 291  acquireLeaderLease(input: LeaderLeaseAcquireInput): Promise<LeaderLeaseAcquireResult>;
 292  sendControllerHeartbeat(input: ControllerHeartbeatInput): Promise<void>;
 293}
 294
 295export interface ConductorControlApiClientOptions {
 296  bearerToken?: string | null;
 297  defaultHeaders?: Record<string, string>;
 298  fetchImpl?: typeof fetch;
 299}
 300
 301export interface ConductorDaemonHooks {
 302  loadLocalRuns?: () => Promise<void>;
 303  onLeaseStateChange?: (snapshot: ConductorStatusSnapshot) => Promise<void> | void;
 304  reconcileLocalRuns?: () => Promise<void>;
 305}
 306
 307export interface ConductorDaemonOptions {
 308  autoStartLoops?: boolean;
 309  clearIntervalImpl?: (handle: TimerHandle) => void;
 310  client?: ConductorControlApiClient;
 311  controlApiBearerToken?: string | null;
 312  fetchImpl?: typeof fetch;
 313  hooks?: ConductorDaemonHooks;
 314  now?: () => number;
 315  setIntervalImpl?: (handler: () => void, intervalMs: number) => TimerHandle;
 316}
 317
 318export interface ConductorRuntimeOptions extends ConductorDaemonOptions {
 319  browserRequestPolicyOptions?: BrowserRequestPolicyControllerOptions;
 320  env?: ConductorEnvironment;
 321}
 322
 323export type ConductorEnvironment = Record<string, string | undefined>;
 324
 325export interface ConductorTextWriter {
 326  write(chunk: string): unknown;
 327}
 328
 329type ConductorOutputWriter = ConductorTextWriter | typeof console;
 330
 331export interface ConductorProcessLike {
 332  argv: string[];
 333  env: ConductorEnvironment;
 334  exitCode?: number;
 335  off?(event: string, listener: () => void): unknown;
 336  on?(event: string, listener: () => void): unknown;
 337  pid?: number;
 338}
 339
 340export type ConductorCliRequest =
 341  | {
 342      action: "help";
 343    }
 344  | {
 345      action: "checklist";
 346      printJson: boolean;
 347    }
 348  | {
 349      action: "config";
 350      config: ResolvedConductorRuntimeConfig;
 351      printJson: boolean;
 352    }
 353  | {
 354      action: "start";
 355      config: ResolvedConductorRuntimeConfig;
 356      printJson: boolean;
 357      runOnce: boolean;
 358    };
 359
 360export interface RunConductorCliOptions {
 361  argv?: readonly string[];
 362  env?: ConductorEnvironment;
 363  fetchImpl?: typeof fetch;
 364  processLike?: ConductorProcessLike;
 365  stderr?: ConductorTextWriter;
 366  stdout?: ConductorTextWriter;
 367}
 368
 369interface ConductorRuntimeState {
 370  consecutiveRenewFailures: number;
 371  currentLeaderId: string | null;
 372  currentTerm: number | null;
 373  lastError: string | null;
 374  lastHeartbeatAt: number | null;
 375  lastLeaseOperation: LeaderLeaseOperation | null;
 376  leaseExpiresAt: number | null;
 377  leaseState: LeaseState;
 378}
 379
 380interface ControlApiSuccessEnvelope<T> {
 381  ok: true;
 382  request_id: string;
 383  data: T;
 384}
 385
 386interface ControlApiErrorEnvelope {
 387  ok: false;
 388  request_id: string;
 389  error: string;
 390  message: string;
 391  details?: unknown;
 392}
 393
 394interface JsonRequestOptions {
 395  headers?: Record<string, string>;
 396}
 397
 398interface LocalApiListenConfig {
 399  host: string;
 400  port: number;
 401}
 402
 403interface CliValueOverrides {
 404  artifactInlineThreshold?: string;
 405  artifactSummaryLength?: string;
 406  claudeCodedLocalApiBase?: string;
 407  codexdLocalApiBase?: string;
 408  controlApiBase?: string;
 409  heartbeatIntervalMs?: string;
 410  host?: string;
 411  leaseRenewIntervalMs?: string;
 412  leaseTtlSec?: string;
 413  localApiAllowedHosts?: string;
 414  localApiBase?: string;
 415  logsDir?: string;
 416  nodeId?: string;
 417  preferred?: boolean;
 418  priority?: string;
 419  publicApiBase?: string;
 420  renewFailureThreshold?: string;
 421  role?: string;
 422  runOnce: boolean;
 423  runsDir?: string;
 424  sharedToken?: string;
 425  stateDir?: string;
 426  tmpDir?: string;
 427  timedJobsIntervalMs?: string;
 428  timedJobsMaxMessagesPerTick?: string;
 429  timedJobsMaxTasksPerTick?: string;
 430  timedJobsSettleDelayMs?: string;
 431  version?: string;
 432  worktreesDir?: string;
 433}
 434
 435function getDefaultStartupChecklist(): StartupChecklistItem[] {
 436  return STARTUP_CHECKLIST.map((item) => ({ ...item }));
 437}
 438
 439function defaultNowUnixSeconds(): number {
 440  return Math.floor(Date.now() / 1000);
 441}
 442
 443function normalizeBaseUrl(baseUrl: string): string {
 444  return baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
 445}
 446
 447function normalizeOptionalString(value: string | null | undefined): string | null {
 448  if (value == null) {
 449    return null;
 450  }
 451
 452  const normalized = value.trim();
 453  return normalized === "" ? null : normalized;
 454}
 455
 456function normalizePath(value: string): string {
 457  const baseUrl = "http://conductor.local";
 458  const url = new URL(value || "/", baseUrl);
 459  const normalized = url.pathname.replace(/\/+$/u, "");
 460
 461  return normalized === "" ? "/" : normalized;
 462}
 463
 464function normalizeLoopbackHost(hostname: string): string {
 465  const normalized = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
 466  return normalized.toLowerCase();
 467}
 468
 469function isLoopbackHost(hostname: string): boolean {
 470  const normalized = normalizeLoopbackHost(hostname);
 471  return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1";
 472}
 473
 474function isTailscaleIpv4Host(hostname: string): boolean {
 475  const normalized = normalizeLoopbackHost(hostname);
 476
 477  const octets = normalized.split(".");
 478
 479  if (octets.length !== 4) {
 480    return false;
 481  }
 482
 483  const [firstOctetText, secondOctetText, thirdOctetText, fourthOctetText] = octets;
 484  const firstOctet = Number(firstOctetText);
 485  const secondOctet = Number(secondOctetText);
 486  const thirdOctet = Number(thirdOctetText);
 487  const fourthOctet = Number(fourthOctetText);
 488
 489  if (
 490    !Number.isInteger(firstOctet) ||
 491    !Number.isInteger(secondOctet) ||
 492    !Number.isInteger(thirdOctet) ||
 493    !Number.isInteger(fourthOctet)
 494  ) {
 495    return false;
 496  }
 497
 498  if (
 499    firstOctet < 0 ||
 500    firstOctet > 255 ||
 501    secondOctet < 0 ||
 502    secondOctet > 255 ||
 503    thirdOctet < 0 ||
 504    thirdOctet > 255 ||
 505    fourthOctet < 0 ||
 506    fourthOctet > 255
 507  ) {
 508    return false;
 509  }
 510
 511  return firstOctet === 100 && secondOctet >= 64 && secondOctet <= 127;
 512}
 513
 514function parseLocalApiAllowedHosts(hosts: readonly string[] | string | null | undefined): string[] {
 515  if (hosts == null) {
 516    return [];
 517  }
 518
 519  const tokens = typeof hosts === "string" ? hosts.split(/[,\s]+/u) : hosts;
 520  const normalizedHosts = new Set<string>();
 521
 522  for (const token of tokens) {
 523    const normalized = normalizeOptionalString(token);
 524
 525    if (normalized == null) {
 526      continue;
 527    }
 528
 529    const host = normalizeLoopbackHost(normalized);
 530
 531    if (!isLoopbackHost(host) && !isTailscaleIpv4Host(host)) {
 532      throw new Error(
 533        "Conductor localApiAllowedHosts must contain only loopback hosts or Tailscale 100.x IPv4 addresses."
 534      );
 535    }
 536
 537    normalizedHosts.add(host);
 538  }
 539
 540  return [...normalizedHosts];
 541}
 542
 543function isAllowedLocalApiHost(hostname: string, allowedHosts: readonly string[]): boolean {
 544  const normalized = normalizeLoopbackHost(hostname);
 545
 546  if (isLoopbackHost(normalized)) {
 547    return true;
 548  }
 549
 550  return allowedHosts.includes(normalized);
 551}
 552
 553function readHttpPort(url: URL): number {
 554  if (url.port === "") {
 555    return 80;
 556  }
 557
 558  const port = Number(url.port);
 559
 560  if (!Number.isInteger(port) || port < 0 || port > 65_535) {
 561    throw new Error("Conductor localApiBase must use a valid TCP port.");
 562  }
 563
 564  return port;
 565}
 566
 567function formatLocalApiBaseUrl(hostname: string, port: number): string {
 568  const normalizedHost = normalizeLoopbackHost(hostname);
 569  const formattedHost = normalizedHost.includes(":") ? `[${normalizedHost}]` : normalizedHost;
 570
 571  return `http://${formattedHost}${port === 80 ? "" : `:${port}`}`;
 572}
 573
 574function resolveLocalApiBase(
 575  value: string | null | undefined,
 576  allowedHosts: readonly string[] = []
 577): string | null {
 578  const normalized = normalizeOptionalString(value);
 579
 580  if (normalized == null) {
 581    return null;
 582  }
 583
 584  let url: URL;
 585
 586  try {
 587    url = new URL(normalized);
 588  } catch {
 589    throw new Error("Conductor localApiBase must be a valid absolute http:// URL.");
 590  }
 591
 592  if (url.protocol !== "http:") {
 593    throw new Error("Conductor localApiBase must use the http:// scheme.");
 594  }
 595
 596  if (!isAllowedLocalApiHost(url.hostname, allowedHosts)) {
 597    throw new Error(
 598      "Conductor localApiBase must use a loopback host or an explicitly allowed Tailscale 100.x host."
 599    );
 600  }
 601
 602  if (url.pathname !== "/" || url.search !== "" || url.hash !== "") {
 603    throw new Error("Conductor localApiBase must not include a path, query, or hash.");
 604  }
 605
 606  if (url.username !== "" || url.password !== "") {
 607    throw new Error("Conductor localApiBase must not include credentials.");
 608  }
 609
 610  return formatLocalApiBaseUrl(url.hostname, readHttpPort(url));
 611}
 612
 613function resolveLocalApiListenConfig(localApiBase: string): LocalApiListenConfig {
 614  const url = new URL(localApiBase);
 615
 616  return {
 617    host: normalizeLoopbackHost(url.hostname),
 618    port: readHttpPort(url)
 619  };
 620}
 621
 622async function awaitWritableDrainOrClose(
 623  writableResponse: {
 624    destroyed?: boolean;
 625    off?(event: string, listener: () => void): unknown;
 626    on?(event: string, listener: () => void): unknown;
 627  }
 628): Promise<boolean> {
 629  if (writableResponse.destroyed === true) {
 630    return false;
 631  }
 632
 633  if (typeof writableResponse.on !== "function") {
 634    return true;
 635  }
 636
 637  return new Promise<boolean>((resolve) => {
 638    const cleanup = () => {
 639      writableResponse.off?.("drain", onDrain);
 640      writableResponse.off?.("close", onClose);
 641      writableResponse.off?.("error", onError);
 642    };
 643    const onDrain = () => {
 644      cleanup();
 645      resolve(true);
 646    };
 647    const onClose = () => {
 648      cleanup();
 649      resolve(false);
 650    };
 651    const onError = () => {
 652      cleanup();
 653      resolve(false);
 654    };
 655
 656    writableResponse.on?.("drain", onDrain);
 657    writableResponse.on?.("close", onClose);
 658    writableResponse.on?.("error", onError);
 659
 660    if (writableResponse.destroyed === true) {
 661      cleanup();
 662      resolve(false);
 663    }
 664  });
 665}
 666
 667export async function writeHttpResponse(
 668  response: ServerResponse<IncomingMessage>,
 669  payload: ConductorHttpResponse
 670): Promise<void> {
 671  const writableResponse = response as ServerResponse<IncomingMessage> & {
 672    destroyed?: boolean;
 673    off?(event: string, listener: () => void): unknown;
 674    on?(event: string, listener: () => void): unknown;
 675    writableEnded?: boolean;
 676    write?(chunk: string | Uint8Array): boolean;
 677  };
 678  response.statusCode = payload.status;
 679
 680  for (const [name, value] of Object.entries(payload.headers)) {
 681    response.setHeader(name, value);
 682  }
 683
 684  if (payload.streamBody == null) {
 685    response.end(payload.body);
 686    return;
 687  }
 688
 689  const hasInitialBody = typeof payload.body === "string" ? payload.body !== "" : payload.body.byteLength > 0;
 690
 691  if (hasInitialBody && typeof writableResponse.write === "function") {
 692    if (!writableResponse.write(payload.body)) {
 693      const canContinue = await awaitWritableDrainOrClose(writableResponse);
 694
 695      if (!canContinue) {
 696        return;
 697      }
 698    }
 699  }
 700
 701  for await (const chunk of payload.streamBody) {
 702    if (writableResponse.destroyed === true) {
 703      break;
 704    }
 705
 706    if (typeof writableResponse.write !== "function") {
 707      continue;
 708    }
 709
 710    if (!writableResponse.write(chunk)) {
 711      const canContinue = await awaitWritableDrainOrClose(writableResponse);
 712
 713      if (!canContinue) {
 714        break;
 715      }
 716    }
 717  }
 718
 719  if (writableResponse.destroyed !== true && writableResponse.writableEnded !== true) {
 720    response.end();
 721  }
 722}
 723
 724async function readIncomingRequestBody(request: IncomingMessage): Promise<string | null> {
 725  if (request.method == null || request.method.toUpperCase() === "GET") {
 726    return null;
 727  }
 728
 729  return new Promise((resolve, reject) => {
 730    let body = "";
 731    request.setEncoding?.("utf8");
 732    request.on?.("data", (chunk) => {
 733      body += typeof chunk === "string" ? chunk : String(chunk);
 734    });
 735    request.on?.("end", () => {
 736      resolve(body === "" ? null : body);
 737    });
 738    request.on?.("error", (error) => {
 739      reject(error);
 740    });
 741  });
 742}
 743
 744function normalizeIncomingRequestHeaders(
 745  headers: IncomingMessage["headers"]
 746): Record<string, string | undefined> {
 747  const normalized: Record<string, string | undefined> = {};
 748
 749  for (const [name, value] of Object.entries(headers)) {
 750    if (typeof value === "string") {
 751      normalized[name] = value;
 752      continue;
 753    }
 754
 755    if (Array.isArray(value)) {
 756      normalized[name] = value.join(", ");
 757    }
 758  }
 759
 760  return normalized;
 761}
 762
 763class ConductorLocalHttpServer {
 764  private readonly artifactStore: ArtifactStore;
 765  private readonly browserRequestPolicy: BrowserRequestPolicyController;
 766  private readonly claudeCodedLocalApiBase: string | null;
 767  private readonly codeRootDir: string;
 768  private readonly codexdLocalApiBase: string | null;
 769  private readonly fetchImpl: typeof fetch;
 770  private readonly firefoxWebSocketServer: ConductorFirefoxWebSocketServer;
 771  private readonly instructionIngest: BaaLiveInstructionIngest;
 772  private readonly localApiBase: string;
 773  private readonly now: () => number;
 774  private readonly repository: ControlPlaneRepository;
 775  private readonly sharedToken: string | null;
 776  private readonly snapshotLoader: () => ConductorRuntimeSnapshot;
 777  private readonly uiSessionManager: UiSessionManager;
 778  private readonly version: string | null;
 779  private resolvedBaseUrl: string;
 780  private server: Server | null = null;
 781
 782  constructor(
 783    localApiBase: string,
 784    repository: ControlPlaneRepository,
 785    artifactStore: ArtifactStore,
 786    snapshotLoader: () => ConductorRuntimeSnapshot,
 787    codeRootDir: string,
 788    codexdLocalApiBase: string | null,
 789    claudeCodedLocalApiBase: string | null,
 790    fetchImpl: typeof fetch,
 791    sharedToken: string | null,
 792    version: string | null,
 793    now: () => number,
 794    uiBrowserAdminPassword: string | null,
 795    uiReadonlyPassword: string | null,
 796    uiSessionTtlSec: number,
 797    artifactInlineThreshold: number,
 798    artifactSummaryLength: number,
 799    browserRequestPolicyOptions: BrowserRequestPolicyControllerOptions = {},
 800    ingestLogDir: string | null = null,
 801    pluginDiagnosticLogDir: string | null = null
 802  ) {
 803    this.artifactStore = artifactStore;
 804    this.browserRequestPolicy = new BrowserRequestPolicyController({
 805      ...browserRequestPolicyOptions,
 806      persistence: browserRequestPolicyOptions.persistence
 807        ?? createArtifactStoreBrowserRequestPolicyPersistence(this.artifactStore)
 808    });
 809    this.claudeCodedLocalApiBase = claudeCodedLocalApiBase;
 810    this.codeRootDir = codeRootDir;
 811    this.codexdLocalApiBase = codexdLocalApiBase;
 812    this.fetchImpl = fetchImpl;
 813    this.localApiBase = localApiBase;
 814    this.now = now;
 815    this.repository = repository;
 816    this.sharedToken = sharedToken;
 817    this.snapshotLoader = snapshotLoader;
 818    this.uiSessionManager = new UiSessionManager({
 819      browserAdminPassword: uiBrowserAdminPassword,
 820      now: () => this.now() * 1000,
 821      readonlyPassword: uiReadonlyPassword,
 822      ttlSec: uiSessionTtlSec
 823    });
 824    this.version = version;
 825    this.resolvedBaseUrl = localApiBase;
 826    const nowMs = () => this.now() * 1000;
 827    const localApiContext = {
 828      artifactStore: this.artifactStore,
 829      codeRootDir: this.codeRootDir,
 830      fetchImpl: this.fetchImpl,
 831      now: this.now,
 832      repository: this.repository,
 833      sharedToken: this.sharedToken,
 834      snapshotLoader: this.snapshotLoader,
 835      uiSessionManager: this.uiSessionManager,
 836      version: this.version
 837    };
 838    const instructionIngest = new BaaLiveInstructionIngest({
 839      center: new BaaInstructionCenter({
 840        deduper: new PersistentBaaInstructionDeduper(this.repository, nowMs),
 841        localApiContext
 842      }),
 843      historyLimit: DEFAULT_BAA_EXECUTION_JOURNAL_LIMIT,
 844      localApiContext: {
 845        ...localApiContext
 846      },
 847      messageDeduper: new PersistentBaaLiveInstructionMessageDeduper(this.repository, nowMs),
 848      now: nowMs,
 849      snapshotStore: new PersistentBaaLiveInstructionSnapshotStore(
 850        this.repository,
 851        DEFAULT_BAA_EXECUTION_JOURNAL_LIMIT
 852      )
 853    });
 854    this.instructionIngest = instructionIngest;
 855    this.firefoxWebSocketServer = new ConductorFirefoxWebSocketServer({
 856      artifactStore: this.artifactStore,
 857      artifactInlineThreshold,
 858      artifactSummaryLength,
 859      baseUrlLoader: () => this.resolvedBaseUrl,
 860      ingestLogDir,
 861      instructionIngest,
 862      now: this.now,
 863      pluginDiagnosticLogDir,
 864      repository: this.repository,
 865      snapshotLoader: this.snapshotLoader
 866    });
 867  }
 868
 869  getBaseUrl(): string {
 870    return this.resolvedBaseUrl;
 871  }
 872
 873  getFirefoxWebSocketUrl(): string | null {
 874    return this.firefoxWebSocketServer.getFirefoxCompatUrl();
 875  }
 876
 877  getBrowserWebSocketUrl(): string | null {
 878    return this.firefoxWebSocketServer.getUrl();
 879  }
 880
 881  getFirefoxBridgeService(): FirefoxBridgeService {
 882    return this.firefoxWebSocketServer.getBridgeService();
 883  }
 884
 885  async start(): Promise<string> {
 886    if (this.server != null) {
 887      return this.resolvedBaseUrl;
 888    }
 889
 890    await this.browserRequestPolicy.initialize();
 891    await this.instructionIngest.initialize();
 892
 893    const listenConfig = resolveLocalApiListenConfig(this.localApiBase);
 894    const server = createServer((request, response) => {
 895      void (async () => {
 896        const requestAbortController = new AbortController();
 897        const abortRequest = () => {
 898          if (!requestAbortController.signal.aborted) {
 899            requestAbortController.abort();
 900          }
 901        };
 902        const requestWithExtendedEvents = request as IncomingMessage & {
 903          on?(event: string, listener: () => void): unknown;
 904        };
 905        const responseWithExtendedEvents = response as ServerResponse<IncomingMessage> & {
 906          on?(event: string, listener: () => void): unknown;
 907        };
 908        requestWithExtendedEvents.on?.("aborted", abortRequest);
 909        responseWithExtendedEvents.on?.("close", abortRequest);
 910
 911        const payload = await handleConductorLocalHttpRequest(
 912          {
 913            body: await readIncomingRequestBody(request),
 914            headers: normalizeIncomingRequestHeaders(request.headers),
 915            method: request.method ?? "GET",
 916            path: request.url ?? "/",
 917            signal: requestAbortController.signal
 918          },
 919          {
 920            artifactStore: this.artifactStore,
 921            deliveryBridge: this.firefoxWebSocketServer.getDeliveryBridge(),
 922            browserBridge:
 923              this.firefoxWebSocketServer.getBridgeService() as unknown as BrowserBridgeController,
 924            browserRequestPolicy: this.browserRequestPolicy,
 925            browserStateLoader: () => this.firefoxWebSocketServer.getStateSnapshot(),
 926            claudeCodedLocalApiBase: this.claudeCodedLocalApiBase,
 927            codexdLocalApiBase: this.codexdLocalApiBase,
 928            fetchImpl: this.fetchImpl,
 929            repository: this.repository,
 930            sharedToken: this.sharedToken,
 931            snapshotLoader: this.snapshotLoader,
 932            uiSessionManager: this.uiSessionManager,
 933            version: this.version
 934          }
 935        );
 936
 937        await writeHttpResponse(response, payload);
 938      })().catch((error: unknown) => {
 939        response.statusCode = 500;
 940        response.setHeader("cache-control", "no-store");
 941        response.setHeader("content-type", "application/json; charset=utf-8");
 942        response.end(
 943          `${JSON.stringify(
 944            {
 945              ok: false,
 946              error: "internal_error",
 947              message: toErrorMessage(error)
 948            },
 949            null,
 950            2
 951          )}\n`
 952        );
 953      });
 954    });
 955    server.on("upgrade", (request, socket, head) => {
 956      this.firefoxWebSocketServer.handleUpgrade(request, socket, head);
 957    });
 958
 959    await new Promise<void>((resolve, reject) => {
 960      const onError = (error: Error) => {
 961        server.off("listening", onListening);
 962        reject(error);
 963      };
 964      const onListening = () => {
 965        server.off("error", onError);
 966        resolve();
 967      };
 968
 969      server.once("error", onError);
 970      server.once("listening", onListening);
 971      server.listen({
 972        host: listenConfig.host,
 973        port: listenConfig.port
 974      });
 975    });
 976
 977    const address = server.address();
 978
 979    if (address == null || typeof address === "string") {
 980      server.close();
 981      throw new Error("Conductor local API started without a TCP listen address.");
 982    }
 983
 984    this.resolvedBaseUrl = formatLocalApiBaseUrl(address.address, (address as AddressInfo).port);
 985    this.server = server;
 986    this.firefoxWebSocketServer.start();
 987    return this.resolvedBaseUrl;
 988  }
 989
 990  async stop(): Promise<void> {
 991    if (this.server == null) {
 992      return;
 993    }
 994
 995    const server = this.server;
 996    this.server = null;
 997    await this.firefoxWebSocketServer.stop();
 998    await this.browserRequestPolicy.flush();
 999
1000    await new Promise<void>((resolve, reject) => {
1001      server.close((error) => {
1002        if (error) {
1003          reject(error);
1004          return;
1005        }
1006
1007        resolve();
1008      });
1009      server.closeAllConnections?.();
1010    });
1011  }
1012}
1013
1014function toErrorMessage(error: unknown): string {
1015  if (error instanceof Error) {
1016    return error.message;
1017  }
1018
1019  return String(error);
1020}
1021
1022function isControlApiSuccessEnvelope<T>(value: unknown): value is ControlApiSuccessEnvelope<T> {
1023  if (value === null || typeof value !== "object") {
1024    return false;
1025  }
1026
1027  const record = value as Record<string, unknown>;
1028  return record.ok === true && "data" in record;
1029}
1030
1031function isControlApiErrorEnvelope(value: unknown): value is ControlApiErrorEnvelope {
1032  if (value === null || typeof value !== "object") {
1033    return false;
1034  }
1035
1036  const record = value as Record<string, unknown>;
1037  return record.ok === false && typeof record.error === "string" && typeof record.message === "string";
1038}
1039
1040function asRecord(value: unknown): Record<string, unknown> | null {
1041  if (value === null || typeof value !== "object" || Array.isArray(value)) {
1042    return null;
1043  }
1044
1045  return value as Record<string, unknown>;
1046}
1047
1048function compactRecord(input: Record<string, unknown>): Record<string, unknown> {
1049  const result: Record<string, unknown> = {};
1050
1051  for (const [key, value] of Object.entries(input)) {
1052    if (value !== undefined) {
1053      result[key] = value;
1054    }
1055  }
1056
1057  return result;
1058}
1059
1060function readFirstString(record: Record<string, unknown>, keys: readonly string[]): string | undefined {
1061  for (const key of keys) {
1062    const value = record[key];
1063
1064    if (typeof value === "string" && value.trim() !== "") {
1065      return value;
1066    }
1067  }
1068
1069  return undefined;
1070}
1071
1072function readFirstNullableString(record: Record<string, unknown>, keys: readonly string[]): string | null | undefined {
1073  for (const key of keys) {
1074    const value = record[key];
1075
1076    if (value === null) {
1077      return null;
1078    }
1079
1080    if (typeof value === "string") {
1081      return value;
1082    }
1083  }
1084
1085  return undefined;
1086}
1087
1088function readFirstNumber(record: Record<string, unknown>, keys: readonly string[]): number | undefined {
1089  for (const key of keys) {
1090    const value = record[key];
1091
1092    if (typeof value === "number" && Number.isFinite(value)) {
1093      return value;
1094    }
1095  }
1096
1097  return undefined;
1098}
1099
1100function readFirstBoolean(record: Record<string, unknown>, keys: readonly string[]): boolean | undefined {
1101  for (const key of keys) {
1102    const value = record[key];
1103
1104    if (typeof value === "boolean") {
1105      return value;
1106    }
1107  }
1108
1109  return undefined;
1110}
1111
1112function parseResponseJson(text: string, path: string): unknown {
1113  try {
1114    return JSON.parse(text) as unknown;
1115  } catch {
1116    throw new Error(`Control API returned invalid JSON for ${path}.`);
1117  }
1118}
1119
1120function buildControlApiError(
1121  path: string,
1122  status: number,
1123  statusText: string,
1124  parsedBody: unknown,
1125  rawBody: string
1126): Error {
1127  if (isControlApiErrorEnvelope(parsedBody)) {
1128    return new Error(`Control API ${status} ${path}: ${parsedBody.error}: ${parsedBody.message}`);
1129  }
1130
1131  if (rawBody !== "") {
1132    return new Error(`Control API ${status} ${path}: ${rawBody}`);
1133  }
1134
1135  return new Error(`Control API ${status} ${path}: ${statusText}`);
1136}
1137
1138async function postJson<T>(
1139  fetchImpl: typeof fetch,
1140  baseUrl: string,
1141  path: string,
1142  body: unknown,
1143  options: JsonRequestOptions = {}
1144): Promise<T> {
1145  const headers = new Headers(options.headers);
1146  headers.set("accept", "application/json");
1147  headers.set("content-type", "application/json");
1148
1149  const response = await fetchImpl(`${normalizeBaseUrl(baseUrl)}${path}`, {
1150    method: "POST",
1151    headers,
1152    body: JSON.stringify(body)
1153  });
1154  const text = await response.text();
1155  const parsedBody = text === "" ? undefined : parseResponseJson(text, path);
1156
1157  if (!response.ok) {
1158    throw buildControlApiError(path, response.status, response.statusText, parsedBody, text);
1159  }
1160
1161  if (parsedBody === undefined) {
1162    return undefined as T;
1163  }
1164
1165  if (isControlApiSuccessEnvelope<T>(parsedBody)) {
1166    return parsedBody.data;
1167  }
1168
1169  if (isControlApiErrorEnvelope(parsedBody)) {
1170    throw new Error(`Control API ${path}: ${parsedBody.error}: ${parsedBody.message}`);
1171  }
1172
1173  return parsedBody as T;
1174}
1175
1176function normalizeLeaseRecord(
1177  value: unknown,
1178  fallback: Pick<LeaderLeaseRecord, "holderHost" | "holderId" | "leaseExpiresAt" | "renewedAt" | "term">
1179): LeaderLeaseRecord {
1180  const record = asRecord(value) ?? {};
1181
1182  return {
1183    leaseName: readFirstString(record, ["leaseName", "lease_name"]) ?? "global",
1184    holderId: readFirstString(record, ["holderId", "holder_id"]) ?? fallback.holderId,
1185    holderHost: readFirstString(record, ["holderHost", "holder_host"]) ?? fallback.holderHost,
1186    term: readFirstNumber(record, ["term"]) ?? fallback.term,
1187    leaseExpiresAt:
1188      readFirstNumber(record, ["leaseExpiresAt", "lease_expires_at"]) ?? fallback.leaseExpiresAt,
1189    renewedAt: readFirstNumber(record, ["renewedAt", "renewed_at"]) ?? fallback.renewedAt,
1190    preferredHolderId:
1191      readFirstNullableString(record, ["preferredHolderId", "preferred_holder_id"]) ?? null,
1192    metadataJson: readFirstNullableString(record, ["metadataJson", "metadata_json"]) ?? null
1193  };
1194}
1195
1196function normalizeAcquireLeaderLeaseResult(
1197  value: unknown,
1198  input: LeaderLeaseAcquireInput
1199): LeaderLeaseAcquireResult {
1200  const record = asRecord(value);
1201
1202  if (!record) {
1203    throw new Error("Control API /v1/leader/acquire returned a non-object payload.");
1204  }
1205
1206  const holderId = readFirstString(record, ["holderId", "holder_id"]);
1207  const term = readFirstNumber(record, ["term"]);
1208  const leaseExpiresAt = readFirstNumber(record, ["leaseExpiresAt", "lease_expires_at"]);
1209
1210  if (!holderId || term == null || leaseExpiresAt == null) {
1211    throw new Error("Control API /v1/leader/acquire response is missing lease identity fields.");
1212  }
1213
1214  const holderHost =
1215    readFirstString(record, ["holderHost", "holder_host"]) ??
1216    (holderId === input.controllerId ? input.host : "unknown");
1217  const renewedAt =
1218    readFirstNumber(record, ["renewedAt", "renewed_at"]) ?? input.now ?? defaultNowUnixSeconds();
1219  const isLeader =
1220    readFirstBoolean(record, ["isLeader", "is_leader"]) ?? holderId === input.controllerId;
1221  const rawOperation = readFirstString(record, ["operation"]);
1222  const operation: LeaderLeaseOperation = rawOperation === "renew" ? "renew" : "acquire";
1223
1224  return {
1225    holderId,
1226    holderHost,
1227    term,
1228    leaseExpiresAt,
1229    renewedAt,
1230    isLeader,
1231    operation,
1232    lease: normalizeLeaseRecord(record.lease, {
1233      holderHost,
1234      holderId,
1235      leaseExpiresAt,
1236      renewedAt,
1237      term
1238    })
1239  };
1240}
1241
1242function resolveFetchClientOptions(
1243  fetchOrOptions: typeof fetch | ConductorControlApiClientOptions | undefined,
1244  maybeOptions: ConductorControlApiClientOptions
1245): { fetchImpl: typeof fetch; options: ConductorControlApiClientOptions } {
1246  if (typeof fetchOrOptions === "function") {
1247    return {
1248      fetchImpl: fetchOrOptions,
1249      options: maybeOptions
1250    };
1251  }
1252
1253  return {
1254    fetchImpl: fetchOrOptions?.fetchImpl ?? globalThis.fetch,
1255    options: fetchOrOptions ?? {}
1256  };
1257}
1258
1259export function createFetchControlApiClient(
1260  baseUrl: string,
1261  fetchOrOptions: typeof fetch | ConductorControlApiClientOptions = globalThis.fetch,
1262  maybeOptions: ConductorControlApiClientOptions = {}
1263): ConductorControlApiClient {
1264  const { fetchImpl, options } = resolveFetchClientOptions(fetchOrOptions, maybeOptions);
1265  const requestHeaders = { ...(options.defaultHeaders ?? {}) };
1266
1267  if (options.bearerToken) {
1268    requestHeaders.authorization = `Bearer ${options.bearerToken}`;
1269  }
1270
1271  return {
1272    async acquireLeaderLease(input: LeaderLeaseAcquireInput): Promise<LeaderLeaseAcquireResult> {
1273      const payload = compactRecord({
1274        controller_id: input.controllerId,
1275        host: input.host,
1276        ttl_sec: input.ttlSec,
1277        preferred: input.preferred ?? false,
1278        metadata_json: input.metadataJson ?? undefined,
1279        now: input.now
1280      });
1281      const response = await postJson<unknown>(
1282        fetchImpl,
1283        baseUrl,
1284        "/v1/leader/acquire",
1285        payload,
1286        {
1287          headers: requestHeaders
1288        }
1289      );
1290
1291      return normalizeAcquireLeaderLeaseResult(response, input);
1292    },
1293    async sendControllerHeartbeat(input: ControllerHeartbeatInput): Promise<void> {
1294      const payload = compactRecord({
1295        controller_id: input.controllerId,
1296        host: input.host,
1297        role: input.role,
1298        priority: input.priority,
1299        status: input.status,
1300        version: input.version ?? undefined,
1301        heartbeat_at: input.heartbeatAt,
1302        started_at: input.startedAt
1303      });
1304
1305      await postJson(fetchImpl, baseUrl, "/v1/controllers/heartbeat", payload, {
1306        headers: requestHeaders
1307      });
1308    }
1309  };
1310}
1311
1312export function createRepositoryControlApiClient(
1313  repository: Pick<ControlPlaneRepository, "acquireLeaderLease" | "heartbeatController">,
1314  now: () => number = defaultNowUnixSeconds
1315): ConductorControlApiClient {
1316  return {
1317    async acquireLeaderLease(input: LeaderLeaseAcquireInput): Promise<LeaderLeaseAcquireResult> {
1318      return repository.acquireLeaderLease(input);
1319    },
1320    async sendControllerHeartbeat(input: ControllerHeartbeatInput): Promise<void> {
1321      await repository.heartbeatController({
1322        ...input,
1323        heartbeatAt: input.heartbeatAt ?? now(),
1324        startedAt: input.startedAt ?? null,
1325        version: input.version ?? null
1326      });
1327    }
1328  };
1329}
1330
1331export class ConductorDaemon {
1332  private readonly autoStartLoops: boolean;
1333  private readonly clearIntervalImpl: (handle: TimerHandle) => void;
1334  private readonly client: ConductorControlApiClient;
1335  private readonly config: CanonicalConductorConfig;
1336  private heartbeatTimer: TimerHandle | null = null;
1337  private readonly hooks?: ConductorDaemonHooks;
1338  private leaseTimer: TimerHandle | null = null;
1339  private readonly now: () => number;
1340  private readonly setIntervalImpl: (handler: () => void, intervalMs: number) => TimerHandle;
1341  private readonly startedAt: number;
1342  private readonly state: ConductorRuntimeState = {
1343    consecutiveRenewFailures: 0,
1344    currentLeaderId: null,
1345    currentTerm: null,
1346    lastError: null,
1347    lastHeartbeatAt: null,
1348    lastLeaseOperation: null,
1349    leaseExpiresAt: null,
1350    leaseState: "standby"
1351  };
1352
1353  constructor(config: ConductorConfig, options: ConductorDaemonOptions = {}) {
1354    const publicApiBase = resolveConfiguredPublicApiBase(config);
1355
1356    if (!publicApiBase) {
1357      throw new Error("Conductor config requires a non-empty publicApiBase.");
1358    }
1359
1360    this.config = {
1361      ...config,
1362      controlApiBase: normalizeBaseUrl(publicApiBase),
1363      publicApiBase: normalizeBaseUrl(publicApiBase)
1364    };
1365    this.autoStartLoops = options.autoStartLoops ?? true;
1366    this.clearIntervalImpl =
1367      options.clearIntervalImpl ?? ((handle) => globalThis.clearInterval(handle));
1368    this.client =
1369      options.client ??
1370      createFetchControlApiClient(this.config.publicApiBase, {
1371        bearerToken: options.controlApiBearerToken,
1372        fetchImpl: options.fetchImpl
1373      });
1374    this.hooks = options.hooks;
1375    this.now = options.now ?? defaultNowUnixSeconds;
1376    this.setIntervalImpl =
1377      options.setIntervalImpl ?? ((handler, intervalMs) => globalThis.setInterval(handler, intervalMs));
1378    this.startedAt = this.config.startedAt ?? this.now();
1379  }
1380
1381  getStartupChecklist(): StartupChecklistItem[] {
1382    return getDefaultStartupChecklist();
1383  }
1384
1385  describeIdentity(): string {
1386    return `${this.config.nodeId}@${this.config.host}(${this.config.role})`;
1387  }
1388
1389  getLoopStatus(): { heartbeat: boolean; lease: boolean } {
1390    return {
1391      heartbeat: this.heartbeatTimer != null,
1392      lease: this.leaseTimer != null
1393    };
1394  }
1395
1396  getStatusSnapshot(now: number = this.now()): ConductorStatusSnapshot {
1397    return {
1398      nodeId: this.config.nodeId,
1399      host: this.config.host,
1400      role: this.config.role,
1401      leaseState: this.state.leaseState,
1402      schedulerEnabled: this.canSchedule(now),
1403      currentLeaderId: this.state.currentLeaderId,
1404      currentTerm: this.state.currentTerm,
1405      leaseExpiresAt: this.state.leaseExpiresAt,
1406      lastHeartbeatAt: this.state.lastHeartbeatAt,
1407      lastLeaseOperation: this.state.lastLeaseOperation,
1408      nextLeaseOperation: this.getNextLeaseOperation(now),
1409      consecutiveRenewFailures: this.state.consecutiveRenewFailures,
1410      lastError: this.state.lastError
1411    };
1412  }
1413
1414  getNextLeaseOperation(now: number = this.now()): LeaderLeaseOperation {
1415    return this.state.currentLeaderId === this.config.nodeId &&
1416      this.state.leaseExpiresAt != null &&
1417      this.state.leaseExpiresAt > now
1418      ? "renew"
1419      : "acquire";
1420  }
1421
1422  canSchedule(now: number = this.now()): boolean {
1423    return (
1424      this.state.leaseState === "leader" &&
1425      this.state.currentLeaderId === this.config.nodeId &&
1426      this.state.leaseExpiresAt != null &&
1427      this.state.leaseExpiresAt > now
1428    );
1429  }
1430
1431  async start(): Promise<LeaseState> {
1432    try {
1433      await this.sendHeartbeat();
1434    } catch (error) {
1435      this.state.lastError = toErrorMessage(error);
1436      await this.transitionTo("degraded");
1437    }
1438
1439    try {
1440      await this.runLeaseCycle();
1441    } catch {
1442      // Keep startup non-throwing so the process can surface degraded state.
1443    }
1444
1445    await this.hooks?.loadLocalRuns?.();
1446    await this.hooks?.reconcileLocalRuns?.();
1447
1448    if (this.autoStartLoops) {
1449      this.startLoops();
1450    }
1451
1452    return this.state.leaseState;
1453  }
1454
1455  stop(): void {
1456    if (this.heartbeatTimer != null) {
1457      this.clearIntervalImpl(this.heartbeatTimer);
1458      this.heartbeatTimer = null;
1459    }
1460
1461    if (this.leaseTimer != null) {
1462      this.clearIntervalImpl(this.leaseTimer);
1463      this.leaseTimer = null;
1464    }
1465  }
1466
1467  async sendHeartbeat(): Promise<void> {
1468    const request = this.buildHeartbeatRequest();
1469    await this.client.sendControllerHeartbeat(request);
1470    this.state.lastHeartbeatAt = request.heartbeatAt ?? this.now();
1471    this.state.lastError = null;
1472  }
1473
1474  async runLeaseCycle(): Promise<LeaseState> {
1475    const requestedOperation = this.getNextLeaseOperation();
1476    this.state.lastLeaseOperation = requestedOperation;
1477
1478    try {
1479      const result = await this.client.acquireLeaderLease(this.buildLeaseRequest());
1480      this.applyLeaseResult(result);
1481      this.state.consecutiveRenewFailures = 0;
1482      this.state.lastError = null;
1483      await this.transitionTo(result.isLeader ? "leader" : "standby");
1484      return this.state.leaseState;
1485    } catch (error) {
1486      this.state.lastError = toErrorMessage(error);
1487
1488      if (requestedOperation === "renew" && this.state.leaseState === "leader") {
1489        this.state.consecutiveRenewFailures += 1;
1490
1491        if (this.state.consecutiveRenewFailures >= this.getRenewFailureThreshold()) {
1492          await this.transitionTo("degraded");
1493        }
1494      } else {
1495        await this.transitionTo("degraded");
1496      }
1497
1498      throw error;
1499    }
1500  }
1501
1502  async runSchedulerPass(
1503    work: (context: SchedulerContext) => Promise<void>
1504  ): Promise<SchedulerDecision> {
1505    if (!this.canSchedule()) {
1506      return "skipped_not_leader";
1507    }
1508
1509    if (this.state.currentTerm == null) {
1510      return "skipped_not_leader";
1511    }
1512
1513    await work({
1514      controllerId: this.config.nodeId,
1515      host: this.config.host,
1516      term: this.state.currentTerm
1517    });
1518
1519    return "scheduled";
1520  }
1521
1522  private applyLeaseResult(result: LeaderLeaseAcquireResult): void {
1523    this.state.currentLeaderId = result.holderId;
1524    this.state.currentTerm = result.term;
1525    this.state.lastLeaseOperation = result.operation;
1526    this.state.leaseExpiresAt = result.leaseExpiresAt;
1527  }
1528
1529  private buildHeartbeatRequest(): ControllerHeartbeatInput {
1530    return {
1531      controllerId: this.config.nodeId,
1532      host: this.config.host,
1533      role: this.config.role,
1534      priority: this.config.priority ?? (this.config.role === "primary" ? 100 : 50),
1535      status: this.state.leaseState === "degraded" ? "degraded" : "alive",
1536      version: this.config.version ?? null,
1537      heartbeatAt: this.now(),
1538      startedAt: this.startedAt
1539    };
1540  }
1541
1542  private buildLeaseRequest(): LeaderLeaseAcquireInput {
1543    return {
1544      controllerId: this.config.nodeId,
1545      host: this.config.host,
1546      ttlSec: this.config.leaseTtlSec ?? DEFAULT_LEASE_TTL_SEC,
1547      preferred: this.config.preferred ?? this.config.role === "primary",
1548      now: this.now()
1549    };
1550  }
1551
1552  private getHeartbeatIntervalMs(): number {
1553    return this.config.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
1554  }
1555
1556  private getLeaseRenewIntervalMs(): number {
1557    return this.config.leaseRenewIntervalMs ?? DEFAULT_LEASE_RENEW_INTERVAL_MS;
1558  }
1559
1560  private getRenewFailureThreshold(): number {
1561    return this.config.renewFailureThreshold ?? DEFAULT_RENEW_FAILURE_THRESHOLD;
1562  }
1563
1564  private startLoops(): void {
1565    if (this.heartbeatTimer == null) {
1566      this.heartbeatTimer = this.setIntervalImpl(() => {
1567        void this.safeHeartbeatTick();
1568      }, this.getHeartbeatIntervalMs());
1569    }
1570
1571    if (this.leaseTimer == null) {
1572      this.leaseTimer = this.setIntervalImpl(() => {
1573        void this.safeLeaseTick();
1574      }, this.getLeaseRenewIntervalMs());
1575    }
1576  }
1577
1578  private async safeHeartbeatTick(): Promise<void> {
1579    try {
1580      await this.sendHeartbeat();
1581    } catch (error) {
1582      this.state.lastError = toErrorMessage(error);
1583    }
1584  }
1585
1586  private async safeLeaseTick(): Promise<void> {
1587    try {
1588      await this.runLeaseCycle();
1589    } catch {
1590      // Background loop keeps the last known state and waits for the next cycle.
1591    }
1592  }
1593
1594  private async transitionTo(nextState: LeaseState): Promise<void> {
1595    if (this.state.leaseState === nextState) {
1596      return;
1597    }
1598
1599    this.state.leaseState = nextState;
1600    await this.hooks?.onLeaseStateChange?.(this.getStatusSnapshot());
1601  }
1602}
1603
1604function createDefaultNodeId(host: string, role: ConductorRole): string {
1605  return `${host}-${role === "primary" ? "main" : "standby"}`;
1606}
1607
1608function parseConductorRole(name: string, value: string | null): ConductorRole {
1609  if (value === "primary" || value === "standby") {
1610    return value;
1611  }
1612
1613  throw new Error(`${name} must be either "primary" or "standby".`);
1614}
1615
1616function parseBooleanValue(name: string, value: string | boolean | null | undefined): boolean | undefined {
1617  if (typeof value === "boolean") {
1618    return value;
1619  }
1620
1621  if (value == null) {
1622    return undefined;
1623  }
1624
1625  switch (value.trim().toLowerCase()) {
1626    case "1":
1627    case "on":
1628    case "true":
1629    case "yes":
1630      return true;
1631    case "0":
1632    case "false":
1633    case "no":
1634    case "off":
1635      return false;
1636    default:
1637      throw new Error(`${name} must be a boolean flag.`);
1638  }
1639}
1640
1641function parseIntegerValue(
1642  name: string,
1643  value: string | null | undefined,
1644  options: { minimum?: number } = {}
1645): number | undefined {
1646  if (value == null) {
1647    return undefined;
1648  }
1649
1650  const normalized = value.trim();
1651
1652  if (!/^-?\d+$/u.test(normalized)) {
1653    throw new Error(`${name} must be an integer.`);
1654  }
1655
1656  const parsed = Number(normalized);
1657
1658  if (!Number.isSafeInteger(parsed)) {
1659    throw new Error(`${name} is outside the supported integer range.`);
1660  }
1661
1662  if (options.minimum != null && parsed < options.minimum) {
1663    throw new Error(`${name} must be >= ${options.minimum}.`);
1664  }
1665
1666  return parsed;
1667}
1668
1669function resolvePathConfig(paths?: Partial<ConductorRuntimePaths>): ConductorRuntimePaths {
1670  return {
1671    logsDir: normalizeOptionalString(paths?.logsDir),
1672    runsDir: normalizeOptionalString(paths?.runsDir),
1673    stateDir: normalizeOptionalString(paths?.stateDir),
1674    tmpDir: normalizeOptionalString(paths?.tmpDir),
1675    worktreesDir: normalizeOptionalString(paths?.worktreesDir)
1676  };
1677}
1678
1679function resolveIngestLogDir(logsDir: string | null): string | null {
1680  return resolveLogSubdir(logsDir, "baa-ingest", "baa-ingest-log");
1681}
1682
1683function resolvePluginDiagnosticLogDir(logsDir: string | null): string | null {
1684  return resolveLogSubdir(logsDir, "baa-plugin", "baa-plugin-log");
1685}
1686
1687function resolveTimedJobsLogDir(logsDir: string | null): string | null {
1688  return resolveLogSubdir(logsDir, "timed-jobs", "timed-jobs-log");
1689}
1690
1691function resolveLogSubdir(
1692  logsDir: string | null,
1693  subdir: string,
1694  label: string
1695): string | null {
1696  const base = logsDir ?? "logs";
1697  const dir = join(base, subdir);
1698
1699  try {
1700    mkdirSync(dir, { recursive: true });
1701  } catch (error) {
1702    console.error(`[${label}] failed to create log directory ${dir}: ${String(error)}`);
1703    return null;
1704  }
1705
1706  return dir;
1707}
1708
1709function resolveConfiguredPublicApiBase(
1710  values: Pick<ConductorConfig, "controlApiBase" | "publicApiBase">
1711): string | null {
1712  return normalizeOptionalString(values.publicApiBase ?? values.controlApiBase);
1713}
1714
1715function resolveSourcePublicApiBase(
1716  env: ConductorEnvironment,
1717  overrides: CliValueOverrides
1718): string | null {
1719  return normalizeOptionalString(
1720    overrides.publicApiBase ??
1721      overrides.controlApiBase ??
1722      env.BAA_CONDUCTOR_PUBLIC_API_BASE ??
1723      env.BAA_CONTROL_API_BASE
1724  );
1725}
1726
1727function resolveCodeRootDir(value: string | null | undefined): string {
1728  return resolve(normalizeOptionalString(value) ?? DEFAULT_CODE_ROOT_DIR);
1729}
1730
1731type CanonicalConductorConfig = Omit<ConductorConfig, "controlApiBase" | "publicApiBase"> & {
1732  controlApiBase: string;
1733  publicApiBase: string;
1734};
1735
1736export function resolveConductorRuntimeConfig(
1737  config: ConductorRuntimeConfig
1738): ResolvedConductorRuntimeConfig {
1739  const nodeId = normalizeOptionalString(config.nodeId);
1740  const host = normalizeOptionalString(config.host);
1741  const publicApiBase = resolveConfiguredPublicApiBase(config);
1742
1743  if (!nodeId) {
1744    throw new Error("Conductor config requires a non-empty nodeId.");
1745  }
1746
1747  if (!host) {
1748    throw new Error("Conductor config requires a non-empty host.");
1749  }
1750
1751  if (!publicApiBase) {
1752    throw new Error("Conductor config requires a non-empty publicApiBase.");
1753  }
1754
1755  const heartbeatIntervalMs = config.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
1756  const leaseRenewIntervalMs = config.leaseRenewIntervalMs ?? DEFAULT_LEASE_RENEW_INTERVAL_MS;
1757  const localApiAllowedHosts = parseLocalApiAllowedHosts(config.localApiAllowedHosts);
1758  const leaseTtlSec = config.leaseTtlSec ?? DEFAULT_LEASE_TTL_SEC;
1759  const renewFailureThreshold = config.renewFailureThreshold ?? DEFAULT_RENEW_FAILURE_THRESHOLD;
1760  const artifactInlineThreshold = config.artifactInlineThreshold ?? DEFAULT_BAA_DELIVERY_INLINE_THRESHOLD;
1761  const artifactSummaryLength = config.artifactSummaryLength ?? DEFAULT_SUMMARY_LENGTH;
1762  const timedJobsIntervalMs = config.timedJobsIntervalMs ?? DEFAULT_TIMED_JOBS_INTERVAL_MS;
1763  const timedJobsMaxMessagesPerTick =
1764    config.timedJobsMaxMessagesPerTick ?? DEFAULT_TIMED_JOBS_MAX_MESSAGES_PER_TICK;
1765  const timedJobsMaxTasksPerTick =
1766    config.timedJobsMaxTasksPerTick ?? DEFAULT_TIMED_JOBS_MAX_TASKS_PER_TICK;
1767  const timedJobsSettleDelayMs =
1768    config.timedJobsSettleDelayMs ?? DEFAULT_TIMED_JOBS_SETTLE_DELAY_MS;
1769  const uiSessionTtlSec = config.uiSessionTtlSec ?? DEFAULT_UI_SESSION_TTL_SEC;
1770  const priority = config.priority ?? (config.role === "primary" ? 100 : 50);
1771
1772  if (heartbeatIntervalMs <= 0) {
1773    throw new Error("Conductor heartbeatIntervalMs must be > 0.");
1774  }
1775
1776  if (leaseRenewIntervalMs <= 0) {
1777    throw new Error("Conductor leaseRenewIntervalMs must be > 0.");
1778  }
1779
1780  if (leaseTtlSec <= 0) {
1781    throw new Error("Conductor leaseTtlSec must be > 0.");
1782  }
1783
1784  if (renewFailureThreshold <= 0) {
1785    throw new Error("Conductor renewFailureThreshold must be > 0.");
1786  }
1787
1788  if (!Number.isInteger(artifactInlineThreshold) || artifactInlineThreshold <= 0) {
1789    throw new Error("Conductor artifactInlineThreshold must be a positive integer.");
1790  }
1791
1792  if (!Number.isInteger(artifactSummaryLength) || artifactSummaryLength <= 0) {
1793    throw new Error("Conductor artifactSummaryLength must be a positive integer.");
1794  }
1795
1796  if (!Number.isInteger(timedJobsIntervalMs) || timedJobsIntervalMs <= 0) {
1797    throw new Error("Conductor timedJobsIntervalMs must be a positive integer.");
1798  }
1799
1800  if (!Number.isInteger(timedJobsMaxMessagesPerTick) || timedJobsMaxMessagesPerTick <= 0) {
1801    throw new Error("Conductor timedJobsMaxMessagesPerTick must be a positive integer.");
1802  }
1803
1804  if (!Number.isInteger(timedJobsMaxTasksPerTick) || timedJobsMaxTasksPerTick <= 0) {
1805    throw new Error("Conductor timedJobsMaxTasksPerTick must be a positive integer.");
1806  }
1807
1808  if (!Number.isInteger(timedJobsSettleDelayMs) || timedJobsSettleDelayMs < 0) {
1809    throw new Error("Conductor timedJobsSettleDelayMs must be a non-negative integer.");
1810  }
1811
1812  if (!Number.isInteger(uiSessionTtlSec) || uiSessionTtlSec <= 0) {
1813    throw new Error("Conductor uiSessionTtlSec must be a positive integer.");
1814  }
1815
1816  return {
1817    ...config,
1818    artifactInlineThreshold,
1819    artifactSummaryLength,
1820    nodeId,
1821    host,
1822    role: parseConductorRole("Conductor role", config.role),
1823    controlApiBase: normalizeBaseUrl(publicApiBase),
1824    claudeCodedLocalApiBase: resolveLocalApiBase(config.claudeCodedLocalApiBase),
1825    codeRootDir: resolveCodeRootDir(config.codeRootDir),
1826    codexdLocalApiBase: resolveLocalApiBase(config.codexdLocalApiBase),
1827    heartbeatIntervalMs,
1828    leaseRenewIntervalMs,
1829    localApiAllowedHosts,
1830    leaseTtlSec,
1831    localApiBase: resolveLocalApiBase(config.localApiBase, localApiAllowedHosts),
1832    paths: resolvePathConfig(config.paths),
1833    preferred: config.preferred ?? config.role === "primary",
1834    priority,
1835    publicApiBase: normalizeBaseUrl(publicApiBase),
1836    renewFailureThreshold,
1837    sharedToken: normalizeOptionalString(config.sharedToken),
1838    timedJobsIntervalMs,
1839    timedJobsMaxMessagesPerTick,
1840    timedJobsMaxTasksPerTick,
1841    timedJobsSettleDelayMs,
1842    uiBrowserAdminPassword: normalizeOptionalString(config.uiBrowserAdminPassword),
1843    uiReadonlyPassword: normalizeOptionalString(config.uiReadonlyPassword),
1844    uiSessionTtlSec,
1845    version: normalizeOptionalString(config.version)
1846  };
1847}
1848
1849function readOptionValue(argv: string[], option: string, index: number): string {
1850  const value = argv[index + 1];
1851
1852  if (!value || value.startsWith("--")) {
1853    throw new Error(`${option} requires a value.`);
1854  }
1855
1856  return value;
1857}
1858
1859function isCliAction(value: string): value is ConductorCliAction {
1860  return value === "checklist" || value === "config" || value === "help" || value === "start";
1861}
1862
1863function resolveRuntimeConfigFromSources(
1864  env: ConductorEnvironment,
1865  overrides: CliValueOverrides
1866): ResolvedConductorRuntimeConfig {
1867  const role = parseConductorRole(
1868    "Conductor role",
1869    normalizeOptionalString(overrides.role ?? env.BAA_CONDUCTOR_ROLE) ?? "primary"
1870  );
1871  const host = normalizeOptionalString(overrides.host ?? env.BAA_CONDUCTOR_HOST) ?? "localhost";
1872  const nodeId =
1873    normalizeOptionalString(overrides.nodeId ?? env.BAA_NODE_ID) ?? createDefaultNodeId(host, role);
1874  const publicApiBase = resolveSourcePublicApiBase(env, overrides);
1875
1876  if (!publicApiBase) {
1877    throw new Error(
1878      "Missing conductor public API base URL. Use --public-api-base or BAA_CONDUCTOR_PUBLIC_API_BASE. Legacy aliases: --control-api-base / BAA_CONTROL_API_BASE."
1879    );
1880  }
1881
1882  return resolveConductorRuntimeConfig({
1883    nodeId,
1884    host,
1885    role,
1886    publicApiBase,
1887    controlApiBase: publicApiBase,
1888    artifactInlineThreshold: parseIntegerValue(
1889      "Conductor artifact inline threshold",
1890      overrides.artifactInlineThreshold ?? env.BAA_ARTIFACT_INLINE_THRESHOLD,
1891      { minimum: 1 }
1892    ),
1893    artifactSummaryLength: parseIntegerValue(
1894      "Conductor artifact summary length",
1895      overrides.artifactSummaryLength ?? env.BAA_ARTIFACT_SUMMARY_LENGTH,
1896      { minimum: 1 }
1897    ),
1898    priority: parseIntegerValue("Conductor priority", overrides.priority ?? env.BAA_CONDUCTOR_PRIORITY, {
1899      minimum: 0
1900    }),
1901    version: normalizeOptionalString(overrides.version ?? env.BAA_CONDUCTOR_VERSION),
1902    preferred:
1903      parseBooleanValue(
1904        "Conductor preferred flag",
1905        overrides.preferred ?? normalizeOptionalString(env.BAA_CONDUCTOR_PREFERRED)
1906      ) ?? role === "primary",
1907    heartbeatIntervalMs: parseIntegerValue(
1908      "Conductor heartbeat interval",
1909      overrides.heartbeatIntervalMs ?? env.BAA_CONDUCTOR_HEARTBEAT_INTERVAL_MS,
1910      { minimum: 1 }
1911    ),
1912    leaseRenewIntervalMs: parseIntegerValue(
1913      "Conductor lease renew interval",
1914      overrides.leaseRenewIntervalMs ?? env.BAA_CONDUCTOR_LEASE_RENEW_INTERVAL_MS,
1915      { minimum: 1 }
1916    ),
1917    leaseTtlSec: parseIntegerValue(
1918      "Conductor lease ttl",
1919      overrides.leaseTtlSec ?? env.BAA_CONDUCTOR_LEASE_TTL_SEC,
1920      { minimum: 1 }
1921    ),
1922    renewFailureThreshold: parseIntegerValue(
1923      "Conductor renew failure threshold",
1924      overrides.renewFailureThreshold ?? env.BAA_CONDUCTOR_RENEW_FAILURE_THRESHOLD,
1925      { minimum: 1 }
1926    ),
1927    timedJobsIntervalMs: parseIntegerValue(
1928      "Conductor timed jobs interval",
1929      overrides.timedJobsIntervalMs ?? env.BAA_TIMED_JOBS_INTERVAL_MS,
1930      { minimum: 1 }
1931    ),
1932    timedJobsMaxMessagesPerTick: parseIntegerValue(
1933      "Conductor timed jobs max messages per tick",
1934      overrides.timedJobsMaxMessagesPerTick ?? env.BAA_TIMED_JOBS_MAX_MESSAGES_PER_TICK,
1935      { minimum: 1 }
1936    ),
1937    timedJobsMaxTasksPerTick: parseIntegerValue(
1938      "Conductor timed jobs max tasks per tick",
1939      overrides.timedJobsMaxTasksPerTick ?? env.BAA_TIMED_JOBS_MAX_TASKS_PER_TICK,
1940      { minimum: 1 }
1941    ),
1942    timedJobsSettleDelayMs: parseIntegerValue(
1943      "Conductor timed jobs settle delay",
1944      overrides.timedJobsSettleDelayMs ?? env.BAA_TIMED_JOBS_SETTLE_DELAY_MS,
1945      { minimum: 0 }
1946    ),
1947    claudeCodedLocalApiBase: normalizeOptionalString(
1948      overrides.claudeCodedLocalApiBase ?? env.BAA_CLAUDE_CODED_LOCAL_API_BASE
1949    ),
1950    codeRootDir: normalizeOptionalString(env.BAA_CODE_ROOT_DIR),
1951    codexdLocalApiBase: normalizeOptionalString(
1952      overrides.codexdLocalApiBase ?? env.BAA_CODEXD_LOCAL_API_BASE
1953    ),
1954    localApiAllowedHosts: overrides.localApiAllowedHosts ?? env.BAA_CONDUCTOR_LOCAL_API_ALLOWED_HOSTS,
1955    localApiBase: normalizeOptionalString(overrides.localApiBase ?? env.BAA_CONDUCTOR_LOCAL_API),
1956    sharedToken: normalizeOptionalString(overrides.sharedToken ?? env.BAA_SHARED_TOKEN),
1957    uiBrowserAdminPassword: normalizeOptionalString(env.BAA_UI_BROWSER_ADMIN_PASSWORD),
1958    uiReadonlyPassword: normalizeOptionalString(env.BAA_UI_READONLY_PASSWORD),
1959    uiSessionTtlSec: parseIntegerValue("Conductor UI session TTL", env.BAA_UI_SESSION_TTL_SEC, {
1960      minimum: 1
1961    }),
1962    paths: {
1963      logsDir: normalizeOptionalString(overrides.logsDir ?? env.BAA_LOGS_DIR),
1964      runsDir: normalizeOptionalString(overrides.runsDir ?? env.BAA_RUNS_DIR),
1965      stateDir: normalizeOptionalString(overrides.stateDir ?? env.BAA_STATE_DIR),
1966      tmpDir: normalizeOptionalString(overrides.tmpDir ?? env.BAA_TMP_DIR),
1967      worktreesDir: normalizeOptionalString(overrides.worktreesDir ?? env.BAA_WORKTREES_DIR)
1968    }
1969  });
1970}
1971
1972export function parseConductorCliRequest(
1973  argv: readonly string[],
1974  env: ConductorEnvironment = {}
1975): ConductorCliRequest {
1976  const tokens = [...argv];
1977  let action: ConductorCliAction = "start";
1978
1979  if (tokens[0] && !tokens[0].startsWith("--")) {
1980    const maybeAction = tokens.shift();
1981
1982    if (!maybeAction || !isCliAction(maybeAction)) {
1983      throw new Error(`Unknown conductor command: ${maybeAction ?? "<empty>"}.`);
1984    }
1985
1986    action = maybeAction;
1987  }
1988
1989  let printJson = false;
1990  let showHelp = action === "help";
1991  const overrides: CliValueOverrides = {
1992    runOnce: false
1993  };
1994
1995  for (let index = 0; index < tokens.length; index += 1) {
1996    const token = tokens[index];
1997
1998    switch (token) {
1999      case "--help":
2000        showHelp = true;
2001        break;
2002      case "--json":
2003        printJson = true;
2004        break;
2005      case "--run-once":
2006        overrides.runOnce = true;
2007        break;
2008      case "--preferred":
2009        overrides.preferred = true;
2010        break;
2011      case "--no-preferred":
2012        overrides.preferred = false;
2013        break;
2014      case "--node-id":
2015        overrides.nodeId = readOptionValue(tokens, token, index);
2016        index += 1;
2017        break;
2018      case "--host":
2019        overrides.host = readOptionValue(tokens, token, index);
2020        index += 1;
2021        break;
2022      case "--role":
2023        overrides.role = readOptionValue(tokens, token, index);
2024        index += 1;
2025        break;
2026      case "--public-api-base":
2027        overrides.publicApiBase = readOptionValue(tokens, token, index);
2028        index += 1;
2029        break;
2030      case "--control-api-base":
2031        overrides.controlApiBase = readOptionValue(tokens, token, index);
2032        index += 1;
2033        break;
2034      case "--codexd-local-api":
2035        overrides.codexdLocalApiBase = readOptionValue(tokens, token, index);
2036        index += 1;
2037        break;
2038      case "--claude-coded-local-api":
2039        overrides.claudeCodedLocalApiBase = readOptionValue(tokens, token, index);
2040        index += 1;
2041        break;
2042      case "--local-api":
2043        overrides.localApiBase = readOptionValue(tokens, token, index);
2044        index += 1;
2045        break;
2046      case "--shared-token":
2047        overrides.sharedToken = readOptionValue(tokens, token, index);
2048        index += 1;
2049        break;
2050      case "--artifact-inline-threshold":
2051        overrides.artifactInlineThreshold = readOptionValue(tokens, token, index);
2052        index += 1;
2053        break;
2054      case "--artifact-summary-length":
2055        overrides.artifactSummaryLength = readOptionValue(tokens, token, index);
2056        index += 1;
2057        break;
2058      case "--priority":
2059        overrides.priority = readOptionValue(tokens, token, index);
2060        index += 1;
2061        break;
2062      case "--version":
2063        overrides.version = readOptionValue(tokens, token, index);
2064        index += 1;
2065        break;
2066      case "--heartbeat-interval-ms":
2067        overrides.heartbeatIntervalMs = readOptionValue(tokens, token, index);
2068        index += 1;
2069        break;
2070      case "--lease-renew-interval-ms":
2071        overrides.leaseRenewIntervalMs = readOptionValue(tokens, token, index);
2072        index += 1;
2073        break;
2074      case "--lease-ttl-sec":
2075        overrides.leaseTtlSec = readOptionValue(tokens, token, index);
2076        index += 1;
2077        break;
2078      case "--renew-failure-threshold":
2079        overrides.renewFailureThreshold = readOptionValue(tokens, token, index);
2080        index += 1;
2081        break;
2082      case "--timed-jobs-interval-ms":
2083        overrides.timedJobsIntervalMs = readOptionValue(tokens, token, index);
2084        index += 1;
2085        break;
2086      case "--timed-jobs-max-messages-per-tick":
2087        overrides.timedJobsMaxMessagesPerTick = readOptionValue(tokens, token, index);
2088        index += 1;
2089        break;
2090      case "--timed-jobs-max-tasks-per-tick":
2091        overrides.timedJobsMaxTasksPerTick = readOptionValue(tokens, token, index);
2092        index += 1;
2093        break;
2094      case "--timed-jobs-settle-delay-ms":
2095        overrides.timedJobsSettleDelayMs = readOptionValue(tokens, token, index);
2096        index += 1;
2097        break;
2098      case "--runs-dir":
2099        overrides.runsDir = readOptionValue(tokens, token, index);
2100        index += 1;
2101        break;
2102      case "--worktrees-dir":
2103        overrides.worktreesDir = readOptionValue(tokens, token, index);
2104        index += 1;
2105        break;
2106      case "--logs-dir":
2107        overrides.logsDir = readOptionValue(tokens, token, index);
2108        index += 1;
2109        break;
2110      case "--tmp-dir":
2111        overrides.tmpDir = readOptionValue(tokens, token, index);
2112        index += 1;
2113        break;
2114      case "--state-dir":
2115        overrides.stateDir = readOptionValue(tokens, token, index);
2116        index += 1;
2117        break;
2118      default:
2119        throw new Error(`Unknown conductor option: ${token}.`);
2120    }
2121  }
2122
2123  if (showHelp) {
2124    return { action: "help" };
2125  }
2126
2127  if (action === "checklist") {
2128    return {
2129      action,
2130      printJson
2131    };
2132  }
2133
2134  const config = resolveRuntimeConfigFromSources(env, overrides);
2135
2136  if (action === "config") {
2137    return {
2138      action,
2139      config,
2140      printJson
2141    };
2142  }
2143
2144  return {
2145    action: "start",
2146    config,
2147    printJson,
2148    runOnce: overrides.runOnce
2149  };
2150}
2151
2152function usesPlaceholderToken(token: string | null): boolean {
2153  return token === "replace-me";
2154}
2155
2156function buildRuntimeWarnings(config: ResolvedConductorRuntimeConfig): string[] {
2157  const warnings: string[] = [];
2158
2159  if (config.sharedToken == null) {
2160    warnings.push("BAA_SHARED_TOKEN is not configured; authenticated Control API requests may fail.");
2161  } else if (usesPlaceholderToken(config.sharedToken)) {
2162    warnings.push("BAA_SHARED_TOKEN is still set to replace-me; replace it before production rollout.");
2163  }
2164
2165  if (config.uiBrowserAdminPassword == null && config.uiReadonlyPassword == null) {
2166    warnings.push("No UI session password is configured; /app/login cannot authenticate any browser session.");
2167  }
2168
2169  if (config.localApiBase == null) {
2170    warnings.push("BAA_CONDUCTOR_LOCAL_API is not configured; only the in-process snapshot interface is available.");
2171  }
2172
2173  if (config.codexdLocalApiBase == null) {
2174    warnings.push("BAA_CODEXD_LOCAL_API_BASE is not configured; /v1/codex routes will stay unavailable.");
2175  }
2176
2177  if (config.claudeCodedLocalApiBase == null) {
2178    warnings.push("BAA_CLAUDE_CODED_LOCAL_API_BASE is not configured; /v1/claude-coded routes will stay unavailable.");
2179  }
2180
2181  if (config.leaseRenewIntervalMs >= config.leaseTtlSec * 1_000) {
2182    warnings.push("lease renew interval is >= lease TTL; leader renewals may race with lease expiry.");
2183  }
2184
2185  return warnings;
2186}
2187
2188function getProcessLike(): ConductorProcessLike | undefined {
2189  return (globalThis as { process?: ConductorProcessLike }).process;
2190}
2191
2192function writeLine(writer: ConductorOutputWriter, line: string): void {
2193  if ("write" in writer) {
2194    writer.write(`${line}\n`);
2195    return;
2196  }
2197
2198  writer.log(line);
2199}
2200
2201function formatChecklistText(checklist: StartupChecklistItem[]): string {
2202  return checklist.map((item, index) => `${index + 1}. ${item.key}: ${item.description}`).join("\n");
2203}
2204
2205function formatConfigText(config: ResolvedConductorRuntimeConfig): string {
2206  return [
2207    `identity: ${config.nodeId}@${config.host}(${config.role})`,
2208    `public_api_base: ${config.publicApiBase}`,
2209    `claude_coded_local_api_base: ${config.claudeCodedLocalApiBase ?? "not-configured"}`,
2210    `codexd_local_api_base: ${config.codexdLocalApiBase ?? "not-configured"}`,
2211    `code_root_dir: ${config.codeRootDir}`,
2212    `local_api_base: ${config.localApiBase ?? "not-configured"}`,
2213    `browser_ws_url: ${buildBrowserWebSocketUrl(config.localApiBase) ?? "not-configured"}`,
2214    `firefox_ws_url: ${buildFirefoxWebSocketUrl(config.localApiBase) ?? "not-configured"}`,
2215    `local_api_allowed_hosts: ${config.localApiAllowedHosts.join(",") || "loopback-only"}`,
2216    `priority: ${config.priority}`,
2217    `preferred: ${String(config.preferred)}`,
2218    `artifact_inline_threshold: ${config.artifactInlineThreshold}`,
2219    `artifact_summary_length: ${config.artifactSummaryLength}`,
2220    `heartbeat_interval_ms: ${config.heartbeatIntervalMs}`,
2221    `lease_renew_interval_ms: ${config.leaseRenewIntervalMs}`,
2222    `lease_ttl_sec: ${config.leaseTtlSec}`,
2223    `renew_failure_threshold: ${config.renewFailureThreshold}`,
2224    `timed_jobs_interval_ms: ${config.timedJobsIntervalMs}`,
2225    `timed_jobs_max_messages_per_tick: ${config.timedJobsMaxMessagesPerTick}`,
2226    `timed_jobs_max_tasks_per_tick: ${config.timedJobsMaxTasksPerTick}`,
2227    `timed_jobs_settle_delay_ms: ${config.timedJobsSettleDelayMs}`,
2228    `shared_token_configured: ${String(config.sharedToken != null)}`,
2229    `runs_dir: ${config.paths.runsDir ?? "not-configured"}`,
2230    `worktrees_dir: ${config.paths.worktreesDir ?? "not-configured"}`,
2231    `logs_dir: ${config.paths.logsDir ?? "not-configured"}`,
2232    `tmp_dir: ${config.paths.tmpDir ?? "not-configured"}`,
2233    `state_dir: ${config.paths.stateDir ?? "not-configured"}`
2234  ].join("\n");
2235}
2236
2237function formatRuntimeSummary(snapshot: ConductorRuntimeSnapshot): string {
2238  return [
2239    `identity=${snapshot.identity}`,
2240    `lease=${snapshot.daemon.leaseState}`,
2241    `leader=${snapshot.daemon.currentLeaderId ?? "none"}`,
2242    `term=${snapshot.daemon.currentTerm ?? "none"}`,
2243    `scheduler=${snapshot.daemon.schedulerEnabled ? "enabled" : "disabled"}`,
2244    `heartbeat_loop=${snapshot.loops.heartbeat ? "running" : "stopped"}`,
2245    `lease_loop=${snapshot.loops.lease ? "running" : "stopped"}`
2246  ].join(" ");
2247}
2248
2249function getUsageText(): string {
2250  return [
2251    "Usage:",
2252    "  node apps/conductor-daemon/dist/index.js [start] [options]",
2253    "  node --experimental-strip-types apps/conductor-daemon/src/index.ts [start] [options]",
2254    "  node .../index.js config [options]",
2255    "  node .../index.js checklist [--json]",
2256    "  node .../index.js help",
2257    "",
2258    "Options:",
2259    "  --node-id <id>",
2260    "  --host <host>",
2261    "  --role <primary|standby>",
2262    "  --public-api-base <url>   conductor upstream/public API base",
2263    "  --control-api-base <url>  legacy alias for --public-api-base",
2264    "  --codexd-local-api <url>",
2265    "  --local-api <url>",
2266    "  --shared-token <token>",
2267    "  --artifact-inline-threshold <integer>",
2268    "  --artifact-summary-length <integer>",
2269    "  --priority <integer>",
2270    "  --version <string>",
2271    "  --preferred | --no-preferred",
2272    "  --heartbeat-interval-ms <integer>",
2273    "  --lease-renew-interval-ms <integer>",
2274    "  --lease-ttl-sec <integer>",
2275    "  --renew-failure-threshold <integer>",
2276    "  --timed-jobs-interval-ms <integer>",
2277    "  --timed-jobs-max-messages-per-tick <integer>",
2278    "  --timed-jobs-max-tasks-per-tick <integer>",
2279    "  --timed-jobs-settle-delay-ms <integer>",
2280    "  --runs-dir <path>",
2281    "  --worktrees-dir <path>",
2282    "  --logs-dir <path>",
2283    "  --tmp-dir <path>",
2284    "  --state-dir <path>",
2285    "  --run-once",
2286    "  --json",
2287    "  --help",
2288    "",
2289    "Environment:",
2290    "  BAA_NODE_ID",
2291    "  BAA_CONDUCTOR_HOST",
2292    "  BAA_CONDUCTOR_ROLE",
2293    "  BAA_CONDUCTOR_PUBLIC_API_BASE",
2294    "  BAA_CONTROL_API_BASE (legacy env alias for conductor upstream/public API base)",
2295    "  BAA_CODE_ROOT_DIR",
2296    "  BAA_CODEXD_LOCAL_API_BASE",
2297    "  BAA_CONDUCTOR_LOCAL_API",
2298    "  BAA_CONDUCTOR_LOCAL_API_ALLOWED_HOSTS",
2299    "  BAA_SHARED_TOKEN",
2300    "  BAA_UI_BROWSER_ADMIN_PASSWORD",
2301    "  BAA_UI_READONLY_PASSWORD",
2302    "  BAA_UI_SESSION_TTL_SEC",
2303    "  BAA_ARTIFACT_INLINE_THRESHOLD",
2304    "  BAA_ARTIFACT_SUMMARY_LENGTH",
2305    "  BAA_CONDUCTOR_PRIORITY",
2306    "  BAA_CONDUCTOR_VERSION",
2307    "  BAA_CONDUCTOR_PREFERRED",
2308    "  BAA_CONDUCTOR_HEARTBEAT_INTERVAL_MS",
2309    "  BAA_CONDUCTOR_LEASE_RENEW_INTERVAL_MS",
2310    "  BAA_CONDUCTOR_LEASE_TTL_SEC",
2311    "  BAA_CONDUCTOR_RENEW_FAILURE_THRESHOLD",
2312    "  BAA_TIMED_JOBS_INTERVAL_MS",
2313    "  BAA_TIMED_JOBS_MAX_MESSAGES_PER_TICK",
2314    "  BAA_TIMED_JOBS_MAX_TASKS_PER_TICK",
2315    "  BAA_TIMED_JOBS_SETTLE_DELAY_MS",
2316    "  BAA_RUNS_DIR",
2317    "  BAA_WORKTREES_DIR",
2318    "  BAA_LOGS_DIR",
2319    "  BAA_TMP_DIR",
2320    "  BAA_STATE_DIR",
2321    "",
2322    "Precedence:",
2323    "  CLI values override environment values.",
2324    "  --public-api-base / BAA_CONDUCTOR_PUBLIC_API_BASE override legacy control-api aliases."
2325  ].join("\n");
2326}
2327
2328export class ConductorRuntime {
2329  private readonly artifactStore: ArtifactStore;
2330  private readonly config: ResolvedConductorRuntimeConfig;
2331  private readonly daemon: ConductorDaemon;
2332  private readonly d1SyncWorker: D1SyncWorker | null;
2333  private readonly localControlPlane: ConductorLocalControlPlane;
2334  private readonly localApiServer: ConductorLocalHttpServer | null;
2335  private localControlPlaneInitialized = false;
2336  private readonly now: () => number;
2337  private readonly timedJobs: ConductorTimedJobs;
2338  private started = false;
2339
2340  constructor(config: ConductorRuntimeConfig, options: ConductorRuntimeOptions = {}) {
2341    this.now = options.now ?? defaultNowUnixSeconds;
2342    const startedAt = config.startedAt ?? this.now();
2343
2344    this.config = resolveConductorRuntimeConfig({
2345      ...config,
2346      startedAt
2347    });
2348    const resolvedStateDir = this.config.paths.stateDir ?? resolveDefaultConductorStateDir();
2349    this.localControlPlane = new ConductorLocalControlPlane({
2350      stateDir: resolvedStateDir
2351    });
2352    this.artifactStore = new ArtifactStore({
2353      artifactDir: join(resolvedStateDir, ARTIFACTS_DIRNAME),
2354      databasePath: join(resolvedStateDir, ARTIFACT_DB_FILENAME),
2355      publicBaseUrl: this.config.publicApiBase,
2356      summaryLength: this.config.artifactSummaryLength
2357    });
2358    this.daemon = new ConductorDaemon(this.config, {
2359      ...options,
2360      client:
2361        options.client ?? createRepositoryControlApiClient(this.localControlPlane.repository, this.now),
2362      controlApiBearerToken: options.controlApiBearerToken ?? this.config.sharedToken,
2363      now: this.now
2364    });
2365    const ingestLogDir = resolveIngestLogDir(this.config.paths.logsDir);
2366    const pluginDiagnosticLogDir = resolvePluginDiagnosticLogDir(this.config.paths.logsDir);
2367    const timedJobsLogDir = resolveTimedJobsLogDir(this.config.paths.logsDir);
2368    this.timedJobs = new ConductorTimedJobs(
2369      {
2370        intervalMs: this.config.timedJobsIntervalMs,
2371        maxMessagesPerTick: this.config.timedJobsMaxMessagesPerTick,
2372        maxTasksPerTick: this.config.timedJobsMaxTasksPerTick,
2373        settleDelayMs: this.config.timedJobsSettleDelayMs
2374      },
2375      {
2376        artifactStore: this.artifactStore,
2377        autoStart: options.autoStartLoops ?? true,
2378        clearIntervalImpl: options.clearIntervalImpl,
2379        logDir: timedJobsLogDir,
2380        schedule: async (work) => {
2381          const automationState = await this.localControlPlane.repository.getAutomationState();
2382          const automationMode = automationState?.mode ?? DEFAULT_AUTOMATION_MODE;
2383
2384          if (automationMode === "paused") {
2385            return "skipped_system_paused";
2386          }
2387
2388          return this.daemon.runSchedulerPass(work);
2389        },
2390        setIntervalImpl: options.setIntervalImpl
2391      }
2392    );
2393    this.timedJobs.registerRunner(
2394      createRenewalProjectorRunner({
2395        now: () => this.now() * 1000,
2396        repository: this.localControlPlane.repository
2397      })
2398    );
2399    this.localApiServer =
2400      this.config.localApiBase == null
2401        ? null
2402        : new ConductorLocalHttpServer(
2403            this.config.localApiBase,
2404            this.localControlPlane.repository,
2405            this.artifactStore,
2406            () => this.getRuntimeSnapshot(),
2407            this.config.codeRootDir,
2408            this.config.codexdLocalApiBase,
2409            this.config.claudeCodedLocalApiBase,
2410            options.fetchImpl ?? globalThis.fetch,
2411            this.config.sharedToken,
2412            this.config.version,
2413            this.now,
2414            this.config.uiBrowserAdminPassword,
2415            this.config.uiReadonlyPassword,
2416            this.config.uiSessionTtlSec,
2417            this.config.artifactInlineThreshold,
2418            this.config.artifactSummaryLength,
2419            options.browserRequestPolicyOptions,
2420            ingestLogDir,
2421            pluginDiagnosticLogDir
2422          );
2423    this.timedJobs.registerRunner(
2424      createRenewalDispatcherRunner({
2425        browserBridge:
2426          this.localApiServer?.getFirefoxBridgeService() as unknown as BrowserBridgeController ?? null,
2427        now: () => this.now() * 1000
2428      })
2429    );
2430
2431    // D1 sync worker — silently skipped when env vars are not set.
2432    this.d1SyncWorker = createD1SyncWorker({
2433      env: options.env ?? getProcessLike()?.env ?? {},
2434      databasePath: join(resolvedStateDir, ARTIFACT_DB_FILENAME),
2435      fetchImpl: options.fetchImpl
2436    });
2437
2438    // Inject sync queue into artifact store so local writes enqueue D1 sync records.
2439    if (this.d1SyncWorker != null) {
2440      this.artifactStore.setSyncQueue(this.d1SyncWorker.getQueue());
2441    }
2442  }
2443
2444  async start(): Promise<ConductorRuntimeSnapshot> {
2445    if (!this.started) {
2446      if (!this.localControlPlaneInitialized) {
2447        await this.localControlPlane.initialize();
2448        this.localControlPlaneInitialized = true;
2449      }
2450
2451      await this.artifactStore.recoverAutomationRuntimeState({
2452        now: this.now() * 1000,
2453        renewalRecoveryDelayMs: Math.max(
2454          DEFAULT_RESTART_RENEWAL_RECOVERY_DELAY_MS,
2455          this.config.timedJobsIntervalMs
2456        )
2457      });
2458
2459      await this.daemon.start();
2460
2461      try {
2462        await this.localApiServer?.start();
2463        await this.timedJobs.start();
2464        this.started = true;
2465      } catch (error) {
2466        this.started = false;
2467        await this.timedJobs.stop();
2468        await this.localApiServer?.stop();
2469        await this.daemon.stop();
2470        throw error;
2471      }
2472
2473      this.d1SyncWorker?.start();
2474    }
2475
2476    return this.getRuntimeSnapshot();
2477  }
2478
2479  async stop(): Promise<ConductorRuntimeSnapshot> {
2480    this.started = false;
2481    this.d1SyncWorker?.stop();
2482    await this.timedJobs.stop();
2483    await this.daemon.stop();
2484    await this.localApiServer?.stop();
2485
2486    return this.getRuntimeSnapshot();
2487  }
2488
2489  getRuntimeSnapshot(now: number = this.now()): ConductorRuntimeSnapshot {
2490    const localApiBase = this.localApiServer?.getBaseUrl() ?? this.config.localApiBase;
2491    const browserWsUrl =
2492      this.localApiServer?.getBrowserWebSocketUrl() ?? buildBrowserWebSocketUrl(localApiBase);
2493    const firefoxWsUrl =
2494      this.localApiServer?.getFirefoxWebSocketUrl() ?? buildFirefoxWebSocketUrl(localApiBase);
2495
2496    return {
2497      daemon: this.daemon.getStatusSnapshot(now),
2498      identity: this.daemon.describeIdentity(),
2499      loops: this.daemon.getLoopStatus(),
2500      paths: { ...this.config.paths },
2501      controlApi: {
2502        baseUrl: this.config.publicApiBase,
2503        browserWsUrl,
2504        firefoxWsUrl,
2505        localApiBase,
2506        hasSharedToken: this.config.sharedToken != null,
2507        usesPlaceholderToken: usesPlaceholderToken(this.config.sharedToken)
2508      },
2509      claudeCoded: {
2510        localApiBase: this.config.claudeCodedLocalApiBase
2511      },
2512      codexd: {
2513        localApiBase: this.config.codexdLocalApiBase
2514      },
2515      runtime: {
2516        pid: getProcessLike()?.pid ?? null,
2517        started: this.started,
2518        startedAt: this.config.startedAt ?? now
2519      },
2520      startupChecklist: this.daemon.getStartupChecklist(),
2521      warnings: buildRuntimeWarnings(this.config)
2522    };
2523  }
2524
2525  getFirefoxBridgeService(): FirefoxBridgeService | null {
2526    return this.localApiServer?.getFirefoxBridgeService() ?? null;
2527  }
2528}
2529
2530async function waitForShutdownSignal(processLike: ConductorProcessLike | undefined): Promise<string | null> {
2531  const subscribe = processLike?.on;
2532
2533  if (!subscribe || !processLike) {
2534    return null;
2535  }
2536
2537  return new Promise((resolve) => {
2538    const signals = ["SIGINT", "SIGTERM"] as const;
2539    const listeners: Partial<Record<(typeof signals)[number], () => void>> = {};
2540    const cleanup = () => {
2541      if (!processLike.off) {
2542        return;
2543      }
2544
2545      for (const signal of signals) {
2546        const listener = listeners[signal];
2547
2548        if (listener) {
2549          processLike.off(signal, listener);
2550        }
2551      }
2552    };
2553
2554    for (const signal of signals) {
2555      const listener = () => {
2556        cleanup();
2557        resolve(signal);
2558      };
2559      listeners[signal] = listener;
2560      subscribe.call(processLike, signal, listener);
2561    }
2562  });
2563}
2564
2565export async function runConductorCli(options: RunConductorCliOptions = {}): Promise<number> {
2566  const processLike = options.processLike ?? getProcessLike();
2567  const stdout = options.stdout ?? console;
2568  const stderr = options.stderr ?? console;
2569  const argv = options.argv ?? processLike?.argv?.slice(2) ?? [];
2570  const env = options.env ?? processLike?.env ?? {};
2571  const request = parseConductorCliRequest(argv, env);
2572
2573  if (request.action === "help") {
2574    writeLine(stdout, getUsageText());
2575    return 0;
2576  }
2577
2578  if (request.action === "checklist") {
2579    const checklist = getDefaultStartupChecklist();
2580
2581    if (request.printJson) {
2582      writeLine(stdout, JSON.stringify(checklist, null, 2));
2583    } else {
2584      writeLine(stdout, formatChecklistText(checklist));
2585    }
2586
2587    return 0;
2588  }
2589
2590  if (request.action === "config") {
2591    if (request.printJson) {
2592      writeLine(stdout, JSON.stringify(request.config, null, 2));
2593    } else {
2594      writeLine(stdout, formatConfigText(request.config));
2595    }
2596
2597    for (const warning of buildRuntimeWarnings(request.config)) {
2598      writeLine(stderr, `warning: ${warning}`);
2599    }
2600
2601    return 0;
2602  }
2603
2604  const runtime = new ConductorRuntime(request.config, {
2605    autoStartLoops: !request.runOnce,
2606    env,
2607    fetchImpl: options.fetchImpl
2608  });
2609  const snapshot = await runtime.start();
2610
2611  if (request.printJson || request.runOnce) {
2612    writeLine(stdout, JSON.stringify(snapshot, null, 2));
2613  } else {
2614    writeLine(stdout, formatRuntimeSummary(snapshot));
2615  }
2616
2617  for (const warning of snapshot.warnings) {
2618    writeLine(stderr, `warning: ${warning}`);
2619  }
2620
2621  if (request.runOnce) {
2622    await runtime.stop();
2623    return 0;
2624  }
2625
2626  const signal = await waitForShutdownSignal(processLike);
2627  await runtime.stop();
2628
2629  if (!request.printJson) {
2630    writeLine(stdout, `conductor stopped${signal ? ` after ${signal}` : ""}`);
2631  }
2632
2633  return 0;
2634}
2635
2636function normalizeFilePath(path: string): string {
2637  return path.replace(/\\/gu, "/");
2638}
2639
2640function fileUrlToPath(fileUrl: string): string {
2641  if (!fileUrl.startsWith("file://")) {
2642    return fileUrl;
2643  }
2644
2645  const decoded = decodeURIComponent(fileUrl.slice("file://".length));
2646  return decoded.startsWith("/") && /^[A-Za-z]:/u.test(decoded.slice(1)) ? decoded.slice(1) : decoded;
2647}
2648
2649function isMainModule(metaUrl: string, argv: readonly string[]): boolean {
2650  const entryPoint = argv[1];
2651
2652  if (!entryPoint) {
2653    return false;
2654  }
2655
2656  return normalizeFilePath(entryPoint) === normalizeFilePath(fileUrlToPath(metaUrl));
2657}
2658
2659if (isMainModule(import.meta.url, getProcessLike()?.argv ?? [])) {
2660  void runConductorCli().then(
2661    (exitCode) => {
2662      const processLike = getProcessLike();
2663
2664      if (processLike) {
2665        processLike.exitCode = exitCode;
2666      }
2667    },
2668    (error: unknown) => {
2669      console.error(error instanceof Error ? error.stack ?? error.message : String(error));
2670      const processLike = getProcessLike();
2671
2672      if (processLike) {
2673        processLike.exitCode = 1;
2674      }
2675    }
2676  );
2677}