Skip to content

Entry and startup ​

源码版本v2026.6.11

Responsibilities ​

Everything between pressing Enter on the openclaw command and the first real business command actually starting belongs to the "entry" layer: Node version checks, compile-cache path selection, help / version fast paths, signal forwarding, and finally handing control to dist/entry.js. entry.js then re-normalizes the environment, parses profile / container options, decides whether to respawn, and calls runMainOrRootHelp to hand argv off to CLI dispatch.

The entry is split into two layers for a reason: openclaw.mjs is plain Node ESM with no TypeScript dependency and is independently executable. It handles "things that must be decided before the TS-compiled artifact loads". src/entry.ts is the TS-compiled entry point and assumes dist/ already exists, so it can do more complex dynamic imports and tracing.

Design motivation ​

Why split the entry into two layers and pack so many fast paths into the first one? The root cause is cold-start latency. OpenClaw is a CLI, and users tolerate openclaw --version latency at the hundred-millisecond level, but fully loading Commander + plugins + config can take several hundred milliseconds or even a second. If every --version ran the full startup chain, the experience would be terrible.

The entry layer's strategy is: don't load what you don't need. The version number is read straight from package.json; the help text comes from a pre-compiled cli-startup-metadata.json. Only when a real command needs to run does it dynamic import dist/entry.js. This lets openclaw --version finish in tens of milliseconds.

The second layer, entry.ts, has its own fast paths: it first tries tryHandleRootHelpFastPath and tryHandlePrecomputedCommandHelpFastPath. Only when neither hits does it dynamic import ./cli/run-main.js. Each step on this import chain is await-ed, so when an earlier fast path hits, later code never loads.

The other motivation is compile-cache isolation. Node 22+'s module.enableCompileCache() can significantly accelerate ESM startup, but source checkouts and early Node 24 builds on Windows have deadlock bugs. The entry layer has a respawn block that detects whether the current process is a source checkout (isSourceCheckoutLauncher) or an affected Node 24 build, and if so respawns a child with compile cache disabled by setting NODE_DISABLE_COMPILE_CACHE=1 in the env. This decision must happen before the TS entry loads, otherwise the cache is already polluted.

Key files ​

Data flow ​

The version guard at the top of the first layer openclaw.mjs (openclaw.mjs:L38):

typescript
const ensureSupportedNodeVersion = () => {
  if (isSupportedNodeVersion(parseNodeVersion(process.versions.node))) {
    return;
  }

  process.stderr.write(
    `openclaw: Node.js v${MIN_NODE_VERSION}+ is required (current: v${process.versions.node}).\n` +
      "If you use nvm, run:\n" +
      `  nvm install ${MIN_NODE_MAJOR}\n` +
      `  nvm use ${MIN_NODE_MAJOR}\n` +
      `  nvm alias default ${MIN_NODE_MAJOR}\n`,
  );
  process.exit(1);
};

MIN_NODE_MAJOR=22 and MIN_NODE_MINOR=19 are hard gates — below that, it exits. After the version guard comes the compile-cache respawn decision (openclaw.mjs:L207):

typescript
const respawnWithoutCompileCacheIfNeeded = () => {
  const needsDisabledCompileCacheRespawn =
    isSourceCheckoutLauncher() || shouldSkipCompileCacheForWindowsNode24();
  if (!needsDisabledCompileCacheRespawn) {
    return false;
  }
  if (process.env[COMPILE_CACHE_DISABLED_RESPAWNED_ENV] === "1") {
    return false;
  }
  // ...
  return runRespawnedChild(
    process.execPath,
    [...process.execArgv, fileURLToPath(import.meta.url), ...process.argv.slice(2)],
    env,
  );
};

Note the self-cycle guard COMPILE_CACHE_DISABLED_RESPAWNED_ENV: the respawned child re-runs this block, but the env var is already "1", so it respawns at most once — no infinite recursion.

When the first layer falls back to dist/entry.js (openclaw.mjs:L765): only when all fast paths miss does it call tryImport("./dist/entry.js"). On failure it tries .mjs; if both fail, buildMissingEntryErrorMessage explains "this is an unbuilt source tree, run pnpm install && pnpm build". tryImport only swallows direct "module not found" errors — other errors propagate as-is, so a missing transitive import inside dist/entry.js doesn't get masked as "missing dist".

The core of the second-layer dispatch in entry.ts (entry.ts:L130):

typescript
if (!tryHandleRootVersionFastPath(process.argv)) {
  await runMainOrRootHelp(process.argv);
}

runMainOrRootHelp itself tries the help fast paths first (entry.ts:L271):

typescript
async function runMainOrRootHelp(argv: string[]): Promise<void> {
  if (await tryHandleRootHelpFastPath(argv)) {
    return;
  }
  if (await tryHandlePrecomputedCommandHelpFastPath(argv)) {
    return;
  }
  try {
    const { runCli } = await gatewayEntryStartupTrace.measure(
      "run-main-import",
      () => import("./cli/run-main.js"),
    );
    await runCli(argv);
  } catch (error) {
    const { formatCliFailureLines } = await import("./cli/failure-output.js");
    for (const line of formatCliFailureLines({
      title: "Could not start the CLI.",
      error,
      argv,
    })) {
      console.error(line);
    }
    process.exit(1);
  }
}

Note gatewayEntryStartupTrace.measure("run-main-import", ...) — this dynamic import of run-main.js is explicitly timed because it is the heaviest step in cold start (hundreds of milliseconds). When runCli throws, it doesn't just print error.stack; it routes through formatCliFailureLines to produce a user-friendly title plus trace, then process.exit(1).

There's also a key isMainModule guard at the top of entry.ts (entry.ts:L56):

typescript
if (
  !isMainModule({
    currentFile: fileURLToPath(import.meta.url),
    wrapperEntryPairs: [...ENTRY_WRAPPER_PAIRS],
  })
) {
  // Imported as a dependency — skip all entry-point side effects.
} else {
  // ... full startup logic
}

The comment explains why: a bundler may treat entry.js as a shared dependency imported by dist/index.js. Without this guard, two gateways would start and the second would crash failing to claim the port.

Boundaries and failures ​

  • respawn signal forwarding: runRespawnedChild (openclaw.mjs:L102) attaches SIGTERM / SIGINT / SIGHUP / SIGQUIT listeners to the child and forwards signals to it, with a 1-second process.exit fallback. This prevents a child from ignoring signals and hanging the launcher forever. Windows has a different signal set (only SIGTERM / SIGINT / SIGBREAK).
  • compile-cache path pollution: if NODE_COMPILE_CACHE points at an unwritable directory, module.enableCompileCache silently fails (openclaw.mjs:L267). The try / catch swallows the error on purpose — cache failure shouldn't block startup.
  • source checkout detection: isSourceCheckoutLauncher() checks for .git or src/entry.ts. If it's a source tree, compile cache is force-disabled (source can change at any time, so cached compile artifacts would go stale).
  • missing dist error: buildMissingEntryErrorMessage (openclaw.mjs:L354) detects whether src/entry.ts exists; if so, it suggests "this is an unbuilt source tree, run pnpm install && pnpm build" instead of just "file not found" — this distinction saves users minutes of investigation.
  • isMainModule double-start: the comment on the top guard of entry.ts (entry.ts:L56) notes that without it, the bundler would treat entry.js as a shared dependency, causing runCli to be called twice; the second instance crashes trying to claim the gateway lock / port.
  • respawn loop prevention: COMPILE_CACHE_DISABLED_RESPAWNED_ENV and OPENCLAW_PACKAGED_COMPILE_CACHE_RESPAWNED each guard one respawn class, guaranteeing at most one respawn.

Summary ​

The entry layer handles "things that can be resolved before TS loads": version guard, compile-cache decision, help / version fast paths, and signal forwarding. openclaw.mjs is a plain Node script that depends on no compiled artifact; entry.ts is the TS-compiled entry that adds the respawn plan, profile parsing, and a help fast-path fallback before handing argv to runCli to enter CLI command dispatch for real. How the dispatch routes argv to a specific subcli, takes the gateway hot path, and lazy-registers commands is on the next page; the gateway's own startup sequence is in Gateway core.

Official references: OpenClaw docs · README.