pi-subagents 0.47.1 · process-level walkthrough

Pi Subagents 到底新增了哪些进程?

从一个 Java 程序员熟悉的视角,逐行追踪主 Pi、Extension、Runner、子 Pi、stdout、结果文件和 pi.sendMessage。本文只把真正拥有 PID 的东西叫“进程”。

直接答案:仅安装或加载 Extension,不新增进程。前台执行一个子任务时临时新增 1 个子 Pi 进程;后台执行一个子任务时临时新增 1 个 Runner 进程 + 1 个子 Pi 进程。

1. 先数进程,不谈抽象名词

场景本机新进程总拓扑
安装 npm 包+0只是把文件放到磁盘
主 Pi 加载 Extension+0Extension 在主 Pi 的 Node.js 进程内执行,共用 PID
前台运行 1 个 subagent+1主 Pi → 子 Pi
前台并行 N 个最多 +N主 Pi 同时持有 N 个子 Pi 进程
后台运行 1 个 subagent+2主 Pi → detached Runner → 子 Pi
后台并行 N 个+1+N1 个 Runner 管理最多 N 个同时运行的子 Pi
本地 OS 进程 · PID 1000

主 Pi CLI / Runtime

进程内组件
主 Session + Agent Loop
进程内组件
pi-subagents Extension
进程内对象
pi、Tool、Watcher、ChildProcess 句柄
都不是本地进程

进程外部的东西

远程模型 API:网络服务
Session:Pi 管理的会话状态
JSON / JSONL 文件:磁盘资源
fs.watch:主进程注册的文件系统监听回调
关键修正:“主 Pi 模型”不是本地常驻的一条模型进程泳道。主 Pi Runtime 经网络向模型服务发请求。 模型返回 Tool Call 后,本地 Runtime 才调用同进程内的 Extension。

2. 五个容易混在一起的概念

同进程代码

Extension

一组被 Pi 加载的 TypeScript 函数。类似 JVM 中加载一个 JAR 后创建的组件,不是新服务。

新进程

子 Pi

Extension 调用 spawn("pi", ...) 启动的新 OS 进程,有独立 PID、内存和 Session。

远程服务

模型

主 Pi 或子 Pi 通过 HTTP/流式协议调用的 LLM 服务,不算本机新增进程。

状态

Session

消息历史、工具结果和回合状态的容器。一个进程可以管理 Session;Session 本身不是线程或进程。

后台协调程序

Runner

后台模式才启动的独立 Node.js 进程。它负责拥有子进程、读 stdout、落盘结果,不负责模型推理。

磁盘

结果文件

跨进程交接结果的持久化信封。写文件不会自己执行代码,文件变化由 Watcher 发现。

Agent Markdown 是什么?

它是“如何启动子 Agent”的配置:系统提示词、模型、工具限制等。它不是一个正在运行的 Agent。 Extension 读取配置,拼成命令参数和 Prompt,真正执行 spawn 后,才出现子 Pi 进程。

agent.md【磁盘配置,不是进程】
        │ Extension 读取并解析
        ▼
启动参数 + system prompt + tool policy【内存数据,不是进程】
        │ spawn("pi", args)
        ▼
子 Pi【现在才产生新 PID】 ──网络请求──► 子模型 API【远程服务】
      

3. 前台模式:主任务像等待一个耗时 Tool

前台调用时,主 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           │                                                │
 │                 │──最终回答─►│──────────┤                    │                 │                                                │
 │◄─────────────────────────────│          │                    │                 │                                                │
 │                              └───────────────────────────────────────────────┘                                                │
      

stdout 是怎样“返回”的

主 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);

4. 后台模式:为什么需要 Runner

如果主 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 同时接管同一任务的可靠性机制, 不是每次普通后台启动都必经的一条消息协议。

5. Runner 写完文件以后,主 Agent 为什么会继续?

1

Runner 原子写入结果 JSON

结果带有 runId、sessionId、状态、摘要和输出。文件只负责保存数据,不会主动调用主 Pi。

2

主 Extension 的 Watcher 收到文件系统事件

fs.watch 是 PID 1000 内注册的回调。内核发现目录变化后,让 Node 事件循环执行回调。它不是另一个进程。

3

Watcher 读取并做归属校验

检查 sessionId、去重记录和任务状态,确认结果属于当前父 Session。原生 watch 不可靠时,代码还有定期扫描和轮询兜底。

4

Notifier 调用主 Pi 的 pi.sendMessage

这里的 pi 是 Extension 加载时由主 Pi Runtime 传入的 API 对象。调用对象方法,就是请求同一个 PID 内的 Runtime 向主 Session 追加一条消息。

5

triggerTurn: true 开启新的 Agent Turn

Runtime 不只保存消息,还主动启动 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,模型未必立刻被调用。

6. 如果同时有很多主 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 校验、去重和接收记录保证。
      
所以正确心智模型不是“所有 Agent 收到以后自己看是不是我的”,而是:多个 Watcher 可能看见门铃, 只有归属校验通过的 Extension 才把信塞进自己的 Session。

7. 运行中追加指令、停止任务,命令怎样反向传过去?

前台模式中,主 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 集成属于另一层能力。

8. 并行、Chain 和 Nested 会怎样改变进程树?

并行 Parallel

调度器同时 spawn 多个子 Pi,并用并发上限控制活跃数。后台通常仍只有一个 Runner,但同一时刻可以有 N 个子 PID。

Chain

Runner/Extension 等前一个子 Pi 结束,取其结构化结果,替换下一步 Prompt 中的 {previous},再 spawn 下一个。默认是先后,不是同一个进程复用。

Nested

子 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 活跃
      

9. 最终 Java 对照表

pi-subagents 里的东西Java 程序员可类比为边界提醒
主 Pi Runtime一个 JVM 应用主进程实际是 Node.js 进程
Extension同 JVM 中加载的组件/JAR不是独立 Spring Boot 服务
spawn("pi")new ProcessBuilder(...).start()新 PID,不是线程池任务
child.stdoutprocess.getInputStream()Node 用事件回调异步消费
Promise 等待 closeCompletableFuture<ProcessResult>业务等待,不是 CPU 忙等待
Detached Runner独立后台 Worker JVM父进程不再持有其生命周期等待
结果 JSON落盘的 Outbox/消息信封本身不会触发业务代码
fs.watchWatchService 回调回调在所在 Pi 进程内执行
pi.sendMessagesession.append(message)进程内 API,不是 A2A 广播
triggerTurnagentLoopExecutor.submit(session)会导致新的模型 API 请求
一句话收束:pi-subagents 的前台发动机是“主 Extension spawn 子 Pi,再从 stdout 收结果”; 后台发动机是“主 Extension spawn detached Runner,Runner spawn 子 Pi并落盘,主 Extension 监听文件后用 pi.sendMessage + triggerTurn 重新推进父 Session”。

10. 这份解释依据哪些实现

文档按 pi-subagents v0.47.1、commit 5d158bf6c8f63855a1b89d135f0cec528e4fde91 核对。

项目主页 · Extension 注册与 Watcher · 前台 spawn · 后台 Runner 启动 · Runner 与子 Pi · 结果 Watcher · sendMessage 通知