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 分支 部署环境不同,必须自带进程边界

单进程的代价与收益很清晰:

顺带纠正一个容易搞错的组件归属: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 集成可以复用同一条核心链路。

用编译期裁剪代替运行期开关。 代价是同一份源码在不同产物里行为不同,调试时必须先确认手里的产物包含哪些分支。收益是外部用户拿到的包不携带内部功能的代码,攻击面与包体积都更小。

自测题

  1. 如果把 entrypoints/cli.tsx 的快分流链删掉,只保留「加载主程序」这一步,用户能感知到的差异出现在哪些场景?请具体到「哪条命令的哪一步变慢」。
  2. 单进程架构下,abortController 为什么比多进程架构更容易实现即时取消?反过来说,多进程架构一定更差吗?
  3. tasks/ 目录被误认为是「循环实现」的根源是什么?请从命名与导出符号两个角度说明如何避免这类误判。
  4. feature() 编译期开关给反向工程和问题排查各自带来了什么麻烦?如果你要设计一套对外发布的 agent 产品,会怎么权衡内部功能与外部包的关系?
  5. 同一个 query() 同时服务交互式 REPL 与 --print 非交互模式,这对「事件模型的完备性」提出了什么要求?举一个只有在非交互模式下才会暴露的问题。

进入 keel 阅读