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」推导出来。这一章回答四个问题:
- 仓库为什么切成
packages/、extensions/、apps/三块,却没有独立 CLI 端; openclaw.mjs(784 行)这个 launcher 为什么要自己做快路径和编译缓存重生;- 常驻 Gateway 为什么必须是单例,单例锁与崩溃循环熔断各自防什么;
- 一次启动从 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/*。实际内容分三层:
packages/(22 个包):按职责切开的库。协议层是gateway-protocol/gateway-client/session-url-contract/plugin-package-contract/workboard-contract;Agent 内核是agent-core(主循环所在)/llm-core/ai;目录是model-catalog-core;媒体是media-core/media-generation-core/media-understanding-common;基础设施是normalization-core/net-policy/retry/tool-call-repair/terminal-core/markdown-core;扩展面是plugin-sdk/sdk/memory-host-sdk/acp-core。extensions/:bundled plugin。本机核对到 148 个带package.json的目录,另有只带openclaw.plugin.json的清单型插件。extensions/AGENTS.md第 1 到 3 行把这条边界钉死:「This directory contains bundled plugins. Treat it as the same boundary that third-party plugins see.」,第 29 到 30 行进一步禁止src/**深导入。apps/:各平台伴生应用。已核对apps/android(Kotlin/Gradle)、apps/ios、apps/macos、apps/macos-mlx-tts、apps/swabble(均为 Swift Package,Package.swift)、apps/linux(Tauri,src-tauri/Cargo.toml)、apps/shared/OpenClawKit。没有 CLI app——CLI 就是src/cli/。
其余顶层目录各有明确身份: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:
tryOutputLauncherVersion()(第 572 行):只有argv.length === 3且第三个参数是--version/-V/-v时才走(isLauncherVersionFastPathArgv)。tryOutputBareRootHelp()(第 721 行):读预生成的dist/cli-startup-metadata.json里的rootHelpText;读不到才回退到import("./dist/cli/program/root-help.js")。
编译缓存若被启用,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 子命令确认实际生效路径,而不是靠肉眼看目录。
自测题
isMainModule守卫挡掉的到底是什么样的产物布局?请解释「bundler 把dist/entry.js当共享依赖」这一条路径上,重复启动是如何导致进程崩溃的(提示:锁与端口各会怎样失败)。tryOutputLauncherVersion只接受argv.length === 3。如果用户写openclaw --version --json,会发生什么?为什么作者不去解析更复杂的 argv?runRespawnedChild里三个定时器(signalExit / signalForceKill / signalHardExit)分别对应什么样的子进程行为?少掉最后一个会有什么后果?- 锁目录路径带 uid 后缀(
openclaw-<uid>)。这在「同一台下同时跑两个 Gateway」和「同一用户故意跑两个 Gateway」两种场景下各是什么表现? loadConfigFromContext里resolveConfigForRead($include展开)排在 legacy 迁移与插件校验之前。如果把展开放到校验之后,会出现什么类型的配置错误?