@wolido/async-subagent-isolation 1.3.0 → 1.5.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
@@ -130,7 +130,7 @@ Once the subagent finishes, its result is pushed into the conversation:
130
130
  - 耗时: 02:34 · 用量: 5 turns/↑12.5k/↓3.2k/$0.0042
131
131
  - 会话: 01912345-6789-7abc-8def-0123456789ab
132
132
 
133
- 在途任务: 1
133
+ 本任务结束时,其他在途任务: 1
134
134
  - 01912345-aaaa-7bbb-8ccc-0123456789ab (writer): 更新 README。
135
135
 
136
136
  ---
@@ -143,20 +143,20 @@ Status enumeration: **成功** (success, exit=0) / **失败** (failure, exit≠0
143
143
 
144
144
  "Cancelled" has three sub-cases with different envelope bodies:
145
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.
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).
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), followed by "取消理由: ..." (the reason given at the confirmation step).
147
147
  - Session shutdown killed the task (cancelledBy: none) → body states the task was terminated by session_shutdown.
148
148
 
149
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.
150
150
 
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.
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 and no clock time** (it answers "what else was running when this task ended", not "how long has it run" or "what time is it"). The block is a **build-time snapshot** whose wording is anchored to this task's end event rather than an absolute "now" — between envelope construction and delivery the main agent may have dispatched new tasks, making the snapshot stale; on conflict with dispatch records the main agent issued itself this turn, the dispatch records prevail. 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.
152
152
 
153
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.
154
154
 
155
155
  ### Notification delivery
156
156
 
157
- Notifications are sent via `pi.sendMessage` with `deliverAs: "followUp"` + `triggerTurn: true`:
157
+ Notifications are sent via `pi.sendMessage` with `deliverAs: "steer"` + `triggerTurn: true`:
158
158
  - When the main agent is idle, it triggers a new conversation turn immediately.
159
- - When the main agent is busy, the notification is queued and triggers a turn after the current one finishes.
159
+ - When the main agent is busy, the notification is queued and delivered after the current assistant turn's tool calls finish, before the next LLM call (steer semantics) — it is not held back until the whole turn ends, so it cannot lag behind tasks dispatched later in the same turn.
160
160
 
161
161
  The main agent is trained (via `promptGuidelines`) to recognize the `[subagent-result]` prefix as a system notification, not a user request.
162
162
 
@@ -194,9 +194,9 @@ To cancel all running tasks at once:
194
194
 
195
195
  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"`.
196
196
 
197
- **Path 2: Main agent `subagent` tool with `action="cancel"`**
197
+ **Path 2: Main agent `subagent` tool with `action="cancel"` (two-step confirmation)**
198
198
 
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.
199
+ The main agent can call the `subagent` tool with `action="cancel"` (parameter `taskId`) to cancel a dispatched background task, but the first call does not execute: it returns a zero-side-effect challenge receipt (`details.confirmRequired: true`) listing the agent name, task summary, elapsed time and last progress age (or "尚无进度上报" when never reported), plus a warning that cancelling discards all in-flight progress and cannot be undone. To actually cancel, call again with `action="cancel"` + the same `taskId` + `confirm:true` + a non-empty `reason` (a missing or blank reason is an error with zero side-effects). On execution the reason is recorded on the task record and quoted in the cancelled envelope body ("取消理由: ..."). The cancel source is recorded as `cancelledBy: "agent"`. On success, the tool returns the remaining in-flight task list (same per-line format as the `[subagent-result]` envelope's in-flight block, but anchored to the moment the cancel request was issued — the task has not ended at that point, so the envelope's "本任务结束" anchor wording is not used); the cancelled task's final result arrives later as a `[subagent-result]` notification.
200
200
 
201
201
  **Usage discipline:** The main agent should only use `action="cancel"` when:
202
202
  - The task is clearly wrong (wrong agent, incorrect task description, etc.).
package/ADVANCED.md CHANGED
@@ -130,7 +130,7 @@ TUI 模式下 `subagent` 立即返回如下回执(不是结果!):
130
130
  - 耗时: 02:34 · 用量: 5 turns/↑12.5k/↓3.2k/$0.0042
131
131
  - 会话: 01912345-6789-7abc-8def-0123456789ab
132
132
 
133
- 在途任务: 1
133
+ 本任务结束时,其他在途任务: 1
134
134
  - 01912345-aaaa-7bbb-8ccc-0123456789ab (writer): 更新 README。
135
135
 
136
136
  ---
@@ -143,20 +143,20 @@ TUI 模式下 `subagent` 立即返回如下回执(不是结果!):
143
143
 
144
144
  "已取消"分三种情况,信封正文不同:
145
145
  - 用户通过 `/subagent-cancel` 取消(cancelledBy: user)→ 正文注明"属用户主动操作。请勿自动重新派发;如需重新派发,先询问用户。"
146
- - 主 agent 通过 `subagent` 工具(`action="cancel"`)取消(cancelledBy: agent)→ 正文注明"该任务已由主 agent 通过 subagent 工具(action=cancel)取消。"
146
+ - 主 agent 通过 `subagent` 工具(`action="cancel"`)取消(cancelledBy: agent)→ 正文注明"该任务已由主 agent 通过 subagent 工具(action=cancel)取消。",并附"取消理由: ..."(两步确认时填写的 reason)
147
147
  - 会话关闭(session_shutdown)终止(cancelledBy: 无)→ 正文注明"任务因会话关闭被终止(session_shutdown)。"
148
148
 
149
149
  主 agent 收到状态为"已取消"的通知时,应区分来源:用户主动取消**不得自动重试**,必须先询问用户;agent 取消是自身决策,不应在无新信息时重新派发;会话关闭终止可在会话恢复后视情况重新派发。
150
150
 
151
- **在途任务块**:信封元信息区的"在途任务"列表列出**其余**仍在运行的后台任务(本任务在构建信封前已从注册表移除,故不包含自身),格式为 `在途任务: N` 加每行 `- taskId (agent名): 任务描述`,无在途任务时为"当前无在途任务。"列表**不含耗时**(回答"还有什么在跑",而非"跑了多久")。主 agent 据此知道还有几个任务没回来:剩余不为 0 时,不要向用户汇报"全部完成"。
151
+ **在途任务块**:信封元信息区的“在途任务”列表列出**其余**仍在运行的后台任务(本任务在构建信封前已从注册表移除,故不包含自身),格式为 `本任务结束时,其他在途任务: N` 加每行 `- taskId (agent名): 任务描述`,无在途任务时为“本任务结束时无其他在途任务。”列表**不含耗时或时钟时间**(回答“本任务结束时还有什么在跑”,而非“跑了多久”或“几点了”)。该列表是**构建时刻快照**,措辞锚定本任务结束事件而非绝对“此刻”——信封构建与送达之间主 agent 可能已派发新任务,快照随之滞后;与主 agent 本回合亲手发出的派发记录冲突时,以派发记录为准。主 agent 据此知道还有几个任务没回来:剩余不为 0 时,不要向用户汇报“全部完成”。
152
152
 
153
153
  结果全量进入 LLM 上下文(不截断)。`details` 携带结构化数据(taskId、agent、status、exitCode、stopReason、durationMs(耗时毫秒数,必填)、usage、sessionId、完整输出),不参与 LLM 上下文,供程序消费。
154
154
 
155
155
  ### 通知投递
156
156
 
157
- 通知通过 `pi.sendMessage` 发送,`deliverAs: "followUp"` + `triggerTurn: true`:
157
+ 通知通过 `pi.sendMessage` 发送,`deliverAs: "steer"` + `triggerTurn: true`:
158
158
  - 主 agent 空闲时直接触发新的对话回合。
159
- - 主 agent 忙碌时进入消息队列,待当前回合结束后触发。
159
+ - 主 agent 忙碌时进入消息队列,在当前 assistant turn 的工具调用执行完后、下一次 LLM 调用前送达(steer 语义),不憋到整个回合结束——避免通知滞后于回合内新派发的任务。
160
160
 
161
161
  主 agent 通过 promptGuidelines 被训练识别 `[subagent-result]` 前缀为系统通知(非用户请求)。
162
162
 
@@ -194,9 +194,9 @@ widget 行中的 taskId 可直接复制,用于 `/subagent-result` 查看结果
194
194
 
195
195
  无参数。与 `/subagent-cancel` 按 taskId 取消单个任务不同,`/subagent-cancel-all` 取消全部运行中的任务。每个被取消任务照常推送各自的"已取消" `[subagent-result]` 通知(主 agent 会收到 N 个已取消信封)。成功时提示"已取消全部 N 个运行中任务",无运行中任务时提示"无运行中任务可取消"。取消来源同样标记为 `cancelledBy: "user"`。
196
196
 
197
- **路径二:主 agent `subagent` 工具(`action="cancel"`)**
197
+ **路径二:主 agent `subagent` 工具(`action="cancel"`,两步确认)**
198
198
 
199
- 主 agent 可调用 `subagent` 工具(`action="cancel"`,参数 `taskId`)取消已派出的后台任务。取消来源标记为 `cancelledBy: "agent"`。取消成功后返回剩余在途任务列表(格式与信封的“在途任务”块一致),被取消任务的最终结果稍后以 `[subagent-result]` 通知返回。
199
+ 主 agent 可调用 `subagent` 工具(`action="cancel"`,参数 `taskId`)取消已派出的后台任务,但首次调用不会直接执行:它返回零副作用的质询回执(`details.confirmRequired: true`),列出 agent 名、任务摘要、已运行时长、最近进度距今(从未上报则明示"尚无进度上报"),并警告取消将丢弃全部在途进度且不可撤销。确认取消需再次调用:`action="cancel"` + 同一 `taskId` + `confirm:true` + 非空 `reason`(缺失或空白报错,零副作用)。执行后 `reason` 记录在任务记录上,并随取消信封正文返回("取消理由: ...")。取消来源标记为 `cancelledBy: "agent"`。取消成功后返回其余在途任务列表(列表行格式与信封的“在途任务”块一致,但措辞锚定取消请求发出时刻——此时该任务并未结束,不用信封的“本任务结束”锚定语),被取消任务的最终结果稍后以 `[subagent-result]` 通知返回。
200
200
 
201
201
  **使用纪律:** 主 agent 仅在以下情况使用 `action="cancel"`:
202
202
  - 任务明显错误(委派了错误的 agent、任务描述有误等)。
package/README.en.md CHANGED
@@ -114,13 +114,13 @@ The subagent runs in a background process. A progress widget appears above the T
114
114
  When the subagent finishes, its result is pushed as a **`[subagent-result]` system notification** (a system message, not a user request):
115
115
 
116
116
  - If the main agent is **idle**, the notification triggers a new turn immediately.
117
- - If the main agent is **busy**, it is queued and triggers a turn after the current one finishes.
117
+ - If the main agent is **busy**, it is queued and delivered with steer semantics — after the current assistant turn's tool calls finish, before the next LLM call — without waiting for the whole turn to end.
118
118
 
119
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.
120
120
 
121
121
  ### 5. Read the full result (`/subagent-result`)
122
122
 
123
- The notification card shows only a summary. Use `/subagent-result <taskId>` to read the full output in a full-screen viewer: `↑↓`/`jk` scroll, `Space`/`b` page, `g`/`G` top/bottom, `Enter`/`Esc`/`q` close.
123
+ The notification card shows only a summary. Use `/subagent-result <taskId>` to read the full output in a full-screen viewer: `↑↓`/`jk` scroll, `Space`/`b` page, `g`/`G` top/bottom, `Enter`/`Esc`/`q` close. With no argument (TUI mode), an interactive picker lists the 5 most recently finished tasks and `Enter` opens the selected one.
124
124
 
125
125
  ### The flow at a glance
126
126
 
@@ -146,7 +146,7 @@ User runs /subagent-result <taskId> to read the full output
146
146
  | Tool | Purpose | Key constraint |
147
147
  |------|---------|----------------|
148
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 |
149
+ | `subagent` `action="cancel"` | Main agent cancels one in-flight task (two-step confirmation: first call returns a challenge; `confirm:true` + a non-empty `reason` executes) | Only when clearly wrong or no longer needed; never for being slow |
150
150
 
151
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
152
 
@@ -154,9 +154,9 @@ User runs /subagent-result <taskId> to read the full output
154
154
 
155
155
  | Command | Purpose |
156
156
  |---------|---------|
157
- | `/subagent-cancel <taskId>` | Cancel one running background task (lists running tasks with no argument) |
157
+ | `/subagent-cancel <taskId>` | Cancel one running background task (no argument opens an interactive picker of running tasks; Enter cancels the selection) |
158
158
  | `/subagent-cancel-all` | Cancel all running background tasks at once |
159
- | `/subagent-result <taskId>` | Read a task's full result in a full-screen viewer |
159
+ | `/subagent-result <taskId>` | Read a task's full result in a full-screen viewer (no argument opens an interactive picker of the 5 most recent finished tasks) |
160
160
 
161
161
  ---
162
162
 
@@ -172,7 +172,7 @@ The `[subagent-result]` notification is **self-contained** — it carries everyt
172
172
  - 耗时: 02:34 · 用量: 5 turns/↑12.5k/↓3.2k/$0.0042
173
173
  - 会话: 01912345-6789-7abc-8def-0123456789ab
174
174
 
175
- 在途任务: 1
175
+ 本任务结束时,其他在途任务: 1
176
176
  - 01912345-aaaa-7bbb-8ccc-0123456789ab (writer): 更新 README。
177
177
 
178
178
  ---
@@ -181,7 +181,7 @@ The `[subagent-result]` notification is **self-contained** — it carries everyt
181
181
 
182
182
  - **Status**: `成功` (success) / `失败` (failure) / `超时` (timeout) / `已取消` (cancelled).
183
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.
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.
184
+ - **In-flight block**: a build-time snapshot anchored to this task's end event (excluding itself), listing the other background tasks still running when this task ended; it may be stale by delivery time — when it conflicts with dispatch records issued this turn, the dispatch records prevail. The main agent learns how many are outstanding — while the count is non-zero, do not report "all done" to the user.
185
185
  - **Full result**: the body enters the LLM context in full, untruncated.
186
186
 
187
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.
@@ -196,7 +196,7 @@ Async mode introduces a few rules, baked into the tool prompts and implementatio
196
196
 
197
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.
198
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.
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).
199
+ - **Anti-abuse cancellation**: `action="cancel"` is a two-step confirmation (the first call only returns a zero-side-effect challenge with elapsed time and last progress; `confirm:true` + a non-empty `reason` executes, and the reason is recorded on the task and quoted in the cancelled envelope body), 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). Waiting means making no tool call at all and ending the turn; there is deliberately no query, nag or status action for in-flight tasks.
200
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.
201
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.
202
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.
package/README.md CHANGED
@@ -114,13 +114,13 @@ TUI 模式下 `subagent` **立即返回派发回执**,不阻塞:
114
114
  子 agent 完成后,结果以 **`[subagent-result]` 系统通知**推送到对话(系统消息,不是用户请求):
115
115
 
116
116
  - 主 agent **空闲**时,通知直接触发新的对话回合,立即处理。
117
- - 主 agent **忙碌**时,通知进入队列,当前回合结束后再触发。
117
+ - 主 agent **忙碌**时,通知进入队列,在当前回合的工具调用执行完后、下一次 LLM 调用前送达(steer 投递),不等整个回合结束。
118
118
 
119
119
  结果自动到达,**无需轮询**。在途任务信息由 `[subagent-result]` 通知信封的“在途任务”块直接提供;`action="status"` 已在 v1.2.0 清理移除。
120
120
 
121
121
  ### 5. 查看全文(`/subagent-result`)
122
122
 
123
- 通知卡片只显示摘要。用 `/subagent-result <taskId>` 在全屏查看器中阅读完整返回:`↑↓`/`jk` 滚动、`Space`/`b` 翻页、`g`/`G` 首尾、`Enter`/`Esc`/`q` 关闭。
123
+ 通知卡片只显示摘要。用 `/subagent-result <taskId>` 在全屏查看器中阅读完整返回:`↑↓`/`jk` 滚动、`Space`/`b` 翻页、`g`/`G` 首尾、`Enter`/`Esc`/`q` 关闭。不带参数时(TUI 模式)弹出选择列表,列出最近 5 个已结束的任务,`Enter` 打开所选任务。
124
124
 
125
125
  ### 完整流程一览
126
126
 
@@ -146,7 +146,7 @@ TUI 模式下 `subagent` **立即返回派发回执**,不阻塞:
146
146
  | 工具 | 作用 | 关键约束 |
147
147
  |------|------|----------|
148
148
  | `subagent` | 单入口工具(`action` 参数);`action="dispatch"`(默认)异步派发任务(TUI 模式),非 TUI 自动降级同步 | 回执≠结果;结果以通知到达,勿轮询 |
149
- | `subagent` `action="cancel"` | 主 agent 取消单个在途任务 | 仅当任务明显错误或不再需要,勿因耗时久而取消 |
149
+ | `subagent` `action="cancel"` | 主 agent 取消单个在途任务(两步确认:首次调用返回质询,`confirm:true` + 非空 `reason` 才执行) | 仅当任务明显错误或不再需要,勿因耗时久而取消 |
150
150
 
151
151
  > **v1.2.0 提示**:`subagent` 工具的 `action="status"` 已作为 cleanup 移除。在途任务信息改由 `[subagent-result]` 通知信封的“在途任务”块提供,不再提供主动查询入口。
152
152
 
@@ -154,9 +154,9 @@ TUI 模式下 `subagent` **立即返回派发回执**,不阻塞:
154
154
 
155
155
  | 命令 | 作用 |
156
156
  |------|------|
157
- | `/subagent-cancel <taskId>` | 取消单个运行中的后台任务(不带参数时列出运行中任务) |
157
+ | `/subagent-cancel <taskId>` | 取消单个运行中的后台任务(不带参数时弹出运行中任务的交互选择列表,Enter 取消所选) |
158
158
  | `/subagent-cancel-all` | 一键取消全部运行中的后台任务 |
159
- | `/subagent-result <taskId>` | 全屏查看某任务的完整返回 |
159
+ | `/subagent-result <taskId>` | 全屏查看某任务的完整返回(不带参数时弹出最近 5 个已结束任务的交互选择列表) |
160
160
 
161
161
  ---
162
162
 
@@ -172,7 +172,7 @@ TUI 模式下 `subagent` **立即返回派发回执**,不阻塞:
172
172
  - 耗时: 02:34 · 用量: 5 turns/↑12.5k/↓3.2k/$0.0042
173
173
  - 会话: 01912345-6789-7abc-8def-0123456789ab
174
174
 
175
- 在途任务: 1
175
+ 本任务结束时,其他在途任务: 1
176
176
  - 01912345-aaaa-7bbb-8ccc-0123456789ab (writer): 更新 README。
177
177
 
178
178
  ---
@@ -181,7 +181,7 @@ TUI 模式下 `subagent` **立即返回派发回执**,不阻塞:
181
181
 
182
182
  - **状态**:`成功` / `失败` / `超时` / `已取消`。
183
183
  - **耗时**:子 agent 的真实运行时长(进程实际启动到结束,格式 `MM:SS`,≥1 小时为 `H:MM:SS`),成功/失败/超时/已取消四种状态都有;取消或内部错误(无结果返回)时从派发时刻起算。
184
- - **在途任务块**:列出其余仍在运行的后台任务(不含自身),让主 agent 知道还有几个任务没回来——剩余不为 0 时,不要向用户汇报"全部完成"。
184
+ - **在途任务块**:锚定“本任务结束”事件的构建时刻快照(不含自身),列出本任务结束时其余仍在运行的后台任务;快照在送达时可能滞后,与本回合亲手发出的派发记录冲突时以派发记录为准。让主 agent 知道还有几个任务没回来——剩余不为 0 时,不要向用户汇报“全部完成”。
185
185
  - **完整结果**:正文全量进入 LLM 上下文,不截断。
186
186
 
187
187
  用户在 TUI 中看到的是**带底色的摘要卡片**,不是全文:成功绿色(✓)、失败红色(✗)、超时/已取消黄色。卡片显示 agent、状态、taskId、耗时和用量摘要(四种状态均带耗时),并提示 `查看全文: /subagent-result <taskId>`;完整结果保存在任务会话文件中。
@@ -196,7 +196,7 @@ TUI 模式下 `subagent` **立即返回派发回执**,不阻塞:
196
196
 
197
197
  - **取消来源区分**:`已取消` 有用户(`/subagent-cancel`)、主 agent(`subagent` 工具 `action="cancel"`)、会话关闭(`session_shutdown`)三种来源;用户取消**不得自动重试**,须先询问。
198
198
  - **防轮询**:结果以通知自动到达;在途任务信息由 `[subagent-result]` 通知信封直接提供,不要主动查询。
199
- - **防滥用取消**:`action="cancel"` 内嵌提示词——仅当任务明显错误或不再需要时取消,勿因耗时长而取消(后台任务本就预期长时间运行)。
199
+ - **防滥用取消**:`action="cancel"` 为两步确认(首次调用只返回含已运行时长/最近进度的质询回执,零副作用;`confirm:true` + 非空 `reason` 才执行,理由记入任务记录并随取消信封正文返回),且内嵌提示词——仅当任务明显错误或不再需要时取消,勿因耗时长而取消(后台任务本就预期长时间运行)。等待 = 不发起任何工具调用、直接结束回合;对在途任务不存在查询/催办/状态确认类动作(刻意设计)。
200
200
  - **资源冲突纪律**:并行派发多个任务前,考虑它们是否会改同一批文件或代码区域;冲突时串行派发或先问用户。
201
201
  - **子 agent 不可调用 subagent 工具**:子 agent(深度 ≥ 1)不可调用任何 `subagent` action(含 `action="cancel"`),深度限制为 1。
202
202
  - **TUI 异步 / 非 TUI 同步降级**:只在 TUI 模式走异步路径;print/json 等非 TUI 模式降级为同步阻塞。
@@ -29,13 +29,13 @@
29
29
  2. **依赖驱动派发** — 无依赖的任务可并行派出。有依赖的必须等对应 `[subagent-result]` 通知到达后再派。
30
30
  3. **派出后继续工作** — `subagent` 返回的只是派发回执(含 `taskId`),不是结果。派出后继续做不依赖该结果的工作,或结束回合。严禁轮询、严禁臆造结果。
31
31
  4. **识别系统通知** — 以 `[subagent-result]` 开头的消息是系统通知(子 agent 结果),不是用户请求。收到后关联到当初派发的任务进行处理。
32
- 5. **通知先看在途任务块** — 每条 `[subagent-result]` 通知的元信息区带"在途任务"列表(剩余仍在运行的任务,不含本任务)。收到后先看剩余在途数:**不为 0 时还有任务未返回,不要向用户汇报"全部完成"**。主 agent 不主动查询后台;若上下文里该任务的 [subagent-result] 通知未到达,向用户报告该 taskId 并建议用户用 /subagent-cancel 或 /subagent-result 命令查看。
32
+ 5. **通知先看在途任务块** — 每条 `[subagent-result]` 通知的元信息区带“在途任务”列表(锚定该任务结束事件的构建时刻快照:该任务结束时剩余仍在运行的任务,不含本任务;快照送达时可能滞后,与你本回合亲手发出的派发记录冲突时以派发记录为准)。收到后先看剩余在途数:**不为 0 时还有任务未返回,不要向用户汇报“全部完成”**。主 agent 不主动查询后台;若上下文里该任务的 [subagent-result] 通知未到达,向用户报告该 taskId 并建议用户用 /subagent-cancel 或 /subagent-result 命令查看。
33
33
  6. **已取消通知的处理** — 收到状态为"已取消"的 `[subagent-result]` 通知时,根据来源区分处理:
34
34
  - 正文注明用户通过 `/subagent-cancel` 取消 → 用户主动操作,**不得自动重新派发**。如需重新派发,先询问用户。
35
35
  - 正文注明主 agent 通过 `subagent` 工具(`action="cancel"`)取消 → 自身决策,不应在无新信息时重新派发。
36
36
  - 正文注明会话关闭(session_shutdown)终止 → 可在会话恢复后视情况重新派发。
37
37
 
38
- 7. **`action="cancel"` 使用纪律** — 你可以使用 `subagent` 工具(`action="cancel"`,参数 `taskId`)纠正错误委派(如委派了错误的 agent、任务描述有误)或取消不再需要的任务。**不要因等待时间长而取消**——后台子 agent 本就预期长时间运行。取消的判据是"这个任务不该继续",不是"等太久了"
38
+ 7. **`action="cancel"` 使用纪律** — 你可以使用 `subagent` 工具(`action="cancel"`,参数 `taskId`)纠正错误委派(如委派了错误的 agent、任务描述有误)或取消不再需要的任务。取消是两步确认:首次调用只返回质询回执(含已运行时长与最近进度,零副作用);确认需再次调用并带同一 `taskId` + `confirm:true` + 非空 `reason`(理由会记入任务记录并随取消信封返回)。**不要因等待时间长而取消**——后台子 agent 本就预期长时间运行。取消的判据是"这个任务不该继续",不是"等太久了"。等待 = 不发起任何工具调用、直接结束回合;对在途任务不存在查询/催办/状态确认类动作(刻意设计)。
39
39
 
40
40
  8. **用标准任务格式** — 每次委派必须包含以下结构:
41
41
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wolido/async-subagent-isolation",
3
- "version": "1.3.0",
3
+ "version": "1.5.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
@@ -27,7 +27,7 @@ import {
27
27
  getAgentDir,
28
28
  parseFrontmatter,
29
29
  } from "@earendil-works/pi-coding-agent";
30
- import { Box, Container, Key, Markdown, matchesKey, Spacer, Text, truncateToWidth, visibleWidth, sliceByColumn } from "@earendil-works/pi-tui";
30
+ import { Box, Container, Key, Markdown, matchesKey, SelectList, type SelectItem, Spacer, Text, truncateToWidth, visibleWidth, sliceByColumn } from "@earendil-works/pi-tui";
31
31
  import { Type } from "typebox";
32
32
 
33
33
  // ===== UUID v7 helper =====
@@ -630,6 +630,10 @@ export function formatDuration(ms: number): string {
630
630
  */
631
631
  export class SubagentProgressManager {
632
632
  private agents = new Map<string, AgentProgress>();
633
+ // Last progress timestamp per session, fed by update(); backs the cancel
634
+ // challenge's "最近进度距今" line. Kept separate from AgentProgress so the
635
+ // challenge can read it without touching widget render state.
636
+ private lastActivityAt = new Map<string, number>();
633
637
  private timer: ReturnType<typeof setInterval> | null = null;
634
638
  private ctx: ExtensionContext | null = null;
635
639
  private widgetSet = false;
@@ -653,9 +657,19 @@ export class SubagentProgressManager {
653
657
  if (update.phase !== undefined) agent.phase = update.phase;
654
658
  if (update.currentTool !== undefined) agent.currentTool = update.currentTool;
655
659
  if (update.recentTools !== undefined) agent.recentTools = update.recentTools;
660
+ this.lastActivityAt.set(sessionId, Date.now());
661
+ }
662
+
663
+ /**
664
+ * Last update() time for a session, or undefined when the task has never
665
+ * reported progress (the cancel challenge renders that as "尚无进度上报").
666
+ */
667
+ getLastActivityAt(sessionId: string): number | undefined {
668
+ return this.lastActivityAt.get(sessionId);
656
669
  }
657
670
 
658
671
  unregister(sessionId: string): void {
672
+ this.lastActivityAt.delete(sessionId);
659
673
  if (!this.agents.has(sessionId)) return;
660
674
  this.agents.delete(sessionId);
661
675
  if (this.agents.size === 0) {
@@ -668,6 +682,18 @@ export class SubagentProgressManager {
668
682
  }
669
683
  }
670
684
 
685
+ /**
686
+ * Drop every registered task (widget state and last-activity timestamps)
687
+ * and stop the refresh timer. Test-isolation hook only: the module-level
688
+ * singleton outlives individual tests, so a case that dispatches without
689
+ * finishing its tasks would otherwise leak state into later cases.
690
+ * Production teardown always goes through per-task unregister.
691
+ */
692
+ resetForTests(): void {
693
+ for (const id of [...this.agents.keys()]) this.unregister(id);
694
+ this.lastActivityAt.clear();
695
+ }
696
+
671
697
  refresh(): void {
672
698
  if (!this.ctx?.hasUI) return;
673
699
  const sorted = [...this.agents.values()].sort((a, b) => a.startedAt - b.startedAt);
@@ -738,6 +764,14 @@ export class SubagentProgressManager {
738
764
 
739
765
  const progressManager = new SubagentProgressManager();
740
766
 
767
+ /**
768
+ * Test-isolation hook for the module-level progress singleton (see
769
+ * SubagentProgressManager.resetForTests). Production code never calls this.
770
+ */
771
+ export function resetProgressManagerForTests(): void {
772
+ progressManager.resetForTests();
773
+ }
774
+
741
775
  /**
742
776
  * Build the isolated session directory for a subagent.
743
777
  * All subagent sessions live under a dedicated root, independent of the main
@@ -1496,6 +1530,14 @@ export interface AsyncSubagentTask {
1496
1530
  * the cancel's origin; undefined for non-cancelled endings.
1497
1531
  */
1498
1532
  cancelledBy?: "user" | "agent";
1533
+ /**
1534
+ * Why the task was cancelled, supplied by the confirming cancel call
1535
+ * (action="cancel" + confirm:true requires a non-empty reason). Recorded
1536
+ * so the cancellation stays auditable: the [subagent-result] envelope body
1537
+ * quotes it (single-lined and capped at 200 chars). Undefined for user
1538
+ * cancels and non-cancelled endings.
1539
+ */
1540
+ cancelReason?: string;
1499
1541
  /**
1500
1542
  * Child process handle, set once runSingleAgent has spawned. Lets the
1501
1543
  * session_shutdown handler SIGKILL directly: on "quit" the main process
@@ -1518,11 +1560,12 @@ export const taskRegistry = new Map<string, AsyncSubagentTask>();
1518
1560
  * /subagent-cancel command and the subagent tool's action="cancel". Returns false when
1519
1561
  * no running task with that id exists.
1520
1562
  */
1521
- function cancelTask(taskId: string, cancelledBy: "user" | "agent"): boolean {
1563
+ function cancelTask(taskId: string, cancelledBy: "user" | "agent", reason?: string): boolean {
1522
1564
  const task = taskRegistry.get(taskId);
1523
1565
  if (!task || task.status !== "running") return false;
1524
1566
  task.status = "cancelled";
1525
1567
  task.cancelledBy = cancelledBy;
1568
+ if (reason) task.cancelReason = reason;
1526
1569
  task.abortController.abort();
1527
1570
  return true;
1528
1571
  }
@@ -1534,16 +1577,138 @@ export function truncateTaskDescription(task: string, maxLen = 200): string {
1534
1577
  }
1535
1578
 
1536
1579
  /**
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".
1580
+ * Format the in-flight task list (status === "running") for the result
1581
+ * envelope's 在途 block. The list is a build-time snapshot; since the
1582
+ * notification may be delivered after the main agent has dispatched new
1583
+ * tasks, the wording is anchored to this envelope's task-end event (an
1584
+ * event the main agent can order against its own dispatch records) instead
1585
+ * of an absolute "right now" claim. Deliberately carries no elapsed time
1586
+ * or clock time: the list answers "what was still running when this task
1587
+ * ended", not "how long has it run" or "what time is it".
1541
1588
  */
1542
1589
  export function formatActiveTasks(): string {
1543
1590
  const running = [...taskRegistry.values()].filter((t) => t.status === "running");
1544
- if (running.length === 0) return "当前无在途任务。";
1591
+ if (running.length === 0) return "本任务结束时无其他在途任务。";
1592
+ const lines = running.map((t) => `- ${t.taskId} (${t.agentName}): ${truncateTaskDescription(t.task)}`);
1593
+ return `本任务结束时,其他在途任务: ${running.length}\n${lines.join("\n")}`;
1594
+ }
1595
+
1596
+ /**
1597
+ * Remaining in-flight list for the action="cancel" confirmation receipt.
1598
+ * NOT shared with the envelope block (formatActiveTasks): at this point no
1599
+ * task has ended — the cancel was merely requested and the cancelled task's
1600
+ * result arrives later — so a "本任务结束" anchor would be wrong here. The
1601
+ * receipt is returned synchronously in the same turn, so anchoring the
1602
+ * snapshot to the cancel request itself is accurate.
1603
+ */
1604
+ function formatRemainingTasksAfterCancelRequest(): string {
1605
+ const running = [...taskRegistry.values()].filter((t) => t.status === "running");
1606
+ if (running.length === 0) return "取消请求发出后,已无其他在途任务。";
1545
1607
  const lines = running.map((t) => `- ${t.taskId} (${t.agentName}): ${truncateTaskDescription(t.task)}`);
1546
- return `在途任务: ${running.length}\n${lines.join("\n")}`;
1608
+ return `取消请求发出后,其余在途任务: ${running.length}\n${lines.join("\n")}`;
1609
+ }
1610
+
1611
+ /** A finished async task, recorded when completeAsyncTask removes it from the registry. */
1612
+ interface CompletedTaskRecord {
1613
+ taskId: string;
1614
+ agentName: string;
1615
+ status: SubagentTaskStatus;
1616
+ finishedAt: number;
1617
+ }
1618
+
1619
+ /**
1620
+ * Recently finished async tasks in completion order (latest last), backing the
1621
+ * no-argument /subagent-result picker. Bounded so a long session cannot grow
1622
+ * it without limit; entries whose session file is gone are filtered at read
1623
+ * time by listViewableFinishedTasks.
1624
+ */
1625
+ const completedTasks: CompletedTaskRecord[] = [];
1626
+ const COMPLETED_TASKS_KEEP = 50;
1627
+
1628
+ /** Record a finished task (called once per task from completeAsyncTask). */
1629
+ function recordCompletedTask(task: AsyncSubagentTask, status: SubagentTaskStatus): void {
1630
+ // A reused sessionId finishes repeatedly: drop its older record first so
1631
+ // the latest finish wins and one task cannot occupy multiple slots.
1632
+ for (let i = completedTasks.length - 1; i >= 0; i--) {
1633
+ if (completedTasks[i].taskId === task.taskId) completedTasks.splice(i, 1);
1634
+ }
1635
+ completedTasks.push({ taskId: task.taskId, agentName: task.agentName, status, finishedAt: Date.now() });
1636
+ if (completedTasks.length > COMPLETED_TASKS_KEEP) {
1637
+ completedTasks.splice(0, completedTasks.length - COMPLETED_TASKS_KEEP);
1638
+ }
1639
+ }
1640
+
1641
+ /**
1642
+ * Latest-first finished tasks whose session transcript still exists on disk
1643
+ * (a task without a session file has nothing to show in the result viewer).
1644
+ * completedTasks holds at most one record per taskId (recordCompletedTask
1645
+ * dedupes), so no further deduplication is needed here.
1646
+ */
1647
+ function listViewableFinishedTasks(limit: number): CompletedTaskRecord[] {
1648
+ const result: CompletedTaskRecord[] = [];
1649
+ for (let i = completedTasks.length - 1; i >= 0 && result.length < limit; i--) {
1650
+ const record = completedTasks[i];
1651
+ if (!findSessionFile(record.taskId)) continue;
1652
+ result.push(record);
1653
+ }
1654
+ return result;
1655
+ }
1656
+
1657
+ /** Build a picker item whose label carries the full taskId (a 36-char UUID). */
1658
+ function taskPickerItem(taskId: string, description: string): SelectItem {
1659
+ return { value: taskId, label: taskId, description };
1660
+ }
1661
+
1662
+ /**
1663
+ * Interactive task picker (TUI only): a SelectList in a Container with
1664
+ * DynamicBorder framing (tui.md Pattern 1). Resolves with the selected item's
1665
+ * value (taskId), or undefined on Esc / q. Neither pi's select() nor
1666
+ * SelectList handles "q", so the wrapper's handleInput intercepts it before
1667
+ * delegating to the list.
1668
+ */
1669
+ async function pickTaskInteractively(
1670
+ ui: ExtensionContext["ui"],
1671
+ title: string,
1672
+ items: SelectItem[],
1673
+ ): Promise<string | undefined> {
1674
+ return ui.custom<string | undefined>((tui, theme, _kb, done) => {
1675
+ const container = new Container();
1676
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1677
+ container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
1678
+ const selectList = new SelectList(
1679
+ items,
1680
+ Math.min(items.length, 10),
1681
+ {
1682
+ selectedPrefix: (t) => theme.fg("accent", t),
1683
+ selectedText: (t) => theme.fg("accent", t),
1684
+ description: (t) => theme.fg("muted", t),
1685
+ scrollInfo: (t) => theme.fg("dim", t),
1686
+ noMatch: (t) => theme.fg("warning", t),
1687
+ },
1688
+ // The label is a 36-char UUID taskId; the default 32-char primary
1689
+ // column would truncate it, so widen the column to fit.
1690
+ { minPrimaryColumnWidth: 40, maxPrimaryColumnWidth: 40 },
1691
+ );
1692
+ selectList.onSelect = (item) => done(item.value);
1693
+ selectList.onCancel = () => done(undefined);
1694
+ container.addChild(selectList);
1695
+ container.addChild(new Text(theme.fg("dim", "↑↓ 选择 · Enter 确认 · Esc/q 退出"), 1, 0));
1696
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1697
+ return {
1698
+ render: (w) => container.render(w),
1699
+ invalidate: () => container.invalidate(),
1700
+ handleInput: (data) => {
1701
+ // Key.shift("q") covers Shift+q / Caps Lock "Q"; matchesKey
1702
+ // lowercases its keyId, so "Q" alone would be a no-op alias.
1703
+ if (matchesKey(data, "q") || matchesKey(data, Key.shift("q"))) {
1704
+ done(undefined);
1705
+ return;
1706
+ }
1707
+ selectList.handleInput(data);
1708
+ tui.requestRender();
1709
+ },
1710
+ };
1711
+ });
1547
1712
  }
1548
1713
 
1549
1714
  /** Derive the envelope status from a finished SingleResult. */
@@ -1593,9 +1758,14 @@ const DETAILS_OUTPUT_MAX_CHARS = 16 * 1024;
1593
1758
  * main agent can tell a deliberate user cancel, an agent-initiated cancel and
1594
1759
  * a session shutdown apart (and does not auto-retry a user cancel).
1595
1760
  */
1596
- function abortedFallbackBody(stopReason?: string, cancelledBy?: "user" | "agent"): string {
1761
+ function abortedFallbackBody(stopReason?: string, cancelledBy?: "user" | "agent", cancelReason?: string): string {
1597
1762
  if (stopReason === "killed_on_shutdown") return "任务因会话关闭被终止(session_shutdown)。";
1598
- if (cancelledBy === "agent") return "该任务已由主 agent 通过 subagent 工具(action=cancel)取消。";
1763
+ if (cancelledBy === "agent") {
1764
+ const base = "该任务已由主 agent 通过 subagent 工具(action=cancel)取消。";
1765
+ // Single-line and cap the reason: it is model-controlled text inlined
1766
+ // into a notification body. The full value stays on the task record.
1767
+ return cancelReason ? `${base}取消理由: ${truncateTaskDescription(cancelReason, 200)}` : base;
1768
+ }
1599
1769
  return "该任务已由用户通过 /subagent-cancel 取消,属用户主动操作。请勿自动重新派发;如需重新派发,先询问用户。";
1600
1770
  }
1601
1771
 
@@ -1622,7 +1792,7 @@ export function buildResultEnvelope(
1622
1792
  // Only genuine failures are labelled "内部错误"; a user cancel or session
1623
1793
  // shutdown rejection is an expected abort, so it gets a note carrying the
1624
1794
  // abort's origin (user cancel vs session shutdown).
1625
- if (!body && errorMessage) body = status === "failure" ? `内部错误: ${errorMessage}` : abortedFallbackBody(stopReason, task.cancelledBy);
1795
+ if (!body && errorMessage) body = status === "failure" ? `内部错误: ${errorMessage}` : abortedFallbackBody(stopReason, task.cancelledBy, task.cancelReason);
1626
1796
  const lines = [
1627
1797
  `## [subagent-result] ${task.agentName} ${statusWord} (taskId: ${task.taskId})`,
1628
1798
  "",
@@ -1636,7 +1806,7 @@ export function buildResultEnvelope(
1636
1806
  formatActiveTasks(),
1637
1807
  "",
1638
1808
  "---",
1639
- body || (status === "cancelled" ? abortedFallbackBody(stopReason, task.cancelledBy) : "(no output)"),
1809
+ body || (status === "cancelled" ? abortedFallbackBody(stopReason, task.cancelledBy, task.cancelReason) : "(no output)"),
1640
1810
  ];
1641
1811
  return {
1642
1812
  content: lines.join("\n"),
@@ -1666,6 +1836,42 @@ function buildDispatchReceipt(agentName: string, taskId: string): string {
1666
1836
  return `已派出 ${agentName}. taskId: ${taskId}`;
1667
1837
  }
1668
1838
 
1839
+ /**
1840
+ * Build the two-step-confirmation challenge for action="cancel" (first call,
1841
+ * confirm !== true): a zero-side-effect receipt spelling out what a cancel
1842
+ * would destroy — agent, task summary, elapsed time, last progress — plus the
1843
+ * exact second-call shape. The main agent must confirm deliberately instead
1844
+ * of reflexively cancelling a healthy in-flight task.
1845
+ */
1846
+ function buildCancelChallenge(task: AsyncSubagentTask): string {
1847
+ const lastActivityAt = progressManager.getLastActivityAt(task.taskId);
1848
+ let progressLine: string;
1849
+ if (lastActivityAt === undefined) {
1850
+ progressLine = "- 最近进度: 尚无进度上报(no progress reported yet)。";
1851
+ } else {
1852
+ // Read the clock once and derive both language phrases from that single
1853
+ // value — two Date.now() reads could straddle a second boundary and
1854
+ // disagree ("5 秒前 (6s ago)").
1855
+ const ageSec = Math.max(0, Math.floor((Date.now() - lastActivityAt) / 1000));
1856
+ // Under an hour, plain seconds read best; past that, fold into
1857
+ // formatDuration (H:MM:SS) instead of a huge second count.
1858
+ progressLine =
1859
+ ageSec < 3600
1860
+ ? `- 最近进度更新: ${ageSec} 秒前 (last activity ${ageSec}s ago)。`
1861
+ : `- 最近进度更新: ${formatDuration(ageSec * 1000)} 前 (last activity ${formatDuration(ageSec * 1000)} ago)。`;
1862
+ }
1863
+ return [
1864
+ `取消确认请求 (cancel confirmation required): 任务 ${task.taskId} 仍在运行;本次调用未取消任何东西。`,
1865
+ `- agent: ${task.agentName}`,
1866
+ `- 任务: ${truncateTaskDescription(task.task)}`,
1867
+ `- 已运行: ${formatDuration(Date.now() - task.startedAt)} (elapsed since dispatch)`,
1868
+ progressLine,
1869
+ "",
1870
+ "⚠️ 取消将丢弃该任务的全部在途进度,且不可撤销(cancelling discards all in-flight progress and cannot be undone)。",
1871
+ `如确认取消,再次调用 subagent 工具: action="cancel" + taskId="${task.taskId}" + confirm:true + reason(reason 必填,说明取消理由)。`,
1872
+ ].join("\n");
1873
+ }
1874
+
1669
1875
  /**
1670
1876
  * Finalize an async task: unregister progress, drop it from the registry, and
1671
1877
  * push the [subagent-result] notification. Called exactly once per task, on
@@ -1703,6 +1909,9 @@ function completeAsyncTask(pi: ExtensionAPI, task: AsyncSubagentTask, result: Si
1703
1909
  : !result
1704
1910
  ? "internal_error"
1705
1911
  : undefined);
1912
+ // Record the finish for the no-argument /subagent-result picker before the
1913
+ // notification goes out; failures of the picker list must not affect this.
1914
+ recordCompletedTask(task, status);
1706
1915
  // Carry the rejection reason into the envelope so internal failures
1707
1916
  // (e.g. the prompt temp-file write failed) are diagnosable instead of
1708
1917
  // showing a bare "(no output)".
@@ -1714,7 +1923,7 @@ function completeAsyncTask(pi: ExtensionAPI, task: AsyncSubagentTask, result: Si
1714
1923
  const envelope = buildResultEnvelope(task, result, status, stopReason, errorMessage);
1715
1924
  pi.sendMessage(
1716
1925
  { customType: "subagent-result", content: envelope.content, display: true, details: envelope.details },
1717
- { deliverAs: "followUp", triggerTurn: true },
1926
+ { deliverAs: "steer", triggerTurn: true },
1718
1927
  );
1719
1928
  } catch {
1720
1929
  // The session may already be gone (e.g. after session_shutdown); the
@@ -1745,6 +1954,15 @@ const SubagentParams = Type.Object({
1745
1954
  taskId: Type.Optional(Type.String({
1746
1955
  description: "taskId of the running background subagent task to cancel (required for action=cancel; from the dispatch receipt).",
1747
1956
  })),
1957
+ confirm: Type.Optional(Type.Boolean({
1958
+ description:
1959
+ 'Set true to actually execute an action=cancel after reviewing the challenge returned by the first call. Default: false — the first action=cancel call only returns a challenge (confirmRequired) and cancels nothing.',
1960
+ default: false,
1961
+ })),
1962
+ reason: Type.Optional(Type.String({
1963
+ description:
1964
+ "Why the task is being cancelled (required and must be non-empty when confirm=true). Recorded on the task and quoted in the [subagent-result] envelope.",
1965
+ })),
1748
1966
  sessionId: Type.Optional(Type.String({
1749
1967
  pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
1750
1968
  description: "仅用于复用此前 dispatch 回执返回的 UUID v7;省略则自动生成",
@@ -1765,7 +1983,7 @@ export default function (pi: ExtensionAPI) {
1765
1983
  "",
1766
1984
  "ACTIONS (action parameter, default \"dispatch\"):",
1767
1985
  "- dispatch: delegate the task (async in TUI mode, blocking otherwise).",
1768
- "- cancel: cancel a running background task by taskId.",
1986
+ "- cancel: request cancellation of a running background task by taskId (two-step: the first call returns a challenge; confirm:true + reason executes).",
1769
1987
  "- 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.",
1770
1988
  "",
1771
1989
  "ASYNC (TUI mode): returns immediately with a dispatch receipt (taskId + session id).",
@@ -1778,9 +1996,18 @@ export default function (pi: ExtensionAPI) {
1778
1996
  " receipt to continue the same task later.",
1779
1997
  "",
1780
1998
  "CANCEL DISCIPLINE: cancel a task (action=\"cancel\") only when it is clearly",
1781
- "wrong (错误) or no longer needed (不再需要). Do NOT cancel just because it is",
1782
- "taking a long time background subagents are expected to run long; be patient",
1783
- "(耐心等待) and let the [subagent-result] notification arrive.",
1999
+ "wrong (错误) or no longer needed (不再需要). Agent-initiated cancel is a",
2000
+ "two-step confirmation: the first action=\"cancel\" call only returns a",
2001
+ "challenge (confirmRequired) with elapsed time and last progress, and",
2002
+ "cancels nothing; to actually cancel, call action=\"cancel\" again with the",
2003
+ "same taskId + confirm:true + a non-empty reason (理由). Do NOT cancel just",
2004
+ "because it is taking a long time — background subagents are expected to",
2005
+ "run long; be patient (耐心等待) and let the [subagent-result]",
2006
+ "notification arrive.",
2007
+ "",
2008
+ "WAITING: 对在途任务不存在查询/催办/状态确认类动作(no query, nag or status",
2009
+ "action for in-flight tasks)——没有提供这类动作是刻意设计。等待 = 不发",
2010
+ "起任何工具调用,直接结束回合(waiting means no tool call: end the turn)。",
1784
2011
  "",
1785
2012
  "SYNC (non-TUI modes): waits for the subagent to finish and returns the full",
1786
2013
  "result directly (no notification follows).",
@@ -1796,7 +2023,10 @@ export default function (pi: ExtensionAPI) {
1796
2023
  "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
2024
  "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.",
1798
2025
  "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.",
2026
+ "subagent: Cancelling a background task is a two-step confirmation: the first action=\"cancel\" call only returns a challenge (confirmRequired) and cancels nothing; to actually cancel, call again with the same taskId + confirm:true + a non-empty reason explaining why. Never cancel just because a task runs long.",
2027
+ "subagent: Waiting for a background task means making NO tool call at all and ending the turn (等待 = 不发起任何工具调用、直接结束回合); there is deliberately no query, nag or status action for in-flight tasks — results arrive on their own as [subagent-result] notifications.",
1799
2028
  "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.",
2029
+ "subagent: The in-flight block in a [subagent-result] envelope is a build-time snapshot (构建时刻快照) anchored to that task's end event and may be stale (可能滞后) by the time you process the notification; if it conflicts with dispatch records you issued yourself this turn, trust your dispatch records (冲突时以派发记录为准).",
1800
2030
  ],
1801
2031
  parameters: SubagentParams,
1802
2032
 
@@ -1836,16 +2066,41 @@ export default function (pi: ExtensionAPI) {
1836
2066
  };
1837
2067
  }
1838
2068
  // Only registry (async/TUI) tasks are cancellable; sync-mode tasks are
1839
- // awaited inline and never enter the registry.
1840
- if (!cancelTask(taskId, "agent")) {
2069
+ // awaited inline and never enter the registry. Existence is checked
2070
+ // BEFORE the confirm/reason gates so a wrong id always fails the same
2071
+ // way regardless of confirmation state.
2072
+ const task = taskRegistry.get(taskId);
2073
+ if (!task || task.status !== "running") {
1841
2074
  return {
1842
2075
  content: [{ type: "text", text: `无此运行中任务: ${taskId} (no running subagent task with this id).` }],
1843
2076
  details: { taskId, cancelled: false },
1844
2077
  isError: true,
1845
2078
  };
1846
2079
  }
2080
+ // Two-step confirmation: the first call (confirm !== true) only
2081
+ // returns a challenge spelling out what would be destroyed — zero
2082
+ // side-effects (no status change, no abort, no notification). This
2083
+ // structural friction exists because the main agent used to fire
2084
+ // reflexive cancels at healthy in-flight tasks.
2085
+ if (params.confirm !== true) {
2086
+ return {
2087
+ content: [{ type: "text", text: buildCancelChallenge(task) }],
2088
+ details: { taskId, cancelled: false, confirmRequired: true },
2089
+ };
2090
+ }
2091
+ // A confirmed cancel must justify itself: the reason is recorded on
2092
+ // the task record and quoted in the [subagent-result] envelope body.
2093
+ const reason = typeof params.reason === "string" ? params.reason.trim() : "";
2094
+ if (!reason) {
2095
+ return {
2096
+ content: [{ type: "text", text: 'Missing or empty required parameter: "reason" (confirm:true 时 reason 必填,不能为空).' }],
2097
+ details: { taskId, cancelled: false },
2098
+ isError: true,
2099
+ };
2100
+ }
2101
+ cancelTask(taskId, "agent", reason);
1847
2102
  return {
1848
- content: [{ type: "text", text: `已发送取消请求: ${taskId} (cancel request sent); 结果稍后以 [subagent-result] 通知返回。\n${formatActiveTasks()}` }],
2103
+ content: [{ type: "text", text: `已发送取消请求: ${taskId} (cancel request sent); 结果稍后以 [subagent-result] 通知返回。\n${formatRemainingTasksAfterCancelRequest()}` }],
1849
2104
  details: { taskId, cancelled: true },
1850
2105
  };
1851
2106
  }
@@ -2199,15 +2454,26 @@ export default function (pi: ExtensionAPI) {
2199
2454
  pi.registerCommand?.("subagent-cancel", {
2200
2455
  description: "Cancel a running background subagent task (usage: /subagent-cancel <taskId>)",
2201
2456
  handler: async (args, cmdCtx) => {
2202
- const taskId = (args ?? "").trim();
2457
+ let taskId = (args ?? "").trim();
2203
2458
  if (!taskId) {
2204
- // No argument: list the running tasks so the user knows what to cancel.
2205
- const running = [...taskRegistry.values()]
2206
- .filter((t) => t.status === "running")
2207
- .map((t) => t.taskId);
2208
- const hint = running.length > 0 ? ` Running tasks: ${running.join(", ")}.` : " No running tasks.";
2209
- cmdCtx.ui?.notify?.(`No running subagent task with id "(none)".${hint}`, "warning");
2210
- return;
2459
+ const runningTasks = [...taskRegistry.values()].filter((t) => t.status === "running");
2460
+ // TUI with running tasks: interactive picker (Enter cancels, Esc/q
2461
+ // dismisses without doing anything). Non-TUI and the empty case keep
2462
+ // the original notify fallback.
2463
+ if (cmdCtx.hasUI && cmdCtx.mode === "tui" && runningTasks.length > 0) {
2464
+ const items: SelectItem[] = runningTasks.map((t) =>
2465
+ taskPickerItem(t.taskId, `${t.agentName}: ${truncateTaskDescription(t.task, 60)}`),
2466
+ );
2467
+ const picked = await pickTaskInteractively(cmdCtx.ui, "取消运行中任务 (cancel subagent task)", items);
2468
+ if (picked === undefined) return;
2469
+ taskId = picked;
2470
+ } else {
2471
+ // No argument: list the running tasks so the user knows what to cancel.
2472
+ const running = runningTasks.map((t) => t.taskId);
2473
+ const hint = running.length > 0 ? ` Running tasks: ${running.join(", ")}.` : " No running tasks.";
2474
+ cmdCtx.ui?.notify?.(`No running subagent task with id "(none)".${hint}`, "warning");
2475
+ return;
2476
+ }
2211
2477
  }
2212
2478
  if (!cancelTask(taskId, "user")) {
2213
2479
  cmdCtx.ui?.notify?.(`No running subagent task with id "${taskId}".`, "warning");
@@ -2244,10 +2510,25 @@ export default function (pi: ExtensionAPI) {
2244
2510
  pi.registerCommand?.("subagent-result", {
2245
2511
  description: "Show the full final result of a background subagent task (usage: /subagent-result <taskId>)",
2246
2512
  handler: async (args, cmdCtx) => {
2247
- const taskId = (args ?? "").trim();
2513
+ let taskId = (args ?? "").trim();
2248
2514
  if (!taskId) {
2249
- cmdCtx.ui?.notify?.("Usage: /subagent-result <taskId> 查看某子 agent 的完整返回。", "warning");
2250
- return;
2515
+ // TUI: interactive picker over the most recent finished tasks (Enter
2516
+ // opens the same viewer as the with-argument path below, Esc/q
2517
+ // dismisses without doing anything). Non-TUI keeps the usage hint.
2518
+ if (cmdCtx.hasUI && cmdCtx.mode === "tui") {
2519
+ const recent = listViewableFinishedTasks(5);
2520
+ if (recent.length === 0) {
2521
+ cmdCtx.ui?.notify?.("没有已运行结束的子 agent 任务记录 (no finished subagent tasks)。", "warning");
2522
+ return;
2523
+ }
2524
+ const items: SelectItem[] = recent.map((r) => taskPickerItem(r.taskId, `${r.agentName} · ${STATUS_WORDS[r.status]}`));
2525
+ const picked = await pickTaskInteractively(cmdCtx.ui, "查看已结束任务结果 (subagent result)", items);
2526
+ if (picked === undefined) return;
2527
+ taskId = picked;
2528
+ } else {
2529
+ cmdCtx.ui?.notify?.("Usage: /subagent-result <taskId> — 查看某子 agent 的完整返回。", "warning");
2530
+ return;
2531
+ }
2251
2532
  }
2252
2533
  // Refuse mid-flight reads: while the task is in the registry its
2253
2534
  // session file only holds a partial snapshot.
@@ -2300,7 +2581,7 @@ export default function (pi: ExtensionAPI) {
2300
2581
  },
2301
2582
  invalidate: () => md.invalidate(),
2302
2583
  handleInput: (data: string) => {
2303
- if (matchesKey(data, Key.enter) || matchesKey(data, Key.escape) || matchesKey(data, "q")) {
2584
+ if (matchesKey(data, Key.enter) || matchesKey(data, Key.escape) || matchesKey(data, "q") || matchesKey(data, Key.shift("q"))) {
2304
2585
  done(undefined);
2305
2586
  return;
2306
2587
  }