@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 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 (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
 
@@ -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 中看到的是**带底色的摘要卡片**,不是全文:成功绿色(✓)、失败红色(✗)、超时/已取消黄色。卡片只显示 agent、状态、taskId 和用量摘要,并提示 `查看全文: /subagent-result <taskId>`;完整结果保存在任务会话文件中。
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
- 你是项目的主 agent。你的职责是理解需求、制定计划、委派执行。你绝不亲自编辑代码、不执行命令、不写文件。
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wolido/async-subagent-isolation",
3
- "version": "1.2.0",
3
+ "version": "1.4.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 =====
@@ -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
- `- 耗时: ${formatElapsed(task.startedAt)} · 用量: ${formatUsageStats(usage, result?.model) || "-"}`,
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
- const taskId = (args ?? "").trim();
2308
+ let taskId = (args ?? "").trim();
2152
2309
  if (!taskId) {
2153
- // No argument: list the running tasks so the user knows what to cancel.
2154
- const running = [...taskRegistry.values()]
2155
- .filter((t) => t.status === "running")
2156
- .map((t) => t.taskId);
2157
- const hint = running.length > 0 ? ` Running tasks: ${running.join(", ")}.` : " No running tasks.";
2158
- cmdCtx.ui?.notify?.(`No running subagent task with id "(none)".${hint}`, "warning");
2159
- return;
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
- const taskId = (args ?? "").trim();
2364
+ let taskId = (args ?? "").trim();
2197
2365
  if (!taskId) {
2198
- cmdCtx.ui?.notify?.("Usage: /subagent-result <taskId> 查看某子 agent 的完整返回。", "warning");
2199
- return;
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