Extension
一组被 Pi 加载的 TypeScript 函数。类似 JVM 中加载一个 JAR 后创建的组件,不是新服务。
从一个 Java 程序员熟悉的视角,逐行追踪主 Pi、Extension、Runner、子 Pi、stdout、结果文件和
pi.sendMessage。本文只把真正拥有 PID 的东西叫“进程”。
| 场景 | 本机新进程 | 总拓扑 |
|---|---|---|
| 安装 npm 包 | +0 | 只是把文件放到磁盘 |
| 主 Pi 加载 Extension | +0 | Extension 在主 Pi 的 Node.js 进程内执行,共用 PID |
| 前台运行 1 个 subagent | +1 | 主 Pi → 子 Pi |
| 前台并行 N 个 | 最多 +N | 主 Pi 同时持有 N 个子 Pi 进程 |
| 后台运行 1 个 subagent | +2 | 主 Pi → detached Runner → 子 Pi |
| 后台并行 N 个 | +1+N | 1 个 Runner 管理最多 N 个同时运行的子 Pi |
pi、Tool、Watcher、ChildProcess 句柄fs.watch:主进程注册的文件系统监听回调一组被 Pi 加载的 TypeScript 函数。类似 JVM 中加载一个 JAR 后创建的组件,不是新服务。
Extension 调用 spawn("pi", ...) 启动的新 OS 进程,有独立 PID、内存和 Session。
主 Pi 或子 Pi 通过 HTTP/流式协议调用的 LLM 服务,不算本机新增进程。
消息历史、工具结果和回合状态的容器。一个进程可以管理 Session;Session 本身不是线程或进程。
后台模式才启动的独立 Node.js 进程。它负责拥有子进程、读 stdout、落盘结果,不负责模型推理。
跨进程交接结果的持久化信封。写文件不会自己执行代码,文件变化由 Watcher 发现。
它是“如何启动子 Agent”的配置:系统提示词、模型、工具限制等。它不是一个正在运行的 Agent。
Extension 读取配置,拼成命令参数和 Prompt,真正执行 spawn 后,才出现子 Pi 进程。
agent.md【磁盘配置,不是进程】
│ Extension 读取并解析
▼
启动参数 + system prompt + tool policy【内存数据,不是进程】
│ spawn("pi", args)
▼
子 Pi【现在才产生新 PID】 ──网络请求──► 子模型 API【远程服务】
前台调用时,主 Pi 已经进入一次 Tool 执行。Extension 直接 spawn 子 Pi,并用 Promise 等待子进程结束。 Node.js 线程没有傻站着做忙等待;事件循环仍能处理 stdout 数据和退出事件。但主 Agent 这次 Tool 调用在业务上不能继续, 直到子 Pi 返回结果。
用户 【远程服务】 ┌────────【本地 OS 进程 PID 1000:主 Pi】────────┐ ┌──【新 OS 进程 PID 2001:子 Pi】──┐ 【远程服务】
│ 主模型 API │ Pi Runtime / 主 Session Extension │ │ 子 Runtime / 子 Session │ 子模型 API
│ │ │ │ │ │ │ │ │ │
│ 提交任务 │ │ │ │ │ │ │ │ │
├─────────────────────────────►│ │ │ │ │ │ │ │
│ │◄──请求─────│──────────┤ │ │ │ │ │ │
│ │──Tool Call►│──────────┤ │ │ │ │ │ │
│ │ │ │ 调用 subagent Tool │ │ │ │ │ │
│ │ │ ├───────────────────►│ │ │ │ │ │
│ │ │ │ │ spawn("pi") │ │ │ │ │
│ │ │ │ ├─────────────────────►│ 新 PID │ │ │
│ │ │ │ │ │ │ │──模型请求────────────►│ │
│ │ │ │ │ │ │ │◄─流式模型响应─────────│ │
│ │ │ │ │◄════ stdout JSONL ════│───────────┤ │ │
│ │ │ │ │ message/tool/usage │ │ │ │
│ │ │ │ │◄════ close(exitCode) ═│───────────┤ 进程退出 │ │
│ │ │ │◄── Tool 业务结果 ──┤ │ └───────────────────────────────────┘ │
│ │◄─下一请求──│──────────┤ 把 Tool 结果加入主 Session │ │
│ │──最终回答─►│──────────┤ │ │ │
│◄─────────────────────────────│ │ │ │ │
│ └───────────────────────────────────────────────┘ │
主 Extension 拿到 child.stdout 的可读流,注册 data/line 回调。子 Pi 每写出一行 JSONL,Node 事件循环就调用解析代码;不是一个线程一直 while 等。
不是复用原来的 HTTP 请求。主 Pi 把 Tool 结果追加进主 Session,再向主模型 API 发下一次请求,让模型基于结果继续回答。
// Java 心智模型(概念等价,不是仓库源码)
Process child = new ProcessBuilder("pi", "--mode", "json", "-p", task).start();
child.inputReader().lines().forEachAsync(this::parseJsonLine); // 事件驱动读取
int exitCode = child.onExit().join().exitValue(); // Tool 业务上等待
return buildToolResult(parsedMessages, exitCode);如果主 Extension 直接拥有子 Pi,主 Pi 退出或当前调用结束后,谁继续读 stdout、写状态、处理停止命令? 后台模式把“子任务所有权”转交给一个 detached Runner。Runner 是一个真正的新 Node.js 进程。
【本地进程 PID 1000】主 Pi
├─ 主 Session / Agent Loop
└─ pi-subagents Extension
│ spawn(node, [subagent-runner.ts, configPath], { detached: true })
│ proc.unref()
▼
【本地进程 PID 3001】Detached Runner ← 新增进程 1
├─ 读取启动配置
├─ 维护 run 状态 / 控制 inbox
├─ 读取并解析子 Pi stdout
└─ spawn("pi", ...)
▼
【本地进程 PID 3002】子 Pi ← 新增进程 2
└─ 通过网络调用【远程子模型 API】
【磁盘资源,不是进程】
/tmp/pi-subagents-<scope>/async-subagent-runs/... 运行状态、日志、控制文件
/tmp/pi-subagents-<scope>/async-subagent-results/... 最终结果信封
detached: true + unref() 的业务含义:Runner 不再要求主 Pi 的事件循环必须等它结束。
主 Tool 可以先返回 runId,主 Pi 继续聊天;Runner 独立把子任务跑完。它不是“新线程”,是独立 PID。
【远程】主模型 ┌────【本地进程 PID 1000:主 Pi】────┐ 【磁盘】 ┌──【进程 PID 3001:Runner】──┐ ┌─【进程 PID 3002:子 Pi】─┐ 【远程】子模型
│ │ Runtime Extension │ │ │ │ │ │ │
│ Tool Call►│ │ │ │ │ │ │ │ │
│ │ 调用 async subagent►│ │ │ │ │ │ │ │
│ │ │ 写 config ───────────►│ │ │ │ │ │
│ │ │ spawn detached Runner ─────────►│ 新 PID │ │ │ │
│ │ │◄── pid / started ────────────────┤ │ │ │ │
│ │◄── runId + asyncDir ┤ │ │ │ │ │ │ │
│◄─Tool结果─│ │ │ │ │ 读 config │ │ │ │
│ │ 主回合可继续或结束 │ │ │◄────────┤ 写 running 状态 │ │ │ │
│ └─────────────────────────────────────┘ │ │ spawn 子 Pi ──────────────────────►│ 新 PID │ │
│ │ │ │ │──模型请求────────────────►│ │
│ 此后主 Pi 不需要卡在 Tool 上等待 │ │ │ │◄─流式响应─────────────────│ │
│ │ │◄════ stdout JSONL ════════════════│ │ │
│ │ │◄════ process close ═══════════════│ 退出 │ │
│ │◄────────┤ 原子写 final result │ └────────────────────────────┘ │
│ │ │ Runner 退出 │ │
恢复、租约接管等特殊路径还会使用启动握手文件;它们是防止两个 Runner 同时接管同一任务的可靠性机制, 不是每次普通后台启动都必经的一条消息协议。
结果带有 runId、sessionId、状态、摘要和输出。文件只负责保存数据,不会主动调用主 Pi。
fs.watch 是 PID 1000 内注册的回调。内核发现目录变化后,让 Node 事件循环执行回调。它不是另一个进程。
检查 sessionId、去重记录和任务状态,确认结果属于当前父 Session。原生 watch 不可靠时,代码还有定期扫描和轮询兜底。
pi.sendMessage这里的 pi 是 Extension 加载时由主 Pi Runtime 传入的 API 对象。调用对象方法,就是请求同一个 PID 内的 Runtime 向主 Session 追加一条消息。
triggerTurn: true 开启新的 Agent TurnRuntime 不只保存消息,还主动启动 Agent Loop:把更新后的 Session 发给主模型 API。模型因此看到“后台任务已完成”,继续分析并回复用户。
┌──【进程 PID 3001:Runner】──┐ 【磁盘资源】 ┌────────────【进程 PID 1000:主 Pi】────────────┐ 【远程模型 API】
│ 子任务完成 │ │ │ │
│ writeAtomicJson(result) ────────────────► result/run-123.json │ │
└─────────────────────────────┘ │ │ │ │
│ │ 内核报告目录变更 │ │
│ └────────► fs.watch 回调【Extension 内函数】 │ │
│ │ 读取 JSON + 检查 sessionId │ │
│ │ pi.sendMessage({result}, │ │
│ │ {triggerTurn: true}) │ │
│ ▼ │ │
│ 主 Session 追加通知 │ │
│ │ 启动新的 Agent Turn │ │
│ └────────模型请求────────────────►│ │
│◄──────────────────────────────模型响应──────────────┤ │
└───────────────────────────────────────────────────┘ │
sendMessage 只做什么把一条消息送进“这个主 Pi Runtime 管理的父 Session”。它不是给所有 Pi 广播,也不是直接跨进程 RPC。
triggerTurn 多做什么在投递消息之外,请 Runtime 运行一次新的 Agent Loop/模型调用。没有它,消息可以先留在 Session,模型未必立刻被调用。
可能有多个 Pi 进程或多个 Extension 实例都监听同一个结果目录。一次文件变化可能让多个 Watcher 回调都被调度, 但这不等于多个 Agent 都收到业务消息。每个 Watcher 都会读取结果身份,并只让匹配的父 Session 接受。
result/run-123.json
{ sessionId: "session-B", ... }
│ 目录变化可能被多个监听者看到
├────────► 主 Pi A / Watcher A:当前 session-A ≠ session-B → 忽略
├────────► 主 Pi B / Watcher B:当前 session-B = session-B → sendMessage + triggerTurn
└────────► 主 Pi C / Watcher C:当前 session-C ≠ session-B → 忽略
“回调可能执行多次”是文件系统通知层事实;“业务结果只投给所属 Session”由 sessionId 校验、去重和接收记录保证。
前台模式中,主 Extension 持有 ChildProcess 句柄,可以直接向它传递控制或信号。后台模式已经解绑,所以使用磁盘控制 inbox 连接主 Extension 与 Runner。
后台控制方向:
用户 / 主模型
│ 调用 steer / stop Tool
▼
【PID 1000】主 Extension
│ 写控制请求
▼
【磁盘】control inbox ← 数据,不是进程
│ fs.watch / polling
▼
【PID 3001】Runner
│ 追加 steering prompt,或向子进程发送中断/终止信号
▼
【PID 3002】子 Pi
完成方向正好相反:子 Pi stdout → Runner → result JSON → 主 Watcher → 父 Session。
这不是标准 A2A 网络协议;核心是本机子进程、stdout JSONL、临时文件和 Pi Extension API。可选 intercom 集成属于另一层能力。
调度器同时 spawn 多个子 Pi,并用并发上限控制活跃数。后台通常仍只有一个 Runner,但同一时刻可以有 N 个子 PID。
Runner/Extension 等前一个子 Pi 结束,取其结构化结果,替换下一步 Prompt 中的 {previous},再 spawn 下一个。默认是先后,不是同一个进程复用。
子 Pi 再调用 subagent 时会继续生成下一级 Runner/子 Pi;实现用深度、并发、Token/Turn Budget 等限制防止无限扩张。
后台并行 3 个: 后台 Chain A → B → C:
主 Pi 主 Pi
└─ Runner └─ Runner
├─ 子 Pi A(同时) ├─ spawn A → 等 A 退出
├─ 子 Pi B(同时) ├─ A.output 替换 {previous}
└─ 子 Pi C(同时) ├─ spawn B → 等 B 退出
├─ B.output 替换 {previous}
活跃新增:1 Runner + 3 子 Pi └─ spawn C → 最终结果
同时通常只有 1 个子 Pi 活跃
| pi-subagents 里的东西 | Java 程序员可类比为 | 边界提醒 |
|---|---|---|
| 主 Pi Runtime | 一个 JVM 应用主进程 | 实际是 Node.js 进程 |
| Extension | 同 JVM 中加载的组件/JAR | 不是独立 Spring Boot 服务 |
spawn("pi") | new ProcessBuilder(...).start() | 新 PID,不是线程池任务 |
child.stdout | process.getInputStream() | Node 用事件回调异步消费 |
| Promise 等待 close | CompletableFuture<ProcessResult> | 业务等待,不是 CPU 忙等待 |
| Detached Runner | 独立后台 Worker JVM | 父进程不再持有其生命周期等待 |
| 结果 JSON | 落盘的 Outbox/消息信封 | 本身不会触发业务代码 |
fs.watch | WatchService 回调 | 回调在所在 Pi 进程内执行 |
pi.sendMessage | session.append(message) | 进程内 API,不是 A2A 广播 |
triggerTurn | agentLoopExecutor.submit(session) | 会导致新的模型 API 请求 |
pi.sendMessage + triggerTurn 重新推进父 Session”。
文档按 pi-subagents v0.47.1、commit 5d158bf6c8f63855a1b89d135f0cec528e4fde91 核对。
项目主页 · Extension 注册与 Watcher · 前台 spawn · 后台 Runner 启动 · Runner 与子 Pi · 结果 Watcher · sendMessage 通知