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

print-mode:打印模式

概述

print-mode.ts 只有 169 行,是三个模式中最简单的。它实现了一个单次执行模式:发送消息给 AI,等待响应,输出结果,退出。没有交互、没有 UI。

触发 print 模式的条件:

条件命令示例说明
--print 或 -ppi -p "hello"显式指定非交互模式
--mode jsonpi --mode json "hello"JSON 输出模式(也是 print 模式)
stdin 不是 TTYecho "hello" | pi管道输入
stdout 不是 TTYpi -p "hello" > out.txt重定向输出

典型用法:

pi -p "列出 src/ 下所有 .ts 文件"        # text 模式,无上下文
pi -p -c "继续上次的讨论"                 # text 模式,有上下文
pi --mode json "分析这段代码"             # json 模式
echo "hello" | pi                        # 管道输入
pi -p @prompt.txt                        # 文件输入

文件结构

1-13    导入
17-27   接口定义(PrintModeOptions)
33-169  核心函数 runPrintMode()

接口定义

export interface PrintModeOptions {
    /** Output mode: "text" for final response only, "json" for all events */
    mode: "text" | "json";
    /** Array of additional prompts to send after initialMessage */
    messages?: string[];
    /** First message to send (may contain @file content) */
    initialMessage?: string;
    /** Images to attach to the initial message */
    initialImages?: ImageContent[];
}
字段类型作用
mode"text" | "json"输出模式:只输出最终回复,还是输出所有事件的 JSON 流
messagesstring[]额外的消息列表
initialMessagestring第一条消息(可能包含 @file 内容)
initialImagesImageContent[]附带的图片

核心函数 runPrintMode()

export async function runPrintMode(runtimeHost: AgentSessionRuntime, options: PrintModeOptions): Promise<number>
  • 参数: runtimeHost 是运行时对象(第四阶段创建的),options 是上面的接口
  • 返回值: Promise<number>,退出码,0 表示成功,1 表示失败

整体生命周期

运行时创建完成
  │
  ▼
runPrintMode(runtime, options)
  │
  ├─ 1. 初始化
  │    ├─ 解构参数
  │    ├─ 注册信号处理器
  │    └─ 设置 rebindSession 回调
  │
  ├─ 2. 绑定会话
  │    ├─ session.bindExtensions()   ← 绑定扩展
  │    └─ session.subscribe()        ← 订阅事件(JSON 模式)
  │
  ├─ 3. 执行消息循环
  │    ├─ session.prompt(initialMessage)  ← 发送初始消息
  │    └─ for message in messages:        ← 发送额外消息
  │         └─ session.prompt(message)
  │
  ├─ 4. 输出结果
  │    ├─ JSON 模式:已经在 subscribe 里实时输出了
  │    └─ Text 模式:取最后一条消息,输出文本
  │
  └─ 5. 清理
       ├─ 移除信号处理器
       ├─ disposeRuntime()
       └─ flushRawStdout()

关键状态变量

let exitCode = 0;                       // 退出码,0=成功,1=失败
let session = runtimeHost.session;      // 当前会话对象
let unsubscribe;                        // 事件订阅的取消函数
let disposed = false;                   // 是否已清理
const signalCleanupHandlers: Array<() => void> = [];  // 信号处理器清理函数

阶段 1:初始化

解构参数

const { mode, messages = [], initialMessage, initialImages } = options;

messages = [] 是默认值语法,如果 messages 是 undefined,使用空数组。

注册信号处理器

const registerSignalHandlers = (): void => {
    const signals: NodeJS.Signals[] = ["SIGTERM"];
    if (process.platform !== "win32") {
        signals.push("SIGHUP");
    }

    for (const signal of signals) {
        const handler = () => {
            killTrackedDetachedChildren();
            void disposeRuntime().finally(() => {
                process.exit(signal === "SIGHUP" ? 129 : 143);
            });
        };
        process.on(signal, handler);
        signalCleanupHandlers.push(() => process.off(signal, handler));
    }
};

支持的信号:

信号平台退出码含义
SIGTERM所有143终止信号(kill 默认信号)
SIGHUP非 Windows129终端关闭

收到信号时的处理:

  1. killTrackedDetachedChildren(),杀掉所有跟踪的子进程(比如 bash 命令)
  2. disposeRuntime(),清理运行时资源
  3. process.exit(143/129),以对应退出码退出

清理函数的注册:

signalCleanupHandlers.push(() => process.off(signal, handler));

把移除监听器的函数存起来,最后在 finally 块中调用,防止内存泄漏。

设置 rebindSession 回调

runtimeHost.setRebindSession(async () => {
    await rebindSession();
});

这是一个回调注册,当运行时需要重建会话时(比如用户切换了模型),会调用这个回调。

阶段 2:绑定会话

const rebindSession = async (): Promise<void> => {
    session = runtimeHost.session;
    await session.bindExtensions({
        mode: mode === "json" ? "json" : "print",
        commandContextActions: {
            waitForIdle: () => session.waitForIdle(),
            newSession: async (newSessionOptions) => runtimeHost.newSession(newSessionOptions),
            fork: async (entryId, forkOptions) => {
                const result = await runtimeHost.fork(entryId, forkOptions);
                return { cancelled: result.cancelled };
            },
            navigateTree: async (targetId, navigateOptions) => {
                const result = await session.navigateTree(targetId, {
                    summarize: navigateOptions?.summarize,
                    customInstructions: navigateOptions?.customInstructions,
                    replaceInstructions: navigateOptions?.replaceInstructions,
                    label: navigateOptions?.label,
                });
                return { cancelled: result.cancelled };
            },
            switchSession: async (sessionPath, switchOptions) => {
                return runtimeHost.switchSession(sessionPath, switchOptions);
            },
            reload: async () => {
                await session.reload();
            },
        },
        onError: (err) => {
            console.error(`Extension error (${err.extensionPath}): ${err.error}`);
        },
    });

    unsubscribe?.();
    unsubscribeBackpressure?.();
    unsubscribe = session.subscribe((event) => {
        if (mode === "json") {
            writeRawStdout(`${JSON.stringify(toJsonEvent(event))}\n`);
        }
    });
    unsubscribeBackpressure =
        mode === "json"
            ? session.agent.subscribe(async () => {
                    await waitForRawStdoutBackpressure();
                })
            : undefined;
};

绑定扩展

await session.bindExtensions({
    mode: mode === "json" ? "json" : "print",
    commandContextActions: {...},
    onError: (err) => {...},
});

把扩展绑定到会话,让扩展可以:

  • 响应工具调用
  • 注入系统提示词
  • 处理自定义命令

commandContextActions 是扩展可以调用的上下文操作:

操作作用
waitForIdle等待会话空闲
newSession创建新会话
fork分支会话
navigateTree在会话树中导航
switchSession切换会话
reload重新加载会话

订阅事件

unsubscribe = session.subscribe((event) => {
    if (mode === "json") {
        writeRawStdout(`${JSON.stringify(toJsonEvent(event))}\n`);
    }
});

session.subscribe() 注册一个回调,每当会话状态变化时触发。

Text 模式: 回调什么都不做(mode !== "json"),事件被忽略。

JSON 模式: 把每个事件转换成 JSON 输出到 stdout。

背压处理(Backpressure)

unsubscribeBackpressure =
    mode === "json"
        ? session.agent.subscribe(async () => {
                await waitForRawStdoutBackpressure();
            })
        : undefined;

JSON 模式下,如果输出太快,waitForRawStdoutBackpressure() 会等待 stdout 缓冲区清空,防止内存溢出。

阶段 3:执行消息循环

try {
    // 1. JSON 模式先输出会话头
    if (mode === "json") {
        const header = session.sessionManager.getHeader();
        if (header) {
            writeRawStdout(`${JSON.stringify(header)}\n`);
        }
    }

    // 2. 绑定会话
    await rebindSession();

    // 3. 发送初始消息
    if (initialMessage) {
        await session.prompt(initialMessage, { images: initialImages });
    }

    // 4. 发送额外消息
    for (const message of messages) {
        await session.prompt(message);
    }

JSON 模式的会话头

if (mode === "json") {
    const header = session.sessionManager.getHeader();
    if (header) {
        writeRawStdout(`${JSON.stringify(header)}\n`);
    }
}

JSON 模式先输出会话头,包含会话 ID、时间戳、工作目录等元信息:

{"type":"session","id":"abc123","timestamp":"2024-01-01T00:00:00Z","cwd":"/my/project"}

session.prompt() 的内部流程

这是最核心的方法。当调用 session.prompt("hello") 时:

session.prompt("hello")
  │
  ├─ 1. 创建用户消息,存入会话历史
  │    └─ session.state.messages.push({role: "user", content: "hello"})
  │
  ├─ 2. 构建上下文(system prompt + 历史消息)
  │    └─ session.buildSessionContext()
  │
  ├─ 3. 调用 AI 模型
  │    └─ modelRuntime.chat(context)
  │
  ├─ 4. AI 返回响应(可能分多次)
  │    ├─ 流式响应:逐块输出文本
  │    ├─ 工具调用:执行工具,结果反馈给 AI
  │    └─ 完成:设置 stopReason
  │
  ├─ 5. 存入会话历史
  │    └─ session.state.messages.push({role: "assistant", content: ...})
  │
  └─ 6. 触发事件
       └─ session.emit("message", ...)  ← subscribe 会收到

阶段 4:输出结果

Text 模式

if (mode === "text") {
    const state = session.state;
    const lastMessage = state.messages[state.messages.length - 1];

    if (lastMessage?.role === "assistant") {
        const assistantMsg = lastMessage as AssistantMessage;
        if (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") {
            console.error(assistantMsg.errorMessage || `Request ${assistantMsg.stopReason}`);
            exitCode = 1;
        } else {
            for (const content of assistantMsg.content) {
                if (content.type === "text") {
                    writeRawStdout(`${content.text}\n`);
                }
            }
        }
    }
}

执行流程:

取最后一条消息
  │
  ├─ 是助手的回复?
  │    │
  │    ├─ 有错误?(stopReason === "error" 或 "aborted")
  │    │    └─ 输出错误信息,exitCode = 1
  │    │
  │    └─ 正常回复
  │         └─ 遍历 content,输出文本块
  │
  └─ 不是助手的回复?
       └─ 不输出(理论上不应该发生)

content 的结构:

助手消息的 content 是一个数组,可能包含多种类型:

[
    { type: "text", text: "我可以帮你..." },
    { type: "tool_use", id: "tool_1", name: "read", input: {...} },
    { type: "text", text: "根据文件内容..." },
]

Text 模式只输出 type === "text" 的内容块。

JSON 模式

JSON 模式的输出已经在 subscribe 回调里实时完成了,这里不需要额外处理。

阶段 5:清理

} catch (error: unknown) {
    console.error(error instanceof Error ? error.message : String(error));
    return 1;
} finally {
    for (const cleanup of signalCleanupHandlers) {
        cleanup();
    }
    await disposeRuntime();
    await flushRawStdout();
}

catch 块: 捕获所有异常,输出错误信息,返回退出码 1。

finally 块: 无论成功还是失败,都要清理:

  1. 移除信号处理器(防止内存泄漏)
  2. 销毁运行时(释放资源)
  3. 刷新 stdout(确保所有输出都写入)

两种输出模式的对比

维度Text 模式JSON 模式
输出内容只输出 AI 的最终文本回复输出所有会话事件的 JSON 流
实时性等 AI 完成后一次性输出实时输出每个事件
使用场景pi -p "hello",脚本调用pi --mode json "hello",程序化调用
错误处理输出错误到 stderrJSON 事件中包含错误信息
背压处理不需要需要,防止内存溢出

Text 模式输出示例:

我可以帮你重构这段代码。首先...

JSON 模式输出示例:

{"type":"session","id":"abc123","timestamp":"...","cwd":"/project"}
{"type":"message","message":{"role":"user","content":"hello"}}
{"type":"model_change","provider":"anthropic","modelId":"claude-3-5-sonnet"}
{"type":"message","message":{"role":"assistant","content":"你好!..."}}

信号处理的状态流转

正常运行
  │
  ├─ 收到 SIGTERM/SIGHUP
  │    │
  │    ├─ killTrackedDetachedChildren()  ← 杀子进程
  │    ├─ disposeRuntime()               ← 清理资源
  │    └─ process.exit(143/129)          ← 退出
  │
  └─ 正常结束
       │
       ├─ finally 块执行
       ├─ 移除信号处理器
       ├─ disposeRuntime()
       └─ flushRawStdout()

总结

print-mode.ts 实现了一个无状态的单次执行模式:

  1. 初始化,注册信号处理器,设置 rebind 回调
  2. 绑定,绑定扩展,订阅事件
  3. 执行,发送消息,等待 AI 响应
  4. 输出,Text 模式输出最终结果,JSON 模式实时输出事件流
  5. 清理,移除处理器,销毁运行时,刷新输出

没有交互、没有 UI、没有会话保存。适合脚本和自动化调用。