KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

01 · 定位与进程模型:单 Gateway 就是本地控制平面 — keel 龙骨

OpenClaw 2026.8.1 的自我描述只有两句。README.md 第 18 行:它是 "a personal AI assistant that runs on your devices and meets you in the channels you already use",并且 "designed for a single operator",把模型、工具、消息渠道和可选伴生应用都接到 "one Gateway" 上。

OpenClaw 2026.8.1 的自我描述只有两句。README.md 第 18 行:它是 "a personal AI assistant that runs on your devices and meets you in the channels you already use",并且 "designed for a single operator",把模型、工具、消息渠道和可选伴生应用都接到 "one Gateway" 上。VISION.md 第 3 到 4 行把同一件事压成口号:The AI that actually does things. It runs on your devices, in your channels, with your rules.

这不是营销语,而是架构约束。整个仓库的进程模型、插件边界、权限模型,都能从「单 operator + 单 Gateway」推导出来。这一章回答四个问题:

  1. 仓库为什么切成 packages/、extensions/、apps/ 三块,却没有独立 CLI 端;
  2. openclaw.mjs(784 行)这个 launcher 为什么要自己做快路径和编译缓存重生;
  3. 常驻 Gateway 为什么必须是单例,单例锁与崩溃循环熔断各自防什么;
  4. 一次启动从 argv 到渠道连接要经过哪几道关卡。

一、仓库骨架:三块边界,一个入口

package.json 给出三条硬事实:description 是 "Multi-channel AI gateway with extensible messaging integrations",bin.openclaw = openclaw.mjs,以及 openclaw.schemaVersions = { state: 9, agent: 17 }——状态与 agent 两套持久化 schema 各自独立版本化,升级时要分别迁移。

pnpm-workspace.yaml 的 workspace 列表是 .、ui、packages/*、extensions/*、examples/*。实际内容分三层:

其余顶层目录各有明确身份:ui/ 是 Control UI(package.json name 为 openclaw-control-ui,Vite 构建);docs/ 是 Mintlify 文档;skills/ 是内置 SKILL.md 包;qa/ 是私有 QA 资产;deploy/ 只有一个文件 fly.private.toml;config/ 不是运行时配置,而是构建与静态检查配置(knip.config.ts、oxlint/、tsconfig/、max-lines-baseline.txt、swiftlint.yml)。

二、launcher:三条快路径与编译缓存重生

openclaw.mjs 的第一件事不是加载应用,而是校验运行时。ensureSupportedRuntimeVersion()(第 16 行)分两支:Bun 下不做版本号判断,而是特性探测 process.getBuiltinModule("node:sqlite");Node 下走 isSupportedOpenClawNodeVersion,支持区间常量是 ">=22.22.3 <23, >=24.15.0 <25, or >=25.9.0"。函数在第 49 行被同步调用,不通过就直接 process.exit(1)。

校验通过后是两条零成本快路径,目的是让 --version 和 --help 不必加载整个 dist:

编译缓存若被启用,launcher 会重生一个子进程并把自己变成监管者。runRespawnedChild()(第 107 行)持有三个定时器(signalExitTimer / signalForceKillTimer / signalHardExitTimer),语义是「先给子进程机会响应转发过来的信号,超时就杀掉子进程并退出自己,避免一个忽略 SIGTERM 的子进程把 wrapper 永久挂住」。代码注释明确提示这段逻辑与 src/entry.compile-cache.ts 有意重叠,直到 launcher 能共享 TS 代码为止。

最后一段是收口(第 769 到 784 行):非重生进程先试两个 help 快路径,都不中才 await import("./dist/entry.js")(第 776 行),失败再试 ./dist/entry.mjs,都失败则抛 buildMissingEntryErrorMessage()。

三、入口守卫:重复启动会自毁

src/entry.ts 第 112 到 118 行是整份代码里最直白的一段防御:

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 的共享依赖一起打包进产物;没有这道守卫,文件顶层的代码会第二次调用 runCli,"starting a duplicate gateway that fails on the lock / port and crashes the process"。

守卫通过后,入口按固定顺序做:设 process.title = "openclaw"、安装 warning filter、normalizeEnv()、normalizeWindowsArgv()、解析 --profile、assertSupportedRuntime(),然后又是 compile-cache respawn 判断(第 141 行)。只有确认自己不是「等待重生的父进程」时,才继续走参数解析和 runMainOrRootHelp(process.argv)(src/entry.ts:280),后者最终 import("./cli/run-main.js") 并调用 runCli(src/cli/run-main.ts:1055)。

四、CLI 命令面:核心命令 + 子命令 + 懒加载

命令面被刻意拆成两个注册表,共同点是全部懒加载。

src/cli/program/command-registry-core.ts 的 coreEntrySpecs(第 42 行)列出核心命令:setup(含隐藏别名 crestodian)、onboard、configure、config、claws、backup、database、migrate、audit、doctor / dashboard / reset / uninstall、mcp、transcripts、status / health / sessions / tasks。

src/cli/program/register.subclis-core.ts 列子命令:acp、gateway、daemon、logs、system、models、promos、infer / capability、approvals / exec-approvals、exec-policy、nodes、devices、users、connect、worker、sandbox、fleet、worktrees、attach、tui / terminal / chat、resume、cron / automations、dns、docs、qa、proxy、hooks、webhooks、qr、clawbot、pairing、plugins、channels、directory、security、secrets、skills、update,以及一个 completion 用途的注册上下文(SubCliRegistrationContext.purpose)。

真正把「懒」落地的是 registerLazyCommandGroup()(src/cli/program/register-command-groups.ts:62):先注册一个占位命令,用户真正敲到它时才把整组命令加载并替换进去。

五、常驻单例 Gateway:端口、锁、熔断、服务化

Gateway 的默认端口是 DEFAULT_GATEWAY_PORT = 18789(src/config/paths.ts:400)。启动实现在 src/cli/gateway-cli/run.ts(1328 行,入口 runGatewayCommand 在第 1286 行)与 src/cli/gateway-cli/run-loop.ts(1154 行)。

单例由文件锁保证:src/infra/gateway-lock.ts 导出 GatewayLockError(第 115 行)与 acquireGatewayLock(第 384 行)。锁目录由 src/config/paths.ts:resolveGatewayLockDir 解析,默认落在状态目录下的 tmp/openclaw-<uid>——路径里带 uid 后缀,这样同一台机器上不同用户可以各自持有自己的 Gateway。

崩溃循环由 src/infra/gateway-boot-lifecycle.ts 熔断:inspectGatewayCrashLoopBreaker(第 99 行)判定是否「窗口内不干净启动次数过多」,recordGatewayBootStart(第 154 行)记录启动,completeGatewayBootLifecycle(第 244 行)标记完成。熔断触发时用的原因是常量 GATEWAY_CRASH_LOOP_BREAKER_REASON = "gateway.crash_loop_breaker"(第 30 行)。

要开机自启就服务化。src/daemon/service.ts 是统一适配层(注释自述「Platform service adapter used by CLI commands across launchd, systemd, and schtasks」),resolveGatewayService()(第 433 行)按平台返回实现,具体后端在 ./launchd.js、./systemd.js、./schtasks.js。

六、启动流程与配置加载

一次 Gateway 启动的关卡顺序是:launcher 版本校验 → 快路径 → 编译缓存重生 → 入口守卫 → argv 解析(含 --container / --profile 互斥检查,冲突时 process.exit(2))→ 配置加载 → 取锁 → server 启动 → 渠道连接 → 每个 workspace 的 BOOT.md 检查。

配置加载入口是 loadConfigFromContext()(src/config/io.load.ts:34)。它读文件后依次做 resolveConfigForRead()($include 展开,测试里可见 { agents: { $include: "./agents.json" } } 这类写法)、legacy 迁移(migrateLegacyContextBudgetConfig、migratePersistedImplicitMainRoster),最后交给 validateConfigObjectWithPlugins()(第 113 行)——插件可以参与配置校验,所以校验不是纯静态的。

渠道连接可以被跳过:src/cli/gateway-cli/run.ts 第 1122 行的判据使用 isTruthyEnvValue(process.env.OPENCLAW_SKIP_CHANNELS),这是在不接任何渠道的情况下把 Gateway 拉起来做调试的开关。

最后是 BOOT.md。文件名常量 BOOT_FILENAME = "BOOT.md"(src/gateway/boot.ts:37),执行入口 runBootOnce()(第 109 行)。它把整份 BOOT.md 包进 internal-runtime-context 分隔符后当作提示词跑一轮,并显式告诉模型「如果 BOOT.md 让你发消息,用 message 工具」。读取上限 MAX_BOOT_FILE_BYTES = 16 * 1024 * 1024。第 83 行的注释说明读取时会解析符号链接,所以 BOOT.md 可以是软链,但不能是逃逸到 workspace 之外的路径。

七、状态落盘与配置文件名

src/config/paths.ts 的配置候选路径构造(第 392 行附近)透露了迁移策略:先算 newStateDir(effectiveHomedir),再拼上 legacyStateDirs(effectiveHomedir),每个目录里既找 CONFIG_FILENAME,也找 LEGACY_CONFIG_FILENAMES 里的历史文件名。旧名不是被废弃,而是被当成候选——这解释了 VISION.md 第 41 到 48 行的配置兼容立场:运行时只读当前 schema,但 openclaw doctor --fix 负责识别旧形状、解释、备份并改写成规范格式。

配套的是 package.json 里的 openclaw.schemaVersions(state: 9、agent: 17)。两套 schema 分开版本号,意味着「状态迁移」与「agent 定义迁移」可以各自推进,不必等对方。

在容器里这套路径被显式钉住。docker-compose.yml 第 18 到 21 行同时设 OPENCLAW_STATE_DIR / OPENCLAW_CONFIG_PATH / OPENCLAW_CONFIG_DIR / OPENCLAW_WORKSPACE_DIR 四个变量,注释解释了动机:宿主 .env 里的 macOS 路径(/Users/<you>/.openclaw/...)曾被带进容器,导致首轮回复时 mkdir '/Users' 触发 EACCES。路径是配置面,但必须是可在容器里被覆盖的配置面。

代码地图

机制 位置 要点
定位声明 README.md: 第 18 行 单 operator、单 Gateway、跑在你自己设备上
愿景声明 VISION.md: 第 3-4 行 "The AI that actually does things."
包元数据 package.json: openclaw schemaVersions state 9 / agent 17;bin 指向 openclaw.mjs
运行时版本校验 openclaw.mjs: ensureSupportedRuntimeVersion 第 16 行;Bun 走 node:sqlite 特性探测
launcher 版本快路径 openclaw.mjs: tryOutputLauncherVersion 第 572 行;仅 argv 长度为 3 时生效
root help 快路径 openclaw.mjs: tryOutputBareRootHelp 第 721 行;读 dist/cli-startup-metadata.json
编译缓存重生监管 openclaw.mjs: runRespawnedChild 第 107 行;三级超时定时器防挂死
最终入口 openclaw.mjs: 第 776 行 import ./dist/entry.js,失败回退 .mjs
防重复启动 src/entry.ts: isMainModule 第 112 行;否则起第二个 Gateway 抢锁/端口
CLI 主入口 src/cli/run-main.ts: runCli 第 1055 行
核心命令注册表 src/cli/program/command-registry-core.ts: coreEntrySpecs 第 42 行;setup/onboard/.../status
子命令注册表 src/cli/program/register.subclis-core.ts: getSubCliEntriesCore 第 22 行导入;gateway/models/devices/...
懒加载命令组 src/cli/program/register-command-groups.ts: registerLazyCommandGroup 第 62 行;占位命令被敲中才替换
默认端口 src/config/paths.ts: DEFAULT_GATEWAY_PORT 第 400 行;值 18789
锁目录解析 src/config/paths.ts: resolveGatewayLockDir 第 406 行;tmp/openclaw-
Gateway 启动 src/cli/gateway-cli/run.ts: runGatewayCommand 第 1286 行,文件 1328 行
单例锁 src/infra/gateway-lock.ts: GatewayLockError 第 115 行;acquireGatewayLock 第 384 行
崩溃循环熔断 src/infra/gateway-boot-lifecycle.ts: inspectGatewayCrashLoopBreaker 第 99 行;原因码第 30 行
服务化分发 src/daemon/service.ts: resolveGatewayService 第 433 行;launchd/systemd/schtasks
配置加载 src/config/io.load.ts: loadConfigFromContext 第 34 行;$include + legacy 迁移
插件感知校验 src/config/io.load.ts: validateConfigObjectWithPlugins 第 113 行
跳过渠道连接 src/cli/gateway-cli/run.ts: OPENCLAW_SKIP_CHANNELS 第 1122 行
BOOT.md 检查 src/gateway/boot.ts: runBootOnce 第 109 行;文件名常量第 37 行
bundled plugin 边界 extensions/AGENTS.md: 第 1-3 行、第 29-30 行 与第三方插件同一边界,禁止深导入 src/**
配置候选路径 src/config/paths.ts: 第 392 行 新目录 + legacy 目录 × 当前名 + 历史名
容器内路径钉住 docker-compose.yml: 第 18-21 行 四个 OPENCLAW_*_DIR/PATH 变量防宿主路径泄漏
双重 schema 版本 package.json: openclaw.schemaVersions state 9 与 agent 17 各自迁移

关键取舍

快路径下沉到 .mjs,代价是元数据与 TS 侧双份维护。
--version 和 root help 不走 dist,意味着这两条路径不能被 TS 代码改写;help 文本必须预生成到 dist/cli-startup-metadata.json,launcher 与 src/entry.ts 各有一份快路径判断逻辑。收益是启动成本从「加载整个 CLI」降到「读一个 JSON 或直接 print」。

入口用 isMainModule 守卫,代价是 entry.ts 不能被当普通模块导入。
这个守卫防的是一个已经发生过的故障:bundler 把 entry 当共享依赖后,顶层副作用会第二次调用 runCli,两个 Gateway 抢同一把锁同一个端口,进程直接崩。代价是任何想复用入口逻辑的代码都必须走导出的 runMainOrRootHelp,而不能 import "entry.js"。

单例 Gateway 而非多进程池,代价是横向扩展只能靠「拆 Gateway」。
锁 + 端口 + uid 后缀的锁目录把「一个 operator 一个控制平面」写进了文件系统语义。好处是会话、审批、渠道连接这些状态天然有唯一属主;代价是同机多实例需要显式换状态目录,而多用户隔离不能靠进程内分区解决(见第 04 章)。

崩溃循环熔断,代价是排障时可能被保护性拒绝启动。
inspectGatewayCrashLoopBreaker 会在窗口内不干净启动过多时主动不启动渠道,避免「启动即崩」把渠道账号打成风控。代价是当根因在别处(比如坏配置)时,运维看到的是「被熔断」而不是「配置错了」,必须配合 gateway status 与日志才能定位。

bundled plugin 与第三方同一边界,代价是核心能力也得绕公开 seam。
extensions/AGENTS.md 要求用 openclaw/plugin-sdk/* 和本地 barrel,禁止 src/** 深导入,且「If an extension needs a new seam, add or replace a typed Plugin SDK subpath instead of reaching into core」。这保证 bundled 插件是第三方插件的真实压力测试;代价是全仓上百个 bundled plugin 任何一次核心重构都要同步迁移 SDK seam,而不是在仓库内开小门。

旧配置文件名仍被当候选,代价是「到底读了哪个文件」需要工具回答。
src/config/paths.ts 同时枚举新目录与 legacy 目录、当前文件名与历史文件名,运行时只认当前 schema,改写交给 openclaw doctor --fix。收益是升级不会因为文件名变化而静默读到空配置;代价是「我改的配置没生效」这类问题必须靠 config / doctor 子命令确认实际生效路径,而不是靠肉眼看目录。

自测题

  1. isMainModule 守卫挡掉的到底是什么样的产物布局?请解释「bundler 把 dist/entry.js 当共享依赖」这一条路径上,重复启动是如何导致进程崩溃的(提示:锁与端口各会怎样失败)。
  2. tryOutputLauncherVersion 只接受 argv.length === 3。如果用户写 openclaw --version --json,会发生什么?为什么作者不去解析更复杂的 argv?
  3. runRespawnedChild 里三个定时器(signalExit / signalForceKill / signalHardExit)分别对应什么样的子进程行为?少掉最后一个会有什么后果?
  4. 锁目录路径带 uid 后缀(openclaw-<uid>)。这在「同一台下同时跑两个 Gateway」和「同一用户故意跑两个 Gateway」两种场景下各是什么表现?
  5. loadConfigFromContext 里 resolveConfigForRead($include 展开)排在 legacy 迁移与插件校验之前。如果把展开放到校验之后,会出现什么类型的配置错误?

进入 keel 阅读