平常我们在终端里输入:
1 | claude |
很快就会进入 Claude Code 的交互界面。但从源码看,这中间其实经过了不少东西:
- Shell 先找到已经安装好的
claude可执行文件; cli.tsx处理--version一类的快速命令;main.tsx判断这次是什么运行模式;- Commander 解析参数并选中对应的
.action(); .action()加载权限、Tools、Skills、Agents 和 MCP;- 最后进入 REPL 或 Headless,由
query()进入queryLoop()。
核心涉及两个文件:src/entrypoints/cli.tsx 和 src/main.tsx。

下边会结合源码把每一步都串起来。
cli.tsx 先处理简单命令
cli.tsx 没有一上来就加载完整的 Claude Code,而是先看当前参数能不能直接处理。
例如:
1 | // src/entrypoints/cli.tsx |
大概流程如下:

值得注意的地方是执行主函数的时候没有在文件开头 import,而是这里动态 import:
1 | const { |
动态加载最直观的好处是启动更快。例如执行 claude --version 时,根本不用加载 React、MCP 和 Tools。
还有个作用是提前处理参数和环境变量,再允许后续模块执行顶层初始化代码。
假设把代码改成静态导入:
1 | import { main } from '../main.js' |
ES Module 会先加载并执行 main.js 以及它依赖的模块,然后才执行当前文件里的普通代码。
如果某个工具模块在顶层读取:
1 | const simpleMode = |
此时等入口再设置环境变量就晚了,因为依赖模块已经读取过旧值。
主要的几个作用:
- 快速命令不用初始化完整 Agent 系统。
--bare等早期配置能在模块求值前生效。- Chrome、Daemon、REPL、SDK 等路径互不污染。
- 完整运行时中的模块出问题时,不会影响
--version这类独立快速路径。
从 main() 到 .action()
进入 main.tsx 后会连续看到 main()、run() 和 .action(),这三个名字很容易混。
先不用看里边的所有代码,可以把它们理解成三层:

main():处理进程级初始化
位置 src/main.tsx:585 ,主要代码:
1 | export async function main() { |
可以看到,main() 还没有加载完整工具池,也没有调用模型。它主要做下边这些事情:
- 建立进程级安全设置;
- 安装退出和中断处理;
- 改写特殊启动参数;
- 提前判断交互、Print、SDK;
- 标记调用方是 CLI、Python SDK、TypeScript SDK、Desktop 还是 Remote;
- 在初始化前加载
--settings; - 最后把控制权交给
run()。
run():注册并解析命令
位置:src/main.tsx:884。
run() 先创建 Commander 实例:
1 | const program = |
接着注册根命令的参数:
1 | program |
然后再注册子命令:
1 | program.command('mcp') |
本文这条交互路径会等子命令注册完成后,在 src/main.tsx:4504 统一解析参数:
1 | await program.parseAsync(process.argv) |
Print 模式为了减少启动开销,会在 src/main.tsx:3887 提前调用 parseAsync(),直接进入根命令的 .action()。
Commander 会根据 argv 选择对应的处理函数:

在真正执行 action 以前,还会先执行一个公共的 preAction:
1 | program.hook('preAction', async () => { |
所以 run() 不是自己决定执行哪个分支,而是先把所有分支注册好,再由 Commander 根据参数选择:

.action():准备一次 Claude 会话
用户执行默认的 claude 命令后,会进入根命令的 .action()。
它拿到两个主要参数:
1 | prompt |
例如用户执行:
1 | claude \ |
经过 Commander 解析后,可以近似理解成:
1 | prompt = '检查登录模块' |
这里的 options 实际还有很多字段,上边只列出后续主流程会用到的部分。没有传入的可选参数大多是 undefined,之后再和用户设置、项目设置以及默认值合并。
接下来 .action() 会准备五组主要数据:

这也是 main.tsx 看起来特别长的原因。它不是在实现某一个复杂算法,而是在把输入、配置、权限和各种能力接到一次会话上。
下面按图中的顺序看:先准备 ①~④,再组装成 ⑤ 会话容器,最后选择 launchRepl() 或 runHeadless()。
① inputPrompt
1 | const effectivePrompt = prompt || ''; |
默认其实就是终端拿到的 prompt,但也兼容了一些其他情况:
stdin 是进程的标准输入,isTTY 表示它是否直接连接着交互终端:
1 | process.stdin.isTTY |
getInputPrompt() 的主逻辑可以简化成:
1 | if (process.stdin.isTTY || process.argv.includes('mcp')) { |
对应三种常见情况:
| 命令 | prompt 最终值 | |
|---|---|---|
claude "检查登录模块" |
'检查登录模块' |
|
| `cat error.log \ | claude -p “分析日志”` | '分析日志\n' + error.log内容 |
SDK 的 stream-json 模式 |
process.stdin 输入流 |
三秒计时器只保护“迟迟收不到第一段数据”的阶段。收到第一段 data 后,计时器会被清除,代码继续等待 end,确保读完全部内容;空管道如果已经关闭,也会直接触发 end。因此它能防止父进程既不写数据也不关闭 stdin 时卡住启动,但如果对方写了一段数据后永远不关闭,读取仍然会等待。
② 工具系统:toolPermissionContext + tools
这段代码分成两个阶段:
- 启动时:选权限模式、合并规则、生成模型可见的工具列表;
- 运行时:模型真的调用工具时,再判断这一次调用能否执行。
为了看清每个参数怎样影响结果,用这个例子看一下后续代码的执行:
1 | claude "修改登录模块并运行测试" \ |
下面把源码和这条命令产生的中间值写在一起。Commander 进入 .action() 后,参数已经被解析成变量:
1 | prompt // => '修改登录模块并运行测试' |
这里五个参数的作用不同:
--tools控制哪些内置工具可以进入候选集合;--allowed-tools为具体工具或命令增加 allow 规则;--disallowed-tools增加 deny 规则;--permission-mode设置默认处理方式;--add-dir扩大允许访问的工作目录。
下面按执行顺序阅读。代码是删去日志、埋点和兼容分支后的主干,... 表示暂时不影响理解的细节。
initialPermissionModeFromCLI():选择模式
源码:src/utils/permissions/permissionSetup.ts
1 | function initialPermissionModeFromCLI({ |
选择顺序就是:
1 | 危险跳过权限参数 |
真实源码还会跳过被组织策略禁用的模式。这里仅仅选模式,还没有判断任何工具。
对外模式有五种;开启相关功能后还会出现 auto。源码中的 bubble 是内部状态,不能通过 CLI 选择。
| 模式 | 遇到原本需要确认的调用时 |
|---|---|
default |
询问用户 |
acceptEdits |
工作目录内的常规文件修改自动允许,其他调用继续判断 |
plan |
先探索和生成计划,经批准后再实施 |
dontAsk |
不询问,直接拒绝 |
bypassPermissions |
普通确认直接允许,但显式 deny、ask 和安全检查仍优先 |
auto |
交给分类器判断,需要相应功能开关 |
plan 改变的是 Agent 的工作流程,并不等于直接从工具列表中删掉 Edit 和 Bash。
initializeToolPermissionContext():合并规则
源码:src/utils/permissions/permissionSetup.ts
1 | async function initializeToolPermissionContext(args) { |
注意,Bash(rm:*) 只禁止匹配的命令,不会让整个 Bash 工具消失;WebFetch 这种没有括号内容的规则才是整项 deny。
这一步只整理规则,不执行工具。
getTools():筛选模型可见的工具
源码:src/tools.ts
1 | function getTools(permissionContext) { |
它取得候选工具,再去掉整项 deny 和当前环境未启用的工具。真实源码还处理 Simple、REPL、Coordinator 以及需要按条件添加的特殊工具。
getTools() 返回的类型是 readonly Tool[],也就是 Tool 对象数组,不是工具名字符串数组。
为什么 Bash 还在?因为 Bash(rm:*) 只拒绝一部分命令;为什么 WebFetch 不在?因为 --tools 没有选择它,初始化时已经生成了整项 deny 规则。
数组中的每一项近似是下面这样的对象:
1 | tools.find(tool => tool.name === 'Read') |
其中 inputSchema 告诉模型参数怎么传,checkPermissions() 检查本次调用,call() 才真正读取文件。具体有哪些工具会随环境变量、Feature Flag、REPL 状态和 deny 规则变化。
关键点只有一句:
出现在
tools中只代表模型看得见,不代表调用一定能执行。
hasPermissionsToUseTool():运行时检查调用
源码:src/utils/permissions/permissions.ts
执行工具时,它会作为 canUseTool 回调。模型每次调用工具前,都会走一次。下面先代入 Bash("npm test -- login"),把本次调用的值直接写在源码旁:
1 | async function hasPermissionsToUseTool( |
整条权限链如下:

getTools()决定模型能看见什么,hasPermissionsToUseTool()决定这一次能不能做。
③ 扩展能力:Skills、Commands、Agents 和 MCP
先把关系理顺:这四个词并不在同一层。
| 名称 | 本质 | 运行时怎么用 |
|---|---|---|
| Command | 用户或模型可以触发的命令入口 | 找到命令后,执行它的处理函数或展开提示词 |
| Skill | 一份可复用的任务说明 | 通常被转换成 Command;展开后仍由模型调用 Tools 完成工作 |
| Agent Definition | 一个子 Agent 的配置模板 | Agent Tool 根据模板创建子 Agent |
| MCP | 外部 Server 提供能力的协议 | 连接 Server 后得到额外的 Tools、Commands 和资源 |
所以这里真正需要准备的是三组数据:
下面只是先展示三个变量的职责,具体赋值源码会在后面逐段标出。
1 | commands |
整条装配关系如下:

下面使用 /Users/me/shop 这个示例项目。假设项目中有:
1 | /Users/me/shop/ |
分别代表一个项目命令、一个项目 Skill、一个项目 Agent 和一份 MCP 配置。
第一步:注册内置 Skills
源码先把随 Claude Code 一起发布的 Plugin 和 Skill 注册到内存:
源码位置:src/main.tsx:1918-1929、src/skills/bundled/index.ts:24
1 | if ( |
这里只是注册元数据和提示词,没有运行任何 Skill。
以 /simplify 为例,注册代码的核心只是返回一段提示词:
源码位置:src/skills/bundled/simplify.ts:56-68
1 | registerBundledSkill({ |
假设用户输入:
1 | /simplify 重点检查重复代码 |
这段函数的值是:
运行值对应:src/skills/bundled/simplify.ts:61-66
1 | args |
展开后的 prompt 会交给模型。真正搜索、修改和测试代码的仍然是 Grep、Read、Edit 和 Bash。

这就是 Skill 和 Tool 最重要的区别:
Skill 告诉模型“这类任务应该怎么做”,Tool 负责“真正执行某个动作”。
第二步:setup() 准备会话环境并确定最终工作目录
setup() 不是加载 Commands、Skills 或 Agents 的函数。它负责把后面这些能力依赖的基础环境先准备好。
调用位置:src/main.tsx:1918-1934
1 | const preSetupCwd = getCwd() |
setup() 内部主要做四类事情:
| 工作 | 为什么要在这里完成 |
|---|---|
| 检查 Node.js、会话 ID 和危险权限模式 | 确保会话可以安全启动 |
| 设置当前目录,读取 Hooks 并启动文件变化监听 | 后续能力必须使用正确的项目配置 |
| 根据参数创建 worktree 和 tmux session | 这一步可能改变实际工作目录 |
| 启动 Session Memory、Plugin Hooks 等基础服务 | 保证第一轮执行前基础设施已经就绪 |
与当前装配图直接相关的是第二、三项。源码先设置普通工作目录:
源码位置:src/setup.ts:160-176
1 | setCwd(cwd) |
如果用户传入 --worktree feature-login,setup() 会真正改变进程和会话记录的目录:
源码位置:src/setup.ts:271-284
1 | process.chdir(worktreeSession.worktreePath) |
所以图里的 setup() 可以理解成一道分界线:
普通模式下,
preSetupCwd已经是最终目录,可以和setup()并行加载;开启 worktree 时,目录可能被setup()改变,必须等它完成后再读取项目 Commands、Skills、Agents 和 Hooks。
第三步:根据最终工作目录加载 Commands 和 Agents
项目级 Command、Skill 和 Agent 都与目录有关,因此源码先确定工作目录:
源码位置:src/main.tsx:1918-2029
1 | const preSetupCwd = getCwd() |
getCommands() 把多种来源统一成一张 Command 列表:
源码位置:src/commands.ts:449-468
1 | return [ |
代入示例项目后,可以只看名字:
运行值来自:src/main.tsx:2029 返回的 commands
1 | commands.map(command => command.name) |
常见误解是:Skill 和普通 Command 加载后会分别存放、分别查找。
实际不是。它们的来源和概念不同,但加载阶段都会被转换成统一的 Command 运行时结构,然后合并进同一个 commands 数组。因此用户输入 /simplify、/review 或 /test-login 时,程序都从这张数组中查找。
这里的“统一结构”只表示共用同一套查找和调用机制,不表示 Skill 与 Command 是同一个概念:普通 Command 可以直接执行处理逻辑,Skill 的主要作用仍是展开一份任务说明,再交给模型和 Tools 完成。
Agents 走的是另一条加载链:
源码位置:src/tools/AgentTool/loadAgentsDir.ts:296-380
1 | const agentDefinitionsResult = |
当模型要委派代码审查时,它会调用已有的 Agent Tool:
调用参数定义:src/tools/AgentTool/AgentTool.tsx:76-130
查找 Agent Definition:src/tools/AgentTool/AgentTool.tsx:286
1 | // 这是模型生成的 Agent Tool 调用参数,不是普通函数的同步返回值 |
Agent Tool 再去 agentDefinitions.activeAgents 中查找 code-reviewer,用它指定的提示词、模型和 Tools 创建子 Agent。Agent Definition 本身不是 Tool,也不会因为被加载就自动执行。
如果使用 --worktree feature-login,setup() 会切换到新 worktree。此时源码不会提前读取旧目录,而是在 setup() 完成后使用新的 currentCwd 加载:
对应分支:src/main.tsx:1928-2029
1 | commandsPromise // => null |
原因很直接:.claude/commands/、.claude/skills/ 和 .claude/agents/ 都可能因工作目录不同而变化。
第四步:确认目录可信后连接 MCP
MCP 与前两组能力不同。Commands 和 Agents 主要是读取文件;连接 MCP 可能会启动外部进程。
Claude Code 可以提前读取配置:
源码位置:src/main.tsx:1800-1816
1 | const mcpConfigPromise = |
此时只是读取 JSON,并没有执行 node ./server.js。交互模式先确认目录可信,再审批项目 .mcp.json 中的 Server,通过后才会连接:
信任界面:src/main.tsx:2239-2242
MCP 连接:src/main.tsx:2408-2455
1 | await showSetupScreens(/* ... */) |
mcpPromise 会在后台继续连接。Server 连接成功后,连接管理逻辑再把结果更新到 AppState.mcp,因此慢 Server 不会卡住界面。

MCP Server 最终可以贡献三类东西:
clients:Claude Code 与外部 Server 的连接;tools:模型可以调用的外部动作;commands:外部 Server 提供的提示词命令。
本节最终得到什么
扩展能力这一节最后只产生三组结果;它们会在第⑤步与输入、权限和模型配置一起装进会话:
1 | commands |
到这里,四个概念就能对应起来了:
| 用户或模型想做什么 | 实际路径 |
|---|---|
执行 /simplify |
commands 找到 Skill → 展开提示词 → 模型调用 Tools |
执行 /review |
commands 找到项目 Command → 展开命令内容 |
委派给 code-reviewer |
Agent Tool → 查找 agentDefinitions → 创建子 Agent |
| 搜索外部项目系统 | 模型调用 mcp__project-server__search → MCP Server |
Commands 是入口表,Skills 是可复用的方法,Agent Definitions 是子 Agent 模板,MCP 是外部能力来源。它们最后都会增强会话,但装载位置和运行方式并不相同。
这一节只回答“能力从哪里来、怎样被触发”。这些数据怎样进入会话、执行时又怎样传给 Tool 和 Agent,要等第⑤步组装完会话后再看。
④ systemPrompt / model / thinking:准备模型配置
假如是下边的命令:
1 | claude \ |
这条命令传了 --model sonnet,没有传 System Prompt 和 Thinking 参数。下面按源码执行顺序看三个结果怎样产生。
systemPrompt:替换还是追加系统指令
Claude Code 支持两组不同参数:
--system-prompt/--system-prompt-file:替换默认 System Prompt;--append-system-prompt/--append-system-prompt-file:在最终 System Prompt 后追加内容。
源码位置:src/main.tsx:1343-1392
1 | // 本例没有传 --system-prompt |
所以这一阶段得到:
1 | systemPrompt // => undefined |
这不表示模型没有 System Prompt,只表示 CLI 没有要求替换或追加。交互模式真正发起一轮请求前,还会构造最终值。
最终构造位置:src/screens/REPL.tsx:2768-2787
合并规则:src/utils/systemPrompt.ts:41-112
1 | const [ |
这里有三个容易混淆的变量:
| 变量 | 本文示例中的值 |
|---|---|
main.tsx 中的 systemPrompt |
undefined,表示没有传入 --system-prompt |
getSystemPrompt() 生成的 defaultSystemPrompt |
Claude Code 动态生成的默认系统提示词 |
buildEffectiveSystemPrompt() 返回的 systemPrompt |
最终发送给模型的 string[];本文就是 defaultSystemPrompt |
默认 System Prompt 具体是什么
它不是写死在一个 DEFAULT_SYSTEM_PROMPT 常量中的大字符串,而是 getSystemPrompt() 根据当前 Tools、目录、模型、配置和 MCP 连接动态拼成的 string[]。
生成位置:src/constants/prompts.ts:444-547
省略缓存边界和按 Feature Flag 开关的区块后,主干可以写成:
1 | return [ |
代入本文的示例环境,缩短后的运行值大致是:
1 | defaultSystemPrompt |
因此默认 System Prompt 的核心可以概括成:
你是帮助用户完成软件工程任务的交互式 Agent;遵守权限和安全边界,先理解现有代码,再使用当前可用 Tools 完成任务;同时读取当前项目、模型、目录、Memory、CLAUDE.md 和 MCP 提供的动态上下文。
还有一个特殊分支:如果使用 --bare,入口会设置 CLAUDE_CODE_SIMPLE=1,默认提示词会缩成最小版本。
简化模式位置:src/constants/prompts.ts:450-456
1 | if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) { |
本文示例没有主线程 Agent 或 Coordinator 覆盖,所以传入 --system-prompt "只做代码审查" 时,customSystemPrompt 会代替默认提示词;传入 --append-system-prompt "回答必须使用中文" 时,则会在最终提示词末尾追加内容。若指定了主线程 Agent,它自己的 Prompt 优先级更高,实际取值由 buildEffectiveSystemPrompt() 统一决定。
model:把别名解析成实际模型
模型选择的优先级是:
- CLI 的
--model; - 当前主线程 Agent Definition 中的
model; - 环境变量
ANTHROPIC_MODEL; - Settings 中的
model; - Claude Code 内置默认模型。
源码位置:src/main.tsx:2019-2020、src/main.tsx:2107-2116、src/utils/model/model.ts:61-88
1 | const userSpecifiedModel = |
sonnet 只是方便用户输入的别名。parseUserSpecifiedModel() 会把它转换成当前版本真正发送给模型服务的名称。
别名解析位置:src/utils/model/model.ts:445-496
本文源码版本的 Sonnet 默认值:src/utils/model/configs.ts:80-83
环境变量 ANTHROPIC_DEFAULT_SONNET_MODEL 可以覆盖这个默认值;本文示例按未覆盖的普通 First-Party 环境展示,所以结果是 claude-sonnet-4-6。
thinkingConfig:决定模型怎样思考
源码先取默认值,再用 CLI 或环境变量覆盖。
本文示例假设没有设置 MAX_THINKING_TOKENS,并且 settings.alwaysThinkingEnabled 没有被设为 false。在这个条件下,源码的默认值是 true。
源码位置:src/main.tsx:2457-2487
默认值来源:src/utils/thinking.ts:146-159
1 | let thinkingEnabled = |
因此本文示例最终得到:
1 | systemPrompt // => undefined |
这里的 Thinking 和前文的 permissionMode: 'plan' 没有直接关系:
plan决定 Agent 先规划还是直接实施,以及相应权限流程;thinkingConfig决定一次模型请求使用 adaptive thinking、固定预算还是关闭 Thinking。
⑤ initialState / sessionConfig:组装会话
前四组数据准备好后,.action() 把它们放进两个对象:
源码位置:src/main.tsx:2926-3090
1 | const initialState = { |
为什么要分成两个对象?看 launchRepl() 把它们交给谁就明白了:
源码位置:src/replLauncher.tsx:12-21
1 | <App {...appProps}> |
main.tsx 只是把数据分成两包:
| 对象 | 交给谁 | 放什么 |
|---|---|---|
initialState |
外层 <App> |
权限、MCP 状态、Todos、通知等共享状态 |
sessionConfig |
内层 <REPL> |
Commands、模型配置、Prompt 覆盖项和回调 |
一句话:initialState 初始化外层 App,sessionConfig 配置内层 REPL。
ToolUseContext:构造每轮执行现场
前面准备的是“整个会话拥有什么”,但 Command、Skill 和 Tool 真正执行时,不能每个函数都单独传十几个参数。ToolUseContext 就是它们共用的一次执行上下文。
类型定义:src/Tool.ts:158-245
1 | export type ToolUseContext = { |
可以把它理解为本轮执行的“工作台”:
options放本轮可用的 Commands、Tools、Agents、MCP 和模型配置;getAppState()读取当前权限、MCP 连接和其他动态状态;setAppState()把 Tool 产生的状态变化写回会话;abortController负责用户按下中断键时停止当前工作。
交互模式会在用户每次提交输入时创建它。下面沿用上一节的权限示例和本节的扩展能力示例,把中间值直接放到源码旁。
创建位置:src/screens/REPL.tsx:2392-2475
1 | const getToolUseContext = useCallback(( |
这里正好解释了为什么它重要:initialState 和 sessionConfig 只是保存数据,ToolUseContext 才把当前时刻的这些数据送到执行现场。
例如权限检查和 Agent Tool 都直接依赖它:
权限检查位置:src/utils/permissions/permissions.ts:1158-1176
Agent 查找位置:src/tools/AgentTool/AgentTool.tsx:286
1 | // 权限检查读取当前模式和规则 |
Skill 也会收到同一个对象。项目 Skill 如果需要执行内嵌 Shell、读取权限或访问会话信息,可以从这里取得,不需要自己重新构造一套运行环境。
Skill 使用位置:src/skills/loadSkillsDir.ts:344-390
所以完整关系不是“加载完 Skills、Agents 和 MCP 就结束”,而是先把扩展能力写入 initialState / sessionConfig,每轮再创建 ToolUseContext,最后由 Command、Skill、Tool 和 Agent 共用它执行。
到这里再补上执行阶段的最后一段就顺了:
| 执行时需要什么 | 从哪里取得 |
|---|---|
| 本轮可用的 Tools、Commands、Agents 和 MCP Clients | toolUseContext.options |
| 当前权限模式、MCP Tools 和其他动态会话状态 | toolUseContext.getAppState() |
| Tool 执行后产生的状态变化 | toolUseContext.setAppState() |
理解了这层运行时桥梁,再回到最外层看 Claude Code 如何选择交互或 Headless 适配器。
选择运行适配器:交互、Print 和 SDK
从使用方式看,Claude Code 有三种模式:
- 交互式 CLI;
- Print / Headless;
- SDK。
但从源码最外层的控制流看,实际上只有两个分支:
launchRepl();runHeadless()。
SDK 没有单独复制一套 Agent 逻辑,它是 runHeadless() 中使用流式输入输出协议的一种模式。

对于本文示例,分流变量是:
1 |
|
如果只把命令改成:
1 | claude -p "检查登录模块" |
那么关键值就会变成:
1 |
|
SDK 模式是怎么识别的
如果传入:
1 | --sdk-url wss://... |
入口会自动补齐:
1 | // 假设用户传入 --sdk-url wss://agent.example/ws |
本地 Python/TypeScript SDK 通常使用:
1 | -p |
并通过 CLAUDE_CODE_ENTRYPOINT 标记调用方:
1 | sdk-py |
因此源码里没有只依赖一个 isSdkMode。判断 SDK 要结合下边几项:
- 是否非交互;
- 输入输出格式;
sdkUrl;CLAUDE_CODE_ENTRYPOINT。
启动部分到这里就结束了:launchRepl() 和 runHeadless() 接收前面准备好的会话数据,分别适配交互式终端与 Print / SDK。
这两条路径最终都会进入同一套执行内核:REPL 在 src/screens/REPL.tsx:2793 直接消费 query() 产生的事件;Headless 通过 QueryEngine 在 src/QueryEngine.ts:675 消费 query()。而 query() 的主干就是把控制权交给 queryLoop():
1 | // src/query.ts:224-241 |
因此本文标题中的“到 Agent 主循环”,指的就是启动流程最终抵达 queryLoop();循环内部怎样请求模型、执行 Tool 和决定下一轮,这里不再展开。
从 Claude Code 源码里可以学到什么
如果只是想知道 Claude Code 怎么启动,看到这里主流程已经结束了。
但源码更有意思的地方,是它怎么处理一个真实 Agent 产品会遇到的问题。下边这些做法不一定需要原样照搬,不过自己写 Agent、CLI 或本地开发工具时都可以参考。
入口文件尽量轻
cli.tsx 没有导入所有模块以后再判断参数,而是先处理 --version、Chrome、Daemon 等简单分支,最后才动态加载 main.tsx:
1 | // src/entrypoints/cli.tsx:33-300 |
这里能学到的不是简单的“使用动态 import”,而是把启动分成了两个阶段:

对于一个普通脚本,静态加载所有模块问题不大。但 Agent 产品通常会包含 UI、模型 SDK、浏览器、MCP、工具系统和大量配置。把入口保持得足够轻,可以得到几个好处:
- 简单命令启动更快;
- 完整运行时中的模块出问题时,不会影响
--version这类独立快速路径; - 可以先设置环境变量,再执行依赖模块的顶层代码;
- 不同运行入口不会无条件加载对方的依赖。
迁移到自己的 Agent CLI 时,入口也只需要保留三件事:识别快速命令、设置必须提前生效的环境变量、动态加载真正的运行模块。
命令入口和业务入口分开
从源码看,Claude Code 有三层入口:

这三层看起来有点绕,但职责并不一样:
cli.tsx::main()解决“这个进程应该加载哪个程序”;main.tsx::main()解决“本次进程以什么模式运行”;.action()解决“这一次 Claude 会话需要哪些数据”。
自己写 CLI 时很容易把参数解析、环境初始化、工具创建、UI 启动和模型调用都放进同一个 main()。代码量少的时候没什么问题;功能变多以后,测试一个 --version 都可能需要初始化数据库或模型客户端。
Claude Code 的处理方式说明,可以按照“进程启动、命令路由、会话组装、任务执行”拆开。以后新增子命令或新的运行模式时,不需要把所有逻辑重新塞回 main()。
小结
仍然代入本文的命令:
1 | claude \ |
整条启动链最终可以压缩成下表:
| 阶段 | 关键源码位置 | 本文示例产生的结果 |
|---|---|---|
| Shell 启动轻量入口 | src/entrypoints/cli.tsx:1-302 |
进入普通会话分支,动态加载 main.tsx |
| 初始化并解析 CLI | src/main.tsx:585、src/main.tsx:884 |
Commander 选择根命令的 .action() |
| 处理输入 | src/main.tsx:857-881 |
inputPrompt => '检查登录模块' |
| 准备权限和 Tools | src/utils/permissions/permissionSetup.ts:689-1055、src/tools.ts:271-321 |
permissionMode => 'plan',生成 toolPermissionContext 和 tools |
| 加载扩展能力 | src/main.tsx:1918-2455 |
得到 commands、agentDefinitions;MCP 在后台连接 |
| 准备模型配置 | src/main.tsx:1343-1392、src/main.tsx:2019-2116、src/main.tsx:2457-2487 |
resolvedInitialModel => 'claude-sonnet-4-6',thinkingConfig => { type: 'adaptive' } |
| 组装会话 | src/main.tsx:2926-3090 |
生成 initialState 和 sessionConfig |
| 选择运行外壳 | src/main.tsx:2829、src/main.tsx:3798 |
本例没有 -p,所以进入 launchRepl() |
| 构造本轮执行现场 | src/Tool.ts:158-245、src/screens/REPL.tsx:2392-2475 |
创建 ToolUseContext,把当前 Tools、权限、Agents、MCP、模型和状态交给执行代码 |
最后把三个对象放在一起:
| 数据 | 交给谁 | 作用 |
|---|---|---|
initialState |
<App> |
初始化会话共享状态 |
sessionConfig |
<REPL> |
配置交互层 |
ToolUseContext |
Tool、Skill、Agent | 提供本轮执行所需的数据 |
所以 ToolUseContext 不是另一种 Tool,也不是新的扩展来源。它是会话容器与执行代码之间的桥梁。
系列文更新中:cc.windliang.wang