第 1 章:进程与标准 I/O

任何程序,无论多复杂,最终都运行在一个进程里,通过文件描述符与世界交换数据。


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

你需要实现

  1. state 对象的四个字段(randomUUID()Date.now()process.cwd()process.stdin.isTTY
  2. 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。


下一章

第 2 章:终端 UI 外壳(Ink + React)