Skip to content

入口与启动

源码版本v2026.6.11

职责

openclaw 命令敲下回车后,到第一条业务命令真正开始执行前,中间的所有事都归"入口 (entry)"管:Node 版本检查、compile cache 路径选择、help/version 快速路径、信号转发、最后把控制权交给 dist/entry.jsentry.js 接过手后再做一遍环境归一化、profile/container 选项解析、respawn 判定,然后调 runMainOrRootHelp 把 argv 交给 CLI 分发

入口分两层是有原因的:openclaw.mjs 是纯 Node ESM、无任何 TypeScript 依赖、可独立执行,它解决的是"在 TS 编译产物加载之前就要决定的事";src/entry.ts 是 TS 编译产物的入口,它假定 dist/ 已经存在,可以做更复杂的动态 import 与 trace。

设计动机

为什么把入口拆成两层、还要在第一层塞这么多 fast path?根因是冷启动延迟。OpenClaw 是个 CLI,用户对 openclaw --version 这种命令的延迟容忍度在百毫秒级,但完整加载 Commander + 插件 + 配置要几百毫秒甚至上秒。如果每次 --version 都走完整启动链路,体验会很差。

入口层的策略是:能不加载就别加载。版本号直接从 package.json 读,help 文本从一份预编译的 cli-startup-metadata.json 读,只有真正要执行命令时才 dynamic import dist/entry.js。这让 openclaw --version 在几十毫秒内就能输出。

第二层 entry.ts 也有自己的 fast path:它先尝试 tryHandleRootHelpFastPathtryHandlePrecomputedCommandHelpFastPath,都不命中才 dynamic import ./cli/run-main.js。这条 import 链每一步都 await,所以前面的 fast path 命中时后面的代码根本不加载。

另一个动机是compile cache 隔离。Node 22+ 的 module.enableCompileCache() 能显著加速 ESM 启动,但源码 checkout 和 Windows 上的 Node 24 早期版本有死锁 bug。入口层专门有一段 respawn 逻辑:先判断当前进程是不是源码 checkout (isSourceCheckoutLauncher)、是不是受影响的 Node 24 版本,如果是就 respawn 一个禁用 compile cache 的子进程,把 NODE_DISABLE_COMPILE_CACHE=1 设进 env。这个判定必须在 TS 入口加载前做完,否则缓存已经污染了。

关键文件

数据流

第一层 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=22MIN_NODE_MINOR=19 是硬门槛,低于直接退出。版本守卫之后是 compile cache 的 respawn 决策(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,
  );
};

注意 COMPILE_CACHE_DISABLED_RESPAWNED_ENV 这个自循环守卫:respawn 出来的子进程会再跑一遍这段逻辑,但环境变量已经是 "1" 了,所以最多只 respawn 一次,不会无限递归。

第一层 fallback 到 dist/entry.js(openclaw.mjs:L765):fast path 全不命中才 tryImport("./dist/entry.js"),失败再试 .mjs,都失败才 buildMissingEntryErrorMessage 给出"这是未构建的源码树,跑 pnpm install && pnpm build"的提示。tryImport 只吞"直接的 module not found",其他错误原样抛——这样 dist/entry.js 存在但其内部某段 import 失败时,错误不会被吞掉伪装成"missing dist"。

第二层 entry.ts 的入口分发的核心(entry.ts:L130):

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

runMainOrRootHelp 自己也先尝试 help fast path(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);
  }
}

注意 gatewayEntryStartupTrace.measure("run-main-import", ...)——run-main.js 这一次 dynamic import 被显式计时,因为它是冷启动里最重的一步(几百毫秒)。runCli 抛错时不直接 console.error(error.stack),而是统一走 formatCliFailureLines 给出一个用户友好的标题 + trace,然后 process.exit(1)

entry.ts 顶部还有个关键的 isMainModule 守卫(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 {
  // ... 完整启动逻辑
}

注释说出了原因:bundler 可能把 entry.js 当成共享依赖被 dist/index.js import,没有这个守卫就会启动两个 gateway,第二个抢端口失败导致崩溃。

边界与失败

  • respawn 信号转发:runRespawnedChild(openclaw.mjs:L102)给子进程挂 SIGTERM/SIGINT/SIGHUP/SIGQUIT 监听,收到信号后转发给子进程,并在 1 秒后兜底 process.exit。这样防止子进程忽略信号导致 launcher 永远挂住。Windows 平台信号集不同(只有 SIGTERM/SIGINT/SIGBREAK)。
  • compile cache 路径污染:如果 NODE_COMPILE_CACHE 环境变量指向一个不可写目录,module.enableCompileCache 会静默失败(openclaw.mjs:L267),try/catch 吞掉错误——这是有意为之,缓存失败不应该阻止启动。
  • 源码 checkout 检测:isSourceCheckoutLauncher() 通过 .gitsrc/entry.ts 是否存在来判断,如果是源码树就强制禁用 compile cache(因为源码可能随时被改,缓存的编译产物会过期)。
  • missing dist 报错:buildMissingEntryErrorMessage(openclaw.mjs:L354)会检测 src/entry.ts 是否存在,存在就提示"这是未构建的源码树,跑 pnpm install && pnpm build",而不是简单说"file not found"——这个区分能省用户几分钟排查时间。
  • isMainModule 双重启动:entry.ts 顶部守卫(entry.ts:L56)的注释指出,没有这个守卫 bundler 会把 entry.js 作为共享依赖,导致 runCli 被调用两次,第二个实例抢 gateway 锁/端口失败崩溃。
  • respawn 循环防护:COMPILE_CACHE_DISABLED_RESPAWNED_ENVOPENCLAW_PACKAGED_COMPILE_CACHE_RESPAWNED 两个环境变量分别守卫两类 respawn,保证最多只 respawn 一次。

小结

入口层做的是"在 TS 加载之前就能解决的事":版本守卫、compile cache 决策、help/version fast path、信号转发。openclaw.mjs 是纯 Node 脚本,不依赖任何编译产物;entry.ts 是 TS 编译产物的入口,补上 respawn 计划、profile 解析、help fast path 兜底,最后才把 argv 交给 runCli 真正进入 CLI 命令分发。分发链路怎么把 argv 路由到具体的 subcli、怎么走 gateway hot path、怎么 lazy-register 命令,看下一页;gateway 本身的启动序列看 网关核心

对照官方资料:OpenClaw 文档 · README