第 10 章:命令真的被执行了

个人公众号

源码验证日期:2026-05-15,基于 commit 0d81bb6

AI 决定要执行 git status。这不是一个模拟——这条命令会在你的电脑上真实地运行。

上一章结束时,AI 完成了思考,输出了一段结构化的 JSON:工具名是 Bash,参数是 { command: "git status" }tool_use 块已经被识别出来,工具对象也找到了。

但”执行”到底是什么意思?你的终端里明明只有一个 Claude Code 程序在运行,它又不是操作系统,怎么能让另一条命令跑起来?

这就要说到 Claude Code 的”手”了。


路线图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
graph LR
CH03["③ 准备工具箱"] --> CH04["④ 回车键之后"]
CH04 --> CH05["⑤ 消息被装进信封"]
CH05 --> CH06["⑥ 工具的注册与发现"]
CH06 --> CH07["⑦ 信封飞向远方"]
CH07 --> CH08["⑧ 文字一个字一个字地回来"]
CH08 --> CH09["⑨ AI说要执行命令"]
CH09 --> CH10["⑩ 命令真的被执行了<br/>⬅ 你在这里"]
CH10 --> CH11["⑪ 你确定吗"]
CH11 --> CH12["⑫ 结果回到AI手中"]
CH12 --> CH13["⑬ 对话越来越长"]
CH13 --> CH14["⑭ 屏幕上的每一帧"]
CH14 --> CH15["⑮ 循环的终点与起点"]
CH15 --> CH16["⑯ 你的第一次追踪"]

style CH10 fill:#4CAF50,color:#fff,stroke:#333
style CH03 fill:#e8f5e9,stroke:#333
style CH04 fill:#e8f5e9,stroke:#333
style CH05 fill:#e8f5e9,stroke:#333
style CH06 fill:#e8f5e9,stroke:#333
style CH07 fill:#e8f5e9,stroke:#333
style CH08 fill:#e8f5e9,stroke:#333
style CH09 fill:#e8f5e9,stroke:#333
style CH11 fill:#e1f5fe,stroke:#333
style CH12 fill:#e1f5fe,stroke:#333
style CH13 fill:#e1f5fe,stroke:#333
style CH14 fill:#e1f5fe,stroke:#333
style CH15 fill:#e1f5fe,stroke:#333
style CH16 fill:#e1f5fe,stroke:#333

源码入口

本章追踪的调用链:

1
2
3
4
StreamingToolExecutor 的 addTool() 添加工具到队列
→ src/services/tools/toolExecution.ts (checkPermissionsAndCallTool — 执行链)
→ src/tools/BashTool/BashTool.tsx (call — Bash 工具执行)
→ src/utils/Shell.ts (exec — spawn 子进程)

完整执行链:五步走

工具执行不是一步到位的。在 checkPermissionsAndCallTool 函数里,有一个精心设计的五步链:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
sequenceDiagram
participant STE as StreamingToolExecutor
participant TE as toolExecution.ts
participant H as PreToolUse Hooks
participant P as 权限系统
participant T as Tool.call()

STE->>TE: runToolUse()
TE->>TE: 步骤 1: Zod 验证输入
TE->>H: 步骤 2: runPreToolUseHooks()
H-->>TE: 允许/阻止/修改输入
TE->>P: 步骤 3: 权限检查
P-->>TE: allow / deny
TE->>T: 步骤 4: tool.call(input)
T-->>TE: ToolResult
TE->>TE: 步骤 5: runPostToolUseHooks()
TE-->>STE: yield 工具结果

步骤 1:Zod 验证。 检查 AI 传过来的参数格式是否正确。

步骤 2:PreToolUse Hooks。 执行前置钩子——用户或插件注册的脚本,可以阻止执行或修改输入。

步骤 3:权限检查。 下一章详细讲——决定这条命令能不能跑。

步骤 4:工具调用。 真正执行工具。

步骤 5:PostToolUse Hooks。 后置钩子——在结果返回之前做额外处理。


工具箱里的扳手:BashTool

BashTool 是 Claude Code 工具箱里最基础、最常用的扳手。它的职责很简单:接收一条命令字符串,在你的电脑上执行它,把结果带回来。

1
2
3
4
5
6
7
8
9
10
11
// → src/tools/BashTool/BashTool.tsx 的 BashTool 定义
export const BashTool = buildTool({
name: BASH_TOOL_NAME,
searchHint: 'execute shell commands',
maxResultSizeChars: 30_000,
strict: true,
async description({ description }) {
return description || 'Run shell command'
},
// ...
})

buildTool 是所有工具的”模具”。不管你是读文件的 FileReadTool、写文件的 FileWriteTool,还是执行命令的 BashTool,都通过同一个 buildTool 创建。这是一个工厂模式——同一条生产线造出不同的工具。


输入格式:Zod 安检

AI 返回的工具调用是 JSON 格式的。但 JSON 可以装任何东西。程序怎么知道这个 JSON 是不是 BashTool 能接受的格式?

答案是 Zod——一个 TypeScript 的数据验证库。BashTool 的输入蓝图:

1
2
3
4
5
6
7
8
9
10
11
12
// → src/tools/BashTool/BashTool.tsx 的输入 Schema
const fullInputSchema = lazySchema(() => z.strictObject({
command: z.string().describe('The command to execute'),
timeout: semanticNumber(z.number().optional())
.describe(`Optional timeout in milliseconds (max ${getMaxTimeoutMs()})`),
description: z.string().optional()
.describe('Clear, concise description of what this command does...'),
run_in_background: semanticBoolean(z.boolean().optional())
.describe('Set to true to run this command in the background.'),
dangerouslyDisableSandbox: semanticBoolean(z.boolean().optional())
.describe('Set this to true to dangerously override sandbox mode...'),
}))

逐行翻译成白话:

  • command:一个字符串,必填。要执行的命令,比如 "git status"
  • timeout:一个数字,选填。多久之后强制停掉。
  • description:一个字符串,选填。AI 给这条命令的简短描述。
  • run_in_background:一个布尔值,选填。是否在后台运行。
  • dangerouslyDisableSandbox:一个布尔值,选填。名字里就有”dangerously”——程序在用命名警告你。

当 AI 返回 { command: "git status", description: "Show working tree status" } 时,Zod 逐个字段检查,全部通过,放行。如果 AI 返回了 { command: 123 }——command 是个数字——Zod 立刻拦住。

这就像机场的安检:不管你带的是什么,都得过一遍扫描仪。格式对了放行,格式不对挡住。


子进程:让另一个程序替你跑腿

输入验证通过了。现在到了最关键的一步:怎么在你的电脑上执行 git status

你的 Claude Code 是一个正在运行的 Node.js 进程。它自己没法直接运行 git status,因为 git 是另一个完全独立的程序。这就像你在办公室里坐着,想让楼下打印店帮你打印一份文件——你得把文件送下去,等打印完再拿回来。

在编程的世界里,这叫”子进程”(child process)。Node.js 提供了 child_process 模块,让你的程序去启动另一个程序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// → src/utils/Shell.ts 的子进程创建
const childProcess = spawn(spawnBinary, shellArgs, {
env: {
...subprocessEnv(),
SHELL: shellType === 'bash' ? binShell : undefined,
GIT_EDITOR: 'true',
CLAUDECODE: '1',
...envOverrides,
},
cwd,
stdio: usePipeMode
? ['pipe', 'pipe', 'pipe']
: ['pipe', outputHandle?.fd, outputHandle?.fd],
detached: provider.detached,
windowsHide: true,
})

这段代码做了几件事:

第一,选择 shell。 spawnBinary 是 shell 的路径,通常是 /bin/zsh/bin/bash。Claude Code 自动检测你系统上的 shell,优先用你配置的那个。

第二,设置环境变量。 CLAUDECODE: '1' 让其他程序知道”我是被 Claude Code 启动的”。GIT_EDITOR: 'true' 防止 git 尝试打开交互式编辑器——因为 Claude Code 运行在终端里,没有可用的交互编辑器。

第三,设置工作目录。 cwd 让子进程在你的项目目录下执行命令——这样 git status 才能正确找到 .git 文件夹。

第四,配置输入输出。 stdio 控制子进程的输入输出管道。['pipe', 'pipe', 'pipe'] 表示子进程的输入输出被”接管”了,Claude Code 可以通过管道读取输出。

spawn 一执行,操作系统就创建一个新的进程。这个新进程独立运行,执行你请求的命令,然后退出。


输出:命令的”嘴”和”错误喇叭”

命令跑完了,结果得拿回来。但一个命令的输出其实分两路:

  • stdout(标准输出):命令的”嘴”——说正常的、预期的内容。git status 把仓库状态打印到 stdout。
  • stderr(标准错误):命令的”错误喇叭”——喊出错了、警告、辅助信息。

在 BashTool 的输出定义里,你可以清楚地看到这种区分:

1
2
3
4
5
6
7
// → src/tools/BashTool/BashTool.tsx 的输出 Schema
const outputSchema = lazySchema(() => z.object({
stdout: z.string().describe('The standard output of the command'),
stderr: z.string().describe('The standard error output of the command'),
interrupted: z.boolean().describe('Whether the command was interrupted'),
// ...还有更多字段
}))

有趣的细节——stdout 和 stderr 在底层被合并到同一个文件描述符:

1
2
3
4
// → src/utils/Shell.ts 的 stdio 配置
stdio: usePipeMode
? ['pipe', 'pipe', 'pipe']
: ['pipe', outputHandle?.fd, outputHandle?.fd],

outputHandle?.fd 重复出现了两次——stdout 和 stderr 都指向同一个文件描述符。在 POSIX 系统上,O_APPEND 标志保证每次写入都是原子的,所以输出会按时间顺序交替出现,不会互相打断。


超时和中断:命令跑太久了怎么办

有些命令可能会跑很久。Claude Code 为此设计了超时机制。

默认超时时间——30 分钟(DEFAULT_TIMEOUT = 30 * 60 * 1000,在 Shell.ts 中定义)。AI 可以在输入里指定一个更短的 timeout

当超时到了,或者用户按下了 Ctrl+C 想中断命令,会发生什么?

Claude Code 使用 AbortController——JavaScript 的标准机制,专门用来发出”停!”的信号:

1
2
3
4
5
6
7
8
// → src/tools/BashTool/BashTool.tsx 的 call 方法
async call(input: BashToolInput, toolUseContext, ...) {
const { abortController, ... } = toolUseContext;
const commandGenerator = runShellCommand({
input,
abortController,
// ...
});

abortController 就像遥控器上的”停止”按钮。当超时到了或者用户主动中断时,程序按下这个按钮,信号沿着管道传到子进程,子进程停止执行。

如果命令被中断了,结果里会标记 interrupted: true。AI 看到后就知道命令没跑完,可以根据情况决定是重试还是换一种方式。

还有一个更精细的机制——run_in_background 选项。设置之后,命令在后台执行,AI 可以继续和你对话。等命令跑完了,系统会发通知告诉 AI 结果出来了。


兄弟错误级联

当多个工具并行执行时,如果其中一个 Bash 命令失败了,会取消所有并行的兄弟:

1
2
3
4
5
6
7
8
// → src/services/tools/StreamingToolExecutor.ts 的错误级联
if (isErrorResult) {
if (tool.block.name === BASH_TOOL_NAME) {
this.hasErrored = true
this.erroredToolDescription = this.getToolDescription(tool)
this.siblingAbortController.abort('sibling_error')
}
}

为什么只有 Bash 错误触发级联?因为 Read、Grep 等是独立的——一个失败不影响其他。但 Bash 命令经常有依赖关系(mkdir 失败 → cp 无意义),所以 Bash 失败时取消所有并行兄弟。


一条命令的一生

让我们把 git status 的完整旅程串一遍:

  1. AI 决定使用 BashTool,输出 tool_use 块:{ name: "Bash", input: { command: "git status" } }
  2. 代码用 Zod 验证输入:command 是字符串,格式正确
  3. 执行 PreToolUse Hooks(如果有)
  4. 权限检查通过(下一章详细讲)
  5. BashTool 的 call 方法被调用,内部调用 runShellCommand
  6. runShellCommand 调用 Shell.tsexec,用 spawn 创建子进程
  7. 子进程在项目目录下执行 git status,输出通过管道被捕获
  8. 命令完成,执行 PostToolUse Hooks
  9. 输出被打包成 { stdout: "...", stderr: "", interrupted: false },准备送回给 AI

从 AI 做出决定,到命令真正在你的电脑上执行,中间经过了输入验证、前置钩子、权限检查、子进程创建、输出捕获、后置钩子好几道工序。每一步都有检查、有保护、有预案。

这不是魔法。这是一个精心设计的系统。


常见错误与检查方法

常见错误检查方法
命令执行失败(非零退出码)检查 stderr 输出和退出码
命令超时检查 timeout 参数和 AbortController 状态
输入验证失败检查 Zod safeParse 的错误信息
子进程无法启动检查 spawnBinary 路径和 shell 配置
PreToolUse Hook 阻止检查 hook 返回的 preventContinuation
工作目录不正确检查 cwd 参数是否指向正确的项目目录

试试看

修改 1:观察工具执行链

src/services/tools/toolExecution.tscheckPermissionsAndCallTool 中,tool.call() 前后加:

1
2
3
console.log('[DEBUG] Tool start:', tool.name, 'at:', Date.now())
const result = await tool.call(callInput, ...)
console.log('[DEBUG] Tool end:', tool.name, 'duration:', Date.now() - startTime, 'ms')

运行后你应该看到类似输出:

1
2
3
4
5
6
[DEBUG] Tool start: Read at: 1747345678901
[DEBUG] Tool start: Grep at: 1747345678901
[DEBUG] Tool end: Read duration: 12 ms
[DEBUG] Tool end: Grep duration: 45 ms
[DEBUG] Tool start: Write at: 1747345678913
[DEBUG] Tool end: Write duration: 8 ms

修改 2:追踪子进程创建

src/utils/Shell.tsspawn 调用之前加:

1
console.log('[DEBUG] Spawning:', spawnBinary, 'args:', shellArgs, 'cwd:', cwd)

修改 3:观察超时和中断

在 BashTool 的 call 方法中,abortController 使用处加日志,追踪命令被中断的原因和时机。


检查点

你现在已经理解了:

  • 完整执行链:Zod 验证 → PreToolUse Hooks → 权限检查 → tool.call() → PostToolUse Hooks
  • BashToolbuildTool 工厂函数创建,call 方法是执行入口
  • Zod 验证:输入蓝图定义了 commandtimeoutdescription 等字段及其约束
  • 子进程机制spawn 创建新进程,环境变量标记 CLAUDECODE: '1'GIT_EDITOR: 'true'
  • stdout/stderr:双路输出在底层合并,原子写入保证顺序
  • 超时和中断:AbortController 信号传播,run_in_background 后台执行
  • 兄弟错误级联:Bash 失败取消并行兄弟,其他工具失败不影响
  • 进度消息:不等待完成就立即 yield,实现实时反馈

下一站预告:第 11 章将深入权限系统——在命令被执行之前,程序会问你”你确定吗?”。这个停顿背后是整套安全防线。


← 上一章:AI说要执行命令 | 下一章:你确定吗 →