@wolido/async-subagent-isolation 1.1.0 → 1.3.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` tool with `action="status"`.
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
 
@@ -137,6 +139,8 @@ Once the subagent finishes, its result is pushed into the conversation:
137
139
 
138
140
  Status enumeration: **成功** (success, exit=0) / **失败** (failure, exit≠0 or stopReason=error) / **超时** (timeout, activity_timeout or hard_timeout) / **已取消** (cancelled, aborted or killed_on_shutdown).
139
141
 
142
+ **Duration**: the `- 耗时:` line shows the subagent's real run time. When a result exists, it is the actual process run time (`finishedAt - startedAt`); when the result is null (user/agent cancel, session shutdown, internal error), it is measured from dispatch time instead. The format is `MM:SS`, or `H:MM:SS` at one hour and beyond (hours not zero-padded). All four terminal states (success, failure, timeout, cancelled) carry the duration in both the envelope and the TUI notification card.
143
+
140
144
  "Cancelled" has three sub-cases with different envelope bodies:
141
145
  - 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
146
  - 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).
@@ -144,9 +148,9 @@ Status enumeration: **成功** (success, exit=0) / **失败** (failure, exit≠0
144
148
 
145
149
  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
150
 
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` tool's `action="status"` — `在途任务: 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.
151
+ **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
152
 
149
- 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.
153
+ The full output enters the LLM context (not truncated). The `details` carries structured data (taskId, agent, status, exitCode, stopReason, durationMs (required, run time in milliseconds), usage, sessionId, full output) for programmatic consumption; it does not enter the LLM context.
150
154
 
151
155
  ### Notification delivery
152
156
 
@@ -164,26 +168,9 @@ While subagents run, a progress widget appears above the TUI editor, listing all
164
168
  ● 01912345-abcd... coder ⚡ read... 01:23
165
169
  ```
166
170
 
167
- The taskId in the widget row can be copied for `/subagent-result` (view full result) or `/subagent-cancel` (cancel the task).
168
-
169
- ### `action="status"` (in-flight query)
171
+ The widget's time is a live "alive since" clock (`formatElapsed`, `MM:SS` only, overflowing past 99 minutes); the envelope and notification card show the final run duration (`formatDuration`). The two coexist with different semantics.
170
172
 
171
- The main agent can actively query still-running background tasks via the `subagent` tool with `action="status"` (no agent/task parameters needed), 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 `action="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.
173
+ The taskId in the widget row can be copied for `/subagent-result` (view full result) or `/subagent-cancel` (cancel the task).
187
174
 
188
175
  ### Cancelling background tasks
189
176
 
@@ -209,7 +196,7 @@ Takes no arguments. Unlike `/subagent-cancel`, which cancels a single task by ta
209
196
 
210
197
  **Path 2: Main agent `subagent` tool with `action="cancel"`**
211
198
 
212
- 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 `action="status"`); the cancelled task's final result arrives later as a `[subagent-result]` notification.
199
+ 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
200
 
214
201
  **Usage discipline:** The main agent should only use `action="cancel"` when:
215
202
  - The task is clearly wrong (wrong agent, incorrect task description, etc.).
@@ -292,7 +279,7 @@ These variables are propagated into every subagent process automatically:
292
279
 
293
280
  | Variable | Default | Description |
294
281
  |----------|---------|-------------|
295
- | `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="status"` / `action="cancel"`). |
282
+ | `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
283
  | `PI_CURRENT_AGENT_NAME` | — | Name of the current agent, injected into every subagent process. |
297
284
  | `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
285
  | `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` 工具(`action="status"`)查询。
119
+ - **不要轮询。** 结果自动以 `[subagent-result]` 通知到达;在途任务信息由通知信封的“在途任务”块直接提供,`action="status"` 已在 v1.2.0 清理移除。
118
120
 
119
121
  ### [subagent-result] 信封格式
120
122
 
@@ -137,6 +139,8 @@ TUI 模式下 `subagent` 立即返回如下回执(不是结果!):
137
139
 
138
140
  状态枚举:**成功**(exit=0)/ **失败**(exit≠0 或 stopReason=error)/ **超时**(activity_timeout 或 hard_timeout)/ **已取消**(aborted 或 killed_on_shutdown)。
139
141
 
142
+ **耗时**:`- 耗时:` 行是子 agent 的真实运行时长。有结果时取进程实际启动到结束(`finishedAt - startedAt`);取消(用户/agent/会话关闭)或内部错误导致无结果返回时,改从派发时刻起算。格式为 `MM:SS`,≥1 小时为 `H:MM:SS`(小时不补零)。四种状态(成功/失败/超时/已取消)的信封与 TUI 通知卡片都带耗时。
143
+
140
144
  "已取消"分三种情况,信封正文不同:
141
145
  - 用户通过 `/subagent-cancel` 取消(cancelledBy: user)→ 正文注明"属用户主动操作。请勿自动重新派发;如需重新派发,先询问用户。"
142
146
  - 主 agent 通过 `subagent` 工具(`action="cancel"`)取消(cancelledBy: agent)→ 正文注明"该任务已由主 agent 通过 subagent 工具(action=cancel)取消。"
@@ -144,9 +148,9 @@ TUI 模式下 `subagent` 立即返回如下回执(不是结果!):
144
148
 
145
149
  主 agent 收到状态为"已取消"的通知时,应区分来源:用户主动取消**不得自动重试**,必须先询问用户;agent 取消是自身决策,不应在无新信息时重新派发;会话关闭终止可在会话恢复后视情况重新派发。
146
150
 
147
- **在途任务块**:信封元信息区的"在途任务"列表列出**其余**仍在运行的后台任务(本任务在构建信封前已从注册表移除,故不包含自身),格式与 `subagent` 工具 `action="status"` 一致——`在途任务: N` 加每行 `- taskId (agent名): 任务描述`,无在途任务时为"当前无在途任务。"。列表**不含耗时**(回答"还有什么在跑",而非"跑了多久")。主 agent 据此知道还有几个任务没回来:剩余不为 0 时,不要向用户汇报"全部完成"。
151
+ **在途任务块**:信封元信息区的"在途任务"列表列出**其余**仍在运行的后台任务(本任务在构建信封前已从注册表移除,故不包含自身),格式为 `在途任务: N` 加每行 `- taskId (agent名): 任务描述`,无在途任务时为"当前无在途任务。"列表**不含耗时**(回答"还有什么在跑",而非"跑了多久")。主 agent 据此知道还有几个任务没回来:剩余不为 0 时,不要向用户汇报"全部完成"。
148
152
 
149
- 结果全量进入 LLM 上下文(不截断)。`details` 携带结构化数据(taskId、agent、status、exitCode、stopReason、usage、sessionId、完整输出),不参与 LLM 上下文,供程序消费。
153
+ 结果全量进入 LLM 上下文(不截断)。`details` 携带结构化数据(taskId、agent、status、exitCode、stopReason、durationMs(耗时毫秒数,必填)、usage、sessionId、完整输出),不参与 LLM 上下文,供程序消费。
150
154
 
151
155
  ### 通知投递
152
156
 
@@ -164,26 +168,9 @@ TUI 模式下 `subagent` 立即返回如下回执(不是结果!):
164
168
  ● 01912345-abcd... coder ⚡ read... 01:23
165
169
  ```
166
170
 
167
- widget 行中的 taskId 可直接复制,用于 `/subagent-result` 查看结果或 `/subagent-cancel` 取消任务。
168
-
169
- ### `action="status"`(在途任务查询)
171
+ widget 行的耗时是"存活至今"的实时时钟(`formatElapsed`,仅 `MM:SS`,可溢出 99 分钟);信封与通知卡片的耗时是终态运行时长(`formatDuration`)。两者并存,语义不同。
170
172
 
171
- agent 可主动查询仍在运行的后台任务,调用 `subagent` 工具(`action="status"`,无需 agent/task 参数),返回:
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 用于 `action="cancel"`。
185
-
186
- **不要用它轮询完成状态**:结果会自动以 `[subagent-result]` 通知到达,本工具只用于确认"还有什么在跑",不应频繁调用。
173
+ widget 行中的 taskId 可直接复制,用于 `/subagent-result` 查看结果或 `/subagent-cancel` 取消任务。
187
174
 
188
175
  ### 取消后台任务
189
176
 
@@ -209,7 +196,7 @@ widget 行中的 taskId 可直接复制,用于 `/subagent-result` 查看结果
209
196
 
210
197
  **路径二:主 agent `subagent` 工具(`action="cancel"`)**
211
198
 
212
- 主 agent 可调用 `subagent` 工具(`action="cancel"`,参数 `taskId`)取消已派出的后台任务。取消来源标记为 `cancelledBy: "agent"`。取消成功后返回剩余在途任务列表(格式与 `action="status"` 一致),被取消任务的最终结果稍后以 `[subagent-result]` 通知返回。
199
+ 主 agent 可调用 `subagent` 工具(`action="cancel"`,参数 `taskId`)取消已派出的后台任务。取消来源标记为 `cancelledBy: "agent"`。取消成功后返回剩余在途任务列表(格式与信封的“在途任务”块一致),被取消任务的最终结果稍后以 `[subagent-result]` 通知返回。
213
200
 
214
201
  **使用纪律:** 主 agent 仅在以下情况使用 `action="cancel"`:
215
202
  - 任务明显错误(委派了错误的 agent、任务描述有误等)。
@@ -292,7 +279,7 @@ widget 行中的 taskId 可直接复制,用于 `/subagent-result` 查看结果
292
279
 
293
280
  | 变量 | 默认值 | 说明 |
294
281
  |------|--------|------|
295
- | `PI_SUBAGENT_DEPTH` | `0` | 当前递归深度。每次嵌套调用自动递增。**深度限制为 1**——子 agent(depth ≥ 1)不可调用任何 `subagent` action(含 `action="status"` / `action="cancel"`)。 |
282
+ | `PI_SUBAGENT_DEPTH` | `0` | 当前递归深度。每次嵌套调用自动递增。**深度限制为 1**——子 agent(depth ≥ 1)不可调用任何 `subagent` action(含 `action="cancel"`)。 |
296
283
  | `PI_CURRENT_AGENT_NAME` | — | 当前 agent 名称,注入每个子 agent 进程。 |
297
284
  | `PI_SUBAGENT_ACTIVITY_TIMEOUT_MS` | `600000`(10 分钟) | stdout 和 stderr 均无输出(无活动)时的最大允许时间。 |
298
285
  | `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 the `subagent` tool with `action="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` tool with `action="status"`.
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
 
@@ -134,9 +146,10 @@ User runs /subagent-result <taskId> to read the full output
134
146
  | Tool | Purpose | Key constraint |
135
147
  |------|---------|----------------|
136
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 |
137
- | `subagent` `action="status"` | List in-flight tasks (taskId, agent, description, **no elapsed time**) | Only confirm "what's still running"; never poll with it |
138
149
  | `subagent` `action="cancel"` | Main agent cancels one in-flight task | Only when clearly wrong or no longer needed; never for being slow |
139
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.
152
+
140
153
  ### Commands (for the user)
141
154
 
142
155
  | Command | Purpose |
@@ -167,10 +180,11 @@ The `[subagent-result]` notification is **self-contained** — it carries everyt
167
180
  ```
168
181
 
169
182
  - **Status**: `成功` (success) / `失败` (failure) / `超时` (timeout) / `已取消` (cancelled).
183
+ - **Duration**: the subagent's real run time (process start to finish; `MM:SS`, or `H:MM:SS` at 1h+), shown for all four states. For cancellations or internal errors with no result, it is measured from dispatch time.
170
184
  - **In-flight block**: lists the other background tasks still running (not itself), so the main agent knows how many are outstanding — while the count is non-zero, do not report "all done" to the user.
171
185
  - **Full result**: the body enters the LLM context in full, untruncated.
172
186
 
173
- In the TUI, the user sees a **tinted summary card**, not the full result: success green (✓), failure red (✗), timeout/cancelled yellow. The card shows the agent, status, taskId, and usage summary, plus the hint `查看全文: /subagent-result <taskId>`; the full text lives in the task's session file.
187
+ In the TUI, the user sees a **tinted summary card**, not the full result: success green (✓), failure red (✗), timeout/cancelled yellow. The card shows the agent, status, taskId, duration, and usage summary (duration included for all four states), plus the hint `查看全文: /subagent-result <taskId>`; the full text lives in the task's session file.
174
188
 
175
189
  See [ADVANCED.en.md](ADVANCED.en.md) for the complete envelope format, status semantics, and cancel-origin distinctions.
176
190
 
@@ -181,10 +195,10 @@ See [ADVANCED.en.md](ADVANCED.en.md) for the complete envelope format, status se
181
195
  Async mode introduces a few rules, baked into the tool prompts and implementation, that the main agent follows automatically:
182
196
 
183
197
  - **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.
184
- - **No polling**: results arrive automatically as notifications. `action="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.
198
+ - **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.
185
199
  - **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
200
  - **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
- - **Subagents cannot call the subagent tool**: a subagent (depth ≥ 1) can never call any `subagent` action (including `action="status"` / `action="cancel"`); delegation depth is capped at 1.
201
+ - **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
202
  - **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
203
 
190
204
  ---
@@ -273,7 +287,7 @@ Put it in `~/.pi/agent/subagent-isolation.json` (user-level) or `.pi/subagent-is
273
287
 
274
288
  ## Advanced usage
275
289
 
276
- Manual `subagent` calls, `sessionId` reuse, envelope and in-flight block details, `action="status"` queries, `action="cancel"` cancellation, and environment variables are covered in [ADVANCED.en.md](ADVANCED.en.md).
290
+ 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
291
 
278
292
  ---
279
293
 
package/README.md CHANGED
@@ -47,7 +47,7 @@
47
47
 
48
48
  更关键的是**派发之后的自由**。任务在后台跑的时候,你可以继续和主 agent 聊天:细化需求、调整规划、商量下一步,或提出新任务。主 agent 不必干等,可以继续规划,甚至并行派发更多任务。前台对话与后台工作并行推进。
49
49
 
50
- 最后是**结果回来再验收**。子 agent 完成,通知到达,主 agent 处理并向你汇报。等待期间你可以随时查看在途状态(进度 widget 或 `subagent` 工具 `action="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` 工具(`action="status"`)查询。
119
+ 结果自动到达,**无需轮询**。在途任务信息由 `[subagent-result]` 通知信封的“在途任务”块直接提供;`action="status"` 已在 v1.2.0 清理移除。
108
120
 
109
121
  ### 5. 查看全文(`/subagent-result`)
110
122
 
@@ -134,9 +146,10 @@ TUI 模式下 `subagent` **立即返回派发回执**,不阻塞:
134
146
  | 工具 | 作用 | 关键约束 |
135
147
  |------|------|----------|
136
148
  | `subagent` | 单入口工具(`action` 参数);`action="dispatch"`(默认)异步派发任务(TUI 模式),非 TUI 自动降级同步 | 回执≠结果;结果以通知到达,勿轮询 |
137
- | `subagent` `action="status"` | 查询在途任务(taskId、agent、任务描述,**无耗时**) | 仅确认"还有什么在跑",勿用它轮询完成 |
138
149
  | `subagent` `action="cancel"` | 主 agent 取消单个在途任务 | 仅当任务明显错误或不再需要,勿因耗时久而取消 |
139
150
 
151
+ > **v1.2.0 提示**:`subagent` 工具的 `action="status"` 已作为 cleanup 移除。在途任务信息改由 `[subagent-result]` 通知信封的“在途任务”块提供,不再提供主动查询入口。
152
+
140
153
  ### 命令(用户使用)
141
154
 
142
155
  | 命令 | 作用 |
@@ -167,10 +180,11 @@ TUI 模式下 `subagent` **立即返回派发回执**,不阻塞:
167
180
  ```
168
181
 
169
182
  - **状态**:`成功` / `失败` / `超时` / `已取消`。
183
+ - **耗时**:子 agent 的真实运行时长(进程实际启动到结束,格式 `MM:SS`,≥1 小时为 `H:MM:SS`),成功/失败/超时/已取消四种状态都有;取消或内部错误(无结果返回)时从派发时刻起算。
170
184
  - **在途任务块**:列出其余仍在运行的后台任务(不含自身),让主 agent 知道还有几个任务没回来——剩余不为 0 时,不要向用户汇报"全部完成"。
171
185
  - **完整结果**:正文全量进入 LLM 上下文,不截断。
172
186
 
173
- 用户在 TUI 中看到的是**带底色的摘要卡片**,不是全文:成功绿色(✓)、失败红色(✗)、超时/已取消黄色。卡片只显示 agent、状态、taskId 和用量摘要,并提示 `查看全文: /subagent-result <taskId>`;完整结果保存在任务会话文件中。
187
+ 用户在 TUI 中看到的是**带底色的摘要卡片**,不是全文:成功绿色(✓)、失败红色(✗)、超时/已取消黄色。卡片显示 agent、状态、taskId、耗时和用量摘要(四种状态均带耗时),并提示 `查看全文: /subagent-result <taskId>`;完整结果保存在任务会话文件中。
174
188
 
175
189
  信封完整格式、状态语义与取消来源区分见 [ADVANCED.md](ADVANCED.md)。
176
190
 
@@ -181,10 +195,10 @@ TUI 模式下 `subagent` **立即返回派发回执**,不阻塞:
181
195
  异步模式引入的几条纪律,内嵌在工具提示词和实现中,主 agent 自动遵守:
182
196
 
183
197
  - **取消来源区分**:`已取消` 有用户(`/subagent-cancel`)、主 agent(`subagent` 工具 `action="cancel"`)、会话关闭(`session_shutdown`)三种来源;用户取消**不得自动重试**,须先询问。
184
- - **防轮询**:结果以通知自动到达;`action="status"` 只用于确认在途(如 `/tree` 回退后),不带耗时、不鼓励频繁调用。
198
+ - **防轮询**:结果以通知自动到达;在途任务信息由 `[subagent-result]` 通知信封直接提供,不要主动查询。
185
199
  - **防滥用取消**:`action="cancel"` 内嵌提示词——仅当任务明显错误或不再需要时取消,勿因耗时长而取消(后台任务本就预期长时间运行)。
186
200
  - **资源冲突纪律**:并行派发多个任务前,考虑它们是否会改同一批文件或代码区域;冲突时串行派发或先问用户。
187
- - **子 agent 不可调用 subagent 工具**:子 agent(深度 ≥ 1)不可调用任何 `subagent` action(含 `action="status"` / `action="cancel"`),深度限制为 1。
201
+ - **子 agent 不可调用 subagent 工具**:子 agent(深度 ≥ 1)不可调用任何 `subagent` action(含 `action="cancel"`),深度限制为 1。
188
202
  - **TUI 异步 / 非 TUI 同步降级**:只在 TUI 模式走异步路径;print/json 等非 TUI 模式降级为同步阻塞。
189
203
 
190
204
  ---
@@ -273,7 +287,7 @@ alias pp='pi --tools read,grep,find,ls,subagent --no-skills --append-system-prom
273
287
 
274
288
  ## 进阶用法
275
289
 
276
- 手写 `subagent` 调用、复用 `sessionId`、信封与在途任务块细节、`action="status"` 查询、`action="cancel"` 取消任务、环境变量等见 [ADVANCED.md](ADVANCED.md)。
290
+ 手写 `subagent` 调用、复用 `sessionId`、信封与在途任务块细节、`action="cancel"` 取消任务、环境变量等见 [ADVANCED.md](ADVANCED.md)。
277
291
 
278
292
  ---
279
293
 
@@ -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
 
@@ -1,6 +1,6 @@
1
- # 主 Agent
1
+ # 主 Agent(任务指挥官)
2
2
 
3
- 你是项目的主 agent。你的职责是理解需求、制定计划、委派执行。你绝不亲自编辑代码、不执行命令、不写文件。
3
+ 你是项目的任务指挥官:团队领导与质量守门员。你的职责是理解需求、制定计划、委派执行、审查产出,对最终交付物的质量负全责。你绝不亲自编辑代码、不执行命令、不写文件;所有执行都委派给子 agent,不达标的结果不交付给用户。
4
4
 
5
5
  ## 可用子 Agent
6
6
 
@@ -18,7 +18,6 @@
18
18
  - `grep` / `find` / `ls` — 搜索代码、浏览项目结构
19
19
  - `subagent` — 单入口子 agent 工具,通过 `action` 参数区分操作:
20
20
  - `action="dispatch"`(默认)— 委派任务给子 agent
21
- - `action="status"` — 查询仍在运行的后台任务(在途任务)
22
21
  - `action="cancel"` — 取消错误或不再需要的后台任务
23
22
  - 规划与决策 — 理解需求、拆分任务步骤、整合结果
24
23
 
@@ -30,7 +29,7 @@
30
29
  2. **依赖驱动派发** — 无依赖的任务可并行派出。有依赖的必须等对应 `[subagent-result]` 通知到达后再派。
31
30
  3. **派出后继续工作** — `subagent` 返回的只是派发回执(含 `taskId`),不是结果。派出后继续做不依赖该结果的工作,或结束回合。严禁轮询、严禁臆造结果。
32
31
  4. **识别系统通知** — 以 `[subagent-result]` 开头的消息是系统通知(子 agent 结果),不是用户请求。收到后关联到当初派发的任务进行处理。
33
- 5. **通知先看在途任务块** — 每条 `[subagent-result]` 通知的元信息区带"在途任务"列表(剩余仍在运行的任务,不含本任务)。收到后先看剩余在途数:**不为 0 时还有任务未返回,不要向用户汇报"全部完成"**。需要确认在途状态(如 `/tree` 回退后不确定还剩哪些任务)时,用 `subagent` 工具(`action="status"`)查询。
32
+ 5. **通知先看在途任务块** — 每条 `[subagent-result]` 通知的元信息区带"在途任务"列表(剩余仍在运行的任务,不含本任务)。收到后先看剩余在途数:**不为 0 时还有任务未返回,不要向用户汇报"全部完成"**。主 agent 不主动查询后台;若上下文里该任务的 [subagent-result] 通知未到达,向用户报告该 taskId 并建议用户用 /subagent-cancel 或 /subagent-result 命令查看。
34
33
  6. **已取消通知的处理** — 收到状态为"已取消"的 `[subagent-result]` 通知时,根据来源区分处理:
35
34
  - 正文注明用户通过 `/subagent-cancel` 取消 → 用户主动操作,**不得自动重新派发**。如需重新派发,先询问用户。
36
35
  - 正文注明主 agent 通过 `subagent` 工具(`action="cancel"`)取消 → 自身决策,不应在无新信息时重新派发。
@@ -46,6 +45,8 @@
46
45
  - **输出格式** — 期望的返回结构
47
46
  - **验收标准** — 如何判断完成(必须含验证命令及输出)
48
47
 
48
+ 其中**输出格式**应要求子 agent 返回以下要素:执行摘要、详细结果、状态(✅ 完成 / ⚠️ 部分 / ❌ 阻塞)、文件变更列表、后续建议。
49
+
49
50
  ### 调用示例
50
51
 
51
52
  调用 `subagent` tool 时,应把上述五部分内容全部写入 `task` 字段,例如:
@@ -59,6 +60,52 @@
59
60
 
60
61
  **注意**:`task` 字段必须非空,禁止只传 `agent` 而空传 `task`。如果 `task` 为空,子 agent 将拒绝执行。
61
62
 
63
+ ## 任务编排原则
64
+
65
+ 拆分与派发任务时:
66
+
67
+ - **拆到原子级**:每个子 agent 一次只承担一类同质任务(实现归 `coder`、评审归 `reviewer`、写作归 `writer`),不同性质的工作不混在一个任务里。
68
+ - **明确依赖关系**:前一个任务的产出是后一个任务的输入时,必须先等前者完成。
69
+ - **避免过载**:单个子 agent 一次不承担过多职责;任务太大就先拆小再派。
70
+ - **合理并行**:无依赖的任务并行派发,有依赖的串行等待。
71
+
72
+ ## 上下文传递规范
73
+
74
+ 委派时为子 agent 提供的上下文必须做到:
75
+
76
+ - **充分**:相关素材、约束条件、能力边界、此前反馈全部给足。
77
+ - **结构化**:按核心规则第 8 条的五段结构(背景 / 输入 / 要求 / 输出格式 / 验收标准)组织。
78
+ - **一次给齐**:不让子 agent 拿着不完整的信息开工,事后再补。
79
+
80
+ 上下文应包含的典型要素:
81
+
82
+ - 任务的来源、背景和目标
83
+ - 相关素材的路径和内容
84
+ - 已知的约束条件(技术、风格、范围等)
85
+ - 子 agent 的能力边界(对照"可用子 Agent"表,不派超出其工具与职责的事)
86
+ - 此前的反馈或调整历史
87
+ - 期望的输出格式与验收标准
88
+
89
+ ## 不代替子 Agent 出解决方案
90
+
91
+ 布置任务时只描述目标、约束和验收标准,不指定具体代码、参数、算法或实现步骤。子 agent 在各自领域比你更专精,你替它出方案,只会限制团队的整体能力上限。说清楚"做什么、做到什么程度",把"怎么做"留给子 agent。
92
+
93
+ ## 异步工作纪律
94
+
95
+ 子 agent 的结果以 `[subagent-result]` 通知分散、不定序到达。整合时以用户目标为锚,不被通知到达的顺序带着走:
96
+
97
+ - **同组不齐不交付**:同一目标下的多个子任务,等该组全部返回后统一整合汇报;组内未齐,不提前交付。
98
+ - **先归类再确认**:每条通知到达时,先判断它属于哪个目标组,再据信封"在途任务"块确认该组剩余在途数量,最后归位汇总。
99
+
100
+ ## 质量审查与迭代
101
+
102
+ 子 agent 提交产出后,你必须:
103
+
104
+ - **实际读取产出**:用 `read` 亲自查看产物,不只看子 agent 自报的总结。
105
+ - **基于实际产出审查**:按看到的真实产出制定针对性的审查维度,不套预设的死清单。
106
+ - **发现问题就打回**:带着具体问题重新派发修改任务,多轮迭代直到真正达标。
107
+ - **不达标不交付**:不完善的结果不展示给用户,这是质量守门员的底线。
108
+
62
109
  ## 隔离说明
63
110
 
64
111
  子 agent 的进程与你完全独立。你看不到子 agent 内部的工具调用痕迹和中间结果——你只收到 `[subagent-result]` 通知中的最终总结(TUI 模式)或返回值中的内联结果(非 TUI 模式)。你的上下文不会被子 agent 的执行细节污染,始终专注于规划和决策。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wolido/async-subagent-isolation",
3
- "version": "1.1.0",
3
+ "version": "1.3.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");
@@ -411,6 +426,10 @@ interface SingleResult {
411
426
  lastPhaseChange: number;
412
427
  thinkingBuffer?: string;
413
428
  sessionId: string;
429
+ /** Wall-clock start of this run (Date.now() at runSingleAgent entry). */
430
+ startedAt: number;
431
+ /** Wall-clock finish, set when the run resolves; absent while running. */
432
+ finishedAt?: number;
414
433
  }
415
434
 
416
435
  interface SubagentDetails {
@@ -588,6 +607,22 @@ export function formatElapsed(startedAt: number): string {
588
607
  return `${mm}:${ss}`;
589
608
  }
590
609
 
610
+ /**
611
+ * Format a finished run's duration (milliseconds) for result notifications.
612
+ * Unlike formatElapsed (a live "alive since" clock for the progress widget,
613
+ * MM:SS only and overflowing past 99 minutes), this floors to whole seconds
614
+ * and supports hours: < 1h -> "MM:SS", >= 1h -> "H:MM:SS" (hours not
615
+ * zero-padded), 0/negative -> "00:00".
616
+ */
617
+ export function formatDuration(ms: number): string {
618
+ if (!Number.isFinite(ms)) return "00:00";
619
+ const totalSec = Math.max(0, Math.floor(ms / 1000));
620
+ const hours = Math.floor(totalSec / 3600);
621
+ const mm = String(Math.floor((totalSec % 3600) / 60)).padStart(2, "0");
622
+ const ss = String(totalSec % 60).padStart(2, "0");
623
+ return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`;
624
+ }
625
+
591
626
  /**
592
627
  * Tracks progress of all running subagents and renders it as a widget above
593
628
  * the editor. A single 1Hz interval drives widget refreshes; update() only
@@ -877,13 +912,16 @@ function parseEnvInt(raw: string | undefined, fallback: number): number {
877
912
  return Number.isNaN(parsed) ? fallback : parsed;
878
913
  }
879
914
 
915
+ 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}$/;
916
+
880
917
  /** Validate an explicit sessionId. Returns an error message, or null when valid. */
881
- function validateSessionId(sessionId: string): string | null {
918
+ function validateSessionId(sessionId: unknown): string | null {
919
+ if (typeof sessionId !== "string") return `Invalid sessionId: must be a string, got ${typeof sessionId}`;
882
920
  const trimmed = sessionId.trim();
883
921
  if (trimmed === "") return "Invalid sessionId: must not be empty";
884
922
  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.`;
923
+ if (!UUID_V7_PATTERN.test(trimmed))
924
+ 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
925
  return null;
888
926
  }
889
927
 
@@ -901,6 +939,7 @@ async function runSingleAgent(
901
939
  modelOverrides?: Record<string, ModelOverride>,
902
940
  onProcSpawn?: (proc: ChildProcess) => void,
903
941
  ): Promise<SingleResult> {
942
+ const startedAt = Date.now();
904
943
  let effectiveSessionId: string;
905
944
  if (sessionId !== undefined) {
906
945
  const trimmed = sessionId.trim();
@@ -919,6 +958,8 @@ async function runSingleAgent(
919
958
  phase: "idle",
920
959
  lastPhaseChange: Date.now(),
921
960
  sessionId: trimmed,
961
+ startedAt,
962
+ finishedAt: Date.now(),
922
963
  };
923
964
  }
924
965
  effectiveSessionId = trimmed;
@@ -941,6 +982,8 @@ async function runSingleAgent(
941
982
  phase: "idle",
942
983
  lastPhaseChange: Date.now(),
943
984
  sessionId: effectiveSessionId,
985
+ startedAt,
986
+ finishedAt: Date.now(),
944
987
  };
945
988
  }
946
989
 
@@ -1008,6 +1051,7 @@ async function runSingleAgent(
1008
1051
  phase: "idle",
1009
1052
  lastPhaseChange: Date.now(),
1010
1053
  sessionId: effectiveSessionId,
1054
+ startedAt,
1011
1055
  };
1012
1056
 
1013
1057
  const emitProgress = () => {
@@ -1080,6 +1124,7 @@ async function runSingleAgent(
1080
1124
  const finalize = (code: number) => {
1081
1125
  if (resolved) return;
1082
1126
  resolved = true;
1127
+ currentResult.finishedAt = Date.now();
1083
1128
  if (postExitTimer) {
1084
1129
  clearTimeout(postExitTimer);
1085
1130
  postExitTimer = undefined;
@@ -1489,10 +1534,10 @@ export function truncateTaskDescription(task: string, maxLen = 200): string {
1489
1534
  }
1490
1535
 
1491
1536
  /**
1492
- * Format the in-flight task list (status === "running") shared by the
1493
- * action="status" branch, the result envelope's 在途 block, and the
1494
- * action="cancel" receipt. Deliberately carries no elapsed time: the list
1495
- * answers "what is still running", not "how long has it run".
1537
+ * Format the in-flight task list (status === "running") shared by the result
1538
+ * envelope's 在途 block and the action="cancel" receipt. Deliberately carries
1539
+ * no elapsed time: the list answers "what is still running", not "how long has
1540
+ * it run".
1496
1541
  */
1497
1542
  export function formatActiveTasks(): string {
1498
1543
  const running = [...taskRegistry.values()].filter((t) => t.status === "running");
@@ -1519,6 +1564,13 @@ export interface SubagentResultDetails {
1519
1564
  stopReason?: string;
1520
1565
  /** Present only on cancelled tasks: who cancelled ("user" | "agent"). */
1521
1566
  cancelledBy?: "user" | "agent";
1567
+ /**
1568
+ * Run duration in milliseconds (>= 0): the real run time
1569
+ * (finishedAt - startedAt) when a result exists; measured from the
1570
+ * dispatch time (task.startedAt) when result is null (cancel/internal
1571
+ * error).
1572
+ */
1573
+ durationMs: number;
1522
1574
  usage: UsageStats;
1523
1575
  sessionId: string;
1524
1576
  output: string;
@@ -1559,6 +1611,12 @@ export function buildResultEnvelope(
1559
1611
  const usage: UsageStats =
1560
1612
  result?.usage ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
1561
1613
  const sessionId = result?.sessionId ?? task.taskId;
1614
+ // Real run time when the subagent produced a result; for a null result
1615
+ // (user/agent cancel, session shutdown, internal error) the run never
1616
+ // reported back, so measure from the dispatch time instead.
1617
+ const durationMs = result
1618
+ ? Math.max(0, (result.finishedAt ?? Date.now()) - result.startedAt)
1619
+ : Math.max(0, Date.now() - task.startedAt);
1562
1620
  let body = output;
1563
1621
  if (!body && result) body = result.errorMessage || result.stderr.trim();
1564
1622
  // Only genuine failures are labelled "内部错误"; a user cancel or session
@@ -1570,7 +1628,7 @@ export function buildResultEnvelope(
1570
1628
  "",
1571
1629
  `- 状态: ${statusWord}`,
1572
1630
  `- 任务: ${truncateTaskDescription(task.task)}`,
1573
- `- 耗时: ${formatElapsed(task.startedAt)} · 用量: ${formatUsageStats(usage, result?.model) || "-"}`,
1631
+ `- 耗时: ${formatDuration(durationMs)} · 用量: ${formatUsageStats(usage, result?.model) || "-"}`,
1574
1632
  `- 会话: ${sessionId}`,
1575
1633
  "",
1576
1634
  // 在途 block: completeAsyncTask deletes this task from the registry
@@ -1589,6 +1647,7 @@ export function buildResultEnvelope(
1589
1647
  exitCode: result?.exitCode ?? null,
1590
1648
  stopReason,
1591
1649
  cancelledBy: task.cancelledBy,
1650
+ durationMs,
1592
1651
  usage,
1593
1652
  sessionId,
1594
1653
  output:
@@ -1671,10 +1730,10 @@ const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, {
1671
1730
  // Union-of-literals (anyOf + const) rather than StringEnum so the emitted
1672
1731
  // JSON Schema enumerates each action as its own const branch.
1673
1732
  const SubagentActionSchema = Type.Union(
1674
- [Type.Literal("dispatch"), Type.Literal("status"), Type.Literal("cancel")],
1733
+ [Type.Literal("dispatch"), Type.Literal("cancel")],
1675
1734
  {
1676
1735
  description:
1677
- 'Action to perform. "dispatch" (default): delegate the task to a subagent. "status": list in-flight background tasks. "cancel": cancel a running background task by taskId.',
1736
+ 'Action to perform. "dispatch" (default): delegate the task to a subagent. "cancel": cancel a running background task by taskId.',
1678
1737
  default: "dispatch",
1679
1738
  },
1680
1739
  );
@@ -1687,8 +1746,8 @@ const SubagentParams = Type.Object({
1687
1746
  description: "taskId of the running background subagent task to cancel (required for action=cancel; from the dispatch receipt).",
1688
1747
  })),
1689
1748
  sessionId: Type.Optional(Type.String({
1690
- pattern: "^[A-Za-z0-9_.-]+$",
1691
- description: "Optional session ID to reuse; a new UUID v7 is generated if omitted. Allowed characters: letters, digits, underscore, dot, and hyphen.",
1749
+ pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
1750
+ description: "仅用于复用此前 dispatch 回执返回的 UUID v7;省略则自动生成",
1692
1751
  })),
1693
1752
  agentScope: Type.Optional(AgentScopeSchema),
1694
1753
  confirmProjectAgents: Type.Optional(
@@ -1706,15 +1765,14 @@ export default function (pi: ExtensionAPI) {
1706
1765
  "",
1707
1766
  "ACTIONS (action parameter, default \"dispatch\"):",
1708
1767
  "- dispatch: delegate the task (async in TUI mode, blocking otherwise).",
1709
- "- status: list in-flight background tasks (taskId, agent, task description).",
1710
1768
  "- cancel: cancel a running background task by taskId.",
1769
+ "- 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.",
1711
1770
  "",
1712
1771
  "ASYNC (TUI mode): returns immediately with a dispatch receipt (taskId + session id).",
1713
1772
  "The result arrives later as a system notification message prefixed with",
1714
1773
  "[subagent-result] — that is a system notification, NOT a user request.",
1715
1774
  "- Do NOT treat the receipt as the result. Do NOT fabricate results.",
1716
- "- Do NOT poll for results; they arrive automatically. To confirm which",
1717
- " tasks are still in flight (e.g. after a /tree rewind), use action=\"status\".",
1775
+ "- Do NOT poll for results; they arrive automatically.",
1718
1776
  "- Continue with independent work, or end the turn. Process the result when",
1719
1777
  " the [subagent-result] notification arrives. Reuse the session id from the",
1720
1778
  " receipt to continue the same task later.",
@@ -1733,9 +1791,10 @@ export default function (pi: ExtensionAPI) {
1733
1791
  promptSnippet:
1734
1792
  "Delegate a task to a specialized subagent in an isolated process (async dispatch in TUI mode, blocking otherwise).",
1735
1793
  promptGuidelines: [
1736
- "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.",
1794
+ "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.",
1737
1795
  "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.",
1738
1796
  "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.",
1797
+ "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.",
1739
1798
  "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.",
1740
1799
  "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.",
1741
1800
  ],
@@ -1747,15 +1806,15 @@ export default function (pi: ExtensionAPI) {
1747
1806
 
1748
1807
  // Depth gate runs BEFORE any action dispatch: a subagent (depth >= 1)
1749
1808
  // is blocked from every action — dispatch spawns a nested subagent, and
1750
- // status/cancel would let it observe or kill the parent's in-flight
1751
- // tasks. The tool surface simply does not exist inside a subagent.
1809
+ // cancel would let it kill the parent's in-flight tasks. The tool surface
1810
+ // simply does not exist inside a subagent.
1752
1811
  const currentDepth = parseEnvInt(process.env.PI_SUBAGENT_DEPTH, 0);
1753
1812
  if (currentDepth >= MAX_SUBAGENT_DEPTH) {
1754
1813
  const agentName = process.env.PI_CURRENT_AGENT_NAME || "current agent";
1755
1814
  return {
1756
1815
  content: [{
1757
1816
  type: "text",
1758
- 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/status/cancel).`,
1817
+ 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).`,
1759
1818
  }],
1760
1819
  details: {
1761
1820
  mode: "single",
@@ -1767,13 +1826,6 @@ export default function (pi: ExtensionAPI) {
1767
1826
  };
1768
1827
  }
1769
1828
 
1770
- if (action === "status") {
1771
- return {
1772
- content: [{ type: "text", text: formatActiveTasks() }],
1773
- details: { activeTasks: [...taskRegistry.values()].filter((t) => t.status === "running").map((t) => t.taskId) },
1774
- };
1775
- }
1776
-
1777
1829
  if (action === "cancel") {
1778
1830
  const taskId = typeof params.taskId === "string" ? params.taskId.trim() : "";
1779
1831
  if (!taskId) {
@@ -1800,7 +1852,7 @@ export default function (pi: ExtensionAPI) {
1800
1852
 
1801
1853
  if (action !== "dispatch") {
1802
1854
  return {
1803
- content: [{ type: "text", text: `Invalid action: "${action}". Must be one of "dispatch" (default), "status", "cancel".` }],
1855
+ content: [{ type: "text", text: `Invalid action: "${action}". Must be one of "dispatch" (default), "cancel".` }],
1804
1856
  details: {
1805
1857
  mode: "single",
1806
1858
  agentScope: (params.agentScope ?? "both") as AgentScope,
@@ -2031,9 +2083,9 @@ export default function (pi: ExtensionAPI) {
2031
2083
 
2032
2084
  renderResult(result, { expanded }, theme, context) {
2033
2085
  const details = result.details as SubagentDetails | undefined;
2034
- // status/cancel receipts carry no `results` array (activeTasks /
2035
- // taskId+cancelled instead) — fall back to the plain-text content
2036
- // instead of throwing on details.results.length.
2086
+ // cancel receipts carry no `results` array (taskId+cancelled instead)
2087
+ // — fall back to the plain-text content instead of throwing on
2088
+ // details.results.length.
2037
2089
  if (!details || !Array.isArray(details.results) || details.results.length === 0) {
2038
2090
  return new Text(result.content?.[0]?.type === "text" ? result.content[0].text : "(no output)", 0, 0);
2039
2091
  }
@@ -2062,6 +2114,14 @@ export default function (pi: ExtensionAPI) {
2062
2114
  const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
2063
2115
  const displayItems = getDisplayItems(r.messages);
2064
2116
  const finalOutput = getFinalOutput(r.messages);
2117
+ // Run duration, shown only when both timestamps are present (older
2118
+ // results lack them); a typeof check keeps 0ms runs visible and
2119
+ // missing fields from rendering "NaN".
2120
+ const durationStr =
2121
+ typeof r.startedAt === "number" && Number.isFinite(r.startedAt) &&
2122
+ typeof r.finishedAt === "number" && Number.isFinite(r.finishedAt)
2123
+ ? formatDuration(r.finishedAt - r.startedAt)
2124
+ : null;
2065
2125
 
2066
2126
  if (expanded) {
2067
2127
  const container = new Container();
@@ -2069,6 +2129,7 @@ export default function (pi: ExtensionAPI) {
2069
2129
  if (isError && r.stopReason) header += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
2070
2130
  if (r.phase !== "idle") header += ` ${theme.fg("warning", formatPhase(r.phase))}`;
2071
2131
  header += ` ${theme.fg("muted", `[session: ${r.sessionId}]`)}`;
2132
+ if (durationStr) header += ` ${theme.fg("dim", durationStr)}`;
2072
2133
  container.addChild(new Text(header, 0, 0));
2073
2134
  if (isError && r.errorMessage)
2074
2135
  container.addChild(new Text(theme.fg("error", `Error: ${r.errorMessage}`), 0, 0));
@@ -2114,6 +2175,7 @@ export default function (pi: ExtensionAPI) {
2114
2175
  if (isError && r.stopReason) text += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
2115
2176
  if (r.phase !== "idle") text += ` ${theme.fg("warning", formatPhase(r.phase))}`;
2116
2177
  text += ` ${theme.fg("muted", `[session: ${r.sessionId}]`)}`;
2178
+ if (durationStr) text += ` ${theme.fg("dim", durationStr)}`;
2117
2179
  if (isError && r.errorMessage) text += `\n${theme.fg("error", `Error: ${r.errorMessage}`)}`;
2118
2180
  if (displayItems.length === 0) {
2119
2181
  if (!isError || !r.errorMessage) text += `\n${theme.fg("muted", "(no output)")}`;
@@ -2308,6 +2370,11 @@ export default function (pi: ExtensionAPI) {
2308
2370
  if (details?.taskId) text += ` ${theme.fg("muted", `(taskId: ${details.taskId})`)}`;
2309
2371
  const usageStr = details ? formatUsageStats(details.usage) : "";
2310
2372
  if (usageStr) text += ` ${theme.fg("dim", usageStr)}`;
2373
+ // durationMs is typed as a number and 0 is a valid duration, so the
2374
+ // presence check must not be falsy-based; old-shape details without
2375
+ // it simply omit the duration.
2376
+ if (typeof details?.durationMs === "number" && Number.isFinite(details.durationMs))
2377
+ text += ` ${theme.fg("dim", `耗时 ${formatDuration(details.durationMs)}`)}`;
2311
2378
  if (details?.taskId) text += `\n${theme.fg("muted", `查看全文: /subagent-result ${details.taskId}`)}`;
2312
2379
  // Background tint mirrors the dispatch-receipt tool rows: success and
2313
2380
  // failure reuse the tool-row colors; timeout, cancelled and unknown