@wolido/async-subagent-isolation 1.0.1 → 1.2.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/ADVANCED.en.md CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
5
  This document covers low-level invocation, configuration fields, and environment variables for `async-subagent-isolation`. Most users can follow the natural-language Quick Start in the main README; refer to this file only when you need to construct `subagent` calls manually, reuse an isolated session, or tune runtime parameters.
6
6
 
7
+ > **v1.2.0 note**: the `subagent` tool's `action="status"` has been removed as a cleanup. In-flight task information is now provided by the `[subagent-result]` notification envelope's in-flight block, with no active-query entry point.
8
+
7
9
  ---
8
10
 
9
11
  ## Agent definition format
@@ -114,7 +116,7 @@ Key points:
114
116
  - **The receipt is a single line.** The async-semantics guidance (don't fabricate results, don't poll, results arrive as a `[subagent-result]` notification) is embedded in the `subagent` tool's `description` / `promptGuidelines`; the receipt itself stays a single line.
115
117
  - **The receipt is not the result.** Do not fabricate results.
116
118
  - **taskId = sessionId.** The `taskId` in the receipt is the session ID; reuse it directly.
117
- - **Do not poll.** Results arrive automatically as `[subagent-result]` notifications; to confirm which tasks are still in flight (e.g. after a `/tree` rewind), use the `subagent_status` tool.
119
+ - **Do not poll.** Results arrive automatically as `[subagent-result]` notifications; in-flight task information is provided directly by the notification envelope's in-flight block. `action="status"` was removed as a cleanup in v1.2.0.
118
120
 
119
121
  ### [subagent-result] envelope format
120
122
 
@@ -139,12 +141,12 @@ Status enumeration: **成功** (success, exit=0) / **失败** (failure, exit≠0
139
141
 
140
142
  "Cancelled" has three sub-cases with different envelope bodies:
141
143
  - User cancelled via `/subagent-cancel` (cancelledBy: user) → body states this is a deliberate user action; the main agent must NOT auto-retry and must ask the user before re-dispatching.
142
- - Main agent cancelled via `subagent_cancel` tool (cancelledBy: agent) → body states the task was cancelled by the main agent via the subagent_cancel tool.
144
+ - Main agent cancelled via the `subagent` tool with `action="cancel"` (cancelledBy: agent) → body states the task was cancelled by the main agent via the subagent tool (action=cancel).
143
145
  - Session shutdown killed the task (cancelledBy: none) → body states the task was terminated by session_shutdown.
144
146
 
145
147
  When the main agent receives a "已取消" notification, it should distinguish the origin: a user cancel must never be auto-retried (ask the user first); an agent cancel is its own decision — do not re-dispatch without new information; a session-shutdown cancel can be re-dispatched after the session resumes, at the agent's discretion.
146
148
 
147
- **In-flight block**: the "在途任务" list in the envelope's metadata section lists the **other** background tasks still running (this task is removed from the registry before the envelope is built, so it never appears in its own list). Its format matches the `subagent_status` tool — `在途任务: N` followed by one `- taskId (agent): task description` line per task, or `当前无在途任务。` when none remain. It deliberately carries **no elapsed time** (it answers "what is still running", not "how long has it run"). The main agent uses it to know how many tasks are still outstanding — while the count is non-zero, do not report "all done" to the user.
149
+ **In-flight block**: the "在途任务" list in the envelope's metadata section lists the **other** background tasks still running (this task is removed from the registry before the envelope is built, so it never appears in its own list). Its format is `在途任务: N` followed by one `- taskId (agent): task description` line per task, or `当前无在途任务。` when none remain. It deliberately carries **no elapsed time** (it answers "what is still running", not "how long has it run"). The main agent uses it to know how many tasks are still outstanding — while the count is non-zero, do not report "all done" to the user.
148
150
 
149
151
  The full output enters the LLM context (not truncated). The `details` carries structured data (taskId, agent, status, exitCode, stopReason, usage, sessionId, full output) for programmatic consumption; it does not enter the LLM context.
150
152
 
@@ -166,25 +168,6 @@ While subagents run, a progress widget appears above the TUI editor, listing all
166
168
 
167
169
  The taskId in the widget row can be copied for `/subagent-result` (view full result) or `/subagent-cancel` (cancel the task).
168
170
 
169
- ### subagent_status (in-flight query)
170
-
171
- The main agent can actively query still-running background tasks via the `subagent_status` tool (no parameters), which returns:
172
-
173
- ```
174
- 在途任务: 2
175
- - 01912345-6789-7abc-8def-0123456789ab (coder): 将认证中间件重构为使用 async/await。
176
- - 01912345-aaaa-7bbb-8ccc-0123456789ab (writer): 更新 README。
177
- ```
178
-
179
- With no in-flight tasks it returns `当前无在途任务。`. Each line carries the taskId, agent name, and task description, with **no elapsed time**.
180
-
181
- Use cases:
182
-
183
- - After a `/tree` rewind loses the receipts, confirm which tasks are still in flight.
184
- - When unsure how many tasks remain, or to pick a taskId for `subagent_cancel`.
185
-
186
- **Do not use it to poll for completion**: results arrive automatically as `[subagent-result]` notifications. This tool only confirms "what is still running" — it should not be called frequently.
187
-
188
171
  ### Cancelling background tasks
189
172
 
190
173
  Cancelling a running background subagent task has two paths, both sharing the same underlying cancel flow (SIGTERM → 5s → SIGKILL cascade, followed by a `[subagent-result]` notification).
@@ -207,11 +190,11 @@ To cancel all running tasks at once:
207
190
 
208
191
  Takes no arguments. Unlike `/subagent-cancel`, which cancels a single task by taskId, this cancels every running task. Each cancelled task still emits its own "已取消" `[subagent-result]` notification (the main agent receives N cancelled envelopes). On success it notifies "已取消全部 N 个运行中任务"; with no running tasks it notifies "无运行中任务可取消". The cancel source is likewise recorded as `cancelledBy: "user"`.
209
192
 
210
- **Path 2: Main agent `subagent_cancel` tool**
193
+ **Path 2: Main agent `subagent` tool with `action="cancel"`**
211
194
 
212
- The main agent can call the `subagent_cancel` tool (parameter `taskId`) to cancel a dispatched background task. The cancel source is recorded as `cancelledBy: "agent"`. On success, the tool returns the remaining in-flight task list (same format as `subagent_status`); the cancelled task's final result arrives later as a `[subagent-result]` notification.
195
+ The main agent can call the `subagent` tool with `action="cancel"` (parameter `taskId`) to cancel a dispatched background task. The cancel source is recorded as `cancelledBy: "agent"`. On success, the tool returns the remaining in-flight task list (same format as the `[subagent-result]` envelope's in-flight block); the cancelled task's final result arrives later as a `[subagent-result]` notification.
213
196
 
214
- **Usage discipline:** The main agent should only use `subagent_cancel` when:
197
+ **Usage discipline:** The main agent should only use `action="cancel"` when:
215
198
  - The task is clearly wrong (wrong agent, incorrect task description, etc.).
216
199
  - The task is no longer needed (requirement change, later discovery that this step is unnecessary).
217
200
 
@@ -292,7 +275,7 @@ These variables are propagated into every subagent process automatically:
292
275
 
293
276
  | Variable | Default | Description |
294
277
  |----------|---------|-------------|
295
- | `PI_SUBAGENT_DEPTH` | `0` | Current recursion depth. Auto-incremented per nested call. **Depth limit is 1** — a subagent (depth ≥ 1) cannot dispatch `subagent`; recursive delegation is blocked entirely. |
278
+ | `PI_SUBAGENT_DEPTH` | `0` | Current recursion depth. Auto-incremented per nested call. **Depth limit is 1** — a subagent (depth ≥ 1) cannot call any `subagent` action (including `action="cancel"`). |
296
279
  | `PI_CURRENT_AGENT_NAME` | — | Name of the current agent, injected into every subagent process. |
297
280
  | `PI_SUBAGENT_ACTIVITY_TIMEOUT_MS` | `600000` (10 min) | Max idle time with no output on either stdout or stderr before the subagent is killed. |
298
281
  | `PI_SUBAGENT_HARD_TIMEOUT_MS` | `0` (disabled) | Absolute maximum runtime for a single call. Set a positive value (ms) to enable. |
package/ADVANCED.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  # async-subagent-isolation 进阶参考
4
4
 
5
+ > **v1.2.0 提示**:`subagent` 工具的 `action="status"` 已作为 cleanup 移除。在途任务信息改由 `[subagent-result]` 通知信封的“在途任务”块提供,不再提供主动查询入口。
6
+
5
7
  这里收录 `async-subagent-isolation` 的底层调用方式、配置字段和环境变量。普通用户按照主 README 的 Quick Start 用自然语言即可;只有当你需要手动构造 `subagent` 调用、复用隔离会话或调整运行参数时才需要查看本文档。
6
8
 
7
9
  ---
@@ -114,7 +116,7 @@ TUI 模式下 `subagent` 立即返回如下回执(不是结果!):
114
116
  - **回执为单行。** 异步语义引导(不臆造结果、不轮询、结果以 `[subagent-result]` 通知到达)已内嵌于 `subagent` 工具的 `description` / `promptGuidelines`,回执本身保持单行。
115
117
  - **回执 ≠ 结果。** 不要臆造结果。
116
118
  - **taskId = sessionId。** 回执中的 `taskId` 就是 session ID,可直接复用。
117
- - **不要轮询。** 结果自动以 `[subagent-result]` 通知到达;如需确认还有哪些任务在途(如 `/tree` 回退后),用 `subagent_status` 工具查询。
119
+ - **不要轮询。** 结果自动以 `[subagent-result]` 通知到达;在途任务信息由通知信封的“在途任务”块直接提供,`action="status"` 已在 v1.2.0 清理移除。
118
120
 
119
121
  ### [subagent-result] 信封格式
120
122
 
@@ -139,12 +141,12 @@ TUI 模式下 `subagent` 立即返回如下回执(不是结果!):
139
141
 
140
142
  "已取消"分三种情况,信封正文不同:
141
143
  - 用户通过 `/subagent-cancel` 取消(cancelledBy: user)→ 正文注明"属用户主动操作。请勿自动重新派发;如需重新派发,先询问用户。"
142
- - 主 agent 通过 `subagent_cancel` 工具取消(cancelledBy: agent)→ 正文注明"该任务已由主 agent 通过 subagent_cancel 工具取消。"
144
+ - 主 agent 通过 `subagent` 工具(`action="cancel"`)取消(cancelledBy: agent)→ 正文注明"该任务已由主 agent 通过 subagent 工具(action=cancel)取消。"
143
145
  - 会话关闭(session_shutdown)终止(cancelledBy: 无)→ 正文注明"任务因会话关闭被终止(session_shutdown)。"
144
146
 
145
147
  主 agent 收到状态为"已取消"的通知时,应区分来源:用户主动取消**不得自动重试**,必须先询问用户;agent 取消是自身决策,不应在无新信息时重新派发;会话关闭终止可在会话恢复后视情况重新派发。
146
148
 
147
- **在途任务块**:信封元信息区的"在途任务"列表列出**其余**仍在运行的后台任务(本任务在构建信封前已从注册表移除,故不包含自身),格式与 `subagent_status` 工具一致——`在途任务: N` 加每行 `- taskId (agent名): 任务描述`,无在途任务时为"当前无在途任务。"。列表**不含耗时**(回答"还有什么在跑",而非"跑了多久")。主 agent 据此知道还有几个任务没回来:剩余不为 0 时,不要向用户汇报"全部完成"。
149
+ **在途任务块**:信封元信息区的"在途任务"列表列出**其余**仍在运行的后台任务(本任务在构建信封前已从注册表移除,故不包含自身),格式为 `在途任务: N` 加每行 `- taskId (agent名): 任务描述`,无在途任务时为"当前无在途任务。"列表**不含耗时**(回答"还有什么在跑",而非"跑了多久")。主 agent 据此知道还有几个任务没回来:剩余不为 0 时,不要向用户汇报"全部完成"。
148
150
 
149
151
  结果全量进入 LLM 上下文(不截断)。`details` 携带结构化数据(taskId、agent、status、exitCode、stopReason、usage、sessionId、完整输出),不参与 LLM 上下文,供程序消费。
150
152
 
@@ -166,25 +168,6 @@ TUI 模式下 `subagent` 立即返回如下回执(不是结果!):
166
168
 
167
169
  widget 行中的 taskId 可直接复制,用于 `/subagent-result` 查看结果或 `/subagent-cancel` 取消任务。
168
170
 
169
- ### subagent_status(在途任务查询)
170
-
171
- 主 agent 可主动查询仍在运行的后台任务,调用 `subagent_status` 工具(无参数),返回:
172
-
173
- ```
174
- 在途任务: 2
175
- - 01912345-6789-7abc-8def-0123456789ab (coder): 将认证中间件重构为使用 async/await。
176
- - 01912345-aaaa-7bbb-8ccc-0123456789ab (writer): 更新 README。
177
- ```
178
-
179
- 无在途任务时返回 `当前无在途任务。`。每行含 taskId、agent 名和任务描述,**不含耗时**。
180
-
181
- 用途场景:
182
-
183
- - `/tree` 回退后回执丢失,确认还有哪些任务在途。
184
- - 不确定剩余任务时快速核对,或选取 taskId 用于 `subagent_cancel`。
185
-
186
- **不要用它轮询完成状态**:结果会自动以 `[subagent-result]` 通知到达,本工具只用于确认"还有什么在跑",不应频繁调用。
187
-
188
171
  ### 取消后台任务
189
172
 
190
173
  取消运行中的后台子 agent 任务有两条路径,底层共享同一套取消流程(SIGTERM → 5s → SIGKILL 级联,最终推送 `[subagent-result]` 通知)。
@@ -207,11 +190,11 @@ widget 行中的 taskId 可直接复制,用于 `/subagent-result` 查看结果
207
190
 
208
191
  无参数。与 `/subagent-cancel` 按 taskId 取消单个任务不同,`/subagent-cancel-all` 取消全部运行中的任务。每个被取消任务照常推送各自的"已取消" `[subagent-result]` 通知(主 agent 会收到 N 个已取消信封)。成功时提示"已取消全部 N 个运行中任务",无运行中任务时提示"无运行中任务可取消"。取消来源同样标记为 `cancelledBy: "user"`。
209
192
 
210
- **路径二:主 agent `subagent_cancel` 工具**
193
+ **路径二:主 agent `subagent` 工具(`action="cancel"`)**
211
194
 
212
- 主 agent 可调用 `subagent_cancel` 工具(参数 `taskId`)取消已派出的后台任务。取消来源标记为 `cancelledBy: "agent"`。取消成功后返回剩余在途任务列表(格式与 `subagent_status` 一致),被取消任务的最终结果稍后以 `[subagent-result]` 通知返回。
195
+ 主 agent 可调用 `subagent` 工具(`action="cancel"`,参数 `taskId`)取消已派出的后台任务。取消来源标记为 `cancelledBy: "agent"`。取消成功后返回剩余在途任务列表(格式与信封的“在途任务”块一致),被取消任务的最终结果稍后以 `[subagent-result]` 通知返回。
213
196
 
214
- **使用纪律:** 主 agent 仅在以下情况使用 `subagent_cancel`:
197
+ **使用纪律:** 主 agent 仅在以下情况使用 `action="cancel"`:
215
198
  - 任务明显错误(委派了错误的 agent、任务描述有误等)。
216
199
  - 任务不再需要(用户需求变更、后续发现无需此步骤)。
217
200
 
@@ -292,7 +275,7 @@ widget 行中的 taskId 可直接复制,用于 `/subagent-result` 查看结果
292
275
 
293
276
  | 变量 | 默认值 | 说明 |
294
277
  |------|--------|------|
295
- | `PI_SUBAGENT_DEPTH` | `0` | 当前递归深度。每次嵌套调用自动递增。**深度限制为 1**——子 agent(depth ≥ 1)不可再派发 `subagent`,递归委派已被完全禁止。 |
278
+ | `PI_SUBAGENT_DEPTH` | `0` | 当前递归深度。每次嵌套调用自动递增。**深度限制为 1**——子 agent(depth ≥ 1)不可调用任何 `subagent` action(含 `action="cancel"`)。 |
296
279
  | `PI_CURRENT_AGENT_NAME` | — | 当前 agent 名称,注入每个子 agent 进程。 |
297
280
  | `PI_SUBAGENT_ACTIVITY_TIMEOUT_MS` | `600000`(10 分钟) | stdout 和 stderr 均无输出(无活动)时的最大允许时间。 |
298
281
  | `PI_SUBAGENT_HARD_TIMEOUT_MS` | `0`(禁用) | 单次调用的绝对最大运行时长。设为正数(毫秒)启用。 |
package/README.en.md CHANGED
@@ -47,7 +47,7 @@ The main agent is the dispatcher: it understands the request, splits it into tas
47
47
 
48
48
  More important is **the freedom after dispatch**. While a task runs in the background, you keep talking to the main agent — refine the requirements, adjust the plan, discuss next steps, or raise a new task. The main agent doesn't wait idle; it can keep planning and even dispatch more tasks in parallel. Foreground conversation and background work move forward together.
49
49
 
50
- Finally, **review when the result returns**. The subagent finishes, the notification arrives, and the main agent processes it and reports back. While you wait, you can check in-flight status any time (the progress widget or `subagent_status`), but you never have to watch.
50
+ Finally, **review when the result returns**. The subagent finishes, the notification arrives, and the main agent processes it and reports back. While you wait, you can check the progress widget, but you never have to watch.
51
51
 
52
52
  In one line: sync traps you in the "swarm execution" block; async keeps you facing a single dispatcher while background work runs alongside your own pace.
53
53
 
@@ -71,6 +71,18 @@ Plain subagents split work. async-subagent-isolation splits everything.
71
71
 
72
72
  ---
73
73
 
74
+ ## Uniqueness and significance
75
+
76
+ This project is built on two design decisions that support each other, and its significance comes from the two together.
77
+
78
+ **Async by default.** Dispatching is not handing over control — it's delivering a task: the call returns a receipt immediately, the task runs in an independent process in the background, and the result is pushed back as a `[subagent-result]` system notification. Async is the default semantics, not an optional switch — the main agent never blocks, can dispatch in parallel and keep planning, and the user always faces the dispatcher alone.
79
+
80
+ **Exclusive skill isolation.** A subagent's skills load from a whitelist: everything is off by default, and only individually listed skills can enter its context. Isolation happens at the process level, not the prompt level — each subagent is its own `pi` process, and none of the main agent's skills can get in. Isolation is therefore not an instruction but a structural fact: a subagent knows only what it is allowed to know, and its domain of focus is precisely controllable.
81
+
82
+ **Why it matters: context partitioning.** The main agent keeps only "what to do" and "what came back"; the subagent's long execution trail stays in its own process and session, never flowing back to the main agent. Context is cut into small slices, each handled by its own agent — the main agent stays clear-headed over the long run, and planning and review are never drowned in detail. Reliable division of labor is thus structure, not discipline: async and isolation are both defaults.
83
+
84
+ ---
85
+
74
86
  ## Async workflow
75
87
 
76
88
  This is the biggest difference from the sync version, and the primary way to use it (TUI mode).
@@ -104,7 +116,7 @@ When the subagent finishes, its result is pushed as a **`[subagent-result]` syst
104
116
  - If the main agent is **idle**, the notification triggers a new turn immediately.
105
117
  - If the main agent is **busy**, it is queued and triggers a turn after the current one finishes.
106
118
 
107
- Results arrive automatically — **no polling**. To confirm which tasks are still in flight (e.g. after a `/tree` rewind loses the receipts), use the `subagent_status` tool.
119
+ Results arrive automatically — **no polling**. In-flight task information is provided directly by the `[subagent-result]` notification envelope; `action="status"` was removed as a cleanup in v1.2.0.
108
120
 
109
121
  ### 5. Read the full result (`/subagent-result`)
110
122
 
@@ -133,9 +145,10 @@ User runs /subagent-result <taskId> to read the full output
133
145
 
134
146
  | Tool | Purpose | Key constraint |
135
147
  |------|---------|----------------|
136
- | `subagent` | Async dispatch (TUI mode); falls back to sync in non-TUI | Receipt ≠ result; results arrive as notifications, don't poll |
137
- | `subagent_status` | List in-flight tasks (taskId, agent, description, **no elapsed time**) | Only confirm "what's still running"; never poll with it |
138
- | `subagent_cancel` | Main agent cancels one in-flight task | Only when clearly wrong or no longer needed; never for being slow |
148
+ | `subagent` | Single-entry tool (`action` parameter); `action="dispatch"` (default) dispatches asynchronously (TUI mode), falls back to sync in non-TUI | Receipt ≠ result; results arrive as notifications, don't poll |
149
+ | `subagent` `action="cancel"` | Main agent cancels one in-flight task | Only when clearly wrong or no longer needed; never for being slow |
150
+
151
+ > **v1.2.0 note**: the `subagent` tool's `action="status"` has been removed as a cleanup. In-flight task information is now provided by the `[subagent-result]` notification envelope's in-flight block, with no active-query entry point.
139
152
 
140
153
  ### Commands (for the user)
141
154
 
@@ -180,11 +193,11 @@ See [ADVANCED.en.md](ADVANCED.en.md) for the complete envelope format, status se
180
193
 
181
194
  Async mode introduces a few rules, baked into the tool prompts and implementation, that the main agent follows automatically:
182
195
 
183
- - **Cancel-origin distinction**: `已取消` (cancelled) has three origins — user (`/subagent-cancel`), main agent (`subagent_cancel` tool), and session shutdown (`session_shutdown`). A user-initiated cancel must **never be auto-retried**; ask the user first.
184
- - **No polling**: results arrive automatically as notifications. `subagent_status` only confirms what's in flight (e.g. after a `/tree` rewind), carries no elapsed time, and is not meant to be called frequently.
185
- - **Anti-abuse cancellation**: `subagent_cancel` ships with prompt guidance — cancel only when the task is clearly wrong or no longer needed, never just because it's slow (background subagents are expected to run long).
196
+ - **Cancel-origin distinction**: `已取消` (cancelled) has three origins — user (`/subagent-cancel`), main agent (`subagent` tool with `action="cancel"`), and session shutdown (`session_shutdown`). A user-initiated cancel must **never be auto-retried**; ask the user first.
197
+ - **No polling**: results arrive automatically as notifications; in-flight task information is provided directly by the `[subagent-result]` notification envelope, with no active-query entry point.
198
+ - **Anti-abuse cancellation**: `action="cancel"` ships with prompt guidance — cancel only when the task is clearly wrong or no longer needed, never just because it's slow (background subagents are expected to run long).
186
199
  - **Resource-conflict discipline**: before dispatching multiple tasks in parallel, consider whether they touch the same files or code areas; when in doubt, dispatch sequentially or ask the user.
187
- - **Recursion blocked entirely**: a subagent (depth ≥ 1) can never dispatch `subagent`; delegation depth is capped at 1.
200
+ - **Subagents cannot call the subagent tool**: a subagent (depth ≥ 1) can never call any `subagent` action (including `action="cancel"`); delegation depth is capped at 1.
188
201
  - **TUI async / non-TUI sync fallback**: only TUI mode takes the async path; print/json and other non-TUI modes fall back to synchronous blocking.
189
202
 
190
203
  ---
@@ -273,7 +286,7 @@ Put it in `~/.pi/agent/subagent-isolation.json` (user-level) or `.pi/subagent-is
273
286
 
274
287
  ## Advanced usage
275
288
 
276
- Manual `subagent` calls, `sessionId` reuse, envelope and in-flight block details, `subagent_status` queries, cancellation, and environment variables are covered in [ADVANCED.en.md](ADVANCED.en.md).
289
+ Manual `subagent` calls, `sessionId` reuse, envelope and in-flight block details, `action="cancel"` cancellation, and environment variables are covered in [ADVANCED.en.md](ADVANCED.en.md).
277
290
 
278
291
  ---
279
292
 
package/README.md CHANGED
@@ -47,7 +47,7 @@
47
47
 
48
48
  更关键的是**派发之后的自由**。任务在后台跑的时候,你可以继续和主 agent 聊天:细化需求、调整规划、商量下一步,或提出新任务。主 agent 不必干等,可以继续规划,甚至并行派发更多任务。前台对话与后台工作并行推进。
49
49
 
50
- 最后是**结果回来再验收**。子 agent 完成,通知到达,主 agent 处理并向你汇报。等待期间你可以随时查看在途状态(进度 widget 或 `subagent_status`),但不必盯着。
50
+ 最后是**结果回来再验收**。子 agent 完成,通知到达,主 agent 处理并向你汇报。等待期间你可以随时查看进度 widget,但不必盯着。
51
51
 
52
52
  一句话:同步版让你陷在"集群执行"的阻塞感里;异步版让你只面对调度者,后台工作与你自己的节奏并行。
53
53
 
@@ -71,6 +71,18 @@ async-subagent-isolation 做的是强制且完全的隔离:
71
71
 
72
72
  ---
73
73
 
74
+ ## 独特性与意义
75
+
76
+ 本项目由两条互为支撑的设计决策构成,意义也由它们共同兑现。
77
+
78
+ **默认异步。** 派发不是转交控制权,而是投递任务:调用即返回回执,任务在独立进程后台运行,结果由系统通知推回。异步是默认语义而非可选开关——主 agent 不被阻塞,可并行派发、继续规划,用户面对的始终只有调度者一个。
79
+
80
+ **排他性 skill 隔离。** 子 agent 的 skill 按白名单加载:全局一律关闭,只有逐条指定的 skill 能进入它的上下文。隔离发生在进程层而非提示词层——每个子 agent 是独立 pi 进程,主 agent 的 skill 一个都进不来。隔离因此不是叮嘱,而是构造事实:子 agent 只有被允许的知识,专注域精确可控。
81
+
82
+ **意义:上下文切割。** 主 agent 只保留"要做什么"与"结果是什么",子 agent 冗长的执行痕迹留在自己的进程与 session 里,不回流主 agent。上下文被切成小块、各管一段,主 agent 得以长期保持清醒,规划与验收不被细节淹没。可靠的分工由此是结构而非纪律:异步与隔离都是默认的。
83
+
84
+ ---
85
+
74
86
  ## 异步工作流
75
87
 
76
88
  这是与同步版最大的不同,也是核心使用方式(TUI 模式)。
@@ -104,7 +116,7 @@ TUI 模式下 `subagent` **立即返回派发回执**,不阻塞:
104
116
  - 主 agent **空闲**时,通知直接触发新的对话回合,立即处理。
105
117
  - 主 agent **忙碌**时,通知进入队列,当前回合结束后再触发。
106
118
 
107
- 结果自动到达,**无需轮询**。如需确认还有哪些任务在途(例如 `/tree` 回退后回执丢失),用 `subagent_status` 工具查询。
119
+ 结果自动到达,**无需轮询**。在途任务信息由 `[subagent-result]` 通知信封的“在途任务”块直接提供;`action="status"` 已在 v1.2.0 清理移除。
108
120
 
109
121
  ### 5. 查看全文(`/subagent-result`)
110
122
 
@@ -133,9 +145,10 @@ TUI 模式下 `subagent` **立即返回派发回执**,不阻塞:
133
145
 
134
146
  | 工具 | 作用 | 关键约束 |
135
147
  |------|------|----------|
136
- | `subagent` | 异步派发任务(TUI 模式);非 TUI 自动降级同步 | 回执≠结果;结果以通知到达,勿轮询 |
137
- | `subagent_status` | 查询在途任务(taskId、agent、任务描述,**无耗时**) | 仅确认"还有什么在跑",勿用它轮询完成 |
138
- | `subagent_cancel` | 主 agent 取消单个在途任务 | 仅当任务明显错误或不再需要,勿因耗时久而取消 |
148
+ | `subagent` | 单入口工具(`action` 参数);`action="dispatch"`(默认)异步派发任务(TUI 模式),非 TUI 自动降级同步 | 回执≠结果;结果以通知到达,勿轮询 |
149
+ | `subagent` `action="cancel"` | agent 取消单个在途任务 | 仅当任务明显错误或不再需要,勿因耗时久而取消 |
150
+
151
+ > **v1.2.0 提示**:`subagent` 工具的 `action="status"` 已作为 cleanup 移除。在途任务信息改由 `[subagent-result]` 通知信封的“在途任务”块提供,不再提供主动查询入口。
139
152
 
140
153
  ### 命令(用户使用)
141
154
 
@@ -180,11 +193,11 @@ TUI 模式下 `subagent` **立即返回派发回执**,不阻塞:
180
193
 
181
194
  异步模式引入的几条纪律,内嵌在工具提示词和实现中,主 agent 自动遵守:
182
195
 
183
- - **取消来源区分**:`已取消` 有用户(`/subagent-cancel`)、主 agent(`subagent_cancel`)、会话关闭(`session_shutdown`)三种来源;用户取消**不得自动重试**,须先询问。
184
- - **防轮询**:结果以通知自动到达;`subagent_status` 只用于确认在途(如 `/tree` 回退后),不带耗时、不鼓励频繁调用。
185
- - **防滥用取消**:`subagent_cancel` 内嵌提示词——仅当任务明显错误或不再需要时取消,勿因耗时长而取消(后台任务本就预期长时间运行)。
196
+ - **取消来源区分**:`已取消` 有用户(`/subagent-cancel`)、主 agent(`subagent` 工具 `action="cancel"`)、会话关闭(`session_shutdown`)三种来源;用户取消**不得自动重试**,须先询问。
197
+ - **防轮询**:结果以通知自动到达;在途任务信息由 `[subagent-result]` 通知信封直接提供,不要主动查询。
198
+ - **防滥用取消**:`action="cancel"` 内嵌提示词——仅当任务明显错误或不再需要时取消,勿因耗时长而取消(后台任务本就预期长时间运行)。
186
199
  - **资源冲突纪律**:并行派发多个任务前,考虑它们是否会改同一批文件或代码区域;冲突时串行派发或先问用户。
187
- - **递归委派完全禁止**:子 agent(深度 ≥ 1)不可再派发 `subagent`,深度限制为 1。
200
+ - **子 agent 不可调用 subagent 工具**:子 agent(深度 ≥ 1)不可调用任何 `subagent` action(含 `action="cancel"`),深度限制为 1。
188
201
  - **TUI 异步 / 非 TUI 同步降级**:只在 TUI 模式走异步路径;print/json 等非 TUI 模式降级为同步阻塞。
189
202
 
190
203
  ---
@@ -273,7 +286,7 @@ alias pp='pi --tools read,grep,find,ls,subagent --no-skills --append-system-prom
273
286
 
274
287
  ## 进阶用法
275
288
 
276
- 手写 `subagent` 调用、复用 `sessionId`、信封与在途任务块细节、`subagent_status` 查询、取消任务、环境变量等见 [ADVANCED.md](ADVANCED.md)。
289
+ 手写 `subagent` 调用、复用 `sessionId`、信封与在途任务块细节、`action="cancel"` 取消任务、环境变量等见 [ADVANCED.md](ADVANCED.md)。
277
290
 
278
291
  ---
279
292
 
@@ -5,6 +5,8 @@
5
5
  `examples/pi/` mirrors `~/.pi/agent/`. Copy it to your user directory so `pi` can discover these agents and skills.
6
6
 
7
7
  > Install the extension first with `pi install npm:@wolido/async-subagent-isolation`.
8
+ >
9
+ > **v1.2.0 note**: the `subagent` tool's `action="status"` has been removed as a cleanup. In-flight task information is now provided by the `[subagent-result]` notification envelope's in-flight block.
8
10
 
9
11
  ## Directory structure
10
12
 
@@ -5,6 +5,8 @@
5
5
  `examples/pi/` 是 `~/.pi/agent/` 的镜像。把它复制到用户目录后,`pi` 就能识别这些 agent 和 skill。
6
6
 
7
7
  > 使用前先用 `pi install npm:@wolido/async-subagent-isolation` 安装扩展。
8
+ >
9
+ > **v1.2.0 提示**:`subagent` 工具的 `action="status"` 已作为 cleanup 移除。在途任务信息改由 `[subagent-result]` 通知信封的“在途任务”块提供。
8
10
 
9
11
  ## 目录结构
10
12
 
@@ -16,9 +16,9 @@
16
16
 
17
17
  - `read` — 读文件
18
18
  - `grep` / `find` / `ls` — 搜索代码、浏览项目结构
19
- - `subagent` — 委派任务给子 agent
20
- - `subagent_status` 查询仍在运行的后台任务(在途任务)
21
- - `subagent_cancel` — 取消错误或不再需要的后台任务
19
+ - `subagent` — 单入口子 agent 工具,通过 `action` 参数区分操作:
20
+ - `action="dispatch"`(默认)— 委派任务给子 agent
21
+ - `action="cancel"` — 取消错误或不再需要的后台任务
22
22
  - 规划与决策 — 理解需求、拆分任务步骤、整合结果
23
23
 
24
24
  仅此而已。你无权执行写入或命令类操作。
@@ -29,21 +29,21 @@
29
29
  2. **依赖驱动派发** — 无依赖的任务可并行派出。有依赖的必须等对应 `[subagent-result]` 通知到达后再派。
30
30
  3. **派出后继续工作** — `subagent` 返回的只是派发回执(含 `taskId`),不是结果。派出后继续做不依赖该结果的工作,或结束回合。严禁轮询、严禁臆造结果。
31
31
  4. **识别系统通知** — 以 `[subagent-result]` 开头的消息是系统通知(子 agent 结果),不是用户请求。收到后关联到当初派发的任务进行处理。
32
- 5. **通知先看在途任务块** — 每条 `[subagent-result]` 通知的元信息区带"在途任务"列表(剩余仍在运行的任务,不含本任务)。收到后先看剩余在途数:**不为 0 时还有任务未返回,不要向用户汇报"全部完成"**。需要确认在途状态(如 `/tree` 回退后不确定还剩哪些任务)时,用 `subagent_status` 工具查询。
32
+ 5. **通知先看在途任务块** — 每条 `[subagent-result]` 通知的元信息区带"在途任务"列表(剩余仍在运行的任务,不含本任务)。收到后先看剩余在途数:**不为 0 时还有任务未返回,不要向用户汇报"全部完成"**。主 agent 不主动查询后台;若上下文里该任务的 [subagent-result] 通知未到达,向用户报告该 taskId 并建议用户用 /subagent-cancel 或 /subagent-result 命令查看。
33
33
  6. **已取消通知的处理** — 收到状态为"已取消"的 `[subagent-result]` 通知时,根据来源区分处理:
34
- - 正文注明用户通过 `/subagent-cancel` 取消 → 用户主动操作,**不得自动重新派发**。如需重新派发,先询问用户。
35
- - 正文注明主 agent 通过 `subagent_cancel` 取消 → 自身决策,不应在无新信息时重新派发。
36
- - 正文注明会话关闭(session_shutdown)终止 → 可在会话恢复后视情况重新派发。
34
+ - 正文注明用户通过 `/subagent-cancel` 取消 → 用户主动操作,**不得自动重新派发**。如需重新派发,先询问用户。
35
+ - 正文注明主 agent 通过 `subagent` 工具(`action="cancel"`)取消 → 自身决策,不应在无新信息时重新派发。
36
+ - 正文注明会话关闭(session_shutdown)终止 → 可在会话恢复后视情况重新派发。
37
37
 
38
- 7. **`subagent_cancel` 使用纪律** — 你可以使用 `subagent_cancel` 工具纠正错误委派(如委派了错误的 agent、任务描述有误)或取消不再需要的任务。**不要因等待时间长而取消**——后台子 agent 本就预期长时间运行。取消的判据是"这个任务不该继续",不是"等太久了"。
38
+ 7. **`action="cancel"` 使用纪律** — 你可以使用 `subagent` 工具(`action="cancel"`,参数 `taskId`)纠正错误委派(如委派了错误的 agent、任务描述有误)或取消不再需要的任务。**不要因等待时间长而取消**——后台子 agent 本就预期长时间运行。取消的判据是"这个任务不该继续",不是"等太久了"。
39
39
 
40
40
  8. **用标准任务格式** — 每次委派必须包含以下结构:
41
41
 
42
- - **背景** — 任务来源、已完成的上下文
43
- - **输入** — 相关文件路径、数据
44
- - **要求** — 明确的任务清单,可逐项检查
45
- - **输出格式** — 期望的返回结构
46
- - **验收标准** — 如何判断完成(必须含验证命令及输出)
42
+ - **背景** — 任务来源、已完成的上下文
43
+ - **输入** — 相关文件路径、数据
44
+ - **要求** — 明确的任务清单,可逐项检查
45
+ - **输出格式** — 期望的返回结构
46
+ - **验收标准** — 如何判断完成(必须含验证命令及输出)
47
47
 
48
48
  ### 调用示例
49
49
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wolido/async-subagent-isolation",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
4
4
  "description": "A pi extension that asynchronously delegates tasks to specialized subagents running in isolated pi processes.",
5
5
  "license": "MIT",
6
6
  "author": "Wolido",
package/src/index.ts CHANGED
@@ -94,7 +94,7 @@ function parseListField(value: unknown): string[] | undefined {
94
94
  return undefined;
95
95
  }
96
96
 
97
- function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig[] {
97
+ export function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig[] {
98
98
  const agents: AgentConfig[] = [];
99
99
  if (!fs.existsSync(dir)) return agents;
100
100
  let entries: fs.Dirent[];
@@ -113,8 +113,23 @@ function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig
113
113
  } catch {
114
114
  continue;
115
115
  }
116
- const { frontmatter, body } = parseFrontmatter<Record<string, unknown>>(content);
117
- if (!frontmatter.name || !frontmatter.description) continue;
116
+ let frontmatter: Record<string, unknown>;
117
+ let body: string;
118
+ try {
119
+ ({ frontmatter, body } = parseFrontmatter<Record<string, unknown>>(content));
120
+ } catch (err) {
121
+ const msg = err instanceof Error ? err.message : String(err);
122
+ console.warn(`[async-subagent-isolation] failed to parse frontmatter in ${entry.name}: ${msg.slice(0, 200)}`);
123
+ continue;
124
+ }
125
+ if (typeof frontmatter.name !== "string" || frontmatter.name.trim() === "") {
126
+ console.warn(`[async-subagent-isolation] ${entry.name}: name must be a non-empty string, skipping.`);
127
+ continue;
128
+ }
129
+ if (typeof frontmatter.description !== "string" || frontmatter.description.trim() === "") {
130
+ console.warn(`[async-subagent-isolation] ${entry.name}: description must be a string (watch YAML flow objects like {foo:bar} or null/~), skipping.`);
131
+ continue;
132
+ }
118
133
  const tools = parseListField(frontmatter.tools);
119
134
  const hasSkills = "skills" in frontmatter;
120
135
  const skills = hasSkills ? parseListField(frontmatter.skills) ?? [] : undefined;
@@ -242,7 +257,7 @@ export function loadModelOverrides(cwd: string): Record<string, ModelOverride> {
242
257
  }
243
258
  }
244
259
 
245
- function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult {
260
+ export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult {
246
261
  const userDir = path.join(getAgentDir(), "agents");
247
262
  const projectAgentsDir = findNearestProjectAgentsDir(cwd);
248
263
  const userAgents = scope === "project" ? [] : loadAgentsFromDir(userDir, "user");
@@ -877,13 +892,16 @@ function parseEnvInt(raw: string | undefined, fallback: number): number {
877
892
  return Number.isNaN(parsed) ? fallback : parsed;
878
893
  }
879
894
 
895
+ const UUID_V7_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
896
+
880
897
  /** Validate an explicit sessionId. Returns an error message, or null when valid. */
881
- function validateSessionId(sessionId: string): string | null {
898
+ function validateSessionId(sessionId: unknown): string | null {
899
+ if (typeof sessionId !== "string") return `Invalid sessionId: must be a string, got ${typeof sessionId}`;
882
900
  const trimmed = sessionId.trim();
883
901
  if (trimmed === "") return "Invalid sessionId: must not be empty";
884
902
  if (trimmed === "." || trimmed === "..") return `Invalid sessionId: "${trimmed}" is not allowed`;
885
- if (!/^[A-Za-z0-9_.-]+$/.test(trimmed))
886
- return `Invalid sessionId: "${trimmed}" contains disallowed characters. Only letters, digits, underscore, dot, and hyphen are allowed.`;
903
+ if (!UUID_V7_PATTERN.test(trimmed))
904
+ return "Invalid sessionId: expected a lowercase UUID v7 from a previous receipt. Only pass sessionId to resume (复用) an earlier taskId; omit it to generate a new one.";
887
905
  return null;
888
906
  }
889
907
 
@@ -1447,7 +1465,7 @@ export interface AsyncSubagentTask {
1447
1465
  status: "running" | "cancelled" | "killed_on_shutdown";
1448
1466
  /**
1449
1467
  * Who cancelled the task: "user" via /subagent-cancel, "agent" via the
1450
- * subagent_cancel tool. Set by cancelTask so the result envelope can name
1468
+ * subagent tool's action="cancel". Set by cancelTask so the result envelope can name
1451
1469
  * the cancel's origin; undefined for non-cancelled endings.
1452
1470
  */
1453
1471
  cancelledBy?: "user" | "agent";
@@ -1470,7 +1488,7 @@ export const taskRegistry = new Map<string, AsyncSubagentTask>();
1470
1488
  * Cancel a running async subagent task: mark it cancelled, record who
1471
1489
  * cancelled it (for the envelope), and fire its abort controller — reusing
1472
1490
  * the SIGTERM -> 5s -> SIGKILL cascade in runSingleAgent. Shared by the
1473
- * /subagent-cancel command and the subagent_cancel tool. Returns false when
1491
+ * /subagent-cancel command and the subagent tool's action="cancel". Returns false when
1474
1492
  * no running task with that id exists.
1475
1493
  */
1476
1494
  function cancelTask(taskId: string, cancelledBy: "user" | "agent"): boolean {
@@ -1489,10 +1507,10 @@ export function truncateTaskDescription(task: string, maxLen = 200): string {
1489
1507
  }
1490
1508
 
1491
1509
  /**
1492
- * Format the in-flight task list (status === "running") shared by the
1493
- * subagent_status tool, the result envelope's 在途 block, and the
1494
- * subagent_cancel receipt. Deliberately carries no elapsed time: the list
1495
- * answers "what is still running", not "how long has it run".
1510
+ * Format the in-flight task list (status === "running") shared by the result
1511
+ * envelope's 在途 block and the action="cancel" receipt. Deliberately carries
1512
+ * no elapsed time: the list answers "what is still running", not "how long has
1513
+ * it run".
1496
1514
  */
1497
1515
  export function formatActiveTasks(): string {
1498
1516
  const running = [...taskRegistry.values()].filter((t) => t.status === "running");
@@ -1543,7 +1561,7 @@ const DETAILS_OUTPUT_MAX_CHARS = 16 * 1024;
1543
1561
  */
1544
1562
  function abortedFallbackBody(stopReason?: string, cancelledBy?: "user" | "agent"): string {
1545
1563
  if (stopReason === "killed_on_shutdown") return "任务因会话关闭被终止(session_shutdown)。";
1546
- if (cancelledBy === "agent") return "该任务已由主 agent 通过 subagent_cancel 工具取消。";
1564
+ if (cancelledBy === "agent") return "该任务已由主 agent 通过 subagent 工具(action=cancel)取消。";
1547
1565
  return "该任务已由用户通过 /subagent-cancel 取消,属用户主动操作。请勿自动重新派发;如需重新派发,先询问用户。";
1548
1566
  }
1549
1567
 
@@ -1668,12 +1686,27 @@ const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, {
1668
1686
  default: "both",
1669
1687
  });
1670
1688
 
1689
+ // Union-of-literals (anyOf + const) rather than StringEnum so the emitted
1690
+ // JSON Schema enumerates each action as its own const branch.
1691
+ const SubagentActionSchema = Type.Union(
1692
+ [Type.Literal("dispatch"), Type.Literal("cancel")],
1693
+ {
1694
+ description:
1695
+ 'Action to perform. "dispatch" (default): delegate the task to a subagent. "cancel": cancel a running background task by taskId.',
1696
+ default: "dispatch",
1697
+ },
1698
+ );
1699
+
1671
1700
  const SubagentParams = Type.Object({
1672
- agent: Type.String({ description: "Name of the agent to invoke" }),
1673
- task: Type.String({ description: "Task to delegate. Must be non-empty and include background, input, requirements, output format, and acceptance criteria." }),
1701
+ action: Type.Optional(SubagentActionSchema),
1702
+ agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (required for action=dispatch)" })),
1703
+ task: Type.Optional(Type.String({ description: "Task to delegate (required for action=dispatch). Must be non-empty and include background, input, requirements, output format, and acceptance criteria." })),
1704
+ taskId: Type.Optional(Type.String({
1705
+ description: "taskId of the running background subagent task to cancel (required for action=cancel; from the dispatch receipt).",
1706
+ })),
1674
1707
  sessionId: Type.Optional(Type.String({
1675
- pattern: "^[A-Za-z0-9_.-]+$",
1676
- description: "Optional session ID to reuse; a new UUID v7 is generated if omitted. Allowed characters: letters, digits, underscore, dot, and hyphen.",
1708
+ pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
1709
+ description: "仅用于复用此前 dispatch 回执返回的 UUID v7;省略则自动生成",
1677
1710
  })),
1678
1711
  agentScope: Type.Optional(AgentScopeSchema),
1679
1712
  confirmProjectAgents: Type.Optional(
@@ -1689,17 +1722,25 @@ export default function (pi: ExtensionAPI) {
1689
1722
  description: [
1690
1723
  "Delegate a task to a specialized subagent with isolated context.",
1691
1724
  "",
1725
+ "ACTIONS (action parameter, default \"dispatch\"):",
1726
+ "- dispatch: delegate the task (async in TUI mode, blocking otherwise).",
1727
+ "- cancel: cancel a running background task by taskId.",
1728
+ "- sessionId: only set when resuming (复用) a previously dispatched task. Must be the UUID v7 from a previous dispatch receipt. Omit otherwise; a new UUID v7 is generated automatically.",
1729
+ "",
1692
1730
  "ASYNC (TUI mode): returns immediately with a dispatch receipt (taskId + session id).",
1693
1731
  "The result arrives later as a system notification message prefixed with",
1694
1732
  "[subagent-result] — that is a system notification, NOT a user request.",
1695
1733
  "- Do NOT treat the receipt as the result. Do NOT fabricate results.",
1696
- "- Do NOT poll for results; they arrive automatically. To confirm which",
1697
- " tasks are still in flight (e.g. after a /tree rewind), use the",
1698
- " subagent_status tool.",
1734
+ "- Do NOT poll for results; they arrive automatically.",
1699
1735
  "- Continue with independent work, or end the turn. Process the result when",
1700
1736
  " the [subagent-result] notification arrives. Reuse the session id from the",
1701
1737
  " receipt to continue the same task later.",
1702
1738
  "",
1739
+ "CANCEL DISCIPLINE: cancel a task (action=\"cancel\") only when it is clearly",
1740
+ "wrong (错误) or no longer needed (不再需要). Do NOT cancel just because it is",
1741
+ "taking a long time — background subagents are expected to run long; be patient",
1742
+ "(耐心等待) and let the [subagent-result] notification arrive.",
1743
+ "",
1703
1744
  "SYNC (non-TUI modes): waits for the subagent to finish and returns the full",
1704
1745
  "result directly (no notification follows).",
1705
1746
  "",
@@ -1709,29 +1750,71 @@ export default function (pi: ExtensionAPI) {
1709
1750
  promptSnippet:
1710
1751
  "Delegate a task to a specialized subagent in an isolated process (async dispatch in TUI mode, blocking otherwise).",
1711
1752
  promptGuidelines: [
1712
- "subagent: In TUI mode this tool is asynchronous — it returns a dispatch receipt, not the result; the real result arrives later as a [subagent-result] system notification, so never fabricate results and never poll for status.",
1753
+ "subagent: In TUI mode this tool is asynchronous — it returns a dispatch receipt, not the result; the real result arrives later as a [subagent-result] system notification, so never fabricate results and never poll.",
1713
1754
  "subagent: A message prefixed with [subagent-result] is a system notification carrying a finished subagent result, not a user request; process it in the context of the task that dispatched it.",
1714
1755
  "subagent: Dispatch subagents driven by task dependencies — delegate only work whose result you actually need, prefer reusing the session id from the receipt to continue a previous subagent task, and keep independent work in the main context.",
1715
- "subagent: A [subagent-result] notification with status 已取消 (cancelled) can come from the user (/subagent-cancel) or from you (subagent_cancel); the envelope body states the source. A user-initiated cancel is a deliberate user action, so do NOT automatically retry or re-dispatch it; ask the user before re-dispatching.",
1756
+ "subagent: The session id is the lowercase UUID v7 returned in the dispatch receipt (e.g. `019ffdd3-3eb5-733d-b481-a53e5292bd00`). Passing any other string (slug, UUID v4, etc.) is rejected; only pass sessionId when resuming a previously dispatched task.",
1757
+ "subagent: A [subagent-result] notification with status 已取消 (cancelled) can come from the user (/subagent-cancel) or from you (action=\"cancel\"); the envelope body states the source. A user-initiated cancel is a deliberate user action, so do NOT automatically retry or re-dispatch it; ask the user before re-dispatching.",
1716
1758
  "subagent: Before dispatching multiple tasks in parallel, consider whether they touch the same files or code areas — parallel tasks modifying the same files can conflict. When in doubt, dispatch sequentially or ask the user.",
1717
1759
  ],
1718
1760
  parameters: SubagentParams,
1719
1761
 
1720
1762
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
1721
- const currentDepth = parseEnvInt(process.env.PI_SUBAGENT_DEPTH, 0);
1763
+ // Single-entry action dispatch (default "dispatch").
1764
+ const action = (params.action as string | undefined) ?? "dispatch";
1722
1765
 
1723
- // Recursive delegation is blocked entirely: a subagent (depth >= 1)
1724
- // can never spawn another subagent.
1766
+ // Depth gate runs BEFORE any action dispatch: a subagent (depth >= 1)
1767
+ // is blocked from every action — dispatch spawns a nested subagent, and
1768
+ // cancel would let it kill the parent's in-flight tasks. The tool surface
1769
+ // simply does not exist inside a subagent.
1770
+ const currentDepth = parseEnvInt(process.env.PI_SUBAGENT_DEPTH, 0);
1725
1771
  if (currentDepth >= MAX_SUBAGENT_DEPTH) {
1726
1772
  const agentName = process.env.PI_CURRENT_AGENT_NAME || "current agent";
1727
1773
  return {
1728
1774
  content: [{
1729
1775
  type: "text",
1730
- text: `Subagent delegation is blocked: depth limit reached (depth: ${currentDepth}, max: ${MAX_SUBAGENT_DEPTH}). Agent \`${agentName}\` runs inside a subagent and recursive delegation is not allowed.`,
1776
+ text: `Subagent tool is blocked: depth limit reached (depth: ${currentDepth}, max: ${MAX_SUBAGENT_DEPTH}). Agent \`${agentName}\` runs inside a subagent and cannot invoke subagent actions (dispatch/cancel).`,
1731
1777
  }],
1732
1778
  details: {
1733
1779
  mode: "single",
1734
- agentScope: params.agentScope ?? "both",
1780
+ agentScope: (params.agentScope ?? "both") as AgentScope,
1781
+ projectAgentsDir: null,
1782
+ results: [],
1783
+ } as SubagentDetails,
1784
+ isError: true,
1785
+ };
1786
+ }
1787
+
1788
+ if (action === "cancel") {
1789
+ const taskId = typeof params.taskId === "string" ? params.taskId.trim() : "";
1790
+ if (!taskId) {
1791
+ return {
1792
+ content: [{ type: "text", text: 'Missing or empty required parameter: "taskId" (taskId 必填,不能为空).' }],
1793
+ details: { taskId: "", cancelled: false },
1794
+ isError: true,
1795
+ };
1796
+ }
1797
+ // Only registry (async/TUI) tasks are cancellable; sync-mode tasks are
1798
+ // awaited inline and never enter the registry.
1799
+ if (!cancelTask(taskId, "agent")) {
1800
+ return {
1801
+ content: [{ type: "text", text: `无此运行中任务: ${taskId} (no running subagent task with this id).` }],
1802
+ details: { taskId, cancelled: false },
1803
+ isError: true,
1804
+ };
1805
+ }
1806
+ return {
1807
+ content: [{ type: "text", text: `已发送取消请求: ${taskId} (cancel request sent); 结果稍后以 [subagent-result] 通知返回。\n${formatActiveTasks()}` }],
1808
+ details: { taskId, cancelled: true },
1809
+ };
1810
+ }
1811
+
1812
+ if (action !== "dispatch") {
1813
+ return {
1814
+ content: [{ type: "text", text: `Invalid action: "${action}". Must be one of "dispatch" (default), "cancel".` }],
1815
+ details: {
1816
+ mode: "single",
1817
+ agentScope: (params.agentScope ?? "both") as AgentScope,
1735
1818
  projectAgentsDir: null,
1736
1819
  results: [],
1737
1820
  } as SubagentDetails,
@@ -1849,7 +1932,7 @@ export default function (pi: ExtensionAPI) {
1849
1932
  };
1850
1933
  }
1851
1934
 
1852
- progressManager.register(ctx, effectiveSessionId, params.agent);
1935
+ progressManager.register(ctx, effectiveSessionId, agentName);
1853
1936
 
1854
1937
  // TUI mode: dispatch asynchronously. execute() returns a receipt
1855
1938
  // immediately; the finished result is pushed later as a
@@ -1858,7 +1941,7 @@ export default function (pi: ExtensionAPI) {
1858
1941
  if (ctx.mode === "tui") {
1859
1942
  const taskRecord: AsyncSubagentTask = {
1860
1943
  taskId: effectiveSessionId,
1861
- agentName: params.agent,
1944
+ agentName,
1862
1945
  task,
1863
1946
  startedAt: Date.now(),
1864
1947
  // Per-task controller: the turn-level `signal` fires when the
@@ -1871,7 +1954,7 @@ export default function (pi: ExtensionAPI) {
1871
1954
  runSingleAgent(
1872
1955
  ctx.cwd,
1873
1956
  agents,
1874
- params.agent,
1957
+ agentName,
1875
1958
  task,
1876
1959
  params.cwd,
1877
1960
  undefined,
@@ -1901,7 +1984,7 @@ export default function (pi: ExtensionAPI) {
1901
1984
  (err) => completeAsyncTask(pi, taskRecord, null, err),
1902
1985
  );
1903
1986
  return {
1904
- content: [{ type: "text", text: buildDispatchReceipt(params.agent, effectiveSessionId) }],
1987
+ content: [{ type: "text", text: buildDispatchReceipt(agentName, effectiveSessionId) }],
1905
1988
  details: makeDetails([]),
1906
1989
  };
1907
1990
  }
@@ -1910,7 +1993,7 @@ export default function (pi: ExtensionAPI) {
1910
1993
  const result = await runSingleAgent(
1911
1994
  ctx.cwd,
1912
1995
  agents,
1913
- params.agent,
1996
+ agentName,
1914
1997
  task,
1915
1998
  params.cwd,
1916
1999
  undefined,
@@ -1959,8 +2042,11 @@ export default function (pi: ExtensionAPI) {
1959
2042
 
1960
2043
  renderResult(result, { expanded }, theme, context) {
1961
2044
  const details = result.details as SubagentDetails | undefined;
1962
- if (!details || details.results.length === 0) {
1963
- return new Text(result.content[0]?.type === "text" ? result.content[0].text : "(no output)", 0, 0);
2045
+ // cancel receipts carry no `results` array (taskId+cancelled instead)
2046
+ // fall back to the plain-text content instead of throwing on
2047
+ // details.results.length.
2048
+ if (!details || !Array.isArray(details.results) || details.results.length === 0) {
2049
+ return new Text(result.content?.[0]?.type === "text" ? result.content[0].text : "(no output)", 0, 0);
1964
2050
  }
1965
2051
 
1966
2052
  const mdTheme = getMarkdownTheme();
@@ -2057,80 +2143,6 @@ export default function (pi: ExtensionAPI) {
2057
2143
  }
2058
2144
  });
2059
2145
 
2060
- // Read-only in-flight query. Results still arrive automatically as
2061
- // [subagent-result] notifications; this tool exists only to confirm which
2062
- // tasks are still running (e.g. after a /tree rewind wiped the receipts).
2063
- pi.registerTool({
2064
- name: "subagent_status",
2065
- label: "Subagent Status",
2066
- description: [
2067
- "List currently running background subagent tasks (在途任务: taskId、agent、任务描述).",
2068
- "Results arrive automatically as [subagent-result] notifications — do NOT use",
2069
- "this tool to poll for completion. Use it only to confirm which tasks are still",
2070
- "in flight (e.g. after a /tree rewind), or to pick a taskId for subagent_cancel.",
2071
- ].join("\n"),
2072
- promptSnippet:
2073
- "List in-flight background subagent tasks (not for polling — results arrive as [subagent-result] notifications).",
2074
- parameters: Type.Object({}),
2075
-
2076
- async execute() {
2077
- return {
2078
- content: [{ type: "text", text: formatActiveTasks() }],
2079
- details: { activeTasks: [...taskRegistry.values()].filter((t) => t.status === "running").map((t) => t.taskId) },
2080
- };
2081
- },
2082
- });
2083
-
2084
- // Cancellation has two paths sharing cancelTask (which reuses the SIGTERM
2085
- // -> 5s -> SIGKILL abort cascade and records who cancelled on the task so
2086
- // the result envelope can name the origin): the main agent's subagent_cancel
2087
- // tool below, and the user's /subagent-cancel command.
2088
- pi.registerTool({
2089
- name: "subagent_cancel",
2090
- label: "Subagent Cancel",
2091
- description: [
2092
- "Cancel a running background subagent task (取消一个仍在运行的后台 subagent 任务) by taskId.",
2093
- "Use only when the task is clearly wrong (错误) or no longer needed (不再需要).",
2094
- "Do NOT cancel just because it is taking a long time — background subagents are",
2095
- "expected to run long; be patient (耐心等待) and let the [subagent-result]",
2096
- "notification arrive.",
2097
- ].join("\n"),
2098
- promptSnippet:
2099
- "Cancel a running background subagent task by taskId (only when it is clearly wrong or no longer needed).",
2100
- promptGuidelines: [
2101
- "subagent_cancel: Cancel a background subagent task only when it is clearly wrong or no longer needed — never cancel merely because it is taking long; the cancellation arrives later as a [subagent-result] notification with status 已取消 (cancelled).",
2102
- ],
2103
- parameters: Type.Object({
2104
- taskId: Type.String({
2105
- description: "taskId of the running background subagent task to cancel (from the subagent dispatch receipt).",
2106
- }),
2107
- }),
2108
-
2109
- async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
2110
- const taskId = typeof params.taskId === "string" ? params.taskId.trim() : "";
2111
- if (!taskId) {
2112
- return {
2113
- content: [{ type: "text", text: 'Missing or empty required parameter: "taskId" (taskId 必填,不能为空).' }],
2114
- details: { taskId: "", cancelled: false },
2115
- isError: true,
2116
- };
2117
- }
2118
- // Only registry (async/TUI) tasks are cancellable; sync-mode tasks are
2119
- // awaited inline and never enter the registry.
2120
- if (!cancelTask(taskId, "agent")) {
2121
- return {
2122
- content: [{ type: "text", text: `无此运行中任务: ${taskId} (no running subagent task with this id).` }],
2123
- details: { taskId, cancelled: false },
2124
- isError: true,
2125
- };
2126
- }
2127
- return {
2128
- content: [{ type: "text", text: `已发送取消请求: ${taskId} (cancel request sent); 结果稍后以 [subagent-result] 通知返回。\n${formatActiveTasks()}` }],
2129
- details: { taskId, cancelled: true },
2130
- };
2131
- },
2132
- });
2133
-
2134
2146
  // /subagent-cancel <taskId> is the user's cancel path.
2135
2147
  // (Optional-call guards keep minimal mock `pi` objects in tests working.)
2136
2148
  pi.registerCommand?.("subagent-cancel", {