SDK 嵌入
把 Pi 作为库放进你自己的 Node.js 进程
SDK 不是 Pi 的「第五种运行模式」。它是把 Pi 当成一个库,import 进你自己的进程。
这个区别决定了所有用法上的差别:
选择规则很干脆:你的宿主本来就是 Node.js / Bun,就用 SDK;否则用 CLI 模式。 上一篇 讲的三种 CLI 接法在语言无关、进程隔离这两点上更合适。
13.1 什么时候该用 SDK
一句话结论
当「跑一次 Pi」这件事需要嵌进你自己的业务逻辑里,而不是作为一个子进程被调用时,用 SDK。
具体的信号有四个:
反过来说,有三种情况不要用 SDK:
- 宿主不是 Node.js / Bun。 硬要引入 Node 运行时只为跑 Pi,不如直接用 RPC。
- 需要把 Pi 隔离在边界外。 Pi 的内置工具在你的进程里执行 shell 命令,没有进程边界就没有隔离。要隔离就用容器或 RPC 子进程。
- 只是想跑一次问答。
pi -p "..."一行就够,引入 SDK 依赖是纯负担。
包和入口
包名是 @earendil-works/pi-coding-agent,它导出两个入口:
{
"exports": {
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
"./rpc-entry": { "import": "./dist/bundle/rpc-entry.js" }
}
}主入口 . 是 SDK 本身(createAgentSession、SessionManager、SettingsManager、RpcClient 等全从这里出)。./rpc-entry 是一个单独的打包入口,给需要以 RPC 模式启动 Pi 的宿主用。
运行环境要求 Node.js >=22.19.0(写在包的 engines 字段里)。
最小例子
import { createAgentSession } from "@earendil-works/pi-coding-agent";
const { session } = await createAgentSession();
try {
await session.prompt("What files are in the current directory?");
console.log(session.getLastAssistantText());
} finally {
session.dispose();
}这一段代码用了全部默认值:当前工作目录、发现到的资源、磁盘上的设置、配置好的凭据。
prompt() 在运行结束时 resolve,包括自动重试跑完。
session.dispose() 不是可选的——它 abort 掉还在跑的工作、作废扩展上下文、断开与 agent 的连接、移除事件监听器。每个会话都要 dispose,否则监听器会一直挂在 agent 上。
13.2 createAgentSession
签名和返回值
const { session, extensionsResult, modelFallbackMessage } = await createAgentSession(options);modelFallbackMessage 是「恢复失败但仍然创建了会话」的唯一信号。恢复历史会话时一定要检查它,否则用户会以为模型换了,实际上是悄悄 fallback 了。
完整选项表
CreateAgentSessionOptions 的每一个字段:
不给任何选项时,工厂会创建这些:
- 一个
ModelRuntime(读agentDir/auth.json和agentDir/models.json) - 一个文件支撑的
SettingsManager - 一个持久化的
SessionManager - 一个
DefaultResourceLoader(并await loader.reload()) - 配置好的默认工具
cwd 决定的东西比想象的多
cwd 不是一个「相对路径基准」,它决定的是一整套按项目划分的资源:
- 项目级资源发现(
.pi/目录下的 settings、mcp.json、extensions、skills、prompts、themes) - 上下文文件(
SYSTEM.md、APPEND_SYSTEM.md) - 会话分组(会话文件按 cwd 编码存放)
- 内置工具的工作路径
目标目录和 process.cwd() 不同时,必须显式传 cwd。 这是嵌入场景最常见的坑:你的服务进程从 / 启动,结果 Pi 在 / 下找项目资源。
模型是怎么选出来的
顺序是有讲究的:
- 显式传了
options.model→ 用它。 - 会话里已有数据 → 从会话的
model_change条目恢复。但要检查modelRuntime.hasConfiguredAuth(provider),凭据没了就不恢复。 - 都不是 → 走
findInitialModel():先看设置里的默认供应商和默认模型,再看供应商默认值。
第 2 步的凭据检查是关键:它不会因为「会话里写着这个模型」就相信你还能用它。恢复失败时 modelFallbackMessage 会被填上,形如 Could not restore model <provider>/<modelId>。
思考等级怎么定
顺序同样是从具体到宽泛:
options.thinkingLevel- 会话里有
thinking_level_change条目 → 用它 - 设置里的按模型覆盖(
getModelThinkingLevel(provider, id)) - 设置里的全局默认(
getDefaultThinkingLevel()) - 兜底
DEFAULT_THINKING_LEVEL,也就是"medium"
最后会按模型能力 clamp(clampThinkingLevel)。没有模型时思考等级被强制设为 "off"。
tools 的两种写法不能混
这一条和命令行 --tools 的规则完全一样(见 工具系统与信任边界):
白名单模式是最容易踩的坑。 传 tools: ["read", "bash"] 之后,MCP 工具虽然还注册着,但不会声明给模型(除非白名单里有 mcp__ 开头的条目)。想要「内置工具 + MCP 工具」,白名单得写成 ["read", "bash", "codemode", "mcp__*"] 这样的形式。
增量模式下,+name / -name 走的是 applyToolModifiers,和 defaultTools 设置同一个函数。
noTools 的两个取值
不传 tools 时,工具选择来自 settingsManager.getDefaultTools() ?? DEFAULT_TOOL_NAMES,也就是设置里的 defaultTools,没有就退回内置默认四个。
excludeTools 是黑名单,在 tools 之后应用,MCP 工具也一起排除。
不要直接改 session.agent.state.messages
SessionManager 是持久化上下文的唯一权威。 Pi 重建模型上下文时,用的是 manager 选出的活动分支加上压缩结果。
直接给 session.agent.state.messages 赋值不会替换持久化的上下文——它只改了内存里的 agent 状态,下次 Pi 重建上下文时你的修改就没了。
要恢复外部的历史,正确的做法是构造一个包含那些条目的 manager,再传给 createAgentSession:
const { session } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
});不想落盘就用 SessionManager.inMemory()。它的四个静态构造方法:
注意:
src/core/sdk.ts的 JSDoc 示例里有一行createAgentSession({ continueSession: true }),但CreateAgentSessionOptions没有continueSession这个字段——那是源码里的过期注释。要继续上一个会话,用sessionManager: SessionManager.continueRecent(cwd)。
13.3 AgentSession 接口
读状态
AgentSession 暴露的状态全是只读 getter:
systemPrompt 有个容易被忽略的性质:它返回的是当前生效的系统提示词,包含还没发给模型的改动。 工具变化会在下一次请求前声明给模型,所以「改了工具但还没发消息」这个窗口里,systemPrompt 已经变了。
三组工具相关的方法
这三个方法问的是三个不同的问题,混淆它们会导致「我的工具怎么不见了」这类问题:
差别来自 exposure 模型:codemode 和 deferred 暴露的工具不是 active 的,但其它工具仍然能调到它们。 这正是 MCP 工具的默认状态(默认 exposure 是 codemode)。
还有 setActiveToolsByName(names):按名字改 active 集合,只有注册表里的工具能被启用,未知和 hidden 的名字会被忽略,改动在下一次 agent 回合生效,同时会重建系统提示词。
提示:prompt / steer / followUp
await session.prompt("Explain this repository");prompt() 做的事比「发一条消息」多:
-
扩展命令优先。 文本以
/开头时,先查扩展注册的命令;命中就立即执行(哪怕正在流式输出中),扩展自己管理 LLM 交互。 -
展开模板。 skill 命令(
/skill:name)和提示词模板(/template)在进入 agent 之前被展开成完整文本。可以用expandPromptTemplates: false关掉。 -
运行中必须表态。 已经在流式输出时调用
prompt()而不给streamingBehavior,直接抛错,不猜:plaintext Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message.
streamingBehavior 的两个取值和 CLI 一致:
steer() 和 followUp() 直接暴露这两种行为,返回 QueuedInputDisposition:
扩展命令走 steer() / followUp() 会抛错——扩展命令要用 prompt() 发。
中止和等待
abort() 内部会连带 abort 掉重试、压缩、分支摘要。
订阅事件
const unsubscribe = session.subscribe((event) => {
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
process.stdout.write(event.assistantMessageEvent.delta);
}
});
try {
await session.prompt("Explain this repository");
} finally {
unsubscribe();
}subscribe() 返回退订函数。订阅要在 prompt 之前装,否则可能漏掉开头的几个事件。
三个语义必须分清:
要判断「这次真的跑完了」,看 agent_settled。
SDK 和 RPC 的一个关键差异:SDK 的
AgentSessionEvent里,message_update携带累积快照(assistantMessageEvent.partial是「到目前为止的完整消息」)。RPC 和 JSON 模式在传输前会把它剥掉。所以从 RPC 迁到 SDK,客户端的拼接逻辑要改——partial直接就是你要的完整快照。
dispose() 必须调用
session.dispose();它做四件事:
「扩展上下文 stale」这件事有明确的时间点:dispose() 之后,或者会话被替换 / reload 之后,扩展之前捕获的 pi 和 ctx 都不能再用了。源码在 dispose() 里给扩展上下文打的警告原话是:
This extension ctx is stale after session replacement or reload. Do not use a captured pi or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload().
dispose() 是同步的,返回 void,不用 await。
其它常用方法
13.4 AgentSessionRuntime
什么时候需要它
createAgentSession() 给你的是一个固定的 AgentSession。如果你需要「换掉当前会话」——新建、恢复另一个、分叉、导入——AgentSessionRuntime 是为此存在的。
它和 AgentSession 的关系是拥有关系:runtime 持有当前的 AgentSession,替换时会把旧的拆掉、造一个新的。
四个替换方法都返回 { cancelled: boolean }。扩展可以取消它们:session_before_switch 和 session_before_fork 事件处理器返回 { cancel: true } 就会中断。
替换流程
每一次替换走的都是同一套流程,源码在 AgentSessionRuntime.teardownCurrent() 和 finishSessionReplacement():
第 2 步的顺序有实际后果:如果不等当前响应落定就替换会话,那一轮的工具结果会丢。
替换之后必须重新绑定订阅
这是 AgentSessionRuntime 最容易写错的地方。
替换完成后,runtime.session 是一个全新的对象。你之前在旧 session 上注册的 subscribe() 回调、bindExtensions() 的绑定,全都还挂在已经 dispose 掉的旧对象上。
官方示例的写法就是把「重新绑定」抽成一个函数:
import {
type CreateAgentSessionRuntimeFactory,
createAgentSessionFromServices,
createAgentSessionRuntime,
createAgentSessionServices,
getAgentDir,
SessionManager,
} from "@earendil-works/pi-coding-agent";
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
const services = await createAgentSessionServices({ cwd });
return {
...(await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent })),
services,
diagnostics: services.diagnostics,
};
};
const runtime = await createAgentSessionRuntime(createRuntime, {
cwd: process.cwd(),
agentDir: getAgentDir(),
sessionManager: SessionManager.create(process.cwd()),
});
let unsubscribe: (() => void) | undefined;
async function bindSession() {
unsubscribe?.();
const session = runtime.session;
await session.bindExtensions({});
unsubscribe = session.subscribe((event) => {
if (event.type === "queue_update") {
console.log("Queued:", event.steering.length + event.followUp.length);
}
});
return session;
}
let session = await bindSession();
const originalSessionFile = session.sessionFile;
await runtime.newSession();
session = await bindSession();
if (originalSessionFile) {
await runtime.switchSession(originalSessionFile);
session = await bindSession();
}
unsubscribe?.();
await runtime.dispose();三个要点:
unsubscribe?.()一定要在重新订阅之前调用——否则旧监听器还活着。await session.bindExtensions({})也要重新做,不只是订阅。runtime.dispose()是异步的(await它),它会发session_shutdown再 dispose 当前会话。
如果你想让 runtime 自动帮你重新绑定
setRebindSession 注册一个回调,runtime 在每次替换后自动调用它:
runtime.setRebindSession(async (session) => {
// 重新订阅、重新绑定扩展……
});CLI 内部就是这么用的——src/modes/rpc/rpc-mode.ts 里 runtimeHost.setRebindSession(async () => { await rebindSession(); }),rebindSession() 做的事就是重新 bindExtensions() + 重新 subscribe()。
如果你用 setRebindSession,就不要再手动 bindSession(),否则会绑两次。
工厂模式:为什么要有 CreateAgentSessionRuntimeFactory
createAgentSessionRuntime() 接受的不是现成的会话,而是一个工厂函数:
type CreateAgentSessionRuntimeFactory = (options: {
cwd: string;
agentDir: string;
sessionManager: SessionManager;
sessionStartEvent?: SessionStartEvent;
projectTrustContext?: ProjectTrustContext;
}) => Promise<CreateAgentSessionRuntimeResult>;工厂被存进 runtime,后续每次替换都复用它。
为什么要这么绕? 因为替换会话不只是换一个 AgentSession——目标工作目录可能变了(switchSession 到另一个项目的会话),而工作目录决定了项目资源、模型路径、内置工具的工作路径。这一整套「与 cwd 绑定的服务」必须重建。
这就是 createAgentSessionServices() 存在的理由:
export interface AgentSessionServices {
cwd: string;
agentDir: string;
modelRuntime: ModelRuntime;
settingsManager: SettingsManager;
resourceLoader: ResourceLoader;
diagnostics: AgentSessionRuntimeDiagnostic[];
}services 是「与 cwd 绑定的基础设施」,AgentSession 是从 services 上长出来的会话。 拆成两层之后,你可以先建 services,再针对 services 解析会话选项(模型、工具),最后才建会话。
AgentSessionRuntimeDiagnostic 很简单:
interface AgentSessionRuntimeDiagnostic {
type: "info" | "warning" | "error";
message: string;
}启动期的诊断信息都在这里,别忘了读 runtime.diagnostics。
runtime 的其余属性
fork 的 position
await runtime.fork(entryId, { position: "before" });"before" 要求 entryId 是一条 user 消息,否则抛 Invalid entry ID for forking。"at" 则接受任意条目。
持久化会话在分叉时会创建新的会话文件;内存会话(isPersisted() 为 false)走另一条路径,在同一个 manager 上分叉。
importFromJsonl
await runtime.importFromJsonl("/path/to/session.jsonl");把外部的会话 JSONL 复制进当前会话目录(重名会自动加 -1、-2 后缀),然后切换过去。
两种失败:
13.5 内置扩展默认不加载
一句话结论
SDK 会话默认一个内置扩展都不加载。 codemode、tool_search、mcp(以及 llama.cpp)都得你自己加进 extensionFactories,然后调用 session.bindExtensions()。
这是从 CLI 迁到 SDK 时最常见的困惑点:「在 CLI 里明明好用的功能,进了 SDK 就没了」。
为什么
内置扩展是在 CLI 的入口里接进去的,不在 createAgentSession() 里:
// src/main.ts:578
const extensionFactories = [...builtInExtensions, ...(options?.extensionFactories ?? [])];builtInExtensions 一共四个:
// src/extensions/index.ts:7-14
export const builtInExtensions: InlineExtension[] = [
{ name: "llama.cpp", factory: llamaExtension, builtin: true },
{ name: "codemode", factory: codemodeExtension, replaceable: true, builtin: true },
{ name: "tool-search", factory: toolSearchExtension, replaceable: true, builtin: true },
{ name: "mcp", factory: mcpExtension, replaceable: true, builtin: true },
];而 createAgentSession() 在没传 resourceLoader 时创建的是:
resourceLoader = new DefaultResourceLoader({ cwd, agentDir, settingsManager });
await resourceLoader.reload();extensionFactories 是空的。 而 DefaultResourceLoader 只有在 extensionFactories 里显式给出 builtin: true 的条目时,才知道 builtin:<name> 指的是哪段代码:
this.builtinExtensions = new Map(factories.filter(isBuiltinExtension).map((input) => [input.name, input]));所以在 SDK 里:
还有一个更隐蔽的失败:如果你在 settings.json 的 extensions 数组里写了 "builtin:mcp",SDK 里加载器找不到对应的内置实现,会往结果里塞一条错误:
Unknown built-in extension: builtin:mcp它不抛异常,只在 extensionsResult.errors 里。 不检查这个数组的话,你会得到一个「配置写了但没生效」的静默失败。
怎么补上
三步:把工厂加进 extensionFactories、用 defaultTools 启用、bindExtensions()。
import {
createAgentSession,
createCodemodeExtension,
createMcpExtension,
createToolSearchExtension,
DefaultResourceLoader,
getAgentDir,
SessionManager,
SettingsManager,
} from "@earendil-works/pi-coding-agent";
const cwd = process.cwd();
const resourceLoader = new DefaultResourceLoader({
cwd,
agentDir: getAgentDir(),
extensionFactories: [
createCodemodeExtension({ mode: "on" }),
createToolSearchExtension(),
// Reads mcp.json from the agent directory and the trusted project, like the CLI.
createMcpExtension(),
],
});
await resourceLoader.reload();
const settingsManager = SettingsManager.create(cwd);
// `+name` adds to the configured default tools instead of replacing them.
settingsManager.applyOverrides({ defaultTools: ["+codemode", "+tool_search"] });
const { session } = await createAgentSession({
resourceLoader,
settingsManager,
sessionManager: SessionManager.inMemory(),
});
try {
// Emits session_start, which connects the MCP servers in the background.
await session.bindExtensions({});
console.log("Active tools:", session.getActiveToolNames().join(", "));
session.subscribe((event) => {
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
process.stdout.write(event.assistantMessageEvent.delta);
}
});
await session.prompt("Use codemode to count the TypeScript files in src/ and list the three largest.");
console.log();
} finally {
session.dispose();
}三个细节,每一个漏了都会出问题:
细节一:它们注册时是「不激活」的
createCodemodeExtension() 和 createToolSearchExtension() 注册工具时都带 defaultActive: false:
// src/extensions/codemode/index.ts
pi.registerTool({ ...createCodemodeToolDefinition({...}), defaultActive: false });
// src/extensions/tool-search/index.ts
pi.registerTool({ ...createToolSearchToolDefinition({ tools: pi }), defaultActive: false });注册了不等于启用了。 启用要走 defaultTools 设置,而且要用 +name 的增量形式:
settingsManager.applyOverrides({ defaultTools: ["+codemode", "+tool_search"] });不要用 tools: ["codemode"] 来启用。 tools 是白名单,一给就会把默认的 read、bash、edit、write 全替换掉,而且 MCP 工具不会再直接声明给模型(白名单里没有 mcp__ 开头的条目时,它们只保留给 codemode 和 tool_search 去够)。你辛辛苦苦接的 MCP 会变成「工具注册了但模型看不见」。
另一种启用路径是让 MCP 扩展去激活它们:codemode exposure 的服务器会激活 codemode,deferred exposure 的服务器会激活 tool_search。所以只接 MCP、不显式设 defaultTools,也能用上。
细节二:必须调 bindExtensions()
createAgentSession() 不会自动绑定扩展。bindExtensions() 做两件关键的事:
await session.bindExtensions({});-
发
session_start事件。 MCP 扩展是在session_start里连接服务器的:typescript pi.on("session_start", (_event, ctx) => { const loaded = (options.loadConfig ?? defaultLoadConfig)(ctx); // ... // Codemode or tool_search is activated from the config // ... });不调
bindExtensions(),MCP 服务器一个都不会连。 -
给扩展装上 UI 上下文和命令上下文动作。
ExtensionBindings的字段:
bindExtensions() 传 {} 是合法的——所有字段都是可选的。
细节三:mode 要选对
SDK 里没有 TUI,mode 一般传 "print" 或 "json"(取决于你要不要事件流)。如果扩展里有 ctx.mode === "tui" 的分支,它在 SDK 里不会走。
换成自己的扩展
extensionFactories 接受两种形式(InlineExtension 类型):
const resourceLoader = new DefaultResourceLoader({
cwd: process.cwd(),
agentDir: getAgentDir(),
additionalExtensionPaths: ["./my-logging-extension.ts"],
extensionFactories: [
// 形式一:匿名工厂函数
(pi) => {
pi.on("agent_start", () => {
console.log("[Inline Extension] Agent starting");
});
},
// 形式二:带名字的对象
{ name: "my-ext", factory: (pi) => { /* ... */ } },
],
});给了 name 的内联扩展会在启动列表里显示为 <inline:name>,诊断信息里也用这个名字。给 replaceable: true 的话,当另一个扩展注册了同名工具 / 命令 / flag 时,它会被跳过而不是报冲突——内置的 codemode、tool_search、mcp 三个就是 replaceable: true。
扩展的完整开发方式(41 个生命周期事件、工具暴露模型、渲染器、命令注册)见 扩展开发,这里只讲 SDK 接入相关的部分。
第三种形式是 { name, factory, builtin: true },它不是内联扩展,而是提供 builtin:<name> 扩展的代码:默认加载,列在 pi config 的扩展列表里;在 extensions 设置里写 -builtin:<name> 可以关掉某一个,noExtensions 可以关掉全部。CLI 的内置扩展用的就是这一种,所以 SDK 里想用 builtin:mcp 这种写法,也得自己把对应的工厂传进去。
13.6 事实来源
本文所有数字、开关名、路径、默认值和枚举清单都可在仓库里复算(基线 commit 8efd314fa,tag v1.3.0-16-g8efd314fa):
完整事实表(含每条断言的推导命令)见专栏仓库的 research/pi-agent/FACTS.md。