跳到主内容
创建时间:最后更新:作者:白川

SDK 嵌入

把 Pi 作为库放进你自己的 Node.js 进程

SDK 不是 Pi 的「第五种运行模式」。它是把 Pi 当成一个库,import 进你自己的进程。

这个区别决定了所有用法上的差别:

CLI 模式(print / JSON / RPC)SDK
进程边界
有,Pi 跑在子进程里
没有,Pi 跑在你的进程里
交互方式
JSONL 文本协议
直接的 TypeScript 方法和事件
你要写的代码
帧解析、请求关联、超时处理
直接调 session.prompt()
隔离性
好,子进程崩了不影响你
差,Pi 崩了你的进程一起崩
依赖
只要能起进程(任何语言都行)
必须是 Node.js 或 Bun

选择规则很干脆:你的宿主本来就是 Node.js / Bun,就用 SDK;否则用 CLI 模式。 上一篇 讲的三种 CLI 接法在语言无关、进程隔离这两点上更合适。


13.1 什么时候该用 SDK

一句话结论

当「跑一次 Pi」这件事需要嵌进你自己的业务逻辑里,而不是作为一个子进程被调用时,用 SDK。

具体的信号有四个:

场景为什么 CLI 不够
你要在同一次调用里读 Pi 的内部状态(消息列表、工具集合、会话树)
CLI 只能通过 RPC 命令间接读,粒度粗且要手写帧
你要挂自己的扩展、注册自己的工具
SDK 里 extensionFactories 直接给工厂函数;CLI 里得写文件、走扩展发现
你要控制会话生命周期(新建、切换、分叉)而不只是发消息
SDK 的 AgentSessionRuntime 直接提供方法;RPC 那边是跨进程的异步命令
你要在同一个进程里跑多个 Pi 会话
SDK 里就是多个 AgentSession 对象;CLI 要起多个子进程

反过来说,有三种情况不要用 SDK:

  • 宿主不是 Node.js / Bun。 硬要引入 Node 运行时只为跑 Pi,不如直接用 RPC。
  • 需要把 Pi 隔离在边界外。 Pi 的内置工具在你的进程里执行 shell 命令,没有进程边界就没有隔离。要隔离就用容器或 RPC 子进程。
  • 只是想跑一次问答。 pi -p "..." 一行就够,引入 SDK 依赖是纯负担。

包和入口

包名是 @earendil-works/pi-coding-agent,它导出两个入口:

json
{
  "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 字段里)。

最小例子

typescript
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

签名和返回值

typescript
const { session, extensionsResult, modelFallbackMessage } = await createAgentSession(options);
返回字段说明
session
创建好的 AgentSession
extensionsResult
扩展加载结果(extensions、errors、runtime),交互式模式拿它做 UI 上下文
modelFallbackMessage
可选。会话恢复时模型不可用(凭据没了、模型下架了)会给出提示

modelFallbackMessage 是「恢复失败但仍然创建了会话」的唯一信号。恢复历史会话时一定要检查它,否则用户会以为模型换了,实际上是悄悄 fallback 了。

完整选项表

CreateAgentSessionOptions 的每一个字段:

选项类型默认值作用
cwd
string
process.cwd()
工作目录
agentDir
string
~/.pi/agent
全局配置目录
modelRuntime
ModelRuntime
用 agentDir 下的 auth.json 和 models.json 创建
模型与凭据运行时
model
Model
设置里的默认 → 第一个可用模型
选中的模型
thinkingLevel
ThinkingLevel
"medium"(按模型能力 clamp)
思考等级
scopedModels
Array<{model, thinkingLevel?}>
——
可切换的模型集合
noTools
"all" | "builtin"
——
工具禁用模式
tools
string[]
设置里的 defaultTools → 内置默认四个
工具白名单或增量
excludeTools
string[]
——
工具黑名单,在 tools 之后应用
customTools
ToolDefinition[]
——
额外注册的自定义工具
resourceLoader
ResourceLoader
DefaultResourceLoader
扩展、skills、提示词模板、主题、上下文文件的来源
sessionManager
SessionManager
SessionManager.create(cwd, ...)
会话存储
settingsManager
SettingsManager
SettingsManager.create(cwd, agentDir)
设置
sessionStartEvent
SessionStartEvent
——
扩展运行时的启动事件元信息

不给任何选项时,工厂会创建这些:

  • 一个 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 在 / 下找项目资源。

模型是怎么选出来的

顺序是有讲究的:

  1. 显式传了 options.model → 用它。
  2. 会话里已有数据 → 从会话的 model_change 条目恢复。但要检查 modelRuntime.hasConfiguredAuth(provider),凭据没了就不恢复。
  3. 都不是 → 走 findInitialModel():先看设置里的默认供应商和默认模型,再看供应商默认值。

第 2 步的凭据检查是关键:它不会因为「会话里写着这个模型」就相信你还能用它。恢复失败时 modelFallbackMessage 会被填上,形如 Could not restore model <provider>/<modelId>。

思考等级怎么定

顺序同样是从具体到宽泛:

  1. options.thinkingLevel
  2. 会话里有 thinking_level_change 条目 → 用它
  3. 设置里的按模型覆盖(getModelThinkingLevel(provider, id))
  4. 设置里的全局默认(getDefaultThinkingLevel())
  5. 兜底 DEFAULT_THINKING_LEVEL,也就是 "medium"

最后会按模型能力 clamp(clampThinkingLevel)。没有模型时思考等级被强制设为 "off"。

tools 的两种写法不能混

这一条和命令行 --tools 的规则完全一样(见 工具系统与信任边界):

tools 的内容语义
["read", "bash"]
白名单:整体替换,只有这两个工具启用
["+codemode", "-write"]
增量:在默认选择上加一个、减一个
两种混在一起
抛错

白名单模式是最容易踩的坑。 传 tools: ["read", "bash"] 之后,MCP 工具虽然还注册着,但不会声明给模型(除非白名单里有 mcp__ 开头的条目)。想要「内置工具 + MCP 工具」,白名单得写成 ["read", "bash", "codemode", "mcp__*"] 这样的形式。

增量模式下,+name / -name 走的是 applyToolModifiers,和 defaultTools 设置同一个函数。

noTools 的两个取值

取值效果
"all"
一个工具都不启用
"builtin"
禁用内置的四个(read、bash、edit、write),保留扩展和自定义工具

不传 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:

typescript
const { session } = await createAgentSession({
	sessionManager: SessionManager.inMemory(),
});

不想落盘就用 SessionManager.inMemory()。它的四个静态构造方法:

方法用途
SessionManager.create(cwd, sessionDir?)
新建一个持久化会话
SessionManager.open(path, sessionDir?, cwdOverride?)
打开已有会话文件
SessionManager.inMemory(cwd?, options?, entries?)
纯内存,不落盘
SessionManager.continueRecent(cwd, sessionDir?)
继续最近一个会话,没有就新建

注意:src/core/sdk.ts 的 JSDoc 示例里有一行 createAgentSession({ continueSession: true }),但 CreateAgentSessionOptions 没有 continueSession 这个字段——那是源码里的过期注释。要继续上一个会话,用 sessionManager: SessionManager.continueRecent(cwd)。


13.3 AgentSession 接口

读状态

AgentSession 暴露的状态全是只读 getter:

getter类型说明
messages
AgentMessage[]
当前会话的全部消息
state
AgentState
底层 agent 的完整状态
model
Model | undefined
当前模型,没选模型时是 undefined
thinkingLevel
ThinkingLevel
当前思考等级
systemPrompt
string
当前生效的系统提示词
isStreaming
boolean
有没有正在跑的 agent 运行
isIdle
boolean
没有运行、没有压缩、没有待续的工作
isCompacting
boolean
正在压缩
sessionFile
string | undefined
会话文件路径,内存会话是 undefined
sessionId
string
会话 ID
sessionName
string | undefined
会话显示名
pendingMessageCount
number
队列里待处理的消息数
steeringMode / followUpMode
"all" | "one-at-a-time"
队列投递模式
autoCompactionEnabled
boolean
自动压缩开关
extensionRunner
ExtensionRunner
扩展运行时
resourceLoader
ResourceLoader
资源加载器

systemPrompt 有个容易被忽略的性质:它返回的是当前生效的系统提示词,包含还没发给模型的改动。 工具变化会在下一次请求前声明给模型,所以「改了工具但还没发消息」这个窗口里,systemPrompt 已经变了。

三组工具相关的方法

这三个方法问的是三个不同的问题,混淆它们会导致「我的工具怎么不见了」这类问题:

方法回答的问题
getActiveToolNames()
哪些工具声明给了模型
getCallableToolNames()
哪些工具能被别的工具通过 ctx.executeTool() 调到
getAllTools()
注册表里全部有哪些工具(含 name、description、参数 schema、prompt 指引、exposure、来源元信息)

差别来自 exposure 模型:codemode 和 deferred 暴露的工具不是 active 的,但其它工具仍然能调到它们。 这正是 MCP 工具的默认状态(默认 exposure 是 codemode)。

还有 setActiveToolsByName(names):按名字改 active 集合,只有注册表里的工具能被启用,未知和 hidden 的名字会被忽略,改动在下一次 agent 回合生效,同时会重建系统提示词。

提示:prompt / steer / followUp

typescript
await session.prompt("Explain this repository");

prompt() 做的事比「发一条消息」多:

  1. 扩展命令优先。 文本以 / 开头时,先查扩展注册的命令;命中就立即执行(哪怕正在流式输出中),扩展自己管理 LLM 交互。

  2. 展开模板。 skill 命令(/skill:name)和提示词模板(/template)在进入 agent 之前被展开成完整文本。可以用 expandPromptTemplates: false 关掉。

  3. 运行中必须表态。 已经在流式输出时调用 prompt() 而不给 streamingBehavior,直接抛错,不猜:

    plaintext
    Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message.

streamingBehavior 的两个取值和 CLI 一致:

取值投递时机
"steer"
当前 assistant 回合的工具调用执行完之后、下一次 LLM 调用之前
"followUp"
等当前运行彻底结束之后

steer() 和 followUp() 直接暴露这两种行为,返回 QueuedInputDisposition:

返回值含义
"queued"
排进队列了(包括被扩展改写之后排队的)
"handled"
被扩展的 input 处理器消费了

扩展命令走 steer() / followUp() 会抛错——扩展命令要用 prompt() 发。

中止和等待

方法行为
abort()
中止当前操作,并等到会话空闲之后才 resolve
waitForIdle()
只等,不中止

abort() 内部会连带 abort 掉重试、压缩、分支摘要。

订阅事件

typescript
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 之前装,否则可能漏掉开头的几个事件。

三个语义必须分清:

事件含义
message_end
权威的完整消息
agent_end
一次底层 agent 运行结束,后面还可能有自动恢复或队列工作
agent_settled
Pi 不会自动继续了

要判断「这次真的跑完了」,看 agent_settled。

SDK 和 RPC 的一个关键差异:SDK 的 AgentSessionEvent 里,message_update 携带累积快照(assistantMessageEvent.partial 是「到目前为止的完整消息」)。RPC 和 JSON 模式在传输前会把它剥掉。所以从 RPC 迁到 SDK,客户端的拼接逻辑要改——partial 直接就是你要的完整快照。

dispose() 必须调用

typescript
session.dispose();

它做四件事:

动作说明
abort 掉所有在跑的东西
重试、压缩、分支摘要、bash、agent 运行
作废扩展上下文
扩展捕获的 pi / ctx 全部变成 stale
断开与 agent 的连接
解除事件订阅
移除所有事件监听器
this._eventListeners = []
清理会话级资源
cleanupSessionResources(this.sessionId)

「扩展上下文 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。

其它常用方法

方法用途
bindExtensions(bindings)
绑定扩展上下文,见 13.5
compact(customInstructions?)
手动压缩上下文
setModel(model, options?) / cycleModel(options?)
换模型
setThinkingLevel(level) / cycleThinkingLevel()
换思考等级
getAvailableThinkingLevels()
当前模型支持的思考等级
setSteeringMode(mode) / setFollowUpMode(mode)
改队列投递模式
executeBash(command, onChunk?, options?)
直接跑 shell 命令
abortBash()
中止跑着的 bash
getSessionStats()
token / 成本 / 上下文占用统计
getContextUsage()
当前上下文窗口占用
getLastAssistantText()
最后一条 assistant 消息的文本
navigateTree(targetId, options?)
导航会话条目树
exportToHtml(outputPath?) / exportToJsonl(outputPath?)
导出会话
reload(options?)
重新加载资源和设置
refreshContext()
强制重建上下文
createReplacedSessionContext()
给「会话被替换」后的回调用的上下文

13.4 AgentSessionRuntime

什么时候需要它

createAgentSession() 给你的是一个固定的 AgentSession。如果你需要「换掉当前会话」——新建、恢复另一个、分叉、导入——AgentSessionRuntime 是为此存在的。

它和 AgentSession 的关系是拥有关系:runtime 持有当前的 AgentSession,替换时会把旧的拆掉、造一个新的。

方法做什么
newSession(options?)
开一个新会话
switchSession(path, options?)
加载另一个会话文件
fork(entryId, options?)
从某条用户消息分叉
importFromJsonl(path, cwdOverride?)
导入一个会话 JSONL 文件
dispose()
拆掉当前会话

四个替换方法都返回 { cancelled: boolean }。扩展可以取消它们:session_before_switch 和 session_before_fork 事件处理器返回 { cancel: true } 就会中断。

替换流程

每一次替换走的都是同一套流程,源码在 AgentSessionRuntime.teardownCurrent() 和 finishSessionReplacement():

步骤做什么
1
发 session_before_switch / session_before_fork,可能被取消
2
await session.abort() —— 先让当前响应落定,被中止的回合(包括工具结果)要写进即将被替换掉的会话文件
3
发 session_shutdown 事件
4
session.dispose() —— 旧会话作废
5
为目标 cwd / sessionManager 重建一套服务
6
apply():换掉 _session 和 _services
7
rebindSession() 回调(如果你注册了)
8
withSession 回调(如果你传了)

第 2 步的顺序有实际后果:如果不等当前响应落定就替换会话,那一轮的工具结果会丢。

替换之后必须重新绑定订阅

这是 AgentSessionRuntime 最容易写错的地方。

替换完成后,runtime.session 是一个全新的对象。你之前在旧 session 上注册的 subscribe() 回调、bindExtensions() 的绑定,全都还挂在已经 dispose 掉的旧对象上。

官方示例的写法就是把「重新绑定」抽成一个函数:

typescript
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();

三个要点:

  1. unsubscribe?.() 一定要在重新订阅之前调用——否则旧监听器还活着。
  2. await session.bindExtensions({}) 也要重新做,不只是订阅。
  3. runtime.dispose() 是异步的(await 它),它会发 session_shutdown 再 dispose 当前会话。

如果你想让 runtime 自动帮你重新绑定

setRebindSession 注册一个回调,runtime 在每次替换后自动调用它:

typescript
runtime.setRebindSession(async (session) => {
	// 重新订阅、重新绑定扩展……
});

CLI 内部就是这么用的——src/modes/rpc/rpc-mode.ts 里 runtimeHost.setRebindSession(async () => { await rebindSession(); }),rebindSession() 做的事就是重新 bindExtensions() + 重新 subscribe()。

如果你用 setRebindSession,就不要再手动 bindSession(),否则会绑两次。

工厂模式:为什么要有 CreateAgentSessionRuntimeFactory

createAgentSessionRuntime() 接受的不是现成的会话,而是一个工厂函数:

typescript
type CreateAgentSessionRuntimeFactory = (options: {
	cwd: string;
	agentDir: string;
	sessionManager: SessionManager;
	sessionStartEvent?: SessionStartEvent;
	projectTrustContext?: ProjectTrustContext;
}) => Promise<CreateAgentSessionRuntimeResult>;

工厂被存进 runtime,后续每次替换都复用它。

为什么要这么绕? 因为替换会话不只是换一个 AgentSession——目标工作目录可能变了(switchSession 到另一个项目的会话),而工作目录决定了项目资源、模型路径、内置工具的工作路径。这一整套「与 cwd 绑定的服务」必须重建。

这就是 createAgentSessionServices() 存在的理由:

typescript
export interface AgentSessionServices {
	cwd: string;
	agentDir: string;
	modelRuntime: ModelRuntime;
	settingsManager: SettingsManager;
	resourceLoader: ResourceLoader;
	diagnostics: AgentSessionRuntimeDiagnostic[];
}

services 是「与 cwd 绑定的基础设施」,AgentSession 是从 services 上长出来的会话。 拆成两层之后,你可以先建 services,再针对 services 解析会话选项(模型、工具),最后才建会话。

AgentSessionRuntimeDiagnostic 很简单:

typescript
interface AgentSessionRuntimeDiagnostic {
	type: "info" | "warning" | "error";
	message: string;
}

启动期的诊断信息都在这里,别忘了读 runtime.diagnostics。

runtime 的其余属性

属性说明
runtime.session
当前会话
runtime.services
当前与 cwd 绑定的服务
runtime.cwd
当前工作目录
runtime.diagnostics
诊断信息(只读数组)
runtime.modelFallbackMessage
模型 fallback 提示

fork 的 position

typescript
await runtime.fork(entryId, { position: "before" });
position语义
"before"(默认)
从这条消息之前分叉,返回那条消息的文本
"at"
从这条消息本身分叉

"before" 要求 entryId 是一条 user 消息,否则抛 Invalid entry ID for forking。"at" 则接受任意条目。

持久化会话在分叉时会创建新的会话文件;内存会话(isPersisted() 为 false)走另一条路径,在同一个 manager 上分叉。

importFromJsonl

typescript
await runtime.importFromJsonl("/path/to/session.jsonl");

把外部的会话 JSONL 复制进当前会话目录(重名会自动加 -1、-2 后缀),然后切换过去。

两种失败:

情况抛什么
路径不存在
SessionImportFileNotFoundError
导入的会话 cwd 解析不出来且没给 override
MissingSessionCwdError

13.5 内置扩展默认不加载

一句话结论

SDK 会话默认一个内置扩展都不加载。 codemode、tool_search、mcp(以及 llama.cpp)都得你自己加进 extensionFactories,然后调用 session.bindExtensions()。

这是从 CLI 迁到 SDK 时最常见的困惑点:「在 CLI 里明明好用的功能,进了 SDK 就没了」。

为什么

内置扩展是在 CLI 的入口里接进去的,不在 createAgentSession() 里:

typescript
// src/main.ts:578
const extensionFactories = [...builtInExtensions, ...(options?.extensionFactories ?? [])];

builtInExtensions 一共四个:

typescript
// 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 时创建的是:

typescript
resourceLoader = new DefaultResourceLoader({ cwd, agentDir, settingsManager });
await resourceLoader.reload();

extensionFactories 是空的。 而 DefaultResourceLoader 只有在 extensionFactories 里显式给出 builtin: true 的条目时,才知道 builtin:<name> 指的是哪段代码:

typescript
this.builtinExtensions = new Map(factories.filter(isBuiltinExtension).map((input) => [input.name, input]));

所以在 SDK 里:

想用的CLI 里SDK 里
codemode 工具
有
没有
tool_search 工具
有(默认关闭)
没有
MCP 支持、/mcp 命令
有
没有
llama.cpp 扩展
有
没有

还有一个更隐蔽的失败:如果你在 settings.json 的 extensions 数组里写了 "builtin:mcp",SDK 里加载器找不到对应的内置实现,会往结果里塞一条错误:

plaintext
Unknown built-in extension: builtin:mcp

它不抛异常,只在 extensionsResult.errors 里。 不检查这个数组的话,你会得到一个「配置写了但没生效」的静默失败。

怎么补上

三步:把工厂加进 extensionFactories、用 defaultTools 启用、bindExtensions()。

typescript
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:

typescript
// 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 的增量形式:

typescript
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() 做两件关键的事:

typescript
await session.bindExtensions({});
  1. 发 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 服务器一个都不会连。

  2. 给扩展装上 UI 上下文和命令上下文动作。 ExtensionBindings 的字段:

    字段作用
    uiContext
    扩展的 ctx.ui 实现
    mode
    "tui" / "rpc" / "json" / "print" 之一
    commandContextActions
    waitForIdle、newSession、fork、navigateTree、switchSession、reload 的实现
    abortHandler
    中止钩子
    shutdownHandler
    关闭钩子
    onError
    扩展抛错时的回调

bindExtensions() 传 {} 是合法的——所有字段都是可选的。

细节三:mode 要选对

SDK 里没有 TUI,mode 一般传 "print" 或 "json"(取决于你要不要事件流)。如果扩展里有 ctx.mode === "tui" 的分支,它在 SDK 里不会走。

换成自己的扩展

extensionFactories 接受两种形式(InlineExtension 类型):

typescript
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):

断言来源
SDK 不是第五种模式,是进程内嵌入
packages/coding-agent/docs/cli-integration.md 开头段
SDK 与 RPC 的选择依据
packages/coding-agent/docs/rpc.md 开头的对照表
包名 @earendil-works/pi-coding-agent
packages/coding-agent/package.json name
两个导出入口 . 与 ./rpc-entry
同上 exports
Node.js 下限 >=22.19.0
同上 engines
createAgentSession() 最小用法
docs/sdk.md 开头;examples/sdk/01-minimal.ts
prompt() 在运行结束时 resolve(含自动重试)
docs/sdk.md "Prompting" 段
session.dispose() 的四项职责
同上 "Session storage" 段;src/core/agent-session.ts:1393-1418
CreateAgentSessionOptions 全部字段
src/core/sdk.ts:48-106
返回值 session / extensionsResult / modelFallbackMessage
同上 :109-116
默认创建的四个服务
同上 :198-207
模型解析顺序与凭据检查
同上 :209-251
思考等级解析顺序与 "medium" 兜底
同上 :253-278;src/core/defaults.ts:3
tools 白名单 vs 增量、混用抛错
同上 :280-294;src/core/settings-manager.ts:100-104
noTools 两个取值的语义
同上 :64-71
默认工具来源 getDefaultTools() ?? DEFAULT_TOOL_NAMES
同上 :282
excludeTools 在 tools 之后应用
同上 :292-294
SessionManager 是持久化上下文的权威
docs/sdk.md "Session storage" 段
SessionManager 的四个静态方法
src/core/session-manager.ts:1755,1766,1793,1804
cwd 的四类作用
docs/sdk.md "Session storage" 段
continueSession 不是真实选项
src/core/sdk.ts:48-106(无此字段);:173 是过期注释
AgentSession 状态 getter 清单
src/core/agent-session.ts:1430-1677,2457,3284,4425
systemPrompt 含未发送的改动
同上 :1474-1476;docs/sdk.md "Session lifecycle" 段
getActiveToolNames / getCallableToolNames / getAllTools 的区别
同上 :1487-1521
setActiveToolsByName 的语义
同上 :1523-1531
prompt() 处理扩展命令、展开模板
同上 :2058-2067;docs/sdk.md "Prompting" 段
流式中不给 streamingBehavior 会抛错
src/core/agent-session.ts:2104-2106
两种 streamingBehavior 的投递时机
src/core/agent-session.ts:2110-2113;docs/sdk.md "Prompting" 段
steer() / followUp() 返回 "queued" / "handled"
src/core/agent-session.ts:2241-2262;:311 QueuedInputDisposition
扩展命令走 steer/followUp 会抛错
src/core/agent-session.ts:2236,2252 注释
abort() 与 waitForIdle() 的区别
同上 :2464-2481
subscribe() 返回退订函数
同上 :1373-1383
message_end / agent_end / agent_settled 的语义差别
docs/sdk.md "Subscribing to events" 段
SDK 事件带累积快照、wire 协议剥掉
src/modes/json-event.ts:4-8,46-61
dispose() 的四件事与 stale 警告原文
src/core/agent-session.ts:1397-1418
ExtensionBindings 六个字段
同上 :302-309
AgentSessionRuntime 五个方法
src/core/agent-session-runtime.ts:196,226,262,359,404
替换流程七步
同上 :167-194
new_session / switch_session / fork / clone 可被扩展取消
同上 :133-165
官方示例的重新绑定模式
examples/sdk/13-session-runtime.ts
CreateAgentSessionRuntimeFactory 签名
src/core/agent-session-runtime.ts:35-41
AgentSessionServices 六个字段
src/core/agent-session-services.ts:73-80
AgentSessionRuntimeDiagnostic 三个字段
同上 :25-28
fork 的 position 语义与报错
src/core/agent-session-runtime.ts:262-287
importFromJsonl 的两种异常
同上 :44-53,359-362
runtime.diagnostics 可读
同上 :109-111
setRebindSession 的用法
同上 :117-119;src/modes/rpc/rpc-mode.ts:313-315
内置扩展四个、后三个 replaceable
src/extensions/index.ts:7-14
内置扩展只在 CLI 入口接入
src/main.ts:578
SDK 默认不加载内置扩展
src/core/sdk.ts:203-207(DefaultResourceLoader 无 extensionFactories)
builtinExtensions 只认显式传入的工厂
src/core/resource-loader.ts:380-382
builtin:<name> 找不到时报 Unknown built-in extension
src/core/resource-loader.ts:726-732
codemode / tool_search 注册时 defaultActive: false
src/extensions/codemode/index.ts:44;src/extensions/tool-search/index.ts:14
MCP 扩展在 session_start 里连接服务器
src/extensions/mcp/index.ts:1114-1130
MCP 扩展默认读 agent 目录和可信项目的 mcp.json
src/extensions/mcp/index.ts:70-71;examples/sdk/14-codemode-mcp.ts
defaultTools 用 +name 增量形式
examples/sdk/14-codemode-mcp.ts:39-40
createCodemodeExtension({ mode: "on" })
src/extensions/codemode/index.ts:14-21
InlineExtension 的三种形式
src/core/extensions/types.ts:2047-2070
ExtensionMode 四个取值
同上 :323
SettingsManager.inMemory() / applyOverrides()
examples/sdk/10-settings.ts
tools 白名单的完整示例
examples/sdk/05-tools.ts
会话管理的四种模式
examples/sdk/11-sessions.ts

完整事实表(含每条断言的推导命令)见专栏仓库的 research/pi-agent/FACTS.md。