任何程序,无论多复杂,最终都运行在一个进程里,通过文件描述符与世界交换数据。
1.1 核心问题
在构建任何 UI、调用任何 API、执行任何工具之前,我们先回答一个最基础的问题:
一个 Coding Agent 的进程从哪里读输入,往哪里写输出?
这个问题的答案决定了整个系统的"外壳"——其他所有层(终端 UI、LLM 客户端、工具引擎)都运行在这个外壳之内。
1.2 原理讲解
Node.js 进程模型
每个 Node.js 程序启动时,操作系统分配给它三个标准文件描述符:
| 描述符 | 名称 | Node.js 对象 | 默认连接 |
|---|---|---|---|
| 0 | stdin | process.stdin |
键盘输入 |
| 1 | stdout | process.stdout |
终端显示 |
| 2 | stderr | process.stderr |
终端显示(错误) |
这三个流是双向管道:可以重定向(echo "hello" | node main.js),也可以直接读写。
Node.js 的核心是事件循环:单线程轮询 I/O 事件,收到数据则触发回调。程序不会因等待 I/O 而阻塞,但同时只能处理一件事。
TypeScript 的运行方式
Claude Code 使用 TypeScript 编写,通过 tsx(基于 esbuild 的即时编译器)直接运行,无需预先编译:
npx tsx src/main.ts # 直接运行 .ts 文件
生产环境中 Claude Code 使用 Bun 打包,但开发和学习阶段用 tsx 足够。
进程退出与信号处理
进程退出时返回一个退出码(exit code):0 表示成功,非零表示错误。
操作系统还可以向进程发送信号:
| 信号 | 触发方式 | 默认行为 |
|---|---|---|
SIGINT |
Ctrl-C | 终止进程 |
SIGTERM |
kill <pid> |
终止进程 |
Claude Code 拦截这两个信号,在退出前写入会话档案、清理子进程。
Claude Code 的全局单例状态
src/bootstrap/state.ts 定义了一个进程级的单例对象,保存整个会话期间不变的元数据:
// src/bootstrap/state.ts(简化)
type State = {
sessionId: SessionId // 每次启动唯一的 UUID
startTime: number // 进程启动时间戳(ms)
cwd: string // 当前工作目录
originalCwd: string // 启动时的工作目录(不随 cd 变化)
projectRoot: string // 项目根目录(用于 CLAUDE.md 查找)
isInteractive: boolean // 是否是交互模式(有 TTY)
totalCostUSD: number // 本次会话累计 API 费用
// ... 以及数十个其他字段
}
为什么需要全局单例? 因为 Claude Code 的各个模块(UI、工具、LLM 客户端)都需要访问会话 ID、工作目录等共享信息。全局单例比在每个函数调用链中逐层传递参数更实用。
注意:
bootstrap/state.ts是有意保持为导入依赖树的叶节点(leaf node)——它不能导入其他 Claude Code 模块,防止循环依赖。这是贯穿整个代码库的架构约束。
1.3 Claude Code 源码中的关键细节
启动顺序(src/main.tsx 前几行)
// 1. 标记启动时间点(用于性能分析)
profileCheckpoint('main_tsx_entry')
// 2. 提前启动需要时间的后台任务(并行化启动耗时)
startMdmRawRead() // 读取 MDM 企业策略(macOS: plutil,Windows: reg query)
startKeychainPrefetch() // 预取 Keychain 中的 OAuth token 和 API key
// 3. 之后才是 Commander 路由、Ink 启动等主体逻辑
这个模式——在 import 阶段就启动 I/O 密集型后台任务——使 Claude Code 的冷启动时间从 ~200ms 降到 ~65ms。
Session ID 的生成
// src/bootstrap/state.ts
import { randomUUID } from 'src/utils/crypto.js'
sessionId: randomUUID() as SessionId
sessionId 用于:
- 本地会话存档文件命名(
~/.claude/projects/<hash>/<sessionId>.jsonl) - API 请求的
X-Session-Id请求头(用于计费追踪) - 子 Agent 与父 Agent 的关联(
parentSessionId)
isInteractive 的判断
isInteractive: process.stdin.isTTY === true
当 stdin 连接到真实终端时为 true;通过管道(echo "..." | claude)或 CI 环境时为 false。
这个标志决定是否渲染 Ink UI、是否显示权限确认弹窗。
1.4 最小化产出物
代码骨架位于
../chapters/01/src/,参考实现位于../chapters/01/solution/。
本章要实现什么
在 ../chapters/01/src/state.ts 中完成以下内容。
接口规范(已提供,不要修改):
// 全局状态对象
export const state: {
sessionId: string // UUID,每次启动唯一
startTime: number // 启动时间戳(ms)
cwd: string // 当前工作目录
isInteractive: boolean // 是否连接到真实终端
}
// 信号处理注册函数
export function registerSignalHandlers(onExit?: () => void): void
你需要实现:
state对象的四个字段(randomUUID()、Date.now()、process.cwd()、process.stdin.isTTY)registerSignalHandlers():SIGINT(Ctrl-C):打印提示 + 会话时长,调用onExit?.()后退出SIGTERM:调用onExit?.()后退出
关键约束:
state.ts不能import任何其他章节的模块(它是整个系列的叶节点)- 信号处理器注册一次即可,后续章节 import 此文件时自动生效
验收
cd docs/chapters/01
npm install
npm test
初始状态会失败(TODO 未填写)。填写 src/state.ts 后再次运行,预期输出:
=== 第 1 章:进程与标准 I/O 验收 ===
── 文件检查 ──
✓ src/state.ts 存在
✓ src/main.ts 存在
── 实现检查 ──
✓ src/state.ts 已实现
── 功能验收 ──
✓ 管道模式:回显输入内容
✓ 管道模式:处理多行输入(第1行)
✓ 管道模式:处理多行输入(第3行)
✓ --debug 输出 sessionId
✓ --debug 输出 cwd
✓ --debug 输出 isInteractive: false
✓ --debug 模式下 stdout 仍正常输出
✓ --debug 信息不出现在 stdout
✓ state.sessionId 是 UUID(36字符)
✓ state.startTime 是数字
✓ state.cwd 非空
✓ state.isInteractive 为 false(管道环境)
✓ registerSignalHandlers 是函数
结果:16 通过,0 失败
✓ 全部通过!可以进入第 2 章了。
卡住时查看 ../chapters/01/solution/state.ts。
1.5 本章小结
| 概念 | 要记住的要点 |
|---|---|
| 三个标准流 | stdin(0) / stdout(1) / stderr(2),可以重定向 |
| 事件循环 | 单线程,非阻塞 I/O,程序通过回调响应事件 |
| tsx | TypeScript 的即时运行器,无需预编译 |
| SIGINT / SIGTERM | Ctrl-C 发送 SIGINT,必须拦截后才能优雅退出 |
| 全局单例 state | 进程级元数据(sessionId / cwd / startTime),是导入树的叶节点 |
isInteractive |
判断是否有 TTY,决定是否启动交互 UI |
state.ts 独立导出 |
state 和 registerSignalHandlers 被拆分到独立文件,供第 2、3 章 import 复用 |
这一章的产出物是整个系列的地基:一个可以读取输入并回显输出的进程,以及一个可被后续章节直接复用的 state.ts 模块。接下来,我们在这个地基上架起终端 UI。