入口與啟動
職責
openclaw 指令按下 Enter 後,到第一條業務指令真正開始執行前,中間所有的事都歸「入口 (entry)」管:Node 版本檢查、compile cache 路徑選擇、help/version 快速路徑、信號轉發,最後把控制權交給 dist/entry.js。entry.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:它先嘗試 tryHandleRootHelpFastPath 和 tryHandlePrecomputedCommandHelpFastPath,都不命中才 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 版本守護:L11-L53—MIN_NODE_MAJOR=22/MIN_NODE_MINOR=19,低於就process.exit(1)。openclaw.mjs compile cache respawn:L207-L264—respawnWithoutCompileCacheIfNeeded+respawnWithPackagedCompileCacheIfNeeded決定是否需要重啟子進程。openclaw.mjs help fast path:L717-L763—tryOutputBareRootHelp和tryOutputPrecomputedCommandHelp直接吐預編譯 help 文本。openclaw.mjs fallback to entry.js:L765-L780— fast path 全不命中才 dynamic import./dist/entry.js。entry.ts isMainModule 守護:L56-L79— 防止 bundler 把 entry.js 當共享依賴二次 import 時重複啟動 gateway。entry.ts respawn 計畫:L90-L103—buildCliRespawnPlan+runCliRespawnPlan處理需要切換執行時的場景。entry.ts 版本 fast path 與 runMainOrRootHelp 呼叫:L130-L131—tryHandleRootVersionFastPath不命中才走主分派。entry.ts runMainOrRootHelp:L271-L295— help fast path + dynamic import run-main。entry.ts 預編譯指令 help:L208-L269—tryHandlePrecomputedCommandHelpFastPath處理browser/secrets/nodes+ 子指令 help。
資料流
第一層 openclaw.mjs 頂部的版本守護(openclaw.mjs:L38):
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、MIN_NODE_MINOR=19 是硬門檻,低於直接退出。版本守護之後是 compile cache 的 respawn 決策(openclaw.mjs:L207):
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):
if (!tryHandleRootVersionFastPath(process.argv)) {
await runMainOrRootHelp(process.argv);
}runMainOrRootHelp 自己也先嘗試 help fast path(entry.ts:L271):
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):
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()透過.git或src/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_ENV和OPENCLAW_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。