全文目录Contents
- 0 · The Project at a Glance, and a Map of the Code
- 1 · The Entry Layer and Startup
- 2 · The Session Layer: QueryEngine
- 2.1 The problem it solves
- 2.2 What state it holds
- 2.3 The full flow of one submitMessage
- 2.4 Why the user message must hit disk first
- 2.5 Consuming the main loop’s output: one big switch
- 2.6 The compact boundary: proactively releasing memory
- 2.7 Three kinds of exit result
- 2.8 ask(): a convenience wrapper for one-shot calls
- 3 · The Agent Main Loop ★
- 3.1 The skeleton of the loop
- 3.2 State: centralizing cross-iteration state
- 3.3 transition: a field that exists purely for testability
- 3.4 The seven transition edges, one by one
- 3.5 The error-withholding mechanism
- 3.6 Interrupt handling
- 3.7 Model fallback: three actions
- 3.8 The three laws of thinking blocks
- 3.9 Other mechanisms in the loop
- 3.10 Every exit point of the loop
- 4 · The Tool Model
- 4.1 The Tool interface: seven orthogonal capability groups
- 4.2 Why the “safety predicates” deserve their own group
- 4.3 Fail-safe defaults
- 4.4 The 40 built-in tools, by category
- 4.5 Progressive tool loading
- 4.6 Tool list assembly: a hidden constraint about caching
- 4.7 backfillObservableInput: an extreme example of cache protection
- 5 · Tool Execution
- 5.1 The execution pipeline at a glance
- 5.2 Concurrency partitioning: a greedy algorithm
- 5.3 Context modifications are queued until the batch ends
- 5.4 A single execution: the full flow of runToolUse
- 5.5 The streaming tool executor
- 5.6 The sibling abort controller: the most elegant design in the file
- 5.7 The discard mechanism
- 5.8 “Tombstone” messages
- 5.9 Final processing of results
- 6 · Context Management ★
- 7 · The Permission System
- 8 · Subagents
- 9 · The Extension System
- 10 · The Terminal UI Layer
- 10.1 Writing a Terminal UI in React
- 10.2 The Four Biggest Components
- 10.3 Why the Input Box Is 347 KB
- 10.4 The Virtualized Message List
- 10.5 Six Rendering States for Tool Results
- 10.6 Collapsing: Avoiding Screen Flood
- 10.7 87 State-Management Units
- 10.8 The Interface Between UI and Kernel: Callbacks in ToolUseContext
- 10.9 A Fun Detail: ANSI to PNG
- 11 · Persistence and Resume
- 12 · The Observability System
- 13 · Build and Distribution
1 · 入口层与启动流程
这一章讲:你在终端敲下 claude 到界面出现之间,程序做了什么。
1.1 四种启动形态
同一个可执行文件,根据参数进入四种完全不同的运行模式:
| 形态 | 怎么触发 | 用途与特征 |
|---|---|---|
| 交互模式 REPL |
claude(不带任何参数) |
启动一个持续对话的终端界面。有输入框、有滚动的消息列表、有快捷键。这是绝大多数人使用的形态。 REPL = Read-Eval-Print Loop,「读取-求值-打印 循环」,是交互式命令行界面的通用叫法。 |
| 无头模式 headless / print |
claude -p "帮我改这个文件" |
不显示界面。给一个问题、执行完、把结果打印到标准输出、退出。用于写在脚本里自动化调用。 配合 --output-format json 可以输出机器可读的结构化结果。 |
| 软件开发工具包模式 Agent SDK |
被别的程序作为库调用 | 输入输出都走标准输入输出的流式 JSON 协议(--input-format stream-json)。让其他软件可以把 Claude Code 当成一个智能体引擎嵌进去。 |
| 特殊子进程 | --daemon-worker--claude-in-chrome-mcp 等 |
由主进程派生的辅助进程。比如浏览器扩展的本地宿主、后台守护工作进程、远程桥接服务。 |
1.2 启动的第一个设计:快路径分派
entrypoints/cli.tsx 是真正的程序入口,只有 302 行。它的注释开门见山:
「Bootstrap entrypoint - checks for special flags before loading the full CLI. All imports are dynamic to minimize module evaluation for fast paths. Fast-path for --version has zero imports beyond this file.」
译:引导入口 —— 在加载完整命令行程序之前先检查特殊参数。所有导入都是动态的,以便让快路径尽可能少地执行模块代码。--version 这条快路径除了本文件之外零导入。
为什么这很重要
先解释一个背景概念:JavaScript 程序在「导入」一个模块时,那个模块的顶层代码会立刻执行。如果一个程序静态导入了几百个模块,那么光是启动就要把这几百个模块全部执行一遍,即使这次运行根本用不到它们。
Claude Code 打包后是一个 300 MB 的单文件程序,模块数量极其庞大。如果每次运行都全量加载,claude --version 这种只想看一眼版本号的命令也要等好几秒。
所以入口文件用的是动态导入 —— 只有真的走到某条分支时,才去加载那条分支需要的模块:
async function main(): Promise<void> {
const args = process.argv.slice(2); // 取命令行参数
// 快路径 1:--version,零模块加载
if (args.length === 1 && (args[0] === '--version' || args[0] === '-v')) {
console.log(`${MACRO.VERSION} (Claude Code)`); // 版本号在编译期就被写死进来了
return; // 直接返回,什么都没加载
}
// 其余路径才加载启动性能分析器
const { profileCheckpoint } = await import('../utils/startupProfiler.js');
profileCheckpoint('cli_entry'); // 打一个时间戳
// 快路径 2:--dump-system-prompt(导出系统提示词,用于评测)
if (feature('DUMP_SYSTEM_PROMPT') && args[0] === '--dump-system-prompt') {
const { enableConfigs } = await import('../utils/config.js');
...
return;
}
// 快路径 3:浏览器扩展的本地宿主进程
if (process.argv[2] === '--claude-in-chrome-mcp') { ... return; }
// 快路径 4:守护工作进程(由主进程派生,对性能敏感)
if (feature('DAEMON') && args[0] === '--daemon-worker') {
const { runDaemonWorker } = await import('../daemon/workerRegistry.js');
await runDaemonWorker(args[1]);
return;
}
...
// 全部快路径都不匹配 → 加载完整的命令行程序
}
claude-code/src/entrypoints/cli.tsx
MACRO.VERSION 里的 MACRO 是编译期宏 —— 打包时被替换成字面量字符串。所以拿版本号连读配置文件都不需要。
如果你的命令行程序有「启动很慢」的问题,先看有没有高频的轻量命令被重量级的启动流程拖累了。
典型的例子:--version、--help、shell 补全脚本(这个尤其重要 —— 用户每敲一次 Tab 键就会调用一次)、以及被主进程高频派生的子进程。
把这些做成「在加载任何东西之前就分派掉」的快路径,收益立竿见影。
1.3 命令行参数:60 多个选项
完整的命令行接口定义在 main.tsx 里,用的是 Commander.js 这个库。选项数量非常多,下面按用途分组梳理:
模式与输入输出
| 选项 | 作用 |
|---|---|
-p, --print | 无头模式:输出结果后退出。注意:这个模式会跳过「工作目录信任」确认对话框,所以只应在你信任的目录里用。 |
--output-format <格式> | text(默认)/ json(单个结果对象)/ stream-json(实时流式) |
--input-format <格式> | text(默认)/ stream-json(从标准输入实时读取) |
--json-schema <模式> | 要求输出符合指定的 JSON 结构。程序会用一个特殊的「结构化输出工具」强制模型产出合规结果,不合规就重试(最多 5 次)。 |
--include-partial-messages | 把模型流式返回的每一个片段都吐出来,而不只是完整消息 |
权限与安全
| 选项 | 作用 |
|---|---|
--permission-mode <模式> | 设定权限模式。可选值见第 7 章。 |
--dangerously-skip-permissions | 跳过所有权限确认。官方描述:「仅推荐在没有互联网访问的沙箱环境中使用」。注意即使开了它,仍有一层检查绕不过 —— 第 7 章详述。 |
--allow-dangerously-skip-permissions | 只是允许使用上面那个模式,但不默认开启。给管理员做策略配置用。 |
--allowed-tools / --disallowed-tools | 允许 / 禁止的工具清单。支持带参数的写法,比如 Bash(git:*) 表示「只允许 git 开头的 bash 命令」。 |
--tools | 直接指定可用的内建工具集合。传空字符串就是禁用所有工具。 |
--add-dir <目录...> | 额外授权访问的目录(默认只能访问当前工作目录) |
模型与预算
| 选项 | 作用 |
|---|---|
--model <模型> | 可以传别名(sonnet、opus)或完整型号名 |
--fallback-model <模型> | 主模型过载时自动降级到这个。只在无头模式下生效(交互模式下会直接问用户) |
--effort <级别> | 思考力度:low / medium / high / max |
--thinking <模式> | enabled(等同 adaptive 自适应)/ disabled |
--max-turns <次数> | 最多进行几轮。超过就提前退出。只在无头模式生效。 |
--max-budget-usd <金额> | 花费上限(美元)。超过就停。只在无头模式生效。 |
会话与恢复
| 选项 | 作用 |
|---|---|
-c, --continue | 继续当前目录下最近的那次对话 |
-r, --resume [值] | 按会话 ID 恢复,或打开一个交互式选择器 |
--fork-session | 恢复时创建新的会话 ID,而不是复用原来的。相当于「从这个存档点分叉出一条新线」 |
--resume-session-at <消息 ID> | 只恢复到指定消息为止,后面的丢弃 |
--rewind-files <消息 ID> | 把文件恢复到某条消息时的状态然后退出。这是一个「撤销」功能 —— 第 11 章会讲文件历史怎么实现的。 |
--no-session-persistence | 不落盘。这次对话结束就没了,无法恢复。 |
扩展与集成
| 选项 | 作用 |
|---|---|
--mcp-config <配置...> | 加载 MCP 外部工具服务(可以传文件路径或 JSON 字符串) |
--strict-mcp-config | 只用命令行指定的 MCP 服务,忽略所有其他来源的配置 |
--plugin-dir <路径> | 从指定目录加载插件(可以重复传多个) |
--agents <JSON> | 用 JSON 直接定义自定义子智能体 |
--settings <文件或 JSON> | 额外的配置来源 |
--setting-sources <来源> | 指定从哪几个来源读配置:user(用户级)/ project(项目级)/ local(本地覆盖) |
--ide | 启动时自动连接编程软件(如果恰好只有一个可连的) |
-w, --worktree [名字] | 为这次会话创建一个新的 git 工作树(隔离的代码副本) |
1.4 --bare:一个值得单独讲的极简模式
这个选项的官方描述很长,值得逐条拆开看,因为它等于列出了「一次正常启动到底做了多少额外的事」:
「Minimal mode: skip hooks, LSP, plugin sync, attribution, auto-memory, background prefetches, keychain reads, and CLAUDE.md auto-discovery.」
译:极简模式:跳过钩子、语言服务协议、插件同步、提交署名、自动记忆、后台预取、系统钥匙串读取,以及 CLAUDE.md 的自动发现。
反过来读这句话,正常启动时会做这些事:
| 正常启动会做的事 | 为什么它慢 / 有副作用 |
|---|---|
| 执行钩子 | 用户配置的启动脚本,可能是任意程序,耗时不可控 |
| 启动语言服务协议 LSP | 为了让模型能做「跳转到定义」这类代码导航,需要启动一个语言服务器进程 —— 这在大项目上可能要几秒 |
| 同步插件 | 可能触发网络请求去拉取插件的最新版本 |
| 提交署名 | 往 git 提交里追加署名信息,需要读 git 配置 |
| 自动记忆 | 加载长期记忆目录 |
| 后台预取 | 提前拉取可能用得上的数据 |
| 读系统钥匙串 | 在 macOS 上读钥匙串会弹出系统授权对话框,在自动化脚本里是致命的 |
| 自动发现 CLAUDE.md | 沿着目录树向上逐级查找项目规范文件 |
--bare 模式还有一个关键的行为改变,描述里写得很明确:
「Anthropic auth is strictly ANTHROPIC_API_KEY or apiKeyHelper via --settings (OAuth and keychain are never read).」
译:认证严格限定为环境变量 ANTHROPIC_API_KEY 或通过 --settings 指定的密钥获取脚本(OAuth 登录态和系统钥匙串永远不会被读取)。
这是为自动化场景专门设计的:认证来源必须是完全确定、不需要任何交互的。一个跑在持续集成流水线里的任务,绝不能因为「弹出了一个钥匙串授权框」而卡死。
1.5 启动时序
走完快路径分派之后,完整启动大致是这个顺序:
1.6 系统提示词的三段结构
第 ⑦ 步返回的三个部分不是随意划分的,它们对应三个不同的缓存稳定性等级:
| 部分 | 放在哪里 | 稳定性 |
|---|---|---|
defaultSystemPrompt默认系统提示词 |
上下文最开头 | 最稳定。同一个版本的程序、同一个模型,所有用户都一样 —— 所以它能跨用户共享缓存。 |
systemContext系统上下文 |
追加在系统提示词之后 | 较稳定。包含当前工作目录、操作系统、git 状态等环境信息。同一个用户的同一场会话内基本不变。 |
userContext用户上下文 |
拼在消息序列前面 | 最不稳定。包含用户的项目规范文件内容等。它被放在消息区而不是系统提示词区,正是为了不污染前面两段的缓存。 |
对应的代码在主循环里是这样调用的:
// query.ts
const fullSystemPrompt = asSystemPrompt(
appendSystemContext(systemPrompt, systemContext) // 系统提示词 + 系统上下文
)
...
messages: prependUserContext(messagesForQuery, userContext) // 用户上下文 + 消息
把「所有人都一样的部分」「这个用户不变的部分」「随时可能变的部分」按稳定性从高到低依次排列,是缓存友好的通用做法。
因为提示词缓存是前缀匹配的 —— 从最开头逐字比对,一旦对不上,后面全部失效。所以最不容易变的东西必须放在最前面。
如果把用户的项目规范文件(随时可能被编辑)放在系统提示词开头,那么用户每改一次 CLAUDE.md,整段缓存就全废。放到消息区之后,改动只影响它自己那一小段。
1 · The Entry Layer and Startup
This chapter covers what the program does between the moment you type claude in the terminal and the moment the interface appears.
1.1 Four launch modes
The same executable enters one of four completely different run modes depending on its arguments:
| Mode | How to trigger it | Purpose and characteristics |
|---|---|---|
| Interactive mode REPL |
claude(no arguments) |
Starts a terminal UI for an ongoing conversation. There is an input box, a scrolling message list, keyboard shortcuts. This is how the vast majority of people use it. REPL = Read-Eval-Print Loop, the generic name for an interactive command-line interface. |
| Headless mode headless / print |
claude -p "fix this file for me" |
No interface. Give it a question, it runs to completion, prints the result to standard output, and exits. Meant for automated calls from scripts. Combined with --output-format json it emits machine-readable structured output. |
| SDK mode Agent SDK |
Called as a library by another program | Input and output both go over a streaming JSON protocol on standard input/output (--input-format stream-json). Lets other software embed Claude Code as an agent engine. |
| Special child processes | --daemon-worker--claude-in-chrome-mcp etc. |
Helper processes spawned by the main process. For example the local host for the browser extension, background daemon workers, the remote bridge service. |
1.2 The first design decision at startup: fast-path dispatch
entrypoints/cli.tsx is the real program entry point, and it is only 302 lines. Its header comment gets straight to the point:
“Bootstrap entrypoint - checks for special flags before loading the full CLI. All imports are dynamic to minimize module evaluation for fast paths. Fast-path for --version has zero imports beyond this file.”
In other words: the bootstrap entry checks for special flags before loading the full CLI. Every import is dynamic, so the fast paths execute as little module code as possible. The --version fast path imports nothing beyond this file.
Why this matters
First, a bit of background: when a JavaScript program “imports” a module, that module’s top-level code runs immediately. If a program statically imports several hundred modules, then merely starting up means executing all several hundred of them, even if this particular run never uses them.
Bundled, Claude Code is a 300 MB single-file program with an enormous number of modules. If every run loaded all of it, even claude --version — a command that just wants to glance at the version number — would take several seconds.
So the entry file uses dynamic imports — a branch’s modules are loaded only when execution actually reaches that branch:
async function main(): Promise<void> {
const args = process.argv.slice(2); // grab the command-line arguments
// Fast path 1: --version, zero modules loaded
if (args.length === 1 && (args[0] === '--version' || args[0] === '-v')) {
console.log(`${MACRO.VERSION} (Claude Code)`); // the version is baked in at compile time
return; // return immediately; nothing was loaded
}
// Only the remaining paths load the startup profiler
const { profileCheckpoint } = await import('../utils/startupProfiler.js');
profileCheckpoint('cli_entry'); // record a timestamp
// Fast path 2: --dump-system-prompt (export the system prompt, for evals)
if (feature('DUMP_SYSTEM_PROMPT') && args[0] === '--dump-system-prompt') {
const { enableConfigs } = await import('../utils/config.js');
...
return;
}
// Fast path 3: the local host process for the browser extension
if (process.argv[2] === '--claude-in-chrome-mcp') { ... return; }
// Fast path 4: daemon worker (spawned by the main process; performance-sensitive)
if (feature('DAEMON') && args[0] === '--daemon-worker') {
const { runDaemonWorker } = await import('../daemon/workerRegistry.js');
await runDaemonWorker(args[1]);
return;
}
...
// No fast path matched → load the full CLI
}
claude-code/src/entrypoints/cli.tsx
The MACRO in MACRO.VERSION is a compile-time macro — it is replaced with a literal string at bundle time. So getting the version number does not even require reading a config file.
If your command-line program has a “slow startup” problem, first check whether high-frequency lightweight commands are being dragged down by a heavyweight startup sequence.
Typical examples: --version, --help, shell completion scripts (these matter most — they run every time the user presses Tab), and child processes the main process spawns frequently.
Turn those into fast paths that are “dispatched before anything is loaded,” and the payoff is immediate.
1.3 Command-line arguments: 60-plus options
The full command-line interface is defined in main.tsx using the Commander.js library. There are a great many options; below they are grouped by purpose:
Mode and I/O
| Option | Effect |
|---|---|
-p, --print | Headless mode: print the result and exit. Note: this mode skips the “trust this working directory” confirmation dialog, so use it only in directories you trust. |
--output-format <format> | text (default) / json (a single result object) / stream-json (real-time streaming) |
--input-format <format> | text (default) / stream-json (read from standard input in real time) |
--json-schema <schema> | Require the output to conform to a given JSON structure. The program uses a special “structured output tool” to force the model to produce a conforming result, and retries if it does not (up to 5 times). |
--include-partial-messages | Emit every fragment of the model’s streamed response, not just complete messages |
Permissions and safety
| Option | Effect |
|---|---|
--permission-mode <mode> | Set the permission mode. See Chapter 7 for the possible values. |
--dangerously-skip-permissions | Skip every permission prompt. Official description: “only recommended for sandboxes with no internet access.” Note that even with this on, one layer of checks cannot be bypassed — Chapter 7 goes into detail. |
--allow-dangerously-skip-permissions | Only allows the mode above to be used, without turning it on by default. For administrators configuring policy. |
--allowed-tools / --disallowed-tools | Lists of allowed / forbidden tools. Supports argument patterns, e.g. Bash(git:*) means “only allow bash commands that start with git.” |
--tools | Directly specify the set of built-in tools available. Passing an empty string disables all tools. |
--add-dir <dirs...> | Additional directories to grant access to (by default only the current working directory is accessible) |
Model and budget
| Option | Effect |
|---|---|
--model <model> | Accepts an alias (sonnet, opus) or a full model name |
--fallback-model <model> | Automatically fall back to this when the primary model is overloaded. Only takes effect in headless mode (interactive mode asks the user directly) |
--effort <level> | Thinking effort: low / medium / high / max |
--thinking <mode> | enabled (same as adaptive) / disabled |
--max-turns <count> | Maximum number of turns. Exits early when exceeded. Headless mode only. |
--max-budget-usd <amount> | Spend cap (US dollars). Stops when exceeded. Headless mode only. |
Sessions and resume
| Option | Effect |
|---|---|
-c, --continue | Continue the most recent conversation in the current directory |
-r, --resume [value] | Resume by session ID, or open an interactive picker |
--fork-session | On resume, create a new session ID instead of reusing the original. Equivalent to “branch a new line off this save point” |
--resume-session-at <message ID> | Resume only up to the given message; discard everything after it |
--rewind-files <message ID> | Restore files to their state as of a given message, then exit. This is an “undo” feature — Chapter 11 explains how file history is implemented. |
--no-session-persistence | Do not write to disk. When this conversation ends it is gone and cannot be resumed. |
Extensions and integrations
| Option | Effect |
|---|---|
--mcp-config <configs...> | Load MCP external tool servers (accepts file paths or JSON strings) |
--strict-mcp-config | Use only the MCP servers given on the command line; ignore config from every other source |
--plugin-dir <path> | Load plugins from the given directory (can be repeated) |
--agents <JSON> | Define custom subagents directly in JSON |
--settings <file or JSON> | An additional config source |
--setting-sources <sources> | Which sources to read config from: user (user level) / project (project level) / local (local overrides) |
--ide | Automatically connect to an IDE at startup (if exactly one is available) |
-w, --worktree [name] | Create a new git worktree (an isolated copy of the code) for this session |
1.4 --bare: a minimal mode worth its own section
This option’s official description is long, and it is worth taking apart item by item, because it amounts to a list of “how much extra work a normal startup actually does”:
“Minimal mode: skip hooks, LSP, plugin sync, attribution, auto-memory, background prefetches, keychain reads, and CLAUDE.md auto-discovery.”
In other words: minimal mode skips hooks, the Language Server Protocol, plugin sync, commit attribution, auto-memory, background prefetches, system keychain reads, and automatic discovery of CLAUDE.md.
Read in reverse, that sentence says a normal startup does all of the following:
| What a normal startup does | Why it is slow / has side effects |
|---|---|
| Run hooks | User-configured startup scripts; they can be arbitrary programs, so their runtime is unbounded |
| Start the Language Server Protocol LSP | To let the model do code navigation like “go to definition,” a language server process has to be started — on a large project that can take seconds |
| Sync plugins | May trigger network requests to fetch the latest plugin versions |
| Commit attribution | Appends attribution to git commits, which requires reading git config |
| Auto-memory | Loads the long-term memory directory |
| Background prefetches | Fetches data that might be needed ahead of time |
| Read the system keychain | On macOS, reading the keychain pops up a system authorization dialog, which is fatal inside an automation script |
| Auto-discover CLAUDE.md | Walks up the directory tree level by level looking for project instruction files |
--bare mode has one more key behavior change, stated plainly in the description:
“Anthropic auth is strictly ANTHROPIC_API_KEY or apiKeyHelper via --settings (OAuth and keychain are never read).”
In other words: authentication is strictly limited to the ANTHROPIC_API_KEY environment variable or a key-helper script specified via --settings (the OAuth login state and the system keychain are never read).
This is designed specifically for automation: the source of credentials must be fully deterministic and require zero interaction. A job running in a continuous-integration pipeline must never hang because “a keychain authorization dialog popped up.”
1.5 Startup sequence
Once fast-path dispatch is out of the way, the full startup runs roughly in this order:
1.6 The three-part structure of the system prompt
The three parts returned in step ⑦ are not an arbitrary split; they correspond to three different levels of cache stability:
| Part | Where it goes | Stability |
|---|---|---|
defaultSystemPromptdefault system prompt |
Very start of the context | Most stable. Identical for every user on the same program version and the same model — so its cache can be shared across users. |
systemContextsystem context |
Appended after the system prompt | Fairly stable. Contains environment information such as the current working directory, operating system, git status. Essentially unchanged within one user’s single session. |
userContextuser context |
Prepended to the message sequence | Least stable. Contains things like the contents of the user’s project instruction files. It lives in the message area rather than the system prompt area precisely so that it does not invalidate the cache for the two parts above. |
The corresponding code in the main loop calls it like this:
// query.ts
const fullSystemPrompt = asSystemPrompt(
appendSystemContext(systemPrompt, systemContext) // system prompt + system context
)
...
messages: prependUserContext(messagesForQuery, userContext) // user context + messages
Ordering “the part everyone shares,” “the part that stays fixed for this user,” and “the part that can change at any moment” from most to least stable is the general recipe for being cache-friendly.
Prompt caching is prefix-matched — it compares character by character from the very beginning, and the moment there is a mismatch, everything after it is invalidated. So whatever changes least must come first.
If the user’s project instruction file (which could be edited at any time) sat at the top of the system prompt, then every edit to CLAUDE.md would throw away the entire cache. Placed in the message area instead, a change affects only its own small segment.