main.ts 第三阶段:基础设施初始化
概述
参数解析完毕后,程序开始创建基础服务。这一阶段的代码量不大,但每个环节都至关重要。
// Run migrations (pass cwd for project-local migrations)
const { migratedAuthProviders: migratedProviders, deprecationWarnings } = runMigrations(cwd);
time("runMigrations");
const startupSettingsManager = SettingsManager.create(cwd, agentDir);
reportDiagnostics(collectSettingsDiagnostics(startupSettingsManager, "startup session lookup"));
// Experimental first-time setup: theme choice and analytics opt-in.
// Runs before any runtime services are created so the chosen settings apply everywhere.
if (appMode === "interactive" && !parsed.help && parsed.listModels === undefined && shouldRunFirstTimeSetup()) {
await showFirstTimeSetup(startupSettingsManager);
time("firstTimeSetup");
}
// Decide the final runtime cwd before creating cwd-bound runtime services.
// --session and --resume may select a session from another project, so project-local
// settings, resources, provider registrations, and models must be resolved only after
// the target session cwd is known. The startup-cwd settings manager is used only for
// sessionDir lookup during session selection.
const envSessionDir = process.env[ENV_SESSION_DIR];
const sessionDir =
(parsed.sessionDir ? normalizePath(parsed.sessionDir) : undefined) ??
(envSessionDir ? expandTildePath(envSessionDir) : undefined) ??
startupSettingsManager.getSessionDir();
let sessionManager = await createSessionManager(parsed, cwd, sessionDir, startupSettingsManager);
const missingSessionCwdIssue = getMissingSessionCwdIssue(sessionManager, cwd);
if (missingSessionCwdIssue) {
if (appMode === "interactive") {
const selectedCwd = await promptForMissingSessionCwd(missingSessionCwdIssue, startupSettingsManager);
if (!selectedCwd) {
process.exit(0);
}
sessionManager = SessionManager.open(missingSessionCwdIssue.sessionFile!, sessionDir, selectedCwd);
} else {
console.error(chalk.red(new MissingSessionCwdError(missingSessionCwdIssue).message));
process.exit(1);
}
}
if (parsed.name !== undefined) {
const name = parsed.name.trim();
if (!name) {
console.error(chalk.red("Error: --name requires a non-empty value"));
process.exit(1);
}
sessionManager.appendSessionInfo(name);
}
time("createSessionManager");
环节 1:数据迁移 runMigrations()
const { migratedAuthProviders: migratedProviders, deprecationWarnings } = runMigrations(cwd);
检查用户的旧版数据文件,自动升级到新版格式。每个迁移函数内部都会先检查是否需要迁移,多次运行不会出问题。
四个一次性迁移:
export function runMigrations(cwd: string): {
migratedAuthProviders: string[];
deprecationWarnings: string[];
} {
const migratedAuthProviders = migrateAuthToAuthJson();
migrateSessionsFromAgentRoot();
migrateToolsToBin();
migrateKeybindingsConfigFile();
const deprecationWarnings = migrateExtensionSystem(cwd);
return { migratedAuthProviders, deprecationWarnings };
}
| 迁移函数 | 做什么 | 为什么 |
|---|---|---|
migrateAuthToAuthJson() | 把 oauth.json 和 settings.json 里的 API key 合并到 auth.json | 旧版把认证信息散落在多个文件里,新版统一到一个文件 |
migrateSessionsFromAgentRoot() | 把 ~/.pi/agent/*.jsonl 移到 ~/.pi/agent/sessions/ 子目录 | v0.30.0 有 bug,会话文件存错了位置 |
migrateToolsToBin() | 把 ~/.pi/agent/tools/ 里的可执行文件移到 ~/.pi/agent/bin/ | 旧版把工具和二进制混在一起,新版分开 |
migrateExtensionSystem() | 更新扩展系统的配置格式 | 扩展系统从旧版升级到新版 |
为什么必须在启动时做迁移,而不是"做了更好"
不同版本的程序会改变数据在磁盘上的存放位置和格式,认证信息从散落多处归拢到 auth.json,会话文件从根目录移到 sessions/ 子目录,可执行文件从 tools/ 分离到 bin/。但用户的磁盘上还保留着旧版的布局。
这不是"做了更好"的防御性措施,而是"不做就硬性故障"的必要步骤,找不到凭据、读不到会话、加载不了扩展。迁移在程序正式启动前把磁盘状态拉到和当前代码期望的一致,让主流程永远只面对最新格式。兼容旧版的复杂度被隔离在 runMigrations() 一个入口里,主流程代码不需要到处写 if-else 判断旧格式。
环节 2:设置管理器 SettingsManager.create()
const startupSettingsManager = SettingsManager.create(cwd, agentDir);
reportDiagnostics(collectSettingsDiagnostics(startupSettingsManager, "startup session lookup"));
加载用户的配置文件,提供统一的设置访问接口。
两层设置
设置从两个地方加载:
全局设置:~/.pi/agent/settings.json ← 所有项目通用
项目设置:当前目录/.pi/settings.json ← 只对当前项目生效
项目设置覆盖全局设置。
SettingsManager 做了什么
- 读取全局设置,从
~/.pi/agent/settings.json加载 - 读取项目设置,从
{cwd}/.pi/settings.json加载 - 合并,项目设置覆盖全局设置
- 提供访问方法,比如
getTheme()、getDefaultProvider()、getEnabledModels()等
settings.json 里的内容
大量配置项,举几个例子:
{
"defaultProvider": "google",
"defaultModel": "gemini-2.5-pro",
"theme": "dark",
"compaction": { "enabled": true, "reserveTokens": 16384 },
"retry": { "enabled": true, "maxRetries": 3 },
"terminal": { "showImages": true, "imageWidthCells": 60 }
}
为什么在第一阶段拦截之后才创建?
因为 pi auth、pi install 这些命令不需要完整的设置,只需要基本的配置。而进入 agent 流程后,需要知道默认模型、主题、重试策略等完整设置。
环节 3:首次运行向导 showFirstTimeSetup()
if (appMode === "interactive" && !parsed.help && parsed.listModels === undefined && shouldRunFirstTimeSetup()) {
await showFirstTimeSetup(startupSettingsManager);
time("firstTimeSetup");
}
触发条件
- 是 interactive 模式
- 不是
--help或--list-models shouldRunFirstTimeSetup()返回 true(检查是否已经做过初始化)
向导内容
- 选择主题(light/dark)
- 是否开启数据分析(telemetry)
- 可能还有简单的功能介绍
为什么要在基础设施初始化阶段?
因为向导选择的结果(比如主题)会影响后续的 UI 显示,必须在创建会话之前完成。
环节 4:会话管理器 createSessionManager()
这是第三阶段最复杂的部分,负责管理"对话记录"的生命周期。
什么是"会话"(Session)?
每次你和 AI 对话,程序会创建一个会话文件(.jsonl 格式),记录整个对话过程:
~/.pi/agent/sessions/<编码后的项目路径>/
├── abc123/session.jsonl ← 会话 A
└── def456/session.jsonl ← 会话 B
会话文件是一个 JSONL(JSON Lines)文件,每行是一个 JSON 对象:
{"type":"session","id":"abc123","timestamp":"2024-01-01T00:00:00Z","cwd":"/my/project"}
{"type":"message","message":{"role":"user","content":"帮我重构代码"}}
{"type":"message","message":{"role":"assistant","content":"好的,让我看看..."}}
{"type":"model_change","provider":"anthropic","modelId":"claude-3-5-sonnet"}
createSessionManager() 的决策逻辑
async function createSessionManager(
parsed: Args,
cwd: string,
sessionDir: string | undefined,
settingsManager: SettingsManager,
): Promise<SessionManager> {
根据用户的参数,决定怎么处理会话:
1. --no-session / --help / --list-models
→ 创建内存会话(不保存到文件)
2. --fork <path|id>
→ 复制一个已有会话,在新目录下继续
3. --session <path|id>
→ 打开指定的会话文件继续对话
4. --resume
→ 弹出会话选择器,让用户选一个历史会话
5. --continue
→ 自动继续当前目录下最近的会话
6. --session-id <id>
→ 找到或创建指定 ID 的会话
7. 其他
→ 创建全新会话
逐个详解
① 内存会话(不保存)
if (parsed.noSession || parsed.help || parsed.listModels !== undefined) {
return SessionManager.inMemory(cwd, parsed.sessionId !== undefined ? { id: parsed.sessionId } : undefined);
}
--no-session 表示用户不想保存这次对话。程序创建一个临时的内存会话,退出后就丢弃。--help 和 --list-models 也不需要保存会话。
② Fork 会话
if (parsed.fork) {
if (parsed.sessionId) {
const existingTarget = await findLocalSessionByExactId(parsed.sessionId, cwd, sessionDir);
if (existingTarget) {
console.error(chalk.red(`Session already exists with id '${parsed.sessionId}'`));
process.exit(1);
}
}
const resolved = await resolveSessionPath(parsed.fork, cwd, sessionDir);
switch (resolved.type) {
case "path":
case "local":
case "global":
return forkSessionOrExit(resolved.path, cwd, sessionDir, parsed.sessionId);
case "not_found":
console.error(chalk.red(`No session found matching '${resolved.arg}'`));
process.exit(1);
}
}
--fork 允许你复制一个已有会话,在当前目录下继续。比如:
# 在项目 A 有一个会话
cd /project-a
pi --name "重构认证模块" "帮我重构 auth"
# 想在项目 B 继续这个对话
cd /project-b
pi --fork abc123 # 复制会话 abc123 到项目 B
resolveSessionPath() 会根据你提供的路径或 ID,找到对应的会话文件。forkSessionOrExit() 创建一个新会话,但把旧会话的历史记录复制过来。
③ 指定会话
if (parsed.session) {
const resolved = await resolveSessionPath(parsed.session, cwd, sessionDir);
switch (resolved.type) {
case "path":
case "local":
return openSessionOrExit(resolved.path, sessionDir);
case "global": {
console.log(chalk.yellow(`Session found in different project: ${resolved.cwd}`));
const shouldFork = await promptConfirm("Fork this session into current directory?");
if (!shouldFork) {
console.log(chalk.dim("Aborted."));
process.exit(0);
}
return forkSessionOrExit(resolved.path, cwd, sessionDir);
}
case "not_found":
console.error(chalk.red(`No session found matching '${resolved.arg}'`));
process.exit(1);
}
}
--session 打开一个指定的会话。如果会话属于另一个项目(type: "global"),程序会问你是否要复制到当前项目。
④ 恢复历史会话
if (parsed.resume) {
try {
const selectedPath = await selectSession(
(onProgress) => SessionManager.list(cwd, sessionDir, onProgress),
(onProgress) => SessionManager.listAll(sessionDir, onProgress),
settingsManager,
);
if (!selectedPath) {
console.log(chalk.dim("No session selected"));
process.exit(0);
}
return SessionManager.open(selectedPath, sessionDir);
} finally {
stopThemeWatcher();
}
}
--resume 会弹出一个交互式选择器,列出所有历史会话,让用户选一个继续。SessionManager.list() 列出当前项目的会话,SessionManager.listAll() 列出所有项目的会话。
⑤ 继续最近会话
if (parsed.continue) {
return SessionManager.continueRecent(cwd, sessionDir);
}
--continue(或 -c)自动找到当前目录下最近使用的会话,直接继续。不用手动选择。
⑥ 指定会话 ID
if (parsed.sessionId) {
const existingSession = await findLocalSessionByExactId(parsed.sessionId, cwd, sessionDir);
if (existingSession) {
return SessionManager.open(existingSession.path, sessionDir);
}
console.error(
chalk.yellow(
`Warning: No project session found with id '${parsed.sessionId}'; creating a new session with that id.`,
),
);
}
--session-id 用精确的 UUID 找会话。如果找不到,不退出,而是创建一个新会话并使用这个 ID(用于程序化调用)。
⑦ 创建新会话
return SessionManager.create(cwd, sessionDir, { id: parsed.sessionId });
如果以上条件都不满足,创建一个全新的会话。
SessionManager 的核心方法
| 方法 | 作用 |
|---|---|
create(cwd, sessionDir, options) | 创建新会话 |
open(path, sessionDir) | 打开已有会话 |
continueRecent(cwd, sessionDir) | 继续最近的会话 |
inMemory(cwd, options) | 创建内存会话(不保存) |
list(cwd, sessionDir, onProgress) | 列出当前项目的会话 |
listAll(sessionDir, onProgress) | 列出所有会话 |
appendSessionInfo(name) | 设置会话显示名称 |
getCwd() | 获取会话的工作目录 |
buildSessionContext() | 构建发送给 AI 的上下文 |
会话不只是"对话的附属品"
createSessionManager() 要处理七种场景,新建、打开、恢复、复制、内存会话等等,是完整的会话生命周期管理器,不只是"建一个新的就完事"。
更关键的问题是:为什么在准备阶段就创建会话,而不是等到用户开口说话时再创建?因为会话是运行时的地基,它决定了程序在哪个项目目录下工作(cwd),而 cwd 又决定了第四阶段加载哪个项目的配置、工具和扩展。如果延迟到对话开始才创建会话,整个第四阶段都没法执行。另外 --continue 和 --session 这类场景需要提前加载历史消息作为 AI 的上下文,也不能等用户输入第一句话才去读。
本质上,会话不是"对话开始后的记录",而是"程序以什么身份在哪个项目下工作"的运行时上下文对象。
环节 5:处理缺失的会话目录
const missingSessionCwdIssue = getMissingSessionCwdIssue(sessionManager, cwd);
if (missingSessionCwdIssue) {
if (appMode === "interactive") {
const selectedCwd = await promptForMissingSessionCwd(missingSessionCwdIssue, startupSettingsManager);
if (!selectedCwd) {
process.exit(0);
}
sessionManager = SessionManager.open(missingSessionCwdIssue.sessionFile!, sessionDir, selectedCwd);
} else {
console.error(chalk.red(new MissingSessionCwdError(missingSessionCwdIssue).message));
process.exit(1);
}
}
什么情况下会触发?
当你用 --session 或 --resume 打开一个会话,但这个会话是在另一个目录下创建的。程序不知道该用哪个 cwd(因为工具的执行依赖 cwd)。
为什么会话要跟目录绑定?
AI Agent 需要知道它在哪个项目目录下工作。Agent 的核心能力是读写文件、执行命令,这些操作都依赖 cwd:
用户:"读一下 src/main.ts"
↓
Agent 需要知道 cwd 是 /d/Projects/agent/pi
↓
才能读到 /d/Projects/agent/pi/src/main.ts
如果会话不绑定目录,会出现混乱:
用户在 /project-a 创建了会话
用户 cd 到 /project-b,--resume 恢复那个会话
Agent 以为自己在 /project-b,但历史记录是 /project-a 时的对话
→ 用户说"读一下 src/main.ts",Agent 读的是 /project-b/src/main.ts
处理方式
- interactive 模式 → 弹出提示让用户选择目录
- 非 interactive 模式 → 直接报错
环节 6:设置会话名称
if (parsed.name !== undefined) {
const name = parsed.name.trim();
if (!name) {
console.error(chalk.red("Error: --name requires a non-empty value"));
process.exit(1);
}
sessionManager.appendSessionInfo(name);
}
--name "Refactor auth" 给会话起个名字,保存到会话文件里,方便以后在选择器中识别。
第三阶段总结
655 runMigrations() ← 一次性迁移旧数据
658 SettingsManager.create() ← 加载配置文件
663 showFirstTimeSetup() ← 首次运行向导(可选)
678 createSessionManager() ← 根据参数创建/打开/恢复会话
679 missingSessionCwdIssue ← 处理会话目录不匹配的问题
692 --name ← 设置会话名称
这些都准备好后,程序才有资格进入第四阶段,创建运行时环境(加载模型、工具、扩展)。