ClaudeCode源码详解01-进入Agent主循环前,Claude Code启动时都做了什么
提前总结版
在进入Agent循环之前需要进行环境准备,这个属于通用逻辑。
像这里的claude code在进入Agent循环之前,也是渲染界面、主题配置、准备Command、读取机器token信息等。统一归纳为环境准备工作,对应着CC代码就是创建Commander、init函数、读取MDM、读取Keychain、是否信任文件、主题配置等。这些事情对于Agent本身的能力并不会有什么提升。对应文件为cli.tsx跟main.tsx。
本文主要是理解一下在进入Agent运行时之前CC到底在做什么以及一些其中的功能与专业术语。
CC执行链路是什么样子
从启动claude,用户输入到最终模型响应,链路大概是这样:
-
先进入真正的入口文件
src/entrypoints/cli.tsx -
cli.tsx分发到src/main.tsx -
对话交互模式下经历 trust / onboarding 等前置流程,然后把渲染终端界面,进入 REPL
-
用户真正提交第一条消息后,才会调用
query(),进入 Agent 的主循环 -
每次迭代:压缩上下文 → 组装 system prompt → 调用模型 API → 处理工具调用 → 继续或退出
在本文当中,我们主要讲一下在用户真正提交信息进行agent循环之前,做了哪些事情
先说几个容易混淆的点
1. main.tsx 不是“主循环本体”
src/main.tsx 更准确的定位是:
-
CLI 总调度入口
-
负责注册命令和参数
-
负责初始化运行时
-
负责决定当前是交互模式、非交互模式、恢复会话、远程控制还是其它分支
-
在合适的时候把 REPL 渲染出来
真正的 Agentic Loop 在 src/query.ts 的 queryLoop() 里,而不是 main.tsx。
2. launchRepl() 不是“开始跑 Agent”
src/replLauncher.tsx 里的 launchRepl() 很短,本质上只是:
-
动态加载
App -
动态加载
REPL -
调用
renderAndRun()把界面挂到 Ink root 上
它做的是“把 REPL 画出来”,不是“开始跑主循环”。
真正进入模型调用,是在 REPL 内部收到用户输入后,才会进一步进入 query()。
cli.tsx 做了哪些事情
src/entrypoints/cli.tsx 不是简单地 import('./main.tsx') 然后执行。它会先检查命令行参数,判断是否能走“轻量路径”。
典型分支包括:
-
--version:直接输出版本号,不加载完整 CLI -
remote-control/rc:走远程控制 bridge 相关逻辑 -
daemon:进入守护进程模式 -
ps/logs/attach/kill:进入后台会话管理 -
--worktree+--tmux:直接走 worktree/tmux 快路径
只有这些分支都没命中,才会继续进入完整 CLI。
从性能角度看,这一层非常重要,因为很多命令根本不需要把“Agent 运行时”整个拉起来。
我们能学到什么
不需要调用Agent运行时的都提前进行处理,降低响应时间,其实大家应该都是这么做的
main.tsx 主要负责什么
如果说 cli.tsx 是前置分发层,那么 src/main.tsx 才是完整 CLI 的总调度器。
它主要做四件事:
-
注册命令(例如plugin命令)和参数(例如–agents)
-
在
preAction中完成通用初始化 -
进入默认 action 后(就是执行claude命令),决定当前启动路径
-
在交互模式下创建 UI 并启动 REPL
main.tsx 负责把 Claude Code CLI 这台机器“装起来”,但真正持续运转的 Agent 循环在后面的 query() 里。
什么是 REPL
REPL 是 Read-Eval-Print Loop 的缩写。
放在 Claude Code 里,可以简单理解成:
-
终端里输入一句话
-
系统处理
-
输出结果
-
继续等待下一次输入
不过这里的 “Eval” 并不只是执行一段表达式,而是一次完整的 Agent 过程:模型采样、工具调用、结果回写、必要时继续下一轮。
为什么要创建 Commander 程序对象
源码里有这样一段:
constprogram=newCommanderCommand().configureHelp(createSortedHelpConfig()).enablePositionalOptions();...program.option('-w, --worktree [name]', 'Create a new git worktree for this session (optionally specify a name)');program.option('--tmux', 'Create a tmux session for the worktree (requires --worktree). Uses iTerm2 native panes when available; use --tmux=classic for traditional tmux.');...program.addOption(newOption('--remote-control [name]', 'Start an interactive session with Remote Control enabled (optionally named)').argParser(value=>value||true).hideHelp());...constmcp=program.command('mcp').description('Configure and manage MCP servers').configureHelp(createSortedHelpConfig()).enablePositionalOptions();
后面会在 program 上不断挂载各种 option 和 subcommand,比如:
-
mcp -
plugin -
doctor -
--worktree -
--tmux -
--remote-control
它的意义并不只是“解析一下 argv”,而是把整个 CLI 的命令树描述出来。指明Claude能用哪些命令,哪些参数。
为什么要在执行命令前挂一个 preAction hook
在 main.tsx 里,程序会注册一个 program.hook('preAction', async ...)。
program.hook('preAction', async thisCommand => { ... # 先等待Mdm配置加载以及Keychain提取完成 await Promise.all([ensureMdmSettingsLoaded(), ensureKeychainPrefetchCompleted()]); # 通用初始化 await init(); # 初始化日志 sink initSinks();...}
这个 hook 的关键意义是:
只要真正要执行某个命令,就先把通用初始化做完;但如果只是
--help之类的展示场景,就不必支付这部分成本。
这个 hook 里至少会做三件重要的事:
-
等待 MDM 配置和 Keychain 预取完成
-
调用
init() -
初始化日志 sink、迁移、远程配置等公共能力
这种做法的好处是:
-
避免重复初始化逻辑散落在每个命令里
-
确保默认命令和子命令有一致的基础运行环境
-
又不会影响纯帮助输出的速度
init() 并不负责“启动 REPL”,它负责“启动底座”
init() 的职责更接近于“把运行时底座准备好”,而不是“开始具体业务流程”。
从源码能看到,它会处理的事情包括:
-
启用配置系统
-
应用安全相关环境变量
-
处理证书、网络、代理等运行时配置
-
设置优雅退出
-
启动一些预热和探测任务
例如:
exportconstinit=memoize(async (): Promise<void>=> {enableConfigs()applySafeConfigEnvironmentVariables()applyExtraCACertsFromConfig()voidpopulateOAuthAccountInfoIfNeeded()setupGracefulShutdown()voidinitJetBrainsDetection()voiddetectCurrentRepository()preconnectAnthropicApi()})
所以可以把它理解成:
-
init()负责地基 -
后面的 action / setup / REPL 负责真正进入使用态
MDM 和 Keychain 在这里分别是什么
这里用一句话解释就够了:
-
MDM:企业托管配置 -
Keychain:系统级凭据读取,主要是 macOS 上的认证信息缓存或预取
源码把这两个动作提前做,本质上是为了降低后续首次读取配置、认证或策略信息时的延迟。
默认命令是怎么进入交互模式的
如果用户没有走 doctor、mcp、plugin 这些显式子命令,而是直接执行:
claude
那么会命中顶层默认命令的 .action(async (prompt, options) => { ... })。
program.name('claude').description(`Claude Code - starts an interactive session by default, use -p/--print for non-interactive output`).argument('[prompt]', 'Your prompt', String).action(async (prompt, options) => {profileCheckpoint('action_handler_start');}
这里才是“普通用户进入 Claude 对话”的主入口。
但需要注意,这个 action 里做的事情远不止“调用 launchRepl()”。
进入交互模式下的前置动作有哪些
1. 创建 Ink root
如果当前不是非交互模式,代码会先创建终端 UI 的 root:
const { createRoot } =awaitimport('./ink.js')root=awaitcreateRoot(ctx.renderOptions)
这一步的意义是:先把终端 UI 容器搭起来,后面很多初始化流程都可能需要弹界面、显示对话框或 onboarding。
2. 执行 showSetupScreens()
紧接着会调用:
constonboardingShown=awaitshowSetupScreens(root,permissionMode,allowDangerouslySkipPermissions,commands,enableClaudeInChrome,devChannels)
这个函数会在 REPL 渲染前,依次检查是不是需要先显示一些“前置页面”,一般都是首次启动引导,完成一些初始配置,例如主题等
3. 最后才调用 launchRepl()
等上述准备工作都结束之后,交互模式才会走到:
awaitlaunchRepl(root, appProps, replProps, renderAndRun)
这里非常适合点明一句:
到这一步,Claude Code 只是“进入了可交互状态”,还没有正式跑进
queryLoop()。
4. launchRepl() 到底做了什么
launchRepl() 的实现非常短:
exportasyncfunctionlaunchRepl(...) {const { App } =awaitimport('./components/App.js')const { REPL } =awaitimport('./screens/REPL.js')awaitrenderAndRun(root,<App {...appProps}><REPL {...replProps} /></App> )}
这段代码传达的信息很明确:
-
App负责上层 provider 和全局状态包装 -
REPL才是真正的交互界面 -
launchRepl()的职责是把这两个组件挂起来
所以严格来说,它启动的是“交互界面”,不是“Agent 主循环”。
夜雨聆风