@arkkwang/pi-subagent 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +138 -2
- package/README.zh-CN.md +143 -0
- package/extension.mjs +100 -0
- package/index.js +7 -0
- package/manager.mjs +280 -0
- package/package.json +50 -6
- package/sessions.mjs +53 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 arkkwang
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,139 @@
|
|
|
1
|
-
|
|
1
|
+
English | [简体中文](README.zh-CN.md)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
# pi-subagent
|
|
4
|
+
|
|
5
|
+
An extension for the official Pi SDK that lets a main agent create independent sub-agent sessions, observe execution, send follow-up instructions, and stop tasks. Supports foreground results and background completion notifications.
|
|
6
|
+
|
|
7
|
+
Uses the official `@earendil-works/pi-coding-agent` package. The current verified SDK baseline is **1.0.0**; Node.js 22.19+ is required.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pi install npm:@arkkwang/pi-subagent
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Or clone this repository and install the local package from its root:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pi install .
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Restart Pi to load the extension. There is no standalone CLI, daemon, or second SDK installation: Pi's extension loader supplies the SDK.
|
|
22
|
+
|
|
23
|
+
## Agent usage
|
|
24
|
+
|
|
25
|
+
The three tools use `exposure: "deferred"`. Agents discover and load them through `tool_search`; tool definitions are not exposed by default, but the extension still initializes normally. Keep `tool_search` available; adding sub-agent tools to `defaultTools` is unnecessary.
|
|
26
|
+
|
|
27
|
+
```js
|
|
28
|
+
subagent_start({
|
|
29
|
+
task: "Read-only review of src/cart.js and test/cart.test.js. Report defects, evidence, and missing tests. Do not modify files.",
|
|
30
|
+
name: "Review", mode: "background"
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
subagent_observe({ id: "returned child ID" }) // Immediate snapshot
|
|
34
|
+
subagent_observe({ id: "...", timeoutSeconds: 60 }) // Wait up to 60 seconds
|
|
35
|
+
|
|
36
|
+
// Running: returns after steer acceptance. Idle: starts a new run in the fixed mode.
|
|
37
|
+
subagent_send({ id: "...", action: "message", message: "Check that quantity=0 is not treated as missing." })
|
|
38
|
+
subagent_send({ id: "...", action: "stop" })
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
- `start` requires `task`: provide context, constraints, and completion criteria. Optional `name` also sets the Pi session title; omitting it preserves Pi's first-message-based title behavior. `cwd` can be absolute or relative to the parent project. `model` uses `provider/modelId`. Defaults are the parent project/model and `mode: "background"`. Mode is fixed when the session is created and cannot be changed.
|
|
42
|
+
- `send` requires `id/action`; the `message` action requires `message`. `stop` does not accept a message. There is no `start.notify`, `send.mode`, or silent mode.
|
|
43
|
+
- `observe` without an ID lists all children of the current parent. Default output is the first 2000 characters of the latest assistant text. `detail: "output"` defaults to 16000 characters; paginate with `offset/limit` (maximum 32000 per call). A non-null `nextOffset` indicates more content.
|
|
44
|
+
|
|
45
|
+
`timeoutSeconds` defaults to 0. Waiting requires an ID and is capped at 240 seconds, shared by runtime validation and tool schema through `OBSERVE_MAX_TIMEOUT_SECONDS` in `manager.mjs`. Single-task observations include `timedOut`. Timeout or cancellation ends only the observation, not the child task. Waiting uses completion events and one timer, not polling.
|
|
46
|
+
|
|
47
|
+
Tool text is pure JSON, also exposed as `structuredContent`. Completion notifications are JSON with `event: "subagent_run_ended"`. For example, a partial result:
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"status": "idle",
|
|
52
|
+
"lastRunOutcome": "ended",
|
|
53
|
+
"lastRunStopReason": "length",
|
|
54
|
+
"lastOutput": "Current output..."
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
This means the last run hit the output limit and is no longer executing—not that the task succeeded.
|
|
59
|
+
|
|
60
|
+
## State and evidence
|
|
61
|
+
|
|
62
|
+
There are only two states:
|
|
63
|
+
|
|
64
|
+
- `running`: includes model work, tools, automatic retries, compaction, and accepted queued instructions while execution remains active.
|
|
65
|
+
- `idle`: nothing is currently executing; the user's goal may still be unmet.
|
|
66
|
+
|
|
67
|
+
| Field | Meaning |
|
|
68
|
+
| --- | --- |
|
|
69
|
+
| `mode` | Fixed foreground/background delivery policy |
|
|
70
|
+
| `lastOutput` | Latest assistant text, possibly partial while streaming; excludes thinking and tool logs |
|
|
71
|
+
| `lastOutputRun` / `run` | Output's source run / current run; output may still belong to the previous run |
|
|
72
|
+
| `lastOutputAt` | Latest text-update time |
|
|
73
|
+
| `lastActivity` | Latest text or tool activity |
|
|
74
|
+
| `queuedMessages` | SDK messages not yet consumed; not confirmation that instructions were fulfilled |
|
|
75
|
+
| `lastRunOutcome` / `lastRun` | Latest completed run and its outcome: `ended`, `failed`, or `stopped` |
|
|
76
|
+
| `lastRunStopReason` | Actual model termination reason, such as `stop`, `length`, `error`, or `aborted` |
|
|
77
|
+
| `error` | Latest execution error; a tool-level `isError` means the call itself failed |
|
|
78
|
+
| `stopRequested` | Stop was requested; if still running, do not assume tools or external processes have ended |
|
|
79
|
+
| `sessionFile` | Full child JSONL log for investigation |
|
|
80
|
+
|
|
81
|
+
A run is one continuous execution interval, not a model turn or every input submission. Tool submissions start counting at acceptance; other SDK activity starts at an observed `agent_start`. Instructions and adjacent continuations before settlement remain in the same run. Manual SDK compaction may show running without creating a new business run.
|
|
82
|
+
|
|
83
|
+
A child that asks a question and finishes is simply idle. The main agent reads the output and decides whether to answer with `message`, add requirements, or accept the result. There is no separate ask/reply protocol, waiting-for-answer state, or prose classifier.
|
|
84
|
+
|
|
85
|
+
## Waiting and notifications
|
|
86
|
+
|
|
87
|
+
| Operation | Foreground session | Background session (default) |
|
|
88
|
+
| --- | --- | --- |
|
|
89
|
+
| `start` | Waits for the run to settle | Returns after acceptance |
|
|
90
|
+
| `send(message)` while idle | Waits for the new run | Returns after acceptance |
|
|
91
|
+
| `send(message)` while running | Returns after steer acceptance | Returns after steer acceptance |
|
|
92
|
+
| Run completion | No parent notification | Returns to waiting observers, or notifies the parent if none are waiting |
|
|
93
|
+
|
|
94
|
+
- Running messages use SDK `prompt(..., { streamingBehavior: 'steer' })` without changing mode. Acceptance is not execution or completion.
|
|
95
|
+
- Completion goes through one finish path. Waiting observers receive the result without an additional background notification. Immediate observations, expired waits, and cancelled waits do not suppress later notifications. `stop` keeps the delivery policy; shutdown cleanup suppresses notifications.
|
|
96
|
+
- Notifications use Pi `sendMessage(..., { deliverAs: 'steer', triggerTurn: true })`: delivery enters at a processable SDK boundary when the parent is busy and wakes it when idle. Children do not need a notification tool.
|
|
97
|
+
- Cancelling a foreground start or idle-send wait requests child cancellation. After background acceptance, or a running-message acceptance, the completed tool call's signal is not attached to the child execution.
|
|
98
|
+
- Notifications are best-effort, in-process delivery, with no restart recovery or exactly-once guarantee. Pi reports asynchronous delivery failures. Results remain available through observe.
|
|
99
|
+
|
|
100
|
+
Stopping does not undo file changes or erase context. When changing goals after a stop, explicitly cancel the old goal in your next instruction. The main agent must verify output; `ended` does not mean business success.
|
|
101
|
+
|
|
102
|
+
## Execution boundaries
|
|
103
|
+
|
|
104
|
+
- Each parent allows up to 16 concurrent and 1024 retained child sessions. State queries use memory and SDK state, not disk scans or history parsing.
|
|
105
|
+
- Children have only `read/bash/edit/write`, project rules, and locally discovered Skills. Parent chat history is not copied.
|
|
106
|
+
- Parent extensions, package resources, MCP servers, and notification tools are not loaded. Recursive delegation tools are not installed. Models must resolve from local Pi model configuration; models registered only temporarily by parent extensions are not inherited.
|
|
107
|
+
- **Not a security sandbox:** children share filesystem, process privileges, and model services. Parent-extension confirmation/permission hooks are not inherited. “Read-only” is an instruction, not enforced filesystem access control. Assign disjoint files for parallel writers. Do not use this default tool set where extension-enforced approval is required.
|
|
108
|
+
- Tasks are parent-scoped. Switching, forking, or closing cancels execution and releases resources. If stopping is unconfirmed after five seconds, state remains running; switching/forking is cancelled and can be retried.
|
|
109
|
+
- Session logs use Pi's normal `~/.pi/agent/sessions/<cwd group>/` layout, respecting `PI_CODING_AGENT_DIR`, and retain parent-session linkage. Pi's default session list and `pi-snap` can discover them. Background execution and task IDs are not restored after process exit.
|
|
110
|
+
- A single-slot model server may serialize requests. Background execution does not imply parallel GPU inference.
|
|
111
|
+
|
|
112
|
+
## Discovery versus ownership
|
|
113
|
+
|
|
114
|
+
Ordinary SDK session files and parent links support discovery by Pi and `pi-snap`. Discovery does not transfer execution ownership: this extension manages run state, messages, cancellation, and result delivery.
|
|
115
|
+
|
|
116
|
+
No WebUI shared-instance or parent-liveness bridges are registered, and runtime state is not handed to upstream. Upstream file-list refresh is outside this extension's responsibilities. Do not resume a still-active child from another process.
|
|
117
|
+
|
|
118
|
+
## Tests
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
npm ci
|
|
122
|
+
npm test
|
|
123
|
+
# Real SDK with a local mock HTTP model; no external model calls:
|
|
124
|
+
PI_SDK_ENTRY=/absolute/path/to/pi/dist/index.js npm test
|
|
125
|
+
PI_SDK_ENTRY=/absolute/path/to/pi/dist/index.js node test/benchmark.mjs
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Without `PI_SDK_ENTRY`, real-SDK tests are skipped. `test/local-smoke.mjs` is a separately invoked real local-model exercise, not part of the default tests.
|
|
129
|
+
|
|
130
|
+
Validated locally with Windows Git Bash, Node 22.23.2, and official Pi 1.0.0. macOS/Linux and other Pi versions have not yet been locally tested. The benchmark compares resource loading with/without the extension and queries with large output; it does not measure full cold startup or model inference.
|
|
131
|
+
|
|
132
|
+
## Related projects
|
|
133
|
+
|
|
134
|
+
- [pi-cron](https://github.com/arkkwang/pi-cron): schedule agent tasks.
|
|
135
|
+
- [pi-snap](https://github.com/arkkwang/pi-snap): help humans and agents investigate session failures.
|
|
136
|
+
|
|
137
|
+
## License
|
|
138
|
+
|
|
139
|
+
[MIT](LICENSE)
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
[English](README.md) | 简体中文
|
|
2
|
+
|
|
3
|
+
# Pi Subagent
|
|
4
|
+
|
|
5
|
+
基于官方 Pi SDK 的子 Agent 扩展:创建独立会话、观察执行、追加消息或停止任务。支持前台结果返回与后台完成通知。
|
|
6
|
+
|
|
7
|
+
使用官方包 `@earendil-works/pi-coding-agent`,当前验证基线为 1.0.0。需要 Node.js 22.19+。
|
|
8
|
+
|
|
9
|
+
## 使用
|
|
10
|
+
|
|
11
|
+
三个工具统一注册为 `exposure: "deferred"`,不默认向模型暴露定义;通过 `tool_search` 按需发现、加载。插件仍正常初始化。这不要求将 subagent 工具加入 `defaultTools`;需保持 `tool_search` 可用。
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
// 异步派发,执行结束后通知当前主会话。
|
|
15
|
+
subagent_start({
|
|
16
|
+
task: "只读检查 src/cart.js 和 test/cart.test.js,报告缺陷、证据和缺失测试,不修改文件。",
|
|
17
|
+
name: "审查", mode: "background"
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
subagent_observe({ id: "返回的子会话 ID" }) // 立即返回
|
|
21
|
+
subagent_observe({ id: "...", timeoutSeconds: 60 }) // 等本轮结束,最多 60 秒
|
|
22
|
+
|
|
23
|
+
// running:steer 受理后返回;idle:沿用创建时的 mode 开始下一轮。
|
|
24
|
+
subagent_send({ id: "...", action: "message", message: "重点检查 quantity=0,不得当作缺省值。" })
|
|
25
|
+
|
|
26
|
+
subagent_send({ id: "...", action: "stop" })
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
- `start`:必填 `task`,应包含必要背景、限制和完成依据;可选 `name`(同时写入 Pi 会话标题;不传时保留界面从首条消息生成的标题)、`cwd`(相对主项目或绝对路径)、`model`(`provider/modelId`)。默认当前项目、主 Agent 模型;`mode` 为 `foreground` / `background`(默认),创建时固定为会话属性,后续不可切换。
|
|
30
|
+
- `send`:必填 `id/action`;`message` 动作必填 `message`;`stop` 不接受消息。没有 `start.notify` 或 `send.mode` 参数,也没有 silent 模式。
|
|
31
|
+
- `observe`:不传 ID 列出当前主会话所有子任务;默认截取最近 assistant 正文前 2000 字符。用 `detail: "output"` 获取最多 16000 字符,`offset/limit` 分页,单次上限 32000。`nextOffset` 非 null 时还有内容。
|
|
32
|
+
|
|
33
|
+
`timeoutSeconds` 默认 0;大于 0 时必须指定 id,上限由 `manager.mjs` 的 `OBSERVE_MAX_TIMEOUT_SECONDS = 240` 统一配置,工具 schema 与运行时校验共用。单任务观察返回 `timedOut`;到期返回当前快照,不停止子任务。取消观察同样不停止子任务。等待使用结束事件与单个定时器,不轮询。
|
|
34
|
+
|
|
35
|
+
工具返回正文为纯 JSON,并同时提供 `structuredContent`;结束通知也是纯 JSON,额外带 `event: "subagent_run_ended"`,不混入自然语言前缀。字段示例(省略其余字段):
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{
|
|
39
|
+
"status": "idle",
|
|
40
|
+
"lastRunOutcome": "ended",
|
|
41
|
+
"lastRunStopReason": "length",
|
|
42
|
+
"lastOutput": "当前输出……"
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
这表示当前未执行、上一轮达到输出上限,不表示任务已完成。
|
|
47
|
+
|
|
48
|
+
**只有两种状态:**
|
|
49
|
+
|
|
50
|
+
- `running`:包括模型执行、工具调用、自动重试、压缩和已接受的排队指令。
|
|
51
|
+
- `idle`:当前没有执行,不保证用户目标已经达成。
|
|
52
|
+
|
|
53
|
+
其他信息不作为状态:
|
|
54
|
+
|
|
55
|
+
| 字段 | 含义 |
|
|
56
|
+
| --- | --- |
|
|
57
|
+
| `mode` | 创建时固定的 `foreground` / `background`,决定 idle 续发的等待方式及每轮通知 |
|
|
58
|
+
| `lastOutput` | 最近 assistant 正文;流式执行时可能只是部分内容,不含思考或工具日志 |
|
|
59
|
+
| `lastOutputRun` / `run` | 输出来源轮次 / 当前轮次;新一轮尚未输出时,可能显示旧输出 |
|
|
60
|
+
| `lastOutputAt` | 最近正文更新时间 |
|
|
61
|
+
| `lastActivity` | 最近正文或工具活动 |
|
|
62
|
+
| `queuedMessages` | SDK 尚未消费的排队消息数,不是“要求已完成”的确认 |
|
|
63
|
+
| `lastRunOutcome` / `lastRun` | 最近结束的一轮及结果:`ended` / `failed` / `stopped` |
|
|
64
|
+
| `lastRunStopReason` | 模型实际结束原因,例如 `stop`、`length`、`error`、`aborted`;`length` 表示模型输出达到上限 |
|
|
65
|
+
| `error` | 最近运行错误;工具返回 `isError` 则表示调用自身未受理 |
|
|
66
|
+
| `stopRequested` | 已请求停止;仍 running 时不能假定工具或外部进程已结束 |
|
|
67
|
+
| `sessionFile` | 子会话完整 JSONL 记录,可供排查 |
|
|
68
|
+
|
|
69
|
+
`run` 记录一次连续执行区间,而非模型底层 turn 数或每次 UI 提交:工具提交从受理开始,外部 SDK 调用从可观察的 `agent_start` 开始,均到执行收束结束。期间加入的消息、收束前紧邻的续跑属于同一轮。SDK 手动压缩可以显示 running,但不额外增加业务轮次。
|
|
70
|
+
|
|
71
|
+
子 Agent 输出问题后正常结束也只是 idle。主 Agent 查看文本,自行判断是否通过 `message` 回答、追加要求或收取结果;没有 `ask`、`reply`、等待回复状态或文本分类器。
|
|
72
|
+
|
|
73
|
+
## 等待与通知
|
|
74
|
+
|
|
75
|
+
| 操作 | foreground 会话 | background 会话(默认) |
|
|
76
|
+
| --- | --- | --- |
|
|
77
|
+
| `start` | 等待本轮收束后返回 | 受理后返回 |
|
|
78
|
+
| idle 时 `send(message)` | 等待新一轮收束后返回 | 受理后返回 |
|
|
79
|
+
| running 时 `send(message)` | steer 受理后返回,不等待本轮 | steer 受理后返回,不等待本轮 |
|
|
80
|
+
| 每轮结束通知 | 不通知父会话 | 有等待观察者则通过观察返回,否则主动通知父会话 |
|
|
81
|
+
|
|
82
|
+
- running 消息使用 SDK `prompt(..., { streamingBehavior: 'steer' })`,不改变会话 mode。受理不代表消息已执行或任务已完成;需等结果或查询状态。
|
|
83
|
+
- 每轮真正收束统一走 `finish`;等待观察在收束时直接收到结果,避免重复后台通知。立即观察、超时或取消不抑制后续后台通知;`stop` 不改变交付,关闭会话的资源清理仍抑制通知。
|
|
84
|
+
- 插件通过 Pi `sendMessage(..., { deliverAs: 'steer', triggerTurn: true })` 交付结束事件:主 Agent 忙时在 SDK 可处理边界进入,空闲时唤醒。不依赖子模型调用通知工具。
|
|
85
|
+
- 前台启动或 idle 续发的等待调用取消时请求停止子任务;后台受理后及 running 消息受理后,不把已返回调用的取消信号绑定到子执行。
|
|
86
|
+
- 通知是进程内尽力交付,没有重启恢复和严格 exactly-once 保证;Pi API 的异步投递失败由 Pi 报告。可以随时通过 observe 取回结果。
|
|
87
|
+
|
|
88
|
+
停止不撤销文件修改,不删除已有上下文。停止后改变目标,应明确说明“旧任务已取消,接下来做……”。主模型仍需核对输出,不能把 `ended` 当成业务成功。
|
|
89
|
+
|
|
90
|
+
## 运行边界
|
|
91
|
+
|
|
92
|
+
- 本工具每个主会话最多受理 16 个并行子会话、保留 1024 个子会话。状态结合内存记录与 SDK 状态,查询不扫描磁盘或解析历史。
|
|
93
|
+
- 子会话只启用 `read/bash/edit/write`;加载目标项目的规则和本地发现的 Skills,不复制主会话聊天历史。
|
|
94
|
+
- 不加载主 Agent 的扩展、包资源、MCP 或通知工具,不自动注册递归派发工具。仅支持本机模型配置可解析的模型,不继承仅由主扩展临时注册的模型。
|
|
95
|
+
- **不是安全沙箱**:共享文件系统、进程权限和模型服务;不继承主会话扩展提供的确认/权限钩子。“只读”是任务约束,不是强制文件权限。并行写任务必须划分文件范围。需要依靠扩展强制审批的项目不应直接使用此默认工具集。
|
|
96
|
+
- 默认不跨主会话共享任务。切换、分叉、关闭会话时取消子执行并释放资源;5 秒内未确认停止则保留 running,切换/分叉会被取消,可稍后重试。
|
|
97
|
+
- 子会话记录使用 Pi 默认目录 `~/.pi/agent/sessions/<工作目录分组>/`(遵循 `PI_CODING_AGENT_DIR`),与普通会话相同,保留父会话关联,默认会话列表和 `pi-snap` 可直接发现。进程退出后不恢复后台执行或任务 ID。
|
|
98
|
+
- 模型服务只有一个推理 slot 时,多个 Agent 请求仍可能由服务端串行处理;后台执行不等于 GPU 并行推理。
|
|
99
|
+
|
|
100
|
+
## 会话发现与状态归属
|
|
101
|
+
|
|
102
|
+
子会话使用官方 SDK 默认的会话文件布局和父会话关联,供 Pi 会话列表或 `pi-snap` 发现。列表发现不等于接管执行:运行状态、消息、取消和结果交付由本扩展管理。
|
|
103
|
+
|
|
104
|
+
不注册 WebUI 共享 SDK 实例或主会话保活接口,不向上游交付运行时状态。上游如何刷新文件列表不属于本扩展职责。不要同时从其他进程恢复一个仍由本扩展执行的会话。
|
|
105
|
+
|
|
106
|
+
## 安装
|
|
107
|
+
|
|
108
|
+
从 npm 安装:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
pi install npm:@arkkwang/pi-subagent
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
也可以克隆仓库后,在仓库目录内安装本地包:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
pi install .
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
重启 Pi 后加载扩展。无独立 CLI、常驻服务或第二份 Pi SDK;SDK 由 Pi 扩展加载器提供。三个工具按需暴露,主 Agent 使用前需通过 `tool_search` 发现。
|
|
121
|
+
|
|
122
|
+
## 验证
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
npm ci
|
|
126
|
+
npm test
|
|
127
|
+
# 完整集成测试:实际 SDK + 本地模拟 HTTP 模型,不调用外部模型。
|
|
128
|
+
PI_SDK_ENTRY=/absolute/path/to/pi/dist/index.js npm test
|
|
129
|
+
PI_SDK_ENTRY=/absolute/path/to/pi/dist/index.js node test/benchmark.mjs
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
未设置 `PI_SDK_ENTRY` 时跳过真实 SDK 测试。`test/local-smoke.mjs` 是需要显式授权的真实本地模型演练,不包含在默认测试中。
|
|
133
|
+
|
|
134
|
+
Windows Git Bash / Node 22.23.2 / 官方 Pi 1.0.0 为本次验证环境;macOS/Linux 和其他 Pi 版本尚未实测。性能脚本对照带/不带扩展的资源加载,并测量大输出状态查询;不代表完整冷启动或模型推理耗时。
|
|
135
|
+
|
|
136
|
+
## 相关项目
|
|
137
|
+
|
|
138
|
+
- [pi-cron](https://github.com/arkkwang/pi-cron):定时执行 Agent 任务。
|
|
139
|
+
- [pi-snap](https://github.com/arkkwang/pi-snap):帮助人和 Agent 快速定位会话问题。
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
[MIT](LICENSE)
|
package/extension.mjs
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Subagents, OBSERVE_MAX_TIMEOUT_SECONDS } from './manager.mjs';
|
|
2
|
+
import { sessionFactory } from './sessions.mjs';
|
|
3
|
+
|
|
4
|
+
const string = { type: 'string', minLength: 1 };
|
|
5
|
+
const mode = { type: 'string', enum: ['foreground', 'background'], description: '创建时固定交付模式,后续沿用:foreground 等待执行结束后由工具返回;background 立即返回,结束后通知主会话(默认)。' };
|
|
6
|
+
const schema = (properties, required = []) => ({ type: 'object', properties, required, additionalProperties: false });
|
|
7
|
+
const result = value => ({ content: [{ type: 'text', text: JSON.stringify(value) }], structuredContent: value, details: value });
|
|
8
|
+
|
|
9
|
+
export function registerSubagents(pi, sdk, createFactory = sessionFactory) {
|
|
10
|
+
let owner;
|
|
11
|
+
let manager;
|
|
12
|
+
let shutdownDrain;
|
|
13
|
+
function current(ctx) {
|
|
14
|
+
const id = ctx.sessionManager.getSessionId();
|
|
15
|
+
if (!manager) {
|
|
16
|
+
owner = id;
|
|
17
|
+
manager = new Subagents({
|
|
18
|
+
createSession: createFactory(sdk),
|
|
19
|
+
notify: event => {
|
|
20
|
+
const payload = { event: 'subagent_run_ended', ...event };
|
|
21
|
+
pi.sendMessage({
|
|
22
|
+
customType: 'subagent-result', display: true,
|
|
23
|
+
content: JSON.stringify(payload),
|
|
24
|
+
details: payload,
|
|
25
|
+
}, { deliverAs: 'steer', triggerTurn: true });
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
if (owner !== id) throw new Error('子会话仍属于上一主会话,清理后才能继续。');
|
|
30
|
+
return manager;
|
|
31
|
+
}
|
|
32
|
+
async function cleanup() {
|
|
33
|
+
if (manager) await manager.close();
|
|
34
|
+
shutdownDrain = undefined;
|
|
35
|
+
manager = undefined;
|
|
36
|
+
owner = undefined;
|
|
37
|
+
}
|
|
38
|
+
async function beforeSwitch(_event, ctx) {
|
|
39
|
+
try { await cleanup(); }
|
|
40
|
+
catch (error) { ctx.ui.notify(error.message, 'error'); return { cancel: true }; }
|
|
41
|
+
}
|
|
42
|
+
async function shutdown() {
|
|
43
|
+
try { await cleanup(); }
|
|
44
|
+
catch (error) {
|
|
45
|
+
// A host may dispose the parent after its own shutdown deadline. Keep the
|
|
46
|
+
// finalizer alive independently; release children only after work really ends.
|
|
47
|
+
const closing = manager;
|
|
48
|
+
if (closing && !shutdownDrain) {
|
|
49
|
+
shutdownDrain = Promise.allSettled([...closing.records.values()].map(r => r.work))
|
|
50
|
+
.then(() => { if (manager === closing) return cleanup(); })
|
|
51
|
+
.catch(failure => console.error('[subagent] Deferred shutdown cleanup failed:', failure));
|
|
52
|
+
}
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
pi.on('session_before_switch', beforeSwitch);
|
|
57
|
+
pi.on('session_before_fork', beforeSwitch);
|
|
58
|
+
pi.on('session_shutdown', shutdown);
|
|
59
|
+
pi.on('session_start', async (_event, ctx) => {
|
|
60
|
+
if (manager && owner !== ctx.sessionManager.getSessionId()) await cleanup();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
function tool(name, label, description, parameters, execute) {
|
|
64
|
+
pi.registerTool({ name, label, description, parameters, exposure: 'deferred',
|
|
65
|
+
async execute(_id, args, signal, _update, ctx) {
|
|
66
|
+
try { return result(await execute(current(ctx), args, signal, ctx)); }
|
|
67
|
+
catch (error) { return { ...result({ error: error.message }), isError: true }; }
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
tool('subagent_start', '启动子 Agent',
|
|
72
|
+
'创建独立上下文的子会话;task 必须包含必要背景、范围与限制。共享文件系统,不是沙箱。'
|
|
73
|
+
+ '子会话仅有 read/bash/edit/write 和项目规则/本地 Skills,不继承主会话历史、MCP 或扩展。'
|
|
74
|
+
+ 'mode 在创建时确定,后续不切换。默认后台运行,真正结束本轮后通知主会话;主会话忙时在可处理边界送达,空闲时唤醒。前台通过工具返回结果,不额外通知。'
|
|
75
|
+
+ '返回 idle 只表示本轮结束,必须检查输出和 lastRunOutcome。并行任务避免修改同一文件。',
|
|
76
|
+
schema({ task: string, name: string, cwd: string, model: { ...string, description: 'provider/modelId;默认沿用主 Agent 模型。' }, mode }, ['task']),
|
|
77
|
+
(m, args, signal, ctx) => {
|
|
78
|
+
if (!args.task.trim()) throw new Error('task 不能为空。');
|
|
79
|
+
return m.start(args, ctx, signal);
|
|
80
|
+
});
|
|
81
|
+
tool('subagent_observe', '观察子 Agent',
|
|
82
|
+
'观察子会话;默认立即返回,不传 id 列出当前主会话的子任务。状态只有 idle/running。'
|
|
83
|
+
+ `指定 id 和 timeoutSeconds 可等待本轮结束,最多 ${OBSERVE_MAX_TIMEOUT_SECONDS} 秒;超时返回 timedOut=true,不停止子任务。取消只退出观察。`
|
|
84
|
+
+ '等待期间结束则直接返回,不重复发后台通知;超时或取消后仍由后台通知交付。无其他工作时用等待观察,不要自行 sleep 轮询。'
|
|
85
|
+
+ 'lastOutput 是最近 assistant 正文,运行中可能不完整;lastOutputRun 标明来源轮次,可能来自上一轮。'
|
|
86
|
+
+ 'idle 不保证目标完成,检查 lastRunOutcome/error;lastRunStopReason=length 表示模型达到输出上限。输出展示截断时用 detail=output、offset/limit 续取。',
|
|
87
|
+
schema({ id: string, timeoutSeconds: { type: 'number', minimum: 0, maximum: OBSERVE_MAX_TIMEOUT_SECONDS, default: 0, description: '最多等待秒数;0 立即返回,大于 0 必须指定 id。' }, detail: { type: 'string', enum: ['summary', 'output'] }, offset: { type: 'integer', minimum: 0 }, limit: { type: 'integer', minimum: 1, maximum: 32000 } }),
|
|
88
|
+
(m, args, signal) => m.observeStatus(args, signal));
|
|
89
|
+
tool('subagent_send', '指挥子 Agent',
|
|
90
|
+
'向已有子会话发送 message 或 stop。running 时消息排队,到可处理点读取,不撤销已经发生的操作;'
|
|
91
|
+
+ 'idle 时保留上下文开始新一轮;停止后仍保留旧任务历史,改变目标时请明确说明。回复输出中的问题也使用 message,不需要专用回复动作。'
|
|
92
|
+
+ '运行中 message 受理后返回,不另行等待或改变交付模式;idle 时沿用创建时 mode:前台等待新一轮结果,后台立即返回、结束后通知。stop 取消执行和排队消息,不回滚文件,也不改变原有结果交付;'
|
|
93
|
+
+ '若仍为 running 且 stopRequested=true,表示尚未确认停止。',
|
|
94
|
+
schema({ id: string, action: { type: 'string', enum: ['message', 'stop'] }, message: string }, ['id', 'action']),
|
|
95
|
+
(m, args, signal) => {
|
|
96
|
+
if (args.action === 'stop' && args.message !== undefined)
|
|
97
|
+
throw new Error('stop 不接受 message。');
|
|
98
|
+
return m.send(args, signal);
|
|
99
|
+
});
|
|
100
|
+
}
|
package/index.js
ADDED
package/manager.mjs
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
// Shared by runtime validation and the tool schema (seconds).
|
|
4
|
+
export const OBSERVE_MAX_TIMEOUT_SECONDS = 240;
|
|
5
|
+
|
|
6
|
+
const textOf = message => (message?.content ?? []).filter(c => c.type === 'text').map(c => c.text).join('\n');
|
|
7
|
+
const errorText = error => error instanceof Error ? error.message : String(error);
|
|
8
|
+
const now = () => new Date().toISOString();
|
|
9
|
+
|
|
10
|
+
/** One manager belongs to one parent session. SDK work, not model prose, owns status. */
|
|
11
|
+
export class Subagents {
|
|
12
|
+
constructor({ createSession, notify, maxRunning = 16, maxSessions = 1024, stopTimeout = 5000 }) {
|
|
13
|
+
Object.assign(this, { createSession, notify, maxRunning, maxSessions, stopTimeout });
|
|
14
|
+
this.records = new Map();
|
|
15
|
+
this.closed = false;
|
|
16
|
+
this.starting = 0;
|
|
17
|
+
this.creating = new Set();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
isRunning(r) {
|
|
21
|
+
return r.status === 'running' || !r.session.isIdle || r.session.isCompacting;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
checkCapacity() {
|
|
25
|
+
if (this.closed) throw new Error('子会话管理器已关闭。');
|
|
26
|
+
if (this.starting + [...this.records.values()].filter(r => this.isRunning(r)).length >= this.maxRunning)
|
|
27
|
+
throw new Error(`最多同时运行 ${this.maxRunning} 个子会话;请先等待或停止已有任务。`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
get(id) {
|
|
31
|
+
const record = this.records.get(id);
|
|
32
|
+
if (!record) throw new Error(`当前主会话没有子会话 ${id}。`);
|
|
33
|
+
return record;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
snapshot(r, { detail = 'summary', offset = 0, limit = detail === 'output' ? 16000 : 2000 } = {}) {
|
|
37
|
+
limit = Math.min(Math.max(limit, 1), 32000);
|
|
38
|
+
return {
|
|
39
|
+
id: r.id, name: r.name, cwd: r.cwd,
|
|
40
|
+
model: r.session.model ? `${r.session.model.provider}/${r.session.model.id}` : null,
|
|
41
|
+
status: this.isRunning(r) ? 'running' : 'idle', run: r.run,
|
|
42
|
+
mode: r.mode, startedAt: r.startedAt, endedAt: r.endedAt,
|
|
43
|
+
lastActivity: r.lastActivity, queuedMessages: r.queuedMessages,
|
|
44
|
+
lastOutput: r.lastOutput.slice(offset, offset + limit), lastOutputAt: r.lastOutputAt,
|
|
45
|
+
lastOutputRun: r.lastOutputRun,
|
|
46
|
+
outputOffset: offset, outputLength: r.lastOutput.length,
|
|
47
|
+
nextOffset: offset + limit < r.lastOutput.length ? offset + limit : null,
|
|
48
|
+
lastRunOutcome: r.lastRunOutcome, lastRun: r.lastRun, lastRunStopReason: r.lastRunStopReason,
|
|
49
|
+
error: r.error, stopRequested: r.stopRequested,
|
|
50
|
+
notificationError: r.notificationError,
|
|
51
|
+
sessionFile: r.session.sessionFile ?? null,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
status(args = {}) {
|
|
56
|
+
if (args.id) return this.snapshot(this.get(args.id), args);
|
|
57
|
+
return [...this.records.values()].map(r => this.snapshot(r));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async observeStatus(args = {}, signal) {
|
|
61
|
+
const { timeoutSeconds = 0 } = args;
|
|
62
|
+
if (!Number.isFinite(timeoutSeconds) || timeoutSeconds < 0 || timeoutSeconds > OBSERVE_MAX_TIMEOUT_SECONDS)
|
|
63
|
+
throw new Error(`timeoutSeconds 必须在 0 到 ${OBSERVE_MAX_TIMEOUT_SECONDS} 秒之间。`);
|
|
64
|
+
if (timeoutSeconds > 0 && !args.id) throw new Error('等待观察必须指定 id。');
|
|
65
|
+
if (signal?.aborted) throw new Error('观察已取消;子任务继续运行。');
|
|
66
|
+
if (!args.id) return this.status(args);
|
|
67
|
+
const r = this.get(args.id);
|
|
68
|
+
if (!timeoutSeconds || !this.isRunning(r)) return { ...this.snapshot(r, args), timedOut: false };
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
let timer;
|
|
71
|
+
const cleanup = () => {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
r.observers.delete(done);
|
|
74
|
+
signal?.removeEventListener('abort', abort);
|
|
75
|
+
};
|
|
76
|
+
const done = (timedOut = false) => {
|
|
77
|
+
cleanup();
|
|
78
|
+
resolve({ ...this.snapshot(r, args), timedOut });
|
|
79
|
+
};
|
|
80
|
+
const abort = () => { cleanup(); reject(new Error('观察已取消;子任务继续运行。')); };
|
|
81
|
+
r.observers.add(done);
|
|
82
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
83
|
+
timer = setTimeout(() => done(true), timeoutSeconds * 1000);
|
|
84
|
+
// Manual SDK compaction is busy but does not produce a manager run/finish.
|
|
85
|
+
if (r.status !== 'running') r.session.waitForIdle().then(() => {
|
|
86
|
+
if (r.observers.has(done) && !this.isRunning(r)) done();
|
|
87
|
+
}, error => { if (r.observers.has(done)) { cleanup(); reject(error); } });
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async start(args, context, signal) {
|
|
92
|
+
this.checkCapacity();
|
|
93
|
+
if (this.records.size + this.starting >= this.maxSessions)
|
|
94
|
+
throw new Error(`当前主会话最多保留 ${this.maxSessions} 个子会话。`);
|
|
95
|
+
this.starting++;
|
|
96
|
+
const creation = Promise.resolve().then(() => this.createSession(args, context));
|
|
97
|
+
this.creating.add(creation);
|
|
98
|
+
let session;
|
|
99
|
+
try {
|
|
100
|
+
session = await creation;
|
|
101
|
+
if (this.closed || signal?.aborted) {
|
|
102
|
+
session.dispose();
|
|
103
|
+
throw new Error('子会话启动已取消。');
|
|
104
|
+
}
|
|
105
|
+
} finally {
|
|
106
|
+
this.creating.delete(creation);
|
|
107
|
+
this.starting--;
|
|
108
|
+
}
|
|
109
|
+
const r = {
|
|
110
|
+
id: `sa-${randomUUID().slice(0, 8)}`, name: args.name ?? '子任务', session,
|
|
111
|
+
cwd: session.sessionManager?.getCwd() ?? context.cwd,
|
|
112
|
+
status: 'idle', run: 0, mode: args.mode ?? 'background',
|
|
113
|
+
lastOutput: '', lastOutputAt: null, lastOutputRun: null,
|
|
114
|
+
lastRunOutcome: null, lastRun: null, lastRunStopReason: null, error: null, notificationError: null,
|
|
115
|
+
startedAt: null, endedAt: null, lastActivity: null, queuedMessages: 0,
|
|
116
|
+
stopRequested: false, dispatches: new Set(), observers: new Set(),
|
|
117
|
+
};
|
|
118
|
+
r.unsubscribe = session.subscribe(event => this.onEvent(r, event));
|
|
119
|
+
this.records.set(r.id, r);
|
|
120
|
+
this.launch(r, args.task);
|
|
121
|
+
return r.mode === 'foreground' ? this.wait(r, signal) : this.snapshot(r);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
onEvent(r, event) {
|
|
125
|
+
if (event.type === 'agent_start' && r.status !== 'running') {
|
|
126
|
+
this.begin(r);
|
|
127
|
+
// External callers own prompt(), but tool foreground/stop still need a work promise.
|
|
128
|
+
const settled = new Promise(resolve => { r.settle = resolve; });
|
|
129
|
+
// SDK settled listeners run BEFORE deferred continuations. Let those start,
|
|
130
|
+
// then observe actual idle without taking ownership of the caller's queue.
|
|
131
|
+
r.work = settled.then(() => new Promise(resolve => setImmediate(resolve)))
|
|
132
|
+
.then(() => this.observe(r));
|
|
133
|
+
}
|
|
134
|
+
if (event.type === 'agent_settled') {
|
|
135
|
+
r.settle?.();
|
|
136
|
+
r.settle = null;
|
|
137
|
+
}
|
|
138
|
+
if (event.type === 'message_start' && event.message.role === 'assistant') r.partial = '';
|
|
139
|
+
if (event.type === 'message_update' && event.assistantMessageEvent.type === 'text_delta') {
|
|
140
|
+
r.partial = (r.partial ?? '') + event.assistantMessageEvent.delta;
|
|
141
|
+
this.output(r, r.partial);
|
|
142
|
+
}
|
|
143
|
+
if (event.type === 'message_end' && event.message.role === 'assistant') {
|
|
144
|
+
const text = textOf(event.message);
|
|
145
|
+
if (text) this.output(r, text);
|
|
146
|
+
// Retried errors may be followed by a successful message. Only the latest counts.
|
|
147
|
+
r.stopReason = event.message.stopReason;
|
|
148
|
+
r.runError = event.message.errorMessage ?? null;
|
|
149
|
+
}
|
|
150
|
+
if (event.type === 'tool_execution_start') r.lastActivity = { type: 'tool', name: event.toolName, at: now() };
|
|
151
|
+
if (event.type === 'queue_update') r.queuedMessages = event.steering.length + event.followUp.length;
|
|
152
|
+
// agent_end is NOT completion: recovery, compaction and queued work may follow.
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
output(r, text) {
|
|
156
|
+
r.lastOutput = text;
|
|
157
|
+
r.lastOutputAt = now();
|
|
158
|
+
r.lastOutputRun = r.run;
|
|
159
|
+
r.lastActivity = { type: 'output', at: r.lastOutputAt };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
begin(r) {
|
|
163
|
+
r.status = 'running';
|
|
164
|
+
r.run++;
|
|
165
|
+
r.startedAt = now();
|
|
166
|
+
r.endedAt = null;
|
|
167
|
+
r.stopRequested = false;
|
|
168
|
+
r.error = null;
|
|
169
|
+
r.runError = null;
|
|
170
|
+
r.stopReason = null;
|
|
171
|
+
r.notificationError = null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async observe(r, failure) {
|
|
175
|
+
do {
|
|
176
|
+
await r.session.waitForIdle();
|
|
177
|
+
while (r.dispatches.size) await Promise.allSettled([...r.dispatches]);
|
|
178
|
+
} while (!r.session.isIdle);
|
|
179
|
+
return this.finish(r, failure);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
launch(r, message) {
|
|
183
|
+
this.begin(r);
|
|
184
|
+
// Defer execution so callers can observe the accepted run before it settles.
|
|
185
|
+
r.work = Promise.resolve().then(() => this.run(r, message));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async run(r, message) {
|
|
189
|
+
let failure;
|
|
190
|
+
try {
|
|
191
|
+
if (!r.stopRequested)
|
|
192
|
+
await r.session.prompt(message, { expandPromptTemplates: false });
|
|
193
|
+
} catch (error) {
|
|
194
|
+
failure = errorText(error);
|
|
195
|
+
}
|
|
196
|
+
return this.observe(r, failure);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
finish(r, failure) {
|
|
200
|
+
r.status = 'idle';
|
|
201
|
+
r.endedAt = now();
|
|
202
|
+
r.lastRun = r.run;
|
|
203
|
+
r.lastRunStopReason = r.stopReason;
|
|
204
|
+
r.lastRunOutcome = r.stopRequested || r.stopReason === 'aborted' ? 'stopped'
|
|
205
|
+
: failure || r.stopReason === 'error' ? 'failed' : 'ended';
|
|
206
|
+
r.error = r.lastRunOutcome === 'failed' ? failure || r.runError || '模型执行失败。' : null;
|
|
207
|
+
const result = this.snapshot(r);
|
|
208
|
+
const observed = r.observers.size > 0;
|
|
209
|
+
for (const done of [...r.observers]) done();
|
|
210
|
+
if (!this.closed && r.mode === 'background' && !observed) {
|
|
211
|
+
try { this.notify(result); }
|
|
212
|
+
catch (error) { r.notificationError = errorText(error); }
|
|
213
|
+
}
|
|
214
|
+
return result;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async send(args, signal) {
|
|
218
|
+
const r = this.get(args.id);
|
|
219
|
+
if (args.action === 'stop') return this.stop(r);
|
|
220
|
+
if (this.closed) throw new Error('子会话管理器已关闭。');
|
|
221
|
+
if (!args.message?.trim()) throw new Error('message 动作需要非空 message。');
|
|
222
|
+
if (r.stopRequested && this.isRunning(r)) throw new Error('正在停止,确认 idle 后再发送消息。');
|
|
223
|
+
if (signal?.aborted) throw new Error('发送已取消。');
|
|
224
|
+
if (r.status === 'idle') {
|
|
225
|
+
if (this.isRunning(r)) throw new Error('子会话正在处理 SDK 操作,请等待 idle 后再发送消息。');
|
|
226
|
+
this.checkCapacity();
|
|
227
|
+
this.launch(r, args.message);
|
|
228
|
+
return r.mode === 'foreground' ? this.wait(r, signal) : this.snapshot(r);
|
|
229
|
+
}
|
|
230
|
+
// Supplement the active run; do not create another result consumer.
|
|
231
|
+
const dispatch = r.session.prompt(args.message, { streamingBehavior: 'steer', expandPromptTemplates: false });
|
|
232
|
+
r.dispatches.add(dispatch);
|
|
233
|
+
try { await dispatch; }
|
|
234
|
+
finally { r.dispatches.delete(dispatch); }
|
|
235
|
+
return this.snapshot(r);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async wait(r, signal) {
|
|
239
|
+
let onAbort;
|
|
240
|
+
const cancelled = new Promise(resolve => {
|
|
241
|
+
onAbort = () => { this.stop(r).then(resolve, error => resolve({ ...this.snapshot(r), error: errorText(error) })); };
|
|
242
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
243
|
+
if (signal?.aborted) onAbort();
|
|
244
|
+
});
|
|
245
|
+
try { return await Promise.race([r.work, cancelled]); }
|
|
246
|
+
finally {
|
|
247
|
+
signal?.removeEventListener('abort', onAbort);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async stop(r) {
|
|
252
|
+
if (!this.isRunning(r)) return this.snapshot(r);
|
|
253
|
+
if (!r.stopping) {
|
|
254
|
+
if (r.status === 'running') r.stopRequested = true;
|
|
255
|
+
r.session.clearQueue();
|
|
256
|
+
r.stopping = (async () => {
|
|
257
|
+
let timer;
|
|
258
|
+
try {
|
|
259
|
+
const stopped = Promise.resolve().then(async () => {
|
|
260
|
+
await r.session.abort();
|
|
261
|
+
await r.work;
|
|
262
|
+
});
|
|
263
|
+
await Promise.race([stopped, new Promise(resolve => { timer = setTimeout(resolve, this.stopTimeout); })]);
|
|
264
|
+
return this.snapshot(r); // timeout retains running + stopRequested, never claims idle
|
|
265
|
+
} finally { clearTimeout(timer); r.stopping = null; }
|
|
266
|
+
})();
|
|
267
|
+
}
|
|
268
|
+
return r.stopping;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async close() {
|
|
272
|
+
this.closed = true; // Suppress notifications into a different parent session.
|
|
273
|
+
await Promise.allSettled([...this.creating]);
|
|
274
|
+
await Promise.all([...this.records.values()].map(r => this.stop(r)));
|
|
275
|
+
if ([...this.records.values()].some(r => this.isRunning(r)))
|
|
276
|
+
throw new Error('部分子任务尚未确认停止;不能切换会话,请稍后重试。');
|
|
277
|
+
for (const r of this.records.values()) { r.unsubscribe(); r.session.dispose(); }
|
|
278
|
+
this.records.clear();
|
|
279
|
+
}
|
|
280
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@arkkwang/pi-subagent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@arkkwang/pi-subagent",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"pi": {
|
|
6
|
+
"extensions": [
|
|
7
|
+
"./index.js"
|
|
8
|
+
]
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "node --test test/*.test.mjs"
|
|
12
|
+
},
|
|
13
|
+
"peerDependencies": {
|
|
14
|
+
"@earendil-works/pi-coding-agent": "^1.0.0"
|
|
15
|
+
},
|
|
16
|
+
"peerDependenciesMeta": {
|
|
17
|
+
"@earendil-works/pi-coding-agent": {
|
|
18
|
+
"optional": true
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"description": "Sub-agent sessions for the official Pi SDK",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/arkkwang/pi-subagent.git"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=22.19.0"
|
|
28
|
+
},
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"author": "arkkwang",
|
|
31
|
+
"files": [
|
|
32
|
+
"index.js",
|
|
33
|
+
"extension.mjs",
|
|
34
|
+
"manager.mjs",
|
|
35
|
+
"sessions.mjs",
|
|
36
|
+
"README.zh-CN.md"
|
|
37
|
+
],
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"homepage": "https://github.com/arkkwang/pi-subagent#readme",
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/arkkwang/pi-subagent/issues"
|
|
44
|
+
},
|
|
45
|
+
"keywords": [
|
|
46
|
+
"pi-agent",
|
|
47
|
+
"ai",
|
|
48
|
+
"pi-package"
|
|
49
|
+
]
|
|
50
|
+
}
|
package/sessions.mjs
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { stat } from 'node:fs/promises';
|
|
3
|
+
|
|
4
|
+
/** Lazy SDK setup: loading the parent extension never scans resources or starts a model. */
|
|
5
|
+
export function sessionFactory(sdk) {
|
|
6
|
+
let runtime;
|
|
7
|
+
return async (args, ctx) => {
|
|
8
|
+
const cwd = path.resolve(ctx.cwd, args.cwd ?? '.');
|
|
9
|
+
if (!(await stat(cwd)).isDirectory()) throw new Error(`不是目录:${cwd}`);
|
|
10
|
+
const agentDir = sdk.getAgentDir();
|
|
11
|
+
runtime ??= sdk.ModelRuntime.create();
|
|
12
|
+
let modelRuntime;
|
|
13
|
+
try { modelRuntime = await runtime; }
|
|
14
|
+
catch (error) { runtime = undefined; throw error; }
|
|
15
|
+
let model;
|
|
16
|
+
if (args.model) {
|
|
17
|
+
const split = args.model.indexOf('/');
|
|
18
|
+
if (split < 1) throw new Error('model 必须为 provider/modelId,例如 llama/iq3。');
|
|
19
|
+
model = modelRuntime.getModel(args.model.slice(0, split), args.model.slice(split + 1));
|
|
20
|
+
} else if (ctx.model) {
|
|
21
|
+
model = modelRuntime.getModel(ctx.model.provider, ctx.model.id);
|
|
22
|
+
}
|
|
23
|
+
if (!model) throw new Error('无法解析子模型;请选择本机 Pi 配置中的 provider/modelId。');
|
|
24
|
+
|
|
25
|
+
// Keep model/compaction settings and AGENTS/Skills, but never recursively load
|
|
26
|
+
// parent extensions, package executables, external notifications or MCP servers.
|
|
27
|
+
const trusted = cwd === path.resolve(ctx.cwd) && ctx.isProjectTrusted();
|
|
28
|
+
const configured = sdk.SettingsManager.create(cwd, agentDir, { projectTrusted: trusted }).getSettings();
|
|
29
|
+
const settingsManager = sdk.SettingsManager.inMemory({
|
|
30
|
+
...configured, packages: [], extensions: [], prompts: [], themes: [],
|
|
31
|
+
}, { projectTrusted: trusted });
|
|
32
|
+
const resourceLoader = new sdk.DefaultResourceLoader({
|
|
33
|
+
cwd, agentDir, settingsManager, noExtensions: true, noPromptTemplates: true, noThemes: true,
|
|
34
|
+
appendSystemPrompt: [
|
|
35
|
+
'You are a sub-agent. Work on the delegated task using the supplied context and project rules. '
|
|
36
|
+
+ 'Your last text output is returned to the main agent. State results, evidence, limitations or questions clearly. '
|
|
37
|
+
+ 'You have an independent conversation but share the filesystem; do not assume file isolation. '
|
|
38
|
+
+ 'Do not launch other agents or send external notifications. Use only the permissions granted for the delegated task.',
|
|
39
|
+
],
|
|
40
|
+
});
|
|
41
|
+
await resourceLoader.reload();
|
|
42
|
+
const sessionManager = sdk.SessionManager.create(cwd, undefined, {
|
|
43
|
+
parentSession: ctx.sessionManager.getSessionFile(),
|
|
44
|
+
});
|
|
45
|
+
const { session } = await sdk.createAgentSession({
|
|
46
|
+
cwd, agentDir, modelRuntime, model, thinkingLevel: ctx.thinkingLevel,
|
|
47
|
+
settingsManager, resourceLoader, sessionManager,
|
|
48
|
+
tools: ['read', 'bash', 'edit', 'write'],
|
|
49
|
+
});
|
|
50
|
+
if (args.name !== undefined) session.setSessionName(args.name);
|
|
51
|
+
return session;
|
|
52
|
+
};
|
|
53
|
+
}
|