会话工厂与工作目录
拆开 createAgentSession() 的默认装配,说明每个边界能覆盖什么、cwd 决定什么,以及会话该在哪里释放。
会话工厂与工作目录
createAgentSession() 看起来只是"创建会话",实际上它是一次装配:模型运行时、设置、会话存储、资源发现、工具集,五个部件各有默认来源,也各有覆盖方式。不了解默认来源,你会遇到"我明明改了配置却没生效"这类问题——因为改的地方和读的地方不是同一个。
还有一个必须说清的概念:工作目录(cwd)不是一个路径参数,它是会话的归属声明。 上下文文件、工具的相对路径、会话的分组目录,全都从它推导。
这篇讲清一次会话创建时装配了什么、每个边界能覆盖什么、cwd 到底决定哪几件事,以及会话该在哪里释放。读它需要会用 TypeScript,并且写过或读过至少一个用 Pi 的程序;读完你能对着源码说出每一项默认来自哪里,并判断"资源到底归谁管"。
Pi 迭代很快,接口可能变化。动手前先记录 pi --version,并按你安装的版本复核参数。
一次会话创建时装配了什么
不给任何选项时,createAgentSession() 会装配下面这些部件。
除了这五个部件,工厂还装配几个"状态":
模型和思考级别的解析顺序是固定的:显式选项 → 会话里已保存的记录 → 设置 → 默认值。恢复已有会话时,会话里记录的模型和思考级别会优先于设置,但只有在凭据可用时才恢复成功;恢复不了会给出 modelFallbackMessage 说明。
工作目录的解析顺序是:cwd 选项 → 会话管理器自带的 cwd → process.cwd()。这条顺序有实际后果:如果你给了一个 SessionManager.inMemory("/some/dir") 但没传 cwd,会话的工作目录会跟着 SessionManager 走。
覆盖边界:哪些可以接管,哪些必须留
每个可覆盖边界解决的是不同的问题。选错了不是"写多了",是资源归属混乱。
tools 是白名单,excludeTools 是黑名单。两者同时给时,黑名单在白名单之后生效。noTools 有两种取值:"all" 表示一个工具都不启用,"builtin" 表示关掉内置工具但保留扩展和自定义工具。
tools 列表如果只含 +name 和 -name 形式的条目,它不是白名单而是增量修改——在默认选择上加或减,和设置里的 defaultTools 语义相同。这种列表不能和普通条目混用,混用会抛错。
cwd 决定什么
cwd 不是一个"告诉工具去哪里找文件"的参数。它是这个会话属于哪个项目的声明。四件事从它推导:
会话文件的路径把工作目录编码进目录名:~/.pi/agent/sessions/--<path>--/<timestamp>_<session-id>.jsonl。路径的前导分隔符被去掉,/、\、: 替换成 -。
上下文文件的发现有一个重要细节:一个上下文文件在 Pi 运行于它所在目录或其下任意子目录时都会生效。父目录的 AGENTS.md 会被子目录的会话继承。这不是 bug,是刻意的分层设计——仓库根目录放一份通用约定,子目录可以再加更具体的。
需要提醒的是这个继承的粒度:向上查找时每个目录最多贡献一个上下文文件,优先级顺序是 AGENTS.override.md、AGENTS.md、AGENTS.MD、CLAUDE.md、CLAUDE.MD——同一个目录里只有第一个存在的生效。所以仓库根有一份 AGENTS.md、子目录有一份 AGENTS.md,两份都会进上下文,而不是后者覆盖前者。
// cwd.ts
import { createAgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
const projectRoot = "/srv/my-project";
const { session } = await createAgentSession({
// 显式传 cwd,不要依赖 process.cwd():cwd 决定会话归属哪个项目
cwd: projectRoot,
// inMemory 会话:不落盘,但 cwd 仍要传,否则工作目录会退回进程目录
sessionManager: SessionManager.inMemory(projectRoot),
// tools 是白名单:只有列出的工具会被声明给模型
tools: ["read", "grep", "find", "ls"],
});
try {
await session.prompt("这个项目的测试怎么跑?");
console.log(session.getLastAssistantText());
} finally {
// dispose 是终态操作:不调用的话扩展的清理处理器不会运行
session.dispose();
}显式传 cwd 的理由很简单:不要依赖调用进程的当前目录。你的应用可能在任何目录下被启动,进程工作目录是外部条件,不是你的设计。
不落盘:SessionManager.inMemory()
SessionManager 拥有持久化或内存中的条目树,并跟踪当前的活跃叶子。会话默认是持久的——不覆盖它,prompt() 之后就会在磁盘上留下文件。
不需要持久化时用 SessionManager.inMemory():
// ephemeral.ts
import { createAgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
const { session } = await createAgentSession({
// 用内存会话:跑完不落盘,磁盘上不会留下 sessions/ 下的文件
sessionManager: SessionManager.inMemory(),
});
try {
await session.prompt("总结一下这段文字");
console.log(session.getLastAssistantText());
} finally {
// 释放后这个会话不再使用;想继续对话用 abort() 而不是 dispose()
session.dispose();
}SessionManager 的四个主要静态工厂方法:
还有两个用于派生和查找的:SessionManager.forkFrom(sourcePath, targetCwd, sessionDir?) 从别的会话派生一个属于当前项目的新会话,SessionManager.findById(cwd, id, sessionDir?) 按 ID 精确查找。
SessionManager 是模型上下文的权威来源。想让外部的历史参与对话,要构造一个包含那些条目的 manager 再传给会话。直接给 session.agent.state.messages 赋值不会替换已持久化的上下文——这一点在做"注入历史消息"时特别容易踩。
释放:dispose() 做的四件事
session.dispose() 具体做这些:
- 中止进行中的工作。
- 失效扩展上下文——之后捕获的扩展
ctx不再可用。 - 与 agent 断开连接。
- 移除事件监听器。
对应的源码层面还包含中止重试、中止压缩、中止分支摘要、中止 bash、清理缓存预热器、清理会话级资源,但对外的可观察效果就是上面四条。
// run.ts
import { createAgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
const { session } = await createAgentSession({
sessionManager: SessionManager.inMemory(),
});
// subscribe 返回退订函数:只移除你注册的这一个监听器
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("解释这个仓库的结构");
} finally {
// 先退订再释放:顺序清楚,两者都做是安全的
unsubscribe();
// dispose 清空全部监听器并断开与 agent 的连接
session.dispose();
}unsubscribe() 和 dispose() 不是一回事。unsubscribe() 只移除你注册的那个监听器;dispose() 清空全部监听器并断开与 agent 的连接。两个都做是安全的,顺序上先退订再释放更清晰。
只调 dispose() 而不退订也能停掉事件——监听器会被移除而不是被调用。但如果你在别处还持有那个退订函数,显式调用它能让"谁负责清理"这件事在代码上更清楚。
注意 dispose() 不是 abort() 的别名。abort() 停掉当前运行并等待会话进入空闲,它保留会话,之后还能继续 prompt。dispose() 是终态操作,之后这个会话不应再使用。
谁负责什么
你现在能做什么
- 在你的程序里打印
session.sessionFile、session.sessionId和session.getActiveToolNames(),确认这次会话落在了哪个文件、启用了哪些工具。 - 显式传一个
cwd(比如某个仓库目录)创建会话,再在里面问一句"这个项目的测试怎么跑",观察模型是否读到了那个目录下的上下文文件——验证cwd真的在起作用。 - 建两个工作目录不同的会话,用
SessionManager.list(cwd)分别列出,确认两个目录的会话互不混淆。 - 写一个只跑一次的临时会话(
SessionManager.inMemory()),跑完prompt()后去~/.pi/agent/sessions/下确认没有新文件——验证不落盘。