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.