KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
01 · 入口与分层:从命令行到 REPL 的一条快分流链 — keel 龙骨
这一章回答一个问题:一个每分钟要被启动几十次、每次都要立刻响应的命令行工具,它的入口该怎么写?
这一章回答一个问题:一个每分钟要被启动几十次、每次都要立刻响应的命令行工具,它的入口该怎么写?
答案不是「把所有初始化做完再进主逻辑」,而是先给最常用的几条路径开一条便道,把重型加载推迟到确定需要它的时候。
一、入口是一条分流链,不是一条直线
entrypoints/cli.tsx 的 main() 是一串「判断—处理—退出」的链条。它在真正加载主程序之前,先处理掉一批极轻量的调用:
--version / -v → 打印版本后退出
--dump-system-prompt → 只渲染 system prompt 后退出(调试用)
--claude-in-chrome-mcp → 直接进 MCP server 模式
--daemon-worker <name> → 进常驻工作进程
remote-control|rc|remote|sync|bridge → 远程会话桥
daemon / ps|logs|attach|kill|--bg → 后台运行管理
new|list|reply → 轻量会话命令
environment-runner / self-hosted-runner → BYOC 执行器
--tmux --worktree → 指定托管形态后继续
只有全部未命中,才执行 await import('../main.js') 进入 main.tsx 的 main()。
这条链的价值在注释里有量化依据:主程序顶部的 import 段落被标注为「后面约 135ms 的 imports」。对一个交互式工具来说,135ms 是用户可感知的延迟——按一次 Enter 到看到第一行输出,中间多 135ms 就是「卡」。
二、为什么敢用单进程
这是本 harness 最容易被误解的一点。Claude Code 没有通用 RPC/worker 架构——screens/REPL.tsx(896KB)与 cli/print.ts(215KB)跑在同一个进程里,最终都调用同一个 query()。
子 agent 也不是子进程,而是同进程的 async generator:tools/AgentTool/runAgent.ts 在同一个事件循环里 for await 消费子会话的事件流(详见第 06 章)。
真正会起子进程的只有三类,而且都不是为了「跑 agent」:
| 场景 | 位置 | 为什么必须独立进程 |
|---|---|---|
| 常驻 worker | daemon/workerRegistry.js: runDaemonWorker |
需要脱离当前终端会话存活 |
| 远程/桥接会话 | bridge/bridgeMain.ts |
需要独立网络连接与生命周期 |
| BYOC / 自托管执行器 | entrypoints/cli.tsx 的 runner 分支 |
部署环境不同,必须自带进程边界 |
单进程的代价与收益很清晰:
- 收益:没有 IPC 序列化开销;
ToolUseContext里的 40+ 个运行时字段(getAppState、abortController、readFileState…)可以直接共享引用;取消一个运行只需要abortController.abort()。 - 代价:一个工具的崩溃可以带走整个会话;无法利用多核;内存上限就是 Node 进程的上限。
- 为什么能接受:编码 Agent 的瓶颈在「等模型」和「等工具 IO」,不在本地 CPU。真正需要 CPU 的活(编译、测试)本来就是交给 Bash 工具去起外部进程。
顺带纠正一个容易搞错的组件归属:tasks/ 目录下的 LocalAgentTask、LocalShellTask、RemoteAgentTask 等,是任务状态的 UI 抽象层,不是循环本体。它们导出的是 createProgressTracker、registerAgentForeground、killAsyncAgent 这类状态机辅助函数。
三、UI 层与核心层的边界
UI 用的是 React + Ink,但 Ink 是 vendored fork,不是 npm 依赖:src/ink.ts 从 ./ink/root.js、./ink/components/Box.js、./ink/dom.js 等本地路径导出,并在外层统一包一层 ThemeProvider。
分开看两个消费端,边界就很清楚:
screens/REPL.tsx (交互式,896KB) ─┐
├─→ query.ts: query() → queryLoop()
cli/print.ts (非交互,215KB) ─┘
两个体量巨大的 UI 文件共享同一个循环入口,说明循环层与渲染层是干净的切分:循环只产出事件,UI 只消费事件。这也是为什么 --print 这类无界面模式能复用全部核心逻辑,而不需要一套平行实现。
四、编译期裁剪:同一份源码出多个产物
入口里能看到两种裁剪手段同时存在:
feature('DAEMON') // bun:bundle 的编译期开关,整段分支可被 dead code elimination
process.env.USER_TYPE === 'ant' // 运行期门禁,区分内部/外部构建
feature() 来自 bun:bundle,在打包时按开关决定是否保留分支。这解释了为什么反解产物里某些功能「找不到」——它们可能在导出这份 bundle 的构建配置里被裁掉了。
代码地图
| 机制 | 位置 | 要点 |
|---|---|---|
| CLI 快分流链 | entrypoints/cli.tsx: main() |
未命中的分支才动态 import ../main.js |
| 命令定义 | main.tsx: main() |
805KB,commander 定义与 flag→options 映射;顶部注明 135ms imports |
| 交互式 UI | screens/REPL.tsx |
896KB;REPL 启动经 replLauncher.tsx |
| 非交互输出 | cli/print.ts |
215KB;与 REPL 共享同一个 query() |
| Ink fork | src/ink.ts |
从 ./ink/root.js 等本地路径导出,非 npm 包 |
| 编译期开关 | entrypoints/cli.tsx |
feature() from bun:bundle + USER_TYPE 门禁 |
| 常驻 worker | daemon/workerRegistry.js: runDaemonWorker |
三类真子进程之一 |
| 远程桥 | bridge/bridgeMain.ts |
网络 IPC 而非进程 IPC |
| 任务状态层 | tasks/LocalAgentTask/LocalAgentTask.tsx |
导出 createProgressTracker 等,是 UI 抽象不是 loop |
| 共享运行时上下文 | Tool.ts: ToolUseContext |
40+ 字段能力包,单进程直接共享引用 |
关键取舍
把「启动快」当成一条不可退让的约束。 代价是入口必须自己维护一张分流表,每加一个轻量命令都要记得在链上插一段。收益是交互式工具最基本的体感:按下就有反应。
用单进程换掉全部 IPC 复杂度。 代价是稳定性上限受 Node 约束,一个原生模块崩溃能带走整个会话。对交互式工具是可接受的——会话崩了用户重开一次,损失的是一轮上下文,不是一条业务数据。
让循环层与 UI 层只通过事件通信。 代价是 UI 侧要维护一套事件到渲染状态的映射(896KB 的 REPL 大部分是这件事)。收益是无界面模式、SDK 模式、IDE 集成可以复用同一条核心链路。
用编译期裁剪代替运行期开关。 代价是同一份源码在不同产物里行为不同,调试时必须先确认手里的产物包含哪些分支。收益是外部用户拿到的包不携带内部功能的代码,攻击面与包体积都更小。
自测题
- 如果把
entrypoints/cli.tsx的快分流链删掉,只保留「加载主程序」这一步,用户能感知到的差异出现在哪些场景?请具体到「哪条命令的哪一步变慢」。 - 单进程架构下,
abortController为什么比多进程架构更容易实现即时取消?反过来说,多进程架构一定更差吗? tasks/目录被误认为是「循环实现」的根源是什么?请从命名与导出符号两个角度说明如何避免这类误判。feature()编译期开关给反向工程和问题排查各自带来了什么麻烦?如果你要设计一套对外发布的 agent 产品,会怎么权衡内部功能与外部包的关系?- 同一个
query()同时服务交互式 REPL 与--print非交互模式,这对「事件模型的完备性」提出了什么要求?举一个只有在非交互模式下才会暴露的问题。