@wolido/async-subagent-isolation 1.2.0 → 1.4.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 +5 -1
- package/ADVANCED.md +5 -1
- package/README.en.md +5 -4
- package/README.md +5 -4
- package/examples/pi/agent/master.md +50 -2
- package/package.json +1 -1
- package/src/index.ts +202 -14
package/ADVANCED.en.md
CHANGED
|
@@ -139,6 +139,8 @@ Once the subagent finishes, its result is pushed into the conversation:
|
|
|
139
139
|
|
|
140
140
|
Status enumeration: **成功** (success, exit=0) / **失败** (failure, exit≠0 or stopReason=error) / **超时** (timeout, activity_timeout or hard_timeout) / **已取消** (cancelled, aborted or killed_on_shutdown).
|
|
141
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
|
+
|
|
142
144
|
"Cancelled" has three sub-cases with different envelope bodies:
|
|
143
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.
|
|
144
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).
|
|
@@ -148,7 +150,7 @@ When the main agent receives a "已取消" notification, it should distinguish t
|
|
|
148
150
|
|
|
149
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.
|
|
150
152
|
|
|
151
|
-
The full output enters the LLM context (not truncated). The `details` carries structured data (taskId, agent, status, exitCode, stopReason, usage, sessionId, full output) for programmatic consumption; it does not enter the LLM context.
|
|
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.
|
|
152
154
|
|
|
153
155
|
### Notification delivery
|
|
154
156
|
|
|
@@ -166,6 +168,8 @@ While subagents run, a progress widget appears above the TUI editor, listing all
|
|
|
166
168
|
● 01912345-abcd... coder ⚡ read... 01:23
|
|
167
169
|
```
|
|
168
170
|
|
|
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.
|
|
172
|
+
|
|
169
173
|
The taskId in the widget row can be copied for `/subagent-result` (view full result) or `/subagent-cancel` (cancel the task).
|
|
170
174
|
|
|
171
175
|
### Cancelling background tasks
|
package/ADVANCED.md
CHANGED
|
@@ -139,6 +139,8 @@ TUI 模式下 `subagent` 立即返回如下回执(不是结果!):
|
|
|
139
139
|
|
|
140
140
|
状态枚举:**成功**(exit=0)/ **失败**(exit≠0 或 stopReason=error)/ **超时**(activity_timeout 或 hard_timeout)/ **已取消**(aborted 或 killed_on_shutdown)。
|
|
141
141
|
|
|
142
|
+
**耗时**:`- 耗时:` 行是子 agent 的真实运行时长。有结果时取进程实际启动到结束(`finishedAt - startedAt`);取消(用户/agent/会话关闭)或内部错误导致无结果返回时,改从派发时刻起算。格式为 `MM:SS`,≥1 小时为 `H:MM:SS`(小时不补零)。四种状态(成功/失败/超时/已取消)的信封与 TUI 通知卡片都带耗时。
|
|
143
|
+
|
|
142
144
|
"已取消"分三种情况,信封正文不同:
|
|
143
145
|
- 用户通过 `/subagent-cancel` 取消(cancelledBy: user)→ 正文注明"属用户主动操作。请勿自动重新派发;如需重新派发,先询问用户。"
|
|
144
146
|
- 主 agent 通过 `subagent` 工具(`action="cancel"`)取消(cancelledBy: agent)→ 正文注明"该任务已由主 agent 通过 subagent 工具(action=cancel)取消。"
|
|
@@ -148,7 +150,7 @@ TUI 模式下 `subagent` 立即返回如下回执(不是结果!):
|
|
|
148
150
|
|
|
149
151
|
**在途任务块**:信封元信息区的"在途任务"列表列出**其余**仍在运行的后台任务(本任务在构建信封前已从注册表移除,故不包含自身),格式为 `在途任务: N` 加每行 `- taskId (agent名): 任务描述`,无在途任务时为"当前无在途任务。"列表**不含耗时**(回答"还有什么在跑",而非"跑了多久")。主 agent 据此知道还有几个任务没回来:剩余不为 0 时,不要向用户汇报"全部完成"。
|
|
150
152
|
|
|
151
|
-
结果全量进入 LLM 上下文(不截断)。`details` 携带结构化数据(taskId、agent、status、exitCode、stopReason、usage、sessionId、完整输出),不参与 LLM 上下文,供程序消费。
|
|
153
|
+
结果全量进入 LLM 上下文(不截断)。`details` 携带结构化数据(taskId、agent、status、exitCode、stopReason、durationMs(耗时毫秒数,必填)、usage、sessionId、完整输出),不参与 LLM 上下文,供程序消费。
|
|
152
154
|
|
|
153
155
|
### 通知投递
|
|
154
156
|
|
|
@@ -166,6 +168,8 @@ TUI 模式下 `subagent` 立即返回如下回执(不是结果!):
|
|
|
166
168
|
● 01912345-abcd... coder ⚡ read... 01:23
|
|
167
169
|
```
|
|
168
170
|
|
|
171
|
+
widget 行的耗时是"存活至今"的实时时钟(`formatElapsed`,仅 `MM:SS`,可溢出 99 分钟);信封与通知卡片的耗时是终态运行时长(`formatDuration`)。两者并存,语义不同。
|
|
172
|
+
|
|
169
173
|
widget 行中的 taskId 可直接复制,用于 `/subagent-result` 查看结果或 `/subagent-cancel` 取消任务。
|
|
170
174
|
|
|
171
175
|
### 取消后台任务
|
package/README.en.md
CHANGED
|
@@ -120,7 +120,7 @@ Results arrive automatically — **no polling**. In-flight task information is p
|
|
|
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
|
|
|
@@ -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 (
|
|
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
|
|
|
@@ -180,10 +180,11 @@ The `[subagent-result]` notification is **self-contained** — it carries everyt
|
|
|
180
180
|
```
|
|
181
181
|
|
|
182
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.
|
|
183
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
185
|
- **Full result**: the body enters the LLM context in full, untruncated.
|
|
185
186
|
|
|
186
|
-
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.
|
|
187
188
|
|
|
188
189
|
See [ADVANCED.en.md](ADVANCED.en.md) for the complete envelope format, status semantics, and cancel-origin distinctions.
|
|
189
190
|
|
package/README.md
CHANGED
|
@@ -120,7 +120,7 @@ TUI 模式下 `subagent` **立即返回派发回执**,不阻塞:
|
|
|
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
|
|
|
@@ -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
|
|
|
@@ -180,10 +180,11 @@ TUI 模式下 `subagent` **立即返回派发回执**,不阻塞:
|
|
|
180
180
|
```
|
|
181
181
|
|
|
182
182
|
- **状态**:`成功` / `失败` / `超时` / `已取消`。
|
|
183
|
+
- **耗时**:子 agent 的真实运行时长(进程实际启动到结束,格式 `MM:SS`,≥1 小时为 `H:MM:SS`),成功/失败/超时/已取消四种状态都有;取消或内部错误(无结果返回)时从派发时刻起算。
|
|
183
184
|
- **在途任务块**:列出其余仍在运行的后台任务(不含自身),让主 agent 知道还有几个任务没回来——剩余不为 0 时,不要向用户汇报"全部完成"。
|
|
184
185
|
- **完整结果**:正文全量进入 LLM 上下文,不截断。
|
|
185
186
|
|
|
186
|
-
用户在 TUI
|
|
187
|
+
用户在 TUI 中看到的是**带底色的摘要卡片**,不是全文:成功绿色(✓)、失败红色(✗)、超时/已取消黄色。卡片显示 agent、状态、taskId、耗时和用量摘要(四种状态均带耗时),并提示 `查看全文: /subagent-result <taskId>`;完整结果保存在任务会话文件中。
|
|
187
188
|
|
|
188
189
|
信封完整格式、状态语义与取消来源区分见 [ADVANCED.md](ADVANCED.md)。
|
|
189
190
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# 主 Agent
|
|
1
|
+
# 主 Agent(任务指挥官)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
你是项目的任务指挥官:团队领导与质量守门员。你的职责是理解需求、制定计划、委派执行、审查产出,对最终交付物的质量负全责。你绝不亲自编辑代码、不执行命令、不写文件;所有执行都委派给子 agent,不达标的结果不交付给用户。
|
|
4
4
|
|
|
5
5
|
## 可用子 Agent
|
|
6
6
|
|
|
@@ -45,6 +45,8 @@
|
|
|
45
45
|
- **输出格式** — 期望的返回结构
|
|
46
46
|
- **验收标准** — 如何判断完成(必须含验证命令及输出)
|
|
47
47
|
|
|
48
|
+
其中**输出格式**应要求子 agent 返回以下要素:执行摘要、详细结果、状态(✅ 完成 / ⚠️ 部分 / ❌ 阻塞)、文件变更列表、后续建议。
|
|
49
|
+
|
|
48
50
|
### 调用示例
|
|
49
51
|
|
|
50
52
|
调用 `subagent` tool 时,应把上述五部分内容全部写入 `task` 字段,例如:
|
|
@@ -58,6 +60,52 @@
|
|
|
58
60
|
|
|
59
61
|
**注意**:`task` 字段必须非空,禁止只传 `agent` 而空传 `task`。如果 `task` 为空,子 agent 将拒绝执行。
|
|
60
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
|
+
|
|
61
109
|
## 隔离说明
|
|
62
110
|
|
|
63
111
|
子 agent 的进程与你完全独立。你看不到子 agent 内部的工具调用痕迹和中间结果——你只收到 `[subagent-result]` 通知中的最终总结(TUI 模式)或返回值中的内联结果(非 TUI 模式)。你的上下文不会被子 agent 的执行细节污染,始终专注于规划和决策。
|
package/package.json
CHANGED
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 =====
|
|
@@ -426,6 +426,10 @@ interface SingleResult {
|
|
|
426
426
|
lastPhaseChange: number;
|
|
427
427
|
thinkingBuffer?: string;
|
|
428
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;
|
|
429
433
|
}
|
|
430
434
|
|
|
431
435
|
interface SubagentDetails {
|
|
@@ -603,6 +607,22 @@ export function formatElapsed(startedAt: number): string {
|
|
|
603
607
|
return `${mm}:${ss}`;
|
|
604
608
|
}
|
|
605
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
|
+
|
|
606
626
|
/**
|
|
607
627
|
* Tracks progress of all running subagents and renders it as a widget above
|
|
608
628
|
* the editor. A single 1Hz interval drives widget refreshes; update() only
|
|
@@ -919,6 +939,7 @@ async function runSingleAgent(
|
|
|
919
939
|
modelOverrides?: Record<string, ModelOverride>,
|
|
920
940
|
onProcSpawn?: (proc: ChildProcess) => void,
|
|
921
941
|
): Promise<SingleResult> {
|
|
942
|
+
const startedAt = Date.now();
|
|
922
943
|
let effectiveSessionId: string;
|
|
923
944
|
if (sessionId !== undefined) {
|
|
924
945
|
const trimmed = sessionId.trim();
|
|
@@ -937,6 +958,8 @@ async function runSingleAgent(
|
|
|
937
958
|
phase: "idle",
|
|
938
959
|
lastPhaseChange: Date.now(),
|
|
939
960
|
sessionId: trimmed,
|
|
961
|
+
startedAt,
|
|
962
|
+
finishedAt: Date.now(),
|
|
940
963
|
};
|
|
941
964
|
}
|
|
942
965
|
effectiveSessionId = trimmed;
|
|
@@ -959,6 +982,8 @@ async function runSingleAgent(
|
|
|
959
982
|
phase: "idle",
|
|
960
983
|
lastPhaseChange: Date.now(),
|
|
961
984
|
sessionId: effectiveSessionId,
|
|
985
|
+
startedAt,
|
|
986
|
+
finishedAt: Date.now(),
|
|
962
987
|
};
|
|
963
988
|
}
|
|
964
989
|
|
|
@@ -1026,6 +1051,7 @@ async function runSingleAgent(
|
|
|
1026
1051
|
phase: "idle",
|
|
1027
1052
|
lastPhaseChange: Date.now(),
|
|
1028
1053
|
sessionId: effectiveSessionId,
|
|
1054
|
+
startedAt,
|
|
1029
1055
|
};
|
|
1030
1056
|
|
|
1031
1057
|
const emitProgress = () => {
|
|
@@ -1098,6 +1124,7 @@ async function runSingleAgent(
|
|
|
1098
1124
|
const finalize = (code: number) => {
|
|
1099
1125
|
if (resolved) return;
|
|
1100
1126
|
resolved = true;
|
|
1127
|
+
currentResult.finishedAt = Date.now();
|
|
1101
1128
|
if (postExitTimer) {
|
|
1102
1129
|
clearTimeout(postExitTimer);
|
|
1103
1130
|
postExitTimer = undefined;
|
|
@@ -1519,6 +1546,109 @@ export function formatActiveTasks(): string {
|
|
|
1519
1546
|
return `在途任务: ${running.length}\n${lines.join("\n")}`;
|
|
1520
1547
|
}
|
|
1521
1548
|
|
|
1549
|
+
/** A finished async task, recorded when completeAsyncTask removes it from the registry. */
|
|
1550
|
+
interface CompletedTaskRecord {
|
|
1551
|
+
taskId: string;
|
|
1552
|
+
agentName: string;
|
|
1553
|
+
status: SubagentTaskStatus;
|
|
1554
|
+
finishedAt: number;
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
/**
|
|
1558
|
+
* Recently finished async tasks in completion order (latest last), backing the
|
|
1559
|
+
* no-argument /subagent-result picker. Bounded so a long session cannot grow
|
|
1560
|
+
* it without limit; entries whose session file is gone are filtered at read
|
|
1561
|
+
* time by listViewableFinishedTasks.
|
|
1562
|
+
*/
|
|
1563
|
+
const completedTasks: CompletedTaskRecord[] = [];
|
|
1564
|
+
const COMPLETED_TASKS_KEEP = 50;
|
|
1565
|
+
|
|
1566
|
+
/** Record a finished task (called once per task from completeAsyncTask). */
|
|
1567
|
+
function recordCompletedTask(task: AsyncSubagentTask, status: SubagentTaskStatus): void {
|
|
1568
|
+
// A reused sessionId finishes repeatedly: drop its older record first so
|
|
1569
|
+
// the latest finish wins and one task cannot occupy multiple slots.
|
|
1570
|
+
for (let i = completedTasks.length - 1; i >= 0; i--) {
|
|
1571
|
+
if (completedTasks[i].taskId === task.taskId) completedTasks.splice(i, 1);
|
|
1572
|
+
}
|
|
1573
|
+
completedTasks.push({ taskId: task.taskId, agentName: task.agentName, status, finishedAt: Date.now() });
|
|
1574
|
+
if (completedTasks.length > COMPLETED_TASKS_KEEP) {
|
|
1575
|
+
completedTasks.splice(0, completedTasks.length - COMPLETED_TASKS_KEEP);
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
/**
|
|
1580
|
+
* Latest-first finished tasks whose session transcript still exists on disk
|
|
1581
|
+
* (a task without a session file has nothing to show in the result viewer).
|
|
1582
|
+
* completedTasks holds at most one record per taskId (recordCompletedTask
|
|
1583
|
+
* dedupes), so no further deduplication is needed here.
|
|
1584
|
+
*/
|
|
1585
|
+
function listViewableFinishedTasks(limit: number): CompletedTaskRecord[] {
|
|
1586
|
+
const result: CompletedTaskRecord[] = [];
|
|
1587
|
+
for (let i = completedTasks.length - 1; i >= 0 && result.length < limit; i--) {
|
|
1588
|
+
const record = completedTasks[i];
|
|
1589
|
+
if (!findSessionFile(record.taskId)) continue;
|
|
1590
|
+
result.push(record);
|
|
1591
|
+
}
|
|
1592
|
+
return result;
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
/** Build a picker item whose label carries the full taskId (a 36-char UUID). */
|
|
1596
|
+
function taskPickerItem(taskId: string, description: string): SelectItem {
|
|
1597
|
+
return { value: taskId, label: taskId, description };
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
/**
|
|
1601
|
+
* Interactive task picker (TUI only): a SelectList in a Container with
|
|
1602
|
+
* DynamicBorder framing (tui.md Pattern 1). Resolves with the selected item's
|
|
1603
|
+
* value (taskId), or undefined on Esc / q. Neither pi's select() nor
|
|
1604
|
+
* SelectList handles "q", so the wrapper's handleInput intercepts it before
|
|
1605
|
+
* delegating to the list.
|
|
1606
|
+
*/
|
|
1607
|
+
async function pickTaskInteractively(
|
|
1608
|
+
ui: ExtensionContext["ui"],
|
|
1609
|
+
title: string,
|
|
1610
|
+
items: SelectItem[],
|
|
1611
|
+
): Promise<string | undefined> {
|
|
1612
|
+
return ui.custom<string | undefined>((tui, theme, _kb, done) => {
|
|
1613
|
+
const container = new Container();
|
|
1614
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
1615
|
+
container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
|
|
1616
|
+
const selectList = new SelectList(
|
|
1617
|
+
items,
|
|
1618
|
+
Math.min(items.length, 10),
|
|
1619
|
+
{
|
|
1620
|
+
selectedPrefix: (t) => theme.fg("accent", t),
|
|
1621
|
+
selectedText: (t) => theme.fg("accent", t),
|
|
1622
|
+
description: (t) => theme.fg("muted", t),
|
|
1623
|
+
scrollInfo: (t) => theme.fg("dim", t),
|
|
1624
|
+
noMatch: (t) => theme.fg("warning", t),
|
|
1625
|
+
},
|
|
1626
|
+
// The label is a 36-char UUID taskId; the default 32-char primary
|
|
1627
|
+
// column would truncate it, so widen the column to fit.
|
|
1628
|
+
{ minPrimaryColumnWidth: 40, maxPrimaryColumnWidth: 40 },
|
|
1629
|
+
);
|
|
1630
|
+
selectList.onSelect = (item) => done(item.value);
|
|
1631
|
+
selectList.onCancel = () => done(undefined);
|
|
1632
|
+
container.addChild(selectList);
|
|
1633
|
+
container.addChild(new Text(theme.fg("dim", "↑↓ 选择 · Enter 确认 · Esc/q 退出"), 1, 0));
|
|
1634
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
1635
|
+
return {
|
|
1636
|
+
render: (w) => container.render(w),
|
|
1637
|
+
invalidate: () => container.invalidate(),
|
|
1638
|
+
handleInput: (data) => {
|
|
1639
|
+
// Key.shift("q") covers Shift+q / Caps Lock "Q"; matchesKey
|
|
1640
|
+
// lowercases its keyId, so "Q" alone would be a no-op alias.
|
|
1641
|
+
if (matchesKey(data, "q") || matchesKey(data, Key.shift("q"))) {
|
|
1642
|
+
done(undefined);
|
|
1643
|
+
return;
|
|
1644
|
+
}
|
|
1645
|
+
selectList.handleInput(data);
|
|
1646
|
+
tui.requestRender();
|
|
1647
|
+
},
|
|
1648
|
+
};
|
|
1649
|
+
});
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1522
1652
|
/** Derive the envelope status from a finished SingleResult. */
|
|
1523
1653
|
function getTaskStatus(result: SingleResult): SubagentTaskStatus {
|
|
1524
1654
|
const stopReason = result.stopReason;
|
|
@@ -1537,6 +1667,13 @@ export interface SubagentResultDetails {
|
|
|
1537
1667
|
stopReason?: string;
|
|
1538
1668
|
/** Present only on cancelled tasks: who cancelled ("user" | "agent"). */
|
|
1539
1669
|
cancelledBy?: "user" | "agent";
|
|
1670
|
+
/**
|
|
1671
|
+
* Run duration in milliseconds (>= 0): the real run time
|
|
1672
|
+
* (finishedAt - startedAt) when a result exists; measured from the
|
|
1673
|
+
* dispatch time (task.startedAt) when result is null (cancel/internal
|
|
1674
|
+
* error).
|
|
1675
|
+
*/
|
|
1676
|
+
durationMs: number;
|
|
1540
1677
|
usage: UsageStats;
|
|
1541
1678
|
sessionId: string;
|
|
1542
1679
|
output: string;
|
|
@@ -1577,6 +1714,12 @@ export function buildResultEnvelope(
|
|
|
1577
1714
|
const usage: UsageStats =
|
|
1578
1715
|
result?.usage ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
|
|
1579
1716
|
const sessionId = result?.sessionId ?? task.taskId;
|
|
1717
|
+
// Real run time when the subagent produced a result; for a null result
|
|
1718
|
+
// (user/agent cancel, session shutdown, internal error) the run never
|
|
1719
|
+
// reported back, so measure from the dispatch time instead.
|
|
1720
|
+
const durationMs = result
|
|
1721
|
+
? Math.max(0, (result.finishedAt ?? Date.now()) - result.startedAt)
|
|
1722
|
+
: Math.max(0, Date.now() - task.startedAt);
|
|
1580
1723
|
let body = output;
|
|
1581
1724
|
if (!body && result) body = result.errorMessage || result.stderr.trim();
|
|
1582
1725
|
// Only genuine failures are labelled "内部错误"; a user cancel or session
|
|
@@ -1588,7 +1731,7 @@ export function buildResultEnvelope(
|
|
|
1588
1731
|
"",
|
|
1589
1732
|
`- 状态: ${statusWord}`,
|
|
1590
1733
|
`- 任务: ${truncateTaskDescription(task.task)}`,
|
|
1591
|
-
`- 耗时: ${
|
|
1734
|
+
`- 耗时: ${formatDuration(durationMs)} · 用量: ${formatUsageStats(usage, result?.model) || "-"}`,
|
|
1592
1735
|
`- 会话: ${sessionId}`,
|
|
1593
1736
|
"",
|
|
1594
1737
|
// 在途 block: completeAsyncTask deletes this task from the registry
|
|
@@ -1607,6 +1750,7 @@ export function buildResultEnvelope(
|
|
|
1607
1750
|
exitCode: result?.exitCode ?? null,
|
|
1608
1751
|
stopReason,
|
|
1609
1752
|
cancelledBy: task.cancelledBy,
|
|
1753
|
+
durationMs,
|
|
1610
1754
|
usage,
|
|
1611
1755
|
sessionId,
|
|
1612
1756
|
output:
|
|
@@ -1662,6 +1806,9 @@ function completeAsyncTask(pi: ExtensionAPI, task: AsyncSubagentTask, result: Si
|
|
|
1662
1806
|
: !result
|
|
1663
1807
|
? "internal_error"
|
|
1664
1808
|
: undefined);
|
|
1809
|
+
// Record the finish for the no-argument /subagent-result picker before the
|
|
1810
|
+
// notification goes out; failures of the picker list must not affect this.
|
|
1811
|
+
recordCompletedTask(task, status);
|
|
1665
1812
|
// Carry the rejection reason into the envelope so internal failures
|
|
1666
1813
|
// (e.g. the prompt temp-file write failed) are diagnosable instead of
|
|
1667
1814
|
// showing a bare "(no output)".
|
|
@@ -2073,6 +2220,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
2073
2220
|
const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
|
|
2074
2221
|
const displayItems = getDisplayItems(r.messages);
|
|
2075
2222
|
const finalOutput = getFinalOutput(r.messages);
|
|
2223
|
+
// Run duration, shown only when both timestamps are present (older
|
|
2224
|
+
// results lack them); a typeof check keeps 0ms runs visible and
|
|
2225
|
+
// missing fields from rendering "NaN".
|
|
2226
|
+
const durationStr =
|
|
2227
|
+
typeof r.startedAt === "number" && Number.isFinite(r.startedAt) &&
|
|
2228
|
+
typeof r.finishedAt === "number" && Number.isFinite(r.finishedAt)
|
|
2229
|
+
? formatDuration(r.finishedAt - r.startedAt)
|
|
2230
|
+
: null;
|
|
2076
2231
|
|
|
2077
2232
|
if (expanded) {
|
|
2078
2233
|
const container = new Container();
|
|
@@ -2080,6 +2235,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2080
2235
|
if (isError && r.stopReason) header += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
|
|
2081
2236
|
if (r.phase !== "idle") header += ` ${theme.fg("warning", formatPhase(r.phase))}`;
|
|
2082
2237
|
header += ` ${theme.fg("muted", `[session: ${r.sessionId}]`)}`;
|
|
2238
|
+
if (durationStr) header += ` ${theme.fg("dim", durationStr)}`;
|
|
2083
2239
|
container.addChild(new Text(header, 0, 0));
|
|
2084
2240
|
if (isError && r.errorMessage)
|
|
2085
2241
|
container.addChild(new Text(theme.fg("error", `Error: ${r.errorMessage}`), 0, 0));
|
|
@@ -2125,6 +2281,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2125
2281
|
if (isError && r.stopReason) text += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
|
|
2126
2282
|
if (r.phase !== "idle") text += ` ${theme.fg("warning", formatPhase(r.phase))}`;
|
|
2127
2283
|
text += ` ${theme.fg("muted", `[session: ${r.sessionId}]`)}`;
|
|
2284
|
+
if (durationStr) text += ` ${theme.fg("dim", durationStr)}`;
|
|
2128
2285
|
if (isError && r.errorMessage) text += `\n${theme.fg("error", `Error: ${r.errorMessage}`)}`;
|
|
2129
2286
|
if (displayItems.length === 0) {
|
|
2130
2287
|
if (!isError || !r.errorMessage) text += `\n${theme.fg("muted", "(no output)")}`;
|
|
@@ -2148,15 +2305,26 @@ export default function (pi: ExtensionAPI) {
|
|
|
2148
2305
|
pi.registerCommand?.("subagent-cancel", {
|
|
2149
2306
|
description: "Cancel a running background subagent task (usage: /subagent-cancel <taskId>)",
|
|
2150
2307
|
handler: async (args, cmdCtx) => {
|
|
2151
|
-
|
|
2308
|
+
let taskId = (args ?? "").trim();
|
|
2152
2309
|
if (!taskId) {
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2310
|
+
const runningTasks = [...taskRegistry.values()].filter((t) => t.status === "running");
|
|
2311
|
+
// TUI with running tasks: interactive picker (Enter cancels, Esc/q
|
|
2312
|
+
// dismisses without doing anything). Non-TUI and the empty case keep
|
|
2313
|
+
// the original notify fallback.
|
|
2314
|
+
if (cmdCtx.hasUI && cmdCtx.mode === "tui" && runningTasks.length > 0) {
|
|
2315
|
+
const items: SelectItem[] = runningTasks.map((t) =>
|
|
2316
|
+
taskPickerItem(t.taskId, `${t.agentName}: ${truncateTaskDescription(t.task, 60)}`),
|
|
2317
|
+
);
|
|
2318
|
+
const picked = await pickTaskInteractively(cmdCtx.ui, "取消运行中任务 (cancel subagent task)", items);
|
|
2319
|
+
if (picked === undefined) return;
|
|
2320
|
+
taskId = picked;
|
|
2321
|
+
} else {
|
|
2322
|
+
// No argument: list the running tasks so the user knows what to cancel.
|
|
2323
|
+
const running = runningTasks.map((t) => t.taskId);
|
|
2324
|
+
const hint = running.length > 0 ? ` Running tasks: ${running.join(", ")}.` : " No running tasks.";
|
|
2325
|
+
cmdCtx.ui?.notify?.(`No running subagent task with id "(none)".${hint}`, "warning");
|
|
2326
|
+
return;
|
|
2327
|
+
}
|
|
2160
2328
|
}
|
|
2161
2329
|
if (!cancelTask(taskId, "user")) {
|
|
2162
2330
|
cmdCtx.ui?.notify?.(`No running subagent task with id "${taskId}".`, "warning");
|
|
@@ -2193,10 +2361,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
2193
2361
|
pi.registerCommand?.("subagent-result", {
|
|
2194
2362
|
description: "Show the full final result of a background subagent task (usage: /subagent-result <taskId>)",
|
|
2195
2363
|
handler: async (args, cmdCtx) => {
|
|
2196
|
-
|
|
2364
|
+
let taskId = (args ?? "").trim();
|
|
2197
2365
|
if (!taskId) {
|
|
2198
|
-
|
|
2199
|
-
|
|
2366
|
+
// TUI: interactive picker over the most recent finished tasks (Enter
|
|
2367
|
+
// opens the same viewer as the with-argument path below, Esc/q
|
|
2368
|
+
// dismisses without doing anything). Non-TUI keeps the usage hint.
|
|
2369
|
+
if (cmdCtx.hasUI && cmdCtx.mode === "tui") {
|
|
2370
|
+
const recent = listViewableFinishedTasks(5);
|
|
2371
|
+
if (recent.length === 0) {
|
|
2372
|
+
cmdCtx.ui?.notify?.("没有已运行结束的子 agent 任务记录 (no finished subagent tasks)。", "warning");
|
|
2373
|
+
return;
|
|
2374
|
+
}
|
|
2375
|
+
const items: SelectItem[] = recent.map((r) => taskPickerItem(r.taskId, `${r.agentName} · ${STATUS_WORDS[r.status]}`));
|
|
2376
|
+
const picked = await pickTaskInteractively(cmdCtx.ui, "查看已结束任务结果 (subagent result)", items);
|
|
2377
|
+
if (picked === undefined) return;
|
|
2378
|
+
taskId = picked;
|
|
2379
|
+
} else {
|
|
2380
|
+
cmdCtx.ui?.notify?.("Usage: /subagent-result <taskId> — 查看某子 agent 的完整返回。", "warning");
|
|
2381
|
+
return;
|
|
2382
|
+
}
|
|
2200
2383
|
}
|
|
2201
2384
|
// Refuse mid-flight reads: while the task is in the registry its
|
|
2202
2385
|
// session file only holds a partial snapshot.
|
|
@@ -2249,7 +2432,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2249
2432
|
},
|
|
2250
2433
|
invalidate: () => md.invalidate(),
|
|
2251
2434
|
handleInput: (data: string) => {
|
|
2252
|
-
if (matchesKey(data, Key.enter) || matchesKey(data, Key.escape) || matchesKey(data, "q")) {
|
|
2435
|
+
if (matchesKey(data, Key.enter) || matchesKey(data, Key.escape) || matchesKey(data, "q") || matchesKey(data, Key.shift("q"))) {
|
|
2253
2436
|
done(undefined);
|
|
2254
2437
|
return;
|
|
2255
2438
|
}
|
|
@@ -2319,6 +2502,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
2319
2502
|
if (details?.taskId) text += ` ${theme.fg("muted", `(taskId: ${details.taskId})`)}`;
|
|
2320
2503
|
const usageStr = details ? formatUsageStats(details.usage) : "";
|
|
2321
2504
|
if (usageStr) text += ` ${theme.fg("dim", usageStr)}`;
|
|
2505
|
+
// durationMs is typed as a number and 0 is a valid duration, so the
|
|
2506
|
+
// presence check must not be falsy-based; old-shape details without
|
|
2507
|
+
// it simply omit the duration.
|
|
2508
|
+
if (typeof details?.durationMs === "number" && Number.isFinite(details.durationMs))
|
|
2509
|
+
text += ` ${theme.fg("dim", `耗时 ${formatDuration(details.durationMs)}`)}`;
|
|
2322
2510
|
if (details?.taskId) text += `\n${theme.fg("muted", `查看全文: /subagent-result ${details.taskId}`)}`;
|
|
2323
2511
|
// Background tint mirrors the dispatch-receipt tool rows: success and
|
|
2324
2512
|
// failure reuse the tool-row colors; timeout, cancelled and unknown
|