文章
合集Pi Agent 源码阅读第 5 / 9 篇

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 做了什么

  1. 读取全局设置,从 ~/.pi/agent/settings.json 加载
  2. 读取项目设置,从 {cwd}/.pi/settings.json 加载
  3. 合并,项目设置覆盖全局设置
  4. 提供访问方法,比如 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                    ← 设置会话名称

这些都准备好后,程序才有资格进入第四阶段,创建运行时环境(加载模型、工具、扩展)。